Compare commits

...

39 Commits

Author SHA1 Message Date
diegosouzapw 36856b18db chore: release v2.5.3
Build Electron Desktop App / Validate version (push) Failing after 32s
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
bug fixes (PRs #373, #371, #372, #369 by @kfiramar):
- fix(db): provider_connections.group column migration for existing DBs
- fix(i18n): replace missing deleteConnection key with delete in tooltip
- fix(auth): clear stale error metadata on genuine provider recovery
- fix(startup): unify env loading across npm/electron startup paths

code quality improvements (per kilo-code-bot review):
- docs: document result.success vs response.ok patterns in auth.ts
- refactor: normalize overridePath?.trim() in electron/main.js
- docs: explain preferredEnv merge order intent
2026-03-14 19:53:59 -03:00
Diego Rodrigues de Sa e Souza 66f0a8f994 Merge pull request #369 from kfiramar/fix-startup-env-key-loading
Thanks @kfiramar! 🎉 Critical security fix — different startup paths were generating different `STORAGE_ENCRYPTION_KEY` values over the same SQLite database, causing `Unsupported state or unable to authenticate data` for all stored tokens.

Improvements added on top:
- Normalized `overridePath?.trim()` in `electron/main.js` to match `bootstrap-env.mjs` (addresses kilo-code-bot warning #1)
- Added explanatory comment documenting the `preferredEnv` merge order intent in Electron startup (addresses kilo-code-bot warning #3)

4 commits + 113-line test file. The fail-closed behaviour (refusing to mint a new key when encrypted rows exist) is an excellent safeguard. Merged!
2026-03-14 19:52:09 -03:00
Diego Rodrigues de Sa e Souza 455231170f Merge pull request #372 from kfiramar/fix/clear-provider-error-state
Thanks @kfiramar! 🎉 Critical fix — stale error metadata on recovered provider accounts was preventing valid accounts from being selected properly after recovery. 

Improvement added on top: documented the two valid success-check patterns (`result.success` for open-sse handlers vs `response?.ok` for fetch-based handlers) to address the kilo-code-bot review warning — both patterns are correct by design, now explicitly documented.

5 commits total, 2 test files (+168 lines of coverage). Merged!
2026-03-14 19:49:51 -03:00
Diego Rodrigues de Sa e Souza 5faeb58ab0 Merge pull request #371 from kfiramar/fix/provider-delete-tooltip-i18n
Thanks @kfiramar! Perfect minimal fix — `t("deleteConnection")` was requesting a non-existent key across all 30 locales, causing `MISSING_MESSAGE: providers.deleteConnection` runtime errors on every provider detail page load. Reusing the existing `providers.delete` key is the correct fix. Merged!
2026-03-14 19:48:01 -03:00
Diego Rodrigues de Sa e Souza 056e4a88ff Merge pull request #373 from kfiramar/fix/provider-connections-group-migration
Thanks @kfiramar! 🎉 Critical schema fix — the `group` column was used in all provider_connections queries but missing from the base schema and backfill migration. Databases upgraded from older versions were silently failing on group-related queries. Clean fix with regression test. Merged!
2026-03-14 19:47:58 -03:00
Kfir Amar 8fd944ccf7 fix(auth): type recovered state helpers
Tighten the helper signatures added for recovered provider cleanup.

This removes the new any-typed recovery parameters called out in
review without broadening the PR into unrelated auth typing work.
2026-03-14 23:11:59 +02:00
Kfir Amar 86105a547c fix(auth): clear stale state on non-chat success
Clear recovered provider error metadata after successful
credentialed requests in non-chat API routes as well.

Add route-level regression tests covering a Response-based
success path and a result-object success path.
2026-03-14 22:39:30 +02:00
Kfir Amar 9806648c07 test(auth): cover stale active error metadata path
Refine the recovered-account regression test to match the real
observed state: an account can remain active while still carrying
stale refresh-failure metadata.

This verifies that getProviderCredentials surfaces those fields
and that clearAccountError clears them through the real runtime
path.
2026-03-14 22:31:03 +02:00
Kfir Amar 6186babdb3 fix(auth): include error fields in recovery path
Pass errorCode, lastErrorType, and lastErrorSource through the
runtime credentials object so clearAccountError can clear stale
provider error metadata after a real successful request.

Also update the regression test to use getProviderCredentials,
matching the production call path.
2026-03-14 22:24:08 +02:00
Kfir Amar f2ecefb54a fix(i18n): use existing provider delete label
Replace a missing deleteConnection message lookup with the
existing delete label to avoid the provider-page runtime i18n
overlay.
2026-03-14 22:18:41 +02:00
Kfir Amar 43bd529b78 fix(db): add provider connection group migration
Add the missing provider_connections.group column to both the
base schema and the runtime column backfill path.

Also add a regression test covering upgrade from an older
database that does not yet have the column.
2026-03-14 22:18:41 +02:00
Kfir Amar 9c82b3d4ca fix(auth): clear stale provider error metadata
Clear errorCode, lastErrorType, and lastErrorSource when an
account recovers so provider state returns to a fully clean
active status.

Add a focused regression test for recovered-account cleanup.
2026-03-14 22:18:41 +02:00
Kfir Amar b19e6a8e87 fix(startup): pass env through env-file lookup
Keep getPreferredEnvFilePath consistent with its env parameter by
passing that env through resolveDataDir in both bootstrap and Electron.

This avoids silently falling back to process.env when a custom env map
is supplied.
2026-03-14 21:33:34 +02:00
Kfir Amar e3a2bd75f3 fix(startup): ignore blank data dir override
Treat empty or whitespace-only dataDirOverride values as unset so
bootstrapEnv keeps using the normal DATA_DIR and .env lookup path.

Adds a focused regression test for the whitespace override case.
2026-03-14 21:29:34 +02:00
Kfir Amar da39e1485f fix(startup): fail closed on key inspection errors
Propagate database inspection failures instead of treating them as
missing encrypted credentials.

This keeps startup from generating a fresh encryption key when an
existing database cannot be inspected and adds a regression test for
that path.
2026-03-14 21:23:07 +02:00
Kfir Amar 88cc53a4b0 fix(startup): honor documented env loading
Align the app bootstrap paths with the documented CLI env lookup.

The CLI wrapper already loads DATA_DIR/.env, ~/.omniroute/.env, or ./.env,
but run-next, run-standalone, and Electron were bypassing that behavior.
On machines with encrypted credentials, that could generate a fresh
STORAGE_ENCRYPTION_KEY in server.env and make existing tokens unreadable.

This change:
- uses the same preferred .env lookup in bootstrapEnv and Electron
- keeps Electron secrets rooted in DATA_DIR and passes DATA_DIR to the child
- refuses to mint a new encryption key over an existing encrypted database
- adds a focused regression test for env precedence and key safety
2026-03-14 21:14:19 +02:00
diegosouzapw 245243c7e7 chore: release v2.5.2 (version bump, npm conflict with 2.5.1)
Build Electron Desktop App / Validate version (push) Failing after 39s
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
2026-03-14 16:01:14 -03:00
diegosouzapw 759ac0df3d chore: release v2.5.1
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
- PR #368: gpt-5.4 in Codex model registry (cx/gpt-5.4, codex/gpt-5.4)
- PR #367: Codex fast tier toggle (default-off, full stack, 48 tests)
- PR #366: Codex quota policy 5h/weekly with auto-rotation
- fix #356: analytics charts show provider display names not raw IDs
2026-03-14 15:55:06 -03:00
Diego Rodrigues de Sa e Souza db8d97b6de Merge pull request #366 from rexname/feature/codex-account-limit-rotation
Thanks @rexname (Maulana Hasanudin)! 🎉 

Codex account quota policy (5h/weekly) with auto-rotation is now merged. Highlights:
- Per-account policy toggles (5h + weekly ON/OFF) in the Provider dashboard
- Accounts automatically skipped when enabled quota window reaches 90% threshold
- Auto re-eligibility when resetAt timestamp passes (no manual intervention needed)
- Side-effect free `getQuotaWindowStatus` getter design
- Safe partial merge of `codexLimitPolicy` on provider updates

Merged on top of main (v2.5.0) with no conflicts. Analytics label fix (#356) included. Thanks for the excellent quality and the 2-commit cleanup round! 🙏
2026-03-14 15:54:07 -03:00
Diego Rodrigues de Sa e Souza 27d66e4b3e Merge pull request #367 from kfiramar/feat-codex-fast-toggle
Thanks @kfiramar! Codex fast-tier toggle merged 🎉 — default-off, full stack (UI tab + API + executor injection + translator passthrough + startup restore). 48 tests passing. Users can now enable flex tier in Dashboard → Settings → Codex Service Tier.
2026-03-14 15:49:56 -03:00
Diego Rodrigues de Sa e Souza ca7854210d Merge pull request #368 from kfiramar/fix-codex-gpt54-models
Thanks @kfiramar! gpt-5.4 is now exposed in the model catalog as `cx/gpt-5.4` and `codex/gpt-5.4`. Minimal, tested fix — merged directly. 🙏
2026-03-14 15:49:54 -03:00
Kfir Amar c009c993c3 fix(codex): persist fast-tier toggle before applying runtime state 2026-03-14 20:48:19 +02:00
Kfir Amar 00188f75ae feat(codex): add fast tier settings toggle
Add a default-off dashboard setting that injects Codex fast service tier only when the request did not already specify one.

Also preserve service_tier through OpenAI-to-Responses translation and restore the setting at startup.
2026-03-14 20:41:49 +02:00
diegosouzapw 4d086542aa fix: getProviderCredentials missing allowedConnections param (#363 TS error)
PR #363 added allowedConnections as 3rd arg in chat.ts calls to
getProviderCredentials(), but the function signature in auth.ts
only declared 2 params. Adding the optional 3rd param and applying
the connection filter when provided.
2026-03-14 15:38:12 -03:00
rexname 1555883633 fix(codex): address PR review feedback for quota policy flow
- add user-facing success/error notifications for Codex limit toggle API calls
- deduplicate Codex policy default normalization in providers page
- make getQuotaWindowStatus side-effect free (no cache mutation in getter)
- avoid stale threshold blocking after resetAt has passed
- extract named Codex quota threshold constant
- extract helper for earliest future reset date selection
2026-03-15 01:35:19 +07:00
Kfir Amar 8f2c0acc7e fix(codex): advertise gpt-5.4 models
Add gpt-5.4 to the Codex model registry so OmniRoute exposes cx/gpt-5.4 and codex/gpt-5.4 in its model catalog.

Includes a focused regression test for model resolution.
2026-03-14 20:33:47 +02:00
rexname 0e30d15c01 feat(codex): add account-level 5h/weekly quota policy and auto-rotation
- add quota window status helper for Codex session (5h) and weekly windows
- enforce policy-based account filtering when enabled windows reach threshold
- return all-rate-limited metadata when no Codex account is eligible
- add per-account dashboard toggles for 5h and weekly policy controls
- merge codexLimitPolicy safely on provider updates to preserve partial settings
- document purpose and usage scenarios in README (EN + ID + i18n note)
2026-03-15 01:33:44 +07:00
diegosouzapw da14390fe0 chore: release v2.5.0
Build Electron Desktop App / Validate version (push) Failing after 37s
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
Includes:
- PR #363: strict-random strategy, API key controls, connection groups, Limits UX (AndersonFirmino)
- PR #365: external pricing sync with LiteLLM 3-tier resolution (Regis-RCR)
- fix #355: stream idle timeout 60s → 300s for thinking models
- fix #350: combo test bypasses REQUIRE_API_KEY via X-Internal-Test header
- fix #346: filter tools with empty function.name before forwarding upstream
2026-03-14 15:31:27 -03:00
diegosouzapw 11c0cff4ef merge: bug fixes for #355 #350 #346 into main 2026-03-14 15:30:36 -03:00
Diego Rodrigues de Sa e Souza e322376996 Merge pull request #363 from AndersonFirmino/feat/strict-random-i18n-ux
Merged! Excellent contribution @AndersonFirmino 🎉

This PR delivers four major improvements:
- **strict-random** strategy — Fisher-Yates shuffle deck with anti-repeat guarantee and mutex serialization for concurrent safety
- **API key controls** — allowedConnections, is_active, accessSchedule, autoResolve
- **Connection groups** — environment-based grouping view in Limits page with localStorage persistence  
- **i18n** — 30 languages fully updated, pt-BR fully translated

655 tests passing. Merged with main (v2.4.4) — no conflicts. Thank you for the exceptional quality!
2026-03-14 15:30:19 -03:00
diegosouzapw 4fbe45f30a fix: stream timeout, combo test auth, and empty tool name (#355 #350 #346)
- fix #355: increase STREAM_IDLE_TIMEOUT_MS from 60s to 300s to prevent
  premature stream abortion for extended-thinking models (claude-opus-4-6,
  o3, etc.) that can pause >60s during reasoning phases. Configurable via
  STREAM_IDLE_TIMEOUT_MS env var.

- fix #350: combo health check test now bypasses REQUIRE_API_KEY=true by
  sending X-Internal-Test header, recognized in chat.ts auth pipeline to
  skip API key validation for internal admin-side combo tests. Also
  extended test timeout from 15s to 20s. Uses OpenAI-compatible format
  universally (not Claude-style).

- fix #346: filter out tools with empty function.name before forwarding
  to upstream providers. Claude Code sends empty-name tool definitions
  that cause '400 Invalid input[N].name: empty string' on OpenAI-compat
  providers. Extends existing message/input empty-name filter.
2026-03-14 15:28:53 -03:00
Diego Rodrigues de Sa e Souza 2cd0f60c3c Merge pull request #365 from Regis-RCR/feat/pricing-sync
Merged via review workflow. Excellent contribution by @Regis-RCR — 3-tier pricing resolution with LiteLLM sync, 23 tests, fully opt-in. Minor improvement noted: dashboard UI for sync status will be added in a follow-up.
2026-03-14 15:23:48 -03:00
diegosouzapw 1b354be827 feat: T07 — API Key Round-Robin per provider connection
Build Electron Desktop App / Validate version (push) Failing after 42s
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
- New: open-sse/services/apiKeyRotator.ts — round-robin rotation
  between primary API key + providerSpecificData.extraApiKeys[]
- Modified: open-sse/executors/base.ts — buildHeaders() rotates key
  using getRotatingApiKey() when extraApiKeys configured
- Modified: open-sse/handlers/chatCore.ts — injects connectionId into
  credentials to enable per-connection rotation index tracking
- Modified: providers/[id]/page.tsx — 'Extra API Keys' UI section in
  EditConnectionModal: add/remove keys, persisted in providerSpecificData

T08 (quota window rolling) and T13 (wildcard model routing) confirmed
already implemented in accountFallback.ts and wildcardRouter.ts.
2026-03-14 15:03:54 -03:00
Regis 7db280ee64 fix(api): address review feedback on pricing sync
- Add .catch() to initial and periodic sync promises (Gemini, Kilo)
- Wrap JSON.parse in try-catch for corrupted DB data (Kilo)
- Wrap response.json() in try-catch for invalid LiteLLM JSON (Kilo)
- Validate PRICING_SYNC_INTERVAL (guard against NaN/0 → tight loop) (Copilot)
- Validate and allowlist sources — reject unknown, prevent empty sync
  from clearing pricing_synced data (Copilot, Kilo)
- Extract merge loop into shared iteration to reduce duplication (Gemini)
- Add data/warnings fields to MCP output schema (Copilot)
- Remove unused z import in vitest (Copilot)
- Filter non-string entries from sources array in API route (Copilot)
- Track active interval for accurate getSyncStatus().nextSync (Copilot)
2026-03-14 19:01:27 +01:00
Regis 192c06cadf feat(api): add external pricing sync with LiteLLM source
Add a 3-tier pricing resolution system: user overrides > synced external > hardcoded defaults.

New files:
- src/lib/pricingSync.ts: sync engine (fetch LiteLLM, transform, store in pricing_synced namespace)
- src/app/api/pricing/sync/route.ts: POST (trigger sync), GET (status), DELETE (clear synced)
- tests/unit/pricing-sync.test.mjs: 12 unit tests for transform logic
- open-sse/mcp-server/__tests__/pricingSync.test.ts: 11 vitest tests for MCP schema

Modified files:
- src/lib/db/settings.ts: getPricing() now merges 3 layers (defaults → synced → user)
- src/server-init.ts: init pricing sync on startup when PRICING_SYNC_ENABLED=true
- src/lib/localDb.ts: re-export pricing sync functions
- open-sse/mcp-server/schemas/tools.ts: add omniroute_sync_pricing tool definition
- open-sse/mcp-server/tools/advancedTools.ts: add handleSyncPricing handler
- open-sse/mcp-server/server.ts: register omniroute_sync_pricing tool

Opt-in (PRICING_SYNC_ENABLED=false by default), user overrides are never touched,
graceful fallback on fetch failure, zero new dependencies.
2026-03-14 18:49:35 +01:00
Anderson Firmino ad7e7abda0 🐛 fix: propagate allowedConnections from API key to credential selection
getProviderCredentials already filtered by allowedConnections, but
chat.ts never passed the field from apiKeyInfo. Now both call sites
(combo pre-check and credential retry loop) forward the restriction.
2026-03-14 14:03:08 -03:00
Anderson Firmino 02ccb35e80 ♻️ refactor: consolidate shuffle deck into shared utility with mutex protection
Fixes race condition in combo strict-random (concurrent requests could
reshuffle simultaneously). Eliminates code duplication between combo.ts
and auth.ts by extracting Fisher-Yates shuffle + deck logic into
src/shared/utils/shuffleDeck.ts with per-namespace mutex serialization.
2026-03-14 14:03:08 -03:00
Anderson Firmino a8a29e17c5 feat: strict-random strategy, API key management, connection groups, Limits UX
- Combo layer: strict-random in combo.ts rotates models uniformly
- Credential layer: strict-random in auth.ts rotates connections/accounts
- Anti-repeat guarantee: last of previous cycle ≠ first of next
- Mutex serialization for concurrent request safety
- Independent decks per combo name and per provider

- allowedConnections: restrict which connections a key can use
- autoResolve: per-key toggle for ambiguous model disambiguation
- is_active: enable/disable key instantly (403 on disabled)
- accessSchedule: time-based access control (hours, days, timezone)
- Rename keys via PATCH /api/keys/:id
- Connection restriction badge in API keys table
- Auto-migration for all new columns

- Connection group field on provider connections
- Environment grouping view in Limits page (group by environment)
- Accordion UI with expand/collapse per group
- localStorage persistence for groupBy, autoRefresh, expandedGroups
- Smart default: auto-switches to environment view when groups exist
- Swap SessionsTab above RateLimitStatus

- strict-random option added to combo strategy dropdown (30 languages)
- strategyGuide.strict-random (when/avoid/example)
- pt-BR: translated all strategyRecommendations from English to Portuguese
- en: added API key management strings (accessSchedule, isActive, etc.)

- 11 tests: shuffle deck mechanics (Fisher-Yates, anti-repeat, decks)
- 6 tests: allowedConnections (schema, DB persistence, cache invalidation)
- 12 tests: API key policy (isActive, accessSchedule, autoResolve, budget)
2026-03-14 14:03:08 -03:00
diegosouzapw 75a6d850fc chore: release v2.4.3
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
- fix: Codex/GitHub limits page HTTP 500 → graceful 401/403 messages
- fix: MaintenanceBanner false-positive on page load (stale closure)
- fix: add title tooltips to edit/delete buttons in ConnectionCard
- feat: add fill-first and p2c routing strategies to combo picker
- feat: Free Stack template pre-fills 7 free provider models
- feat: combo create/edit modal wider (max-w-4xl)
2026-03-14 12:49:36 -03:00
98 changed files with 4639 additions and 303 deletions
+73 -1
View File
@@ -2,7 +2,79 @@
## [Unreleased]
## [2.4.2] - 2026-03-14
## [2.5.3] - 2026-03-14
> Critical bugfixes: DB schema migration, startup env loading, provider error state clearing, and i18n tooltip fix. Code quality improvements on top of each PR.
### 🐛 Bug Fixes (PRs #369, #371, #372, #373 by @kfiramar)
- **fix(db) #373**: Add `provider_connections.group` column to base schema + backfill migration for existing databases — column was used in all queries but missing from schema definition
- **fix(i18n) #371**: Replace non-existent `t("deleteConnection")` key with existing `providers.delete` key — fixes `MISSING_MESSAGE: providers.deleteConnection` runtime error on provider detail page
- **fix(auth) #372**: Clear stale error metadata (`errorCode`, `lastErrorType`, `lastErrorSource`) from provider accounts after genuine recovery — previously, recovered accounts kept appearing as failed
- **fix(startup) #369**: Unify env loading across `npm run start`, `run-standalone.mjs`, and Electron to respect `DATA_DIR/.env → ~/.omniroute/.env → ./.env` priority — prevents generating a new `STORAGE_ENCRYPTION_KEY` over an existing encrypted database
### 🔧 Code Quality
- Documented `result.success` vs `response?.ok` patterns in `auth.ts` (both intentional, now explained)
- Normalized `overridePath?.trim()` in `electron/main.js` to match `bootstrap-env.mjs`
- Added `preferredEnv` merge order comment in Electron startup
> Codex account quota policy with auto-rotation, fast tier toggle, gpt-5.4 model, and analytics label fix.
### ✨ New Features (PRs #366, #367, #368)
- **Codex Quota Policy (PR #366)**: Per-account 5h/weekly quota window toggles in Provider dashboard. Accounts are automatically skipped when enabled windows reach 90% threshold and re-admitted after `resetAt`. Includes `quotaCache.ts` with side-effect free status getter.
- **Codex Fast Tier Toggle (PR #367)**: Dashboard → Settings → Codex Service Tier. Default-off toggle injects `service_tier: "flex"` only for Codex requests, reducing cost ~80%. Full stack: UI tab + API endpoint + executor + translator + startup restore.
- **gpt-5.4 Model (PR #368)**: Adds `cx/gpt-5.4` and `codex/gpt-5.4` to the Codex model registry. Regression test included.
### 🐛 Bug Fixes
- **fix #356**: Analytics charts (Top Provider, By Account, Provider Breakdown) now display human-readable provider names/labels instead of raw internal IDs for OpenAI-compatible providers.
> Major release: strict-random routing strategy, API key access controls, connection groups, external pricing sync, and critical bug fixes for thinking models, combo testing, and tool name validation.
### ✨ New Features (PRs #363 & #365)
- **Strict-Random Routing Strategy**: Fisher-Yates shuffle deck with anti-repeat guarantee and mutex serialization for concurrent requests. Independent decks per combo and per provider.
- **API Key Access Controls**: `allowedConnections` (restrict which connections a key can use), `is_active` (enable/disable key with 403), `accessSchedule` (time-based access control), `autoResolve` toggle, rename keys via PATCH.
- **Connection Groups**: Group provider connections by environment. Accordion view in Limits page with localStorage persistence and smart auto-switch.
- **External Pricing Sync (LiteLLM)**: 3-tier pricing resolution (user overrides → synced → defaults). Opt-in via `PRICING_SYNC_ENABLED=true`. MCP tool `omniroute_sync_pricing`. 23 new tests.
- **i18n**: 30 languages updated with strict-random strategy, API key management strings. pt-BR fully translated.
### 🐛 Bug Fixes
- **fix #355**: Stream idle timeout increased from 60s to 300s — prevents aborting extended-thinking models (claude-opus-4-6, o3, etc.) during long reasoning phases. Configurable via `STREAM_IDLE_TIMEOUT_MS`.
- **fix #350**: Combo test now bypasses `REQUIRE_API_KEY=true` using internal header, and uses OpenAI-compatible format universally. Timeout extended from 15s to 20s.
- **fix #346**: Tools with empty `function.name` (forwarded by Claude Code) are now filtered before upstream providers receive them, preventing "Invalid input[N].name: empty string" errors.
### 🗑️ Closed Issues
- **#341**: Debug section removed — replacement is `/dashboard/logs` and `/dashboard/health`.
> API Key Round-Robin support for multi-key provider setups, and confirmation of wildcard routing and quota window rolling already in place.
### ✨ New Features
- **API Key Round-Robin (T07)**: Provider connections can now hold multiple API keys (Edit Connection → Extra API Keys). Requests rotate round-robin between primary + extra keys via `providerSpecificData.extraApiKeys[]`. Keys are held in-memory indexed per connection — no DB schema changes required.
### 📝 Already Implemented (confirmed in audit)
- **Wildcard Model Routing (T13)**: `wildcardRouter.ts` with glob-style wildcard matching (`gpt*`, `claude-?-sonnet`, etc.) is already integrated into `model.ts` with specificity ranking.
- **Quota Window Rolling (T08)**: `accountFallback.ts:isModelLocked()` already auto-advances the window — if `Date.now() > entry.until`, lock is deleted immediately (no stale blocking).
> UI polish, routing strategy additions, and graceful error handling for usage limits.
### ✨ New Features
- **Fill-First & P2C Routing Strategies**: Added `fill-first` (drain quota before moving on) and `p2c` (Power-of-Two-Choices low-latency selection) to combo strategy picker, with full guidance panels and color-coded badges.
- **Free Stack Preset Models**: Creating a combo with the Free Stack template now auto-fills 7 best-in-class free provider models (Gemini CLI, Kiro, iFlow×2, Qwen, NVIDIA NIM, Groq). Users just activate the providers and get a $0/month combo out-of-the-box.
- **Wider Combo Modal**: Create/Edit combo modal now uses `max-w-4xl` for comfortable editing of large combos.
### 🐛 Bug Fixes
- **Limits page HTTP 500 for Codex & GitHub**: `getCodexUsage()` and `getGitHubUsage()` now return a user-friendly message when the provider returns 401/403 (expired token), instead of throwing and causing a 500 error on the Limits page.
- **MaintenanceBanner false-positive**: Banner no longer shows "Server is unreachable" spuriously on page load. Fixed by calling `checkHealth()` immediately on mount and removing stale `show`-state closure.
- **Provider icon tooltips**: Edit (pencil) and delete icon buttons in the provider connection row now have native HTML tooltips — all 6 action icons are now self-documented.
> Multiple improvements from community issue analysis, new provider support, bug fixes for token tracking, model routing, and streaming reliability.
+17
View File
@@ -1292,6 +1292,23 @@ Models:
cx/gpt-5.1-codex-max
```
#### Codex Account Limit Management (5h + Weekly)
Each Codex account now has policy toggles in `Dashboard -> Providers`:
- `5h` (ON/OFF): enforce the 5-hour window threshold policy.
- `Weekly` (ON/OFF): enforce the weekly window threshold policy.
- Threshold behavior: when an enabled window reaches >=90% usage, that account is skipped.
- Rotation behavior: OmniRoute routes to the next eligible Codex account automatically.
- Reset behavior: when the provider `resetAt` time passes, the account becomes eligible again automatically.
Scenarios:
- `5h ON` + `Weekly ON`: account is skipped when either window reaches threshold.
- `5h OFF` + `Weekly ON`: only weekly usage can block the account.
- `5h ON` + `Weekly OFF`: only 5-hour usage can block the account.
- `resetAt` passed: account re-enters rotation automatically (no manual re-enable).
### Gemini CLI (FREE 180K/month!)
```bash
+9
View File
@@ -9,4 +9,13 @@ This directory contains machine-assisted translations based on the English docs.
- **TROUBLESHOOTING.md**: 🇺🇸 [English](../TROUBLESHOOTING.md) | 🇧🇷 [Português (Brasil)](./pt-BR/TROUBLESHOOTING.md) | 🇪🇸 [Español](./es/TROUBLESHOOTING.md) | 🇫🇷 [Français](./fr/TROUBLESHOOTING.md) | 🇮🇹 [Italiano](./it/TROUBLESHOOTING.md) | 🇷🇺 [Русский](./ru/TROUBLESHOOTING.md) | 🇨🇳 [中文 (简体)](./zh-CN/TROUBLESHOOTING.md) | 🇩🇪 [Deutsch](./de/TROUBLESHOOTING.md) | 🇮🇳 [हिन्दी](./in/TROUBLESHOOTING.md) | 🇹🇭 [ไทย](./th/TROUBLESHOOTING.md) | 🇺🇦 [Українська](./uk-UA/TROUBLESHOOTING.md) | 🇸🇦 [العربية](./ar/TROUBLESHOOTING.md) | 🇯🇵 [日本語](./ja/TROUBLESHOOTING.md) | 🇻🇳 [Tiếng Việt](./vi/TROUBLESHOOTING.md) | 🇧🇬 [Български](./bg/TROUBLESHOOTING.md) | 🇩🇰 [Dansk](./da/TROUBLESHOOTING.md) | 🇫🇮 [Suomi](./fi/TROUBLESHOOTING.md) | 🇮🇱 [עברית](./he/TROUBLESHOOTING.md) | 🇭🇺 [Magyar](./hu/TROUBLESHOOTING.md) | 🇮🇩 [Bahasa Indonesia](./id/TROUBLESHOOTING.md) | 🇰🇷 [한국어](./ko/TROUBLESHOOTING.md) | 🇲🇾 [Bahasa Melayu](./ms/TROUBLESHOOTING.md) | 🇳🇱 [Nederlands](./nl/TROUBLESHOOTING.md) | 🇳🇴 [Norsk](./no/TROUBLESHOOTING.md) | 🇵🇹 [Português (Portugal)](./pt/TROUBLESHOOTING.md) | 🇷🇴 [Română](./ro/TROUBLESHOOTING.md) | 🇵🇱 [Polski](./pl/TROUBLESHOOTING.md) | 🇸🇰 [Slovenčina](./sk/TROUBLESHOOTING.md) | 🇸🇪 [Svenska](./sv/TROUBLESHOOTING.md) | 🇵🇭 [Filipino](./phi/TROUBLESHOOTING.md)
- **USER_GUIDE.md**: 🇺🇸 [English](../USER_GUIDE.md) | 🇧🇷 [Português (Brasil)](./pt-BR/USER_GUIDE.md) | 🇪🇸 [Español](./es/USER_GUIDE.md) | 🇫🇷 [Français](./fr/USER_GUIDE.md) | 🇮🇹 [Italiano](./it/USER_GUIDE.md) | 🇷🇺 [Русский](./ru/USER_GUIDE.md) | 🇨🇳 [中文 (简体)](./zh-CN/USER_GUIDE.md) | 🇩🇪 [Deutsch](./de/USER_GUIDE.md) | 🇮🇳 [हिन्दी](./in/USER_GUIDE.md) | 🇹🇭 [ไทย](./th/USER_GUIDE.md) | 🇺🇦 [Українська](./uk-UA/USER_GUIDE.md) | 🇸🇦 [العربية](./ar/USER_GUIDE.md) | 🇯🇵 [日本語](./ja/USER_GUIDE.md) | 🇻🇳 [Tiếng Việt](./vi/USER_GUIDE.md) | 🇧🇬 [Български](./bg/USER_GUIDE.md) | 🇩🇰 [Dansk](./da/USER_GUIDE.md) | 🇫🇮 [Suomi](./fi/USER_GUIDE.md) | 🇮🇱 [עברית](./he/USER_GUIDE.md) | 🇭🇺 [Magyar](./hu/USER_GUIDE.md) | 🇮🇩 [Bahasa Indonesia](./id/USER_GUIDE.md) | 🇰🇷 [한국어](./ko/USER_GUIDE.md) | 🇲🇾 [Bahasa Melayu](./ms/USER_GUIDE.md) | 🇳🇱 [Nederlands](./nl/USER_GUIDE.md) | 🇳🇴 [Norsk](./no/USER_GUIDE.md) | 🇵🇹 [Português (Portugal)](./pt/USER_GUIDE.md) | 🇷🇴 [Română](./ro/USER_GUIDE.md) | 🇵🇱 [Polski](./pl/USER_GUIDE.md) | 🇸🇰 [Slovenčina](./sk/USER_GUIDE.md) | 🇸🇪 [Svenska](./sv/USER_GUIDE.md) | 🇵🇭 [Filipino](./phi/USER_GUIDE.md)
## Recent note: Codex account limit policy
Documentation now includes Codex account-level quota policy behavior:
- Per-account toggles: `5h` and `Weekly` (ON/OFF).
- Threshold policy: enabled window reaching >=90% marks account as ineligible for selection.
- Auto-rotation: traffic moves to the next eligible Codex account.
- Auto-reuse: account becomes eligible again after provider `resetAt` passes.
Generated on 2026-02-26.
+17
View File
@@ -1059,6 +1059,23 @@ Models:
cx/gpt-5.1-codex-max
```
#### Manajemen Limit Akun Codex (5h + Mingguan)
Setiap akun Codex sekarang punya toggle kebijakan di `Dashboard -> Providers`:
- `5h` (ON/OFF): menerapkan kebijakan ambang untuk jendela 5 jam.
- `Weekly` (ON/OFF): menerapkan kebijakan ambang untuk jendela mingguan.
- Perilaku ambang: saat jendela yang aktif mencapai >=90% penggunaan, akun tersebut di-skip.
- Perilaku rotasi: OmniRoute otomatis merutekan ke akun Codex berikutnya yang masih eligible.
- Perilaku reset: saat waktu `resetAt` provider sudah lewat, akun otomatis bisa dipakai lagi.
Skenario:
- `5h ON` + `Weekly ON`: akun di-skip jika salah satu jendela mencapai ambang.
- `5h OFF` + `Weekly ON`: hanya penggunaan mingguan yang bisa memblokir akun.
- `5h ON` + `Weekly OFF`: hanya penggunaan 5 jam yang bisa memblokir akun.
- `resetAt` sudah lewat: akun otomatis masuk rotasi lagi (tanpa enable manual).
### Gemini CLI (GRATIS 180K/bulan!)
```bash
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 2.4.2
version: 2.5.3
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,
+77 -6
View File
@@ -64,6 +64,64 @@ let serverPort = 20128;
const getServerUrl = () => `http://localhost:${serverPort}`;
function resolveDataDir(overridePath, env = process.env) {
if (overridePath && overridePath.trim()) return path.resolve(overridePath);
const configured = env.DATA_DIR?.trim();
if (configured) return path.resolve(configured);
if (process.platform === "win32") {
const appData = env.APPDATA || path.join(require("os").homedir(), "AppData", "Roaming");
return path.join(appData, "omniroute");
}
const xdg = env.XDG_CONFIG_HOME?.trim();
if (xdg) return path.join(path.resolve(xdg), "omniroute");
return path.join(require("os").homedir(), ".omniroute");
}
function getPreferredEnvFilePath(env = process.env) {
const candidates = [];
if (env.DATA_DIR?.trim()) {
candidates.push(path.join(path.resolve(env.DATA_DIR.trim()), ".env"));
}
candidates.push(path.join(resolveDataDir(null, env), ".env"));
candidates.push(path.join(process.cwd(), ".env"));
return candidates.find((filePath) => fs.existsSync(filePath)) || null;
}
function hasEncryptedCredentials(dbPath) {
if (!fs.existsSync(dbPath)) return false;
try {
const Database = require("better-sqlite3");
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const row = db
.prepare(
`SELECT 1
FROM provider_connections
WHERE access_token LIKE 'enc:v1:%'
OR refresh_token LIKE 'enc:v1:%'
OR api_key LIKE 'enc:v1:%'
OR id_token LIKE 'enc:v1:%'
LIMIT 1`
)
.get();
return !!row;
} finally {
db.close();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
}
}
// ── Auto-Updater Configuration ──────────────────────────────
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
@@ -386,12 +444,10 @@ function startNextServer() {
// ── Zero-config bootstrap: auto-generate required secrets ─────────────────
// Electron uses CJS — cannot dynamically import ESM bootstrap-env.mjs.
// This mirrors bootstrap-env.mjs logic synchronously:
// 1. Read persisted secrets from userData/server.env
// 1. Read persisted secrets from the resolved DATA_DIR/server.env
// 2. Generate missing secrets with crypto.randomBytes()
// 3. Persist back to userData/server.env for future restarts
// 3. Persist back to DATA_DIR/server.env for future restarts
const crypto = require("crypto");
const userDataDir = app.getPath("userData");
const serverEnvPath = path.join(userDataDir, "server.env");
// Parse a simple KEY=VALUE file
function parseEnvFile(filePath) {
@@ -407,8 +463,12 @@ function startNextServer() {
return env;
}
const preferredEnvPath = getPreferredEnvFilePath(process.env);
const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {};
const dataDir = resolveDataDir(null, { ...preferredEnv, ...process.env });
const serverEnvPath = path.join(dataDir, "server.env");
const persisted = parseEnvFile(serverEnvPath);
const serverEnv = { ...process.env, ...persisted };
const serverEnv = { ...persisted, ...preferredEnv, ...process.env };
let changed = false;
if (!serverEnv.JWT_SECRET) {
@@ -417,6 +477,16 @@ function startNextServer() {
console.log("[Electron] ✨ JWT_SECRET auto-generated");
}
if (!serverEnv.STORAGE_ENCRYPTION_KEY) {
if (hasEncryptedCredentials(path.join(dataDir, "storage.sqlite"))) {
console.error(
`[Electron] Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${path.join(
dataDir,
"storage.sqlite"
)}. Restore the key via ${preferredEnvPath || "an appropriate .env file"}, ${serverEnvPath}, or process.env.`
);
sendToRenderer("server-status", { status: "error", port: serverPort });
return;
}
serverEnv.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY = crypto
.randomBytes(32)
.toString("hex");
@@ -432,7 +502,7 @@ function startNextServer() {
if (changed) {
serverEnv.OMNIROUTE_BOOTSTRAPPED = "true";
try {
fs.mkdirSync(userDataDir, { recursive: true });
fs.mkdirSync(dataDir, { recursive: true });
const lines = [
"# Auto-generated by OmniRoute bootstrap",
"",
@@ -454,6 +524,7 @@ function startNextServer() {
cwd: NEXT_SERVER_PATH,
env: {
...serverEnv,
DATA_DIR: dataDir,
PORT: String(serverPort),
NODE_ENV: "production",
},
+3 -1
View File
@@ -4,7 +4,9 @@ import { loadProviderCredentials } from "./credentialLoader.ts";
export const FETCH_TIMEOUT_MS = parseInt(process.env.FETCH_TIMEOUT_MS || "120000", 10);
// Idle timeout for SSE streams (ms). Closes stream if no data for this duration.
export const STREAM_IDLE_TIMEOUT_MS = parseInt(process.env.STREAM_IDLE_TIMEOUT_MS || "60000", 10);
// Default: 300s to support extended-thinking models (claude-opus-4-6, o3, etc.)
// that may pause for >60s during deep reasoning phases. Override with STREAM_IDLE_TIMEOUT_MS env var.
export const STREAM_IDLE_TIMEOUT_MS = parseInt(process.env.STREAM_IDLE_TIMEOUT_MS || "300000", 10);
// Provider configurations
// OAuth credentials read from env vars with hardcoded fallbacks for backward compatibility.
+1
View File
@@ -186,6 +186,7 @@ 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.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)" },
+10 -1
View File
@@ -1,5 +1,6 @@
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
import { getRotatingApiKey } from "../services/apiKeyRotator.ts";
type JsonRecord = Record<string, unknown>;
@@ -23,6 +24,7 @@ export type ProviderCredentials = {
refreshToken?: string;
apiKey?: string;
expiresAt?: string;
connectionId?: string; // T07: used for API key rotation index
providerSpecificData?: JsonRecord;
};
@@ -131,7 +133,14 @@ export class BaseExecutor {
if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
} else if (credentials.apiKey) {
headers["Authorization"] = `Bearer ${credentials.apiKey}`;
// T07: rotate between primary + extra API keys when extraApiKeys is configured
const extraKeys =
(credentials.providerSpecificData?.extraApiKeys as string[] | undefined) ?? [];
const effectiveKey =
extraKeys.length > 0 && credentials.connectionId
? getRotatingApiKey(credentials.connectionId, credentials.apiKey, extraKeys)
: credentials.apiKey;
headers["Authorization"] = `Bearer ${effectiveKey}`;
}
if (stream) {
+21
View File
@@ -6,6 +6,20 @@ import { refreshCodexToken } from "../services/tokenRefresh.ts";
// Ordered list of effort levels from lowest to highest
const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh"] as const;
type EffortLevel = (typeof EFFORT_ORDER)[number];
const CODEX_FAST_WIRE_VALUE = "priority";
let defaultFastServiceTierEnabled = false;
function normalizeServiceTierValue(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
if (!normalized) return undefined;
if (normalized === "fast") return CODEX_FAST_WIRE_VALUE;
return normalized;
}
export function setDefaultFastServiceTierEnabled(enabled: boolean): void {
defaultFastServiceTierEnabled = enabled;
}
/**
* Maximum reasoning effort allowed per Codex model.
@@ -103,6 +117,13 @@ export class CodexExecutor extends BaseExecutor {
// Ensure store is false (Codex requirement)
body.store = false;
const requestServiceTier = normalizeServiceTierValue(body.service_tier);
if (requestServiceTier) {
body.service_tier = requestServiceTier;
} else if (defaultFastServiceTierEnabled) {
body.service_tier = CODEX_FAST_WIRE_VALUE;
}
// Extract thinking level from model name suffix
// e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default)
const effortLevels = ["none", "low", "medium", "high", "xhigh"];
+16
View File
@@ -94,6 +94,12 @@ export async function handleChatCore({
// Initialize rate limit settings from persisted DB (once, lazy)
await initializeRateLimits();
// T07: Inject connectionId into credentials so executors can rotate API keys
// using providerSpecificData.extraApiKeys (API Key Round-Robin feature)
if (connectionId && credentials && !credentials.connectionId) {
credentials.connectionId = connectionId;
}
const sourceFormat = detectFormat(body);
const endpointPath = (clientRawRequest?.endpoint || "").toLowerCase();
const isResponsesEndpoint = endpointPath.endsWith("/responses");
@@ -186,6 +192,16 @@ export async function handleChatCore({
return item;
});
}
// ── #346: Strip tools with empty function.name ──
// Claude Code sometimes forwards tool definitions with empty names, causing
// OpenAI-compatible upstream providers to reject with:
// "Invalid 'input[N].name': empty string. Expected minimum length 1."
if (Array.isArray(body.tools)) {
body.tools = body.tools.filter((tool: Record<string, unknown>) => {
const fn = tool.function as Record<string, unknown> | undefined;
return fn?.name && String(fn.name).trim().length > 0;
});
}
translatedBody = translateRequest(
sourceFormat,
@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { syncPricingInput, syncPricingTool, MCP_TOOLS, MCP_TOOL_MAP } from "../schemas/tools.ts";
describe("omniroute_sync_pricing MCP tool schema", () => {
it("should be registered in MCP_TOOLS", () => {
const tool = MCP_TOOLS.find((t) => t.name === "omniroute_sync_pricing");
expect(tool).toBeDefined();
expect(tool?.phase).toBe(2);
});
it("should be in MCP_TOOL_MAP", () => {
expect(MCP_TOOL_MAP["omniroute_sync_pricing"]).toBeDefined();
});
it("should require pricing:write scope", () => {
expect(syncPricingTool.scopes).toContain("pricing:write");
});
it("should have full audit level", () => {
expect(syncPricingTool.auditLevel).toBe("full");
});
it("should validate empty input (all fields optional)", () => {
const result = syncPricingInput.safeParse({});
expect(result.success).toBe(true);
});
it("should validate input with sources array", () => {
const result = syncPricingInput.safeParse({ sources: ["litellm"] });
expect(result.success).toBe(true);
});
it("should validate input with dryRun", () => {
const result = syncPricingInput.safeParse({ dryRun: true });
expect(result.success).toBe(true);
});
it("should validate full input", () => {
const result = syncPricingInput.safeParse({
sources: ["litellm"],
dryRun: false,
});
expect(result.success).toBe(true);
});
it("should reject invalid sources type", () => {
const result = syncPricingInput.safeParse({ sources: "litellm" });
expect(result.success).toBe(false);
});
it("should reject invalid dryRun type", () => {
const result = syncPricingInput.safeParse({ dryRun: "yes" });
expect(result.success).toBe(false);
});
it("should point to correct source endpoint", () => {
expect(syncPricingTool.sourceEndpoints).toContain("/api/pricing/sync");
});
});
+37
View File
@@ -723,6 +723,42 @@ export const getSessionSnapshotTool: McpToolDefinition<
sourceEndpoints: ["/api/usage/analytics", "/api/telemetry/summary"],
};
// --- Tool 17: omniroute_sync_pricing ---
export const syncPricingInput = z.object({
sources: z
.array(z.string())
.optional()
.describe("External pricing sources to sync from (default: ['litellm'])"),
dryRun: z
.boolean()
.optional()
.describe("If true, preview sync results without saving to database"),
});
export const syncPricingOutput = z.object({
success: z.boolean(),
modelCount: z.number(),
providerCount: z.number(),
source: z.string(),
dryRun: z.boolean(),
error: z.string().optional(),
warnings: z.array(z.string()).optional(),
data: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
});
export const syncPricingTool: McpToolDefinition<typeof syncPricingInput, typeof syncPricingOutput> =
{
name: "omniroute_sync_pricing",
description:
"Syncs pricing data from external sources (LiteLLM) into OmniRoute. Synced pricing fills gaps not covered by hardcoded defaults without overwriting user-set prices. Use dryRun=true to preview.",
inputSchema: syncPricingInput,
outputSchema: syncPricingOutput,
scopes: ["pricing:write"],
auditLevel: "full",
phase: 2,
sourceEndpoints: ["/api/pricing/sync"],
};
// ============ Tool Registry ============
/** All MCP tool definitions, ordered by phase then name */
@@ -745,6 +781,7 @@ export const MCP_TOOLS = [
bestComboForTaskTool,
explainRouteTool,
getSessionSnapshotTool,
syncPricingTool,
] as const;
/** Essential tools only (Phase 1) */
+14
View File
@@ -31,6 +31,7 @@ import {
bestComboForTaskInput,
explainRouteInput,
getSessionSnapshotInput,
syncPricingInput,
} from "./schemas/tools.ts";
import { startMcpHeartbeat } from "./runtimeHeartbeat.ts";
@@ -50,6 +51,7 @@ import {
handleBestComboForTask,
handleExplainRoute,
handleGetSessionSnapshot,
handleSyncPricing,
} from "./tools/advancedTools.ts";
import { normalizeQuotaResponse } from "../../src/shared/contracts/quota.ts";
@@ -664,6 +666,18 @@ export function createMcpServer(): McpServer {
})
);
server.registerTool(
"omniroute_sync_pricing",
{
description:
"Syncs pricing data from external sources (LiteLLM) into OmniRoute without overwriting user-set prices",
inputSchema: syncPricingInput,
},
withScopeEnforcement("omniroute_sync_pricing", (args) =>
handleSyncPricing(syncPricingInput.parse(args))
)
);
return server;
}
@@ -678,6 +678,28 @@ export async function handleExplainRoute(args: { requestId: string }) {
}
}
export async function handleSyncPricing(args: { sources?: string[]; dryRun?: boolean }) {
const start = Date.now();
try {
const result = toRecord(
await apiFetch("/api/pricing/sync", {
method: "POST",
body: JSON.stringify({
sources: args.sources,
dryRun: args.dryRun ?? false,
}),
})
);
await logToolCall("omniroute_sync_pricing", args, result, Date.now() - start, true);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await logToolCall("omniroute_sync_pricing", args, null, Date.now() - start, false, msg);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
}
export async function handleGetSessionSnapshot() {
const start = Date.now();
try {
+63
View File
@@ -0,0 +1,63 @@
/**
* apiKeyRotator.ts — T07: API Key Round-Robin
*
* Rotates between a primary API key and extra API keys stored in
* providerSpecificData.extraApiKeys[]. Uses round-robin by default.
*
* Extra keys are stored as plain strings in providerSpecificData.extraApiKeys.
* Example: { extraApiKeys: ["sk-abc...", "sk-def...", "sk-ghi..."] }
*
* The in-memory rotation index resets on process restart, which is intentional —
* it ensures even distribution across restarts without persistence overhead.
*/
// In-memory round-robin index per connection
const _keyIndexes = new Map<string, number>();
/**
* Get the next API key in round-robin rotation for a given connection.
* If no extra keys are configured, returns the primary key unchanged.
*
* @param connectionId - Unique connection identifier (for index isolation)
* @param primaryKey - The main api_key from the connection
* @param extraKeys - Additional API keys from providerSpecificData.extraApiKeys
* @returns The selected API key (may be primary or one of the extras)
*/
export function getRotatingApiKey(
connectionId: string,
primaryKey: string,
extraKeys: string[] = []
): string {
const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0);
// Only 1 key available → no rotation needed
if (validExtras.length === 0) return primaryKey;
const allKeys = [primaryKey, ...validExtras].filter(Boolean);
if (allKeys.length <= 1) return primaryKey;
const current = _keyIndexes.get(connectionId) ?? 0;
const idx = current % allKeys.length;
_keyIndexes.set(connectionId, current + 1);
return allKeys[idx];
}
/**
* Reset the rotation index for a connection.
* Call this when a key fails (401/403) to skip the bad key next time.
*
* @param connectionId - Connection to reset
*/
export function resetRotationIndex(connectionId: string): void {
_keyIndexes.delete(connectionId);
}
/**
* Get the total number of API keys available for a connection.
* Used for logging/observability.
*/
export function getApiKeyCount(primaryKey: string, extraKeys: string[] = []): number {
const validExtras = extraKeys.filter((k) => typeof k === "string" && k.trim().length > 0);
return (primaryKey ? 1 : 0) + validExtras.length;
}
+14 -14
View File
@@ -9,6 +9,7 @@ import { recordComboRequest, getComboMetrics } from "./comboMetrics.ts";
import { resolveComboConfig, getDefaultComboConfig } from "./comboConfig.ts";
import * as semaphore from "./rateLimitSemaphore.ts";
import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker";
import { fisherYatesShuffle, getNextFromDeck } from "../../src/shared/utils/shuffleDeck";
import { parseModel } from "./model.ts";
// Status codes that should mark semaphore + record circuit breaker failures
@@ -150,18 +151,8 @@ function orderModelsForWeightedFallback(models, selectedModel) {
return [selected, ...rest].filter(Boolean).map((e) => e.model);
}
/**
* Fisher-Yates shuffle (in-place)
* @param {Array} arr
* @returns {Array} The shuffled array
*/
function shuffleArray(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// shuffleArray and getNextModelFromDeck moved to src/shared/utils/shuffleDeck.ts
// combo.ts now uses the shared, mutex-protected getNextFromDeck with "combo:" namespace.
/**
* Sort models by pricing (cheapest first) for cost-optimized strategy
@@ -287,8 +278,17 @@ export async function handleComboChat({
}
// Apply strategy-specific ordering
if (strategy === "random") {
orderedModels = shuffleArray([...orderedModels]);
if (strategy === "strict-random") {
const selectedId = await getNextFromDeck(`combo:${combo.name}`, orderedModels);
// Put selected model first so the fallback loop tries it first
const rest = orderedModels.filter((m) => m !== selectedId);
orderedModels = [selectedId, ...rest];
log.info(
"COMBO",
`Strict-random deck: ${selectedId} selected (${orderedModels.length} models)`
);
} else if (strategy === "random") {
orderedModels = fisherYatesShuffle([...orderedModels]);
log.info("COMBO", `Random shuffle: ${orderedModels.length} models`);
} else if (strategy === "least-used") {
orderedModels = sortModelsByUsage(orderedModels, combo.name);
+10
View File
@@ -161,6 +161,11 @@ async function getGitHubUsage(accessToken, providerSpecificData) {
if (!response.ok) {
const error = await response.text();
if (response.status === 401 || response.status === 403) {
return {
message: `GitHub token expired or permission denied. Please re-authenticate the connection.`,
};
}
throw new Error(`GitHub API error: ${error}`);
}
@@ -620,6 +625,11 @@ async function getCodexUsage(accessToken, providerSpecificData: Record<string, u
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
return {
message: `Codex token expired or access denied. Please re-authenticate the connection.`,
};
}
throw new Error(`Codex API error: ${response.status}`);
}
@@ -363,6 +363,7 @@ export function openaiToOpenAIResponsesRequest(
}
// Pass through relevant fields
if (root.service_tier !== undefined) result.service_tier = root.service_tier;
if (root.temperature !== undefined) result.temperature = root.temperature;
if (root.max_tokens !== undefined) result.max_tokens = root.max_tokens;
if (root.top_p !== undefined) result.top_p = root.top_p;
+3 -2
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "2.4.1",
"version": "2.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "2.4.1",
"version": "2.5.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -6866,6 +6866,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "2.4.2",
"version": "2.5.3",
"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": {
+68 -16
View File
@@ -7,22 +7,25 @@
* restarts, Docker volume remounts, and upgrades.
*
* Works across all deployment modes:
* - npm / CLI: called from run-standalone.mjs and run-next.mjs
* - npm / app runners: called from run-standalone.mjs and run-next.mjs
* - Docker: same, secrets persisted in mounted volume
* - Electron: called from main.js startup, persisted in userData
* - Electron: called from main.js startup, persisted in DATA_DIR
*
* Priority (lowest highest):
* 1. Auto-generated defaults
* 2. {DATA_DIR}/server.env (persisted on first boot)
* 3. .env in CWD (user overrides)
* 3. Preferred config .env (DATA_DIR/.env -> ~/.omniroute/.env -> ./.env)
* 4. process.env (shell / Docker -e flags, highest priority)
*/
import { createHash, randomBytes } from "node:crypto";
import { randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
const require = createRequire(import.meta.url);
// ── OAuth secrets that are optional but warn if missing ─────────────────────
const OPTIONAL_OAUTH_SECRETS = [
{ key: "ANTIGRAVITY_OAUTH_CLIENT_SECRET", label: "Antigravity OAuth" },
@@ -31,23 +34,65 @@ const OPTIONAL_OAUTH_SECRETS = [
];
// ── Resolve DATA_DIR (mirrors dataPaths.ts logic) ───────────────────────────
function resolveDataDir(overridePath) {
if (overridePath) return resolve(overridePath);
function resolveDataDir(overridePath, env = process.env) {
if (overridePath?.trim()) return resolve(overridePath);
const configured = process.env.DATA_DIR?.trim();
const configured = env.DATA_DIR?.trim();
if (configured) return resolve(configured);
if (process.platform === "win32") {
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
const appData = env.APPDATA || join(homedir(), "AppData", "Roaming");
return join(appData, "omniroute");
}
const xdg = process.env.XDG_CONFIG_HOME?.trim();
const xdg = env.XDG_CONFIG_HOME?.trim();
if (xdg) return join(resolve(xdg), "omniroute");
return join(homedir(), ".omniroute");
}
function getPreferredEnvFilePath(env = process.env) {
const candidates = [];
if (env.DATA_DIR?.trim()) {
candidates.push(join(resolve(env.DATA_DIR.trim()), ".env"));
}
candidates.push(join(resolveDataDir(null, env), ".env"));
candidates.push(join(process.cwd(), ".env"));
return candidates.find((filePath) => existsSync(filePath)) ?? null;
}
function hasEncryptedCredentials(dataDir) {
const dbPath = join(dataDir, "storage.sqlite");
if (!existsSync(dbPath)) return false;
try {
const Database = require("better-sqlite3");
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const row = db
.prepare(
`SELECT 1
FROM provider_connections
WHERE access_token LIKE 'enc:v1:%'
OR refresh_token LIKE 'enc:v1:%'
OR api_key LIKE 'enc:v1:%'
OR id_token LIKE 'enc:v1:%'
LIMIT 1`
)
.get();
return !!row;
} finally {
db.close();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
}
}
// ── Parse a simple KEY=VALUE env file ───────────────────────────────────────
function parseEnvFile(filePath) {
if (!existsSync(filePath)) return {};
@@ -85,18 +130,17 @@ function writeEnvFile(filePath, env) {
export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
const log = quiet ? () => {} : (msg) => process.stderr.write(`[bootstrap] ${msg}\n`);
const dataDir = resolveDataDir(dataDirOverride);
const preferredEnvPath = getPreferredEnvFilePath(process.env);
const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {};
const dataDir = resolveDataDir(dataDirOverride, { ...preferredEnv, ...process.env });
const serverEnvPath = join(dataDir, "server.env");
const dotEnvPath = join(process.cwd(), ".env");
// ── Layer 1: Load persisted server.env ────────────────────────────────────
let persisted = parseEnvFile(serverEnvPath);
// ── Layer 2: Load .env from CWD (user overrides, higher priority) ─────────
const dotEnv = parseEnvFile(dotEnvPath);
// ── Merge: persisted < .env < process.env ─────────────────────────────────
const merged = { ...persisted, ...dotEnv, ...process.env };
// ── Layer 2: Load the same preferred .env that the CLI wrapper uses ───────
// This keeps run-next / run-standalone consistent with `bin/omniroute.mjs`.
const merged = { ...persisted, ...preferredEnv, ...process.env };
// ── Auto-generate required secrets ────────────────────────────────────────
let needsPersist = false;
@@ -109,6 +153,14 @@ export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
}
if (!merged.STORAGE_ENCRYPTION_KEY?.trim()) {
if (hasEncryptedCredentials(dataDir)) {
throw new Error(
`Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${join(
dataDir,
"storage.sqlite"
)}. Restore the key via ${preferredEnvPath ?? "an appropriate .env file"}, ${serverEnvPath}, or process.env.`
);
}
persisted.STORAGE_ENCRYPTION_KEY = randomBytes(32).toString("hex");
merged.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY;
needsPersist = true;
@@ -52,15 +52,34 @@ function validateKeyName(
return { valid: true };
}
interface AccessSchedule {
enabled: boolean;
from: string;
until: string;
days: number[];
tz: string;
}
interface ApiKey {
id: string;
name: string;
key: string;
allowedModels: string[] | null;
allowedConnections: string[] | null;
noLog?: boolean;
autoResolve?: boolean;
isActive?: boolean;
accessSchedule?: AccessSchedule | null;
createdAt: string;
}
interface ProviderConnection {
id: string;
name: string;
provider: string;
isActive: boolean;
}
interface KeyUsageStats {
totalRequests: number;
lastUsed: string | null;
@@ -79,6 +98,7 @@ export default function ApiManagerPageClient() {
const tc = useTranslations("common");
const [keys, setKeys] = useState<ApiKey[]>([]);
const [allModels, setAllModels] = useState<Model[]>([]);
const [allConnections, setAllConnections] = useState<ProviderConnection[]>([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
@@ -95,6 +115,7 @@ export default function ApiManagerPageClient() {
useEffect(() => {
fetchData();
fetchModels();
fetchConnections();
}, []);
const fetchModels = async () => {
@@ -109,6 +130,18 @@ export default function ApiManagerPageClient() {
}
};
const fetchConnections = async () => {
try {
const res = await fetch("/api/providers");
if (res.ok) {
const data = await res.json();
setAllConnections(data.connections || []);
}
} catch (error) {
console.log("Error fetching connections:", error);
}
};
const fetchData = async () => {
try {
const res = await fetch("/api/keys");
@@ -227,7 +260,14 @@ export default function ApiManagerPageClient() {
setShowPermissionsModal(true);
};
const handleUpdatePermissions = async (allowedModels: string[], noLog: boolean) => {
const handleUpdatePermissions = async (
allowedModels: string[],
noLog: boolean,
allowedConnections: string[],
autoResolve: boolean,
isActive: boolean,
accessSchedule: AccessSchedule | null
) => {
if (!editingKey || !editingKey.id) return;
// Validate models array
@@ -247,6 +287,11 @@ export default function ApiManagerPageClient() {
(id) => typeof id === "string" && id.length > 0 && id.length < 200
);
// Validate connections (must be UUIDs)
const validConnections = allowedConnections.filter(
(id) => typeof id === "string" && /^[0-9a-f-]{36}$/i.test(id)
);
setIsSubmitting(true);
clearError();
@@ -254,7 +299,14 @@ export default function ApiManagerPageClient() {
const res = await fetch(`/api/keys/${encodeURIComponent(editingKey.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ allowedModels: validModels, noLog }),
body: JSON.stringify({
allowedModels: validModels,
allowedConnections: validConnections,
noLog,
autoResolve,
isActive,
accessSchedule,
}),
});
if (res.ok) {
@@ -449,7 +501,11 @@ export default function ApiManagerPageClient() {
{keys.map((key) => {
const stats = usageStats[key.id];
const isRestricted = Array.isArray(key.allowedModels) && key.allowedModels.length > 0;
const hasConnectionRestrictions =
Array.isArray(key.allowedConnections) && key.allowedConnections.length > 0;
const noLogEnabled = key.noLog === true;
const keyIsActive = key.isActive !== false; // default true
const hasSchedule = key.accessSchedule?.enabled === true;
return (
<div
key={key.id}
@@ -496,6 +552,15 @@ export default function ApiManagerPageClient() {
{t("allModels")}
</button>
)}
{hasConnectionRestrictions && (
<button
onClick={() => handleOpenPermissions(key)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-400 text-xs font-medium hover:bg-blue-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">cable</span>
{key.allowedConnections.length} conn
</button>
)}
{noLogEnabled && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-violet-500/10 text-violet-600 dark:text-violet-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">
@@ -504,6 +569,26 @@ export default function ApiManagerPageClient() {
No-Log
</span>
)}
{key.autoResolve && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">
auto_fix_high
</span>
Auto-Resolve
</span>
)}
{!keyIsActive && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-red-500/10 text-red-600 dark:text-red-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">block</span>
{t("disabled")}
</span>
)}
{hasSchedule && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-orange-500/10 text-orange-600 dark:text-orange-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">schedule</span>
{t("scheduleActive")}
</span>
)}
</div>
</div>
<div className="col-span-2 flex flex-col justify-center">
@@ -659,6 +744,7 @@ export default function ApiManagerPageClient() {
apiKey={editingKey}
modelsByProvider={filteredModelsByProvider}
allModels={allModels}
allConnections={allConnections}
searchModel={searchModel}
onSearchChange={setSearchModel}
onSave={handleUpdatePermissions}
@@ -676,6 +762,7 @@ const PermissionsModal = memo(function PermissionsModal({
apiKey,
modelsByProvider,
allModels,
allConnections,
searchModel,
onSearchChange,
onSave,
@@ -685,18 +772,42 @@ const PermissionsModal = memo(function PermissionsModal({
apiKey: ApiKey;
modelsByProvider: ProviderGroup[];
allModels: Model[];
allConnections: ProviderConnection[];
searchModel: string;
onSearchChange: (v: string) => void;
onSave: (models: string[], noLog: boolean) => void;
onSave: (
models: string[],
noLog: boolean,
connections: string[],
autoResolve: boolean,
isActive: boolean,
accessSchedule: AccessSchedule | null
) => void;
}) {
const t = useTranslations("apiManager");
const tc = useTranslations("common");
// Initialize state from props - component remounts when key prop changes
const initialModels = Array.isArray(apiKey?.allowedModels) ? apiKey.allowedModels : [];
const initialConnections = Array.isArray(apiKey?.allowedConnections)
? apiKey.allowedConnections
: [];
const [selectedModels, setSelectedModels] = useState<string[]>(initialModels);
const [allowAll, setAllowAll] = useState(initialModels.length === 0);
const [noLogEnabled, setNoLogEnabled] = useState(apiKey?.noLog === true);
const [autoResolveEnabled, setAutoResolveEnabled] = useState(apiKey?.autoResolve === true);
const [keyIsActive, setKeyIsActive] = useState(apiKey?.isActive !== false);
const [scheduleEnabled, setScheduleEnabled] = useState(apiKey?.accessSchedule?.enabled === true);
const [scheduleFrom, setScheduleFrom] = useState(apiKey?.accessSchedule?.from ?? "08:00");
const [scheduleUntil, setScheduleUntil] = useState(apiKey?.accessSchedule?.until ?? "18:00");
const [scheduleDays, setScheduleDays] = useState<number[]>(
apiKey?.accessSchedule?.days ?? [1, 2, 3, 4, 5]
);
const [scheduleTz, setScheduleTz] = useState(
apiKey?.accessSchedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone
);
const [selectedConnections, setSelectedConnections] = useState<string[]>(initialConnections);
const [allowAllConnections, setAllowAllConnections] = useState(initialConnections.length === 0);
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(() => {
// Expand all providers by default when in restrict mode with existing selections
if (initialModels.length > 0) {
@@ -769,9 +880,51 @@ const PermissionsModal = memo(function PermissionsModal({
setSelectedModels([]);
}, []);
const handleToggleConnection = useCallback(
(connectionId: string) => {
if (allowAllConnections) return;
setSelectedConnections((prev) =>
prev.includes(connectionId)
? prev.filter((c) => c !== connectionId)
: [...prev, connectionId]
);
},
[allowAllConnections]
);
const handleSave = useCallback(() => {
onSave(allowAll ? [] : selectedModels, noLogEnabled);
}, [onSave, allowAll, selectedModels, noLogEnabled]);
const schedule: AccessSchedule | null = scheduleEnabled
? {
enabled: true,
from: scheduleFrom,
until: scheduleUntil,
days: scheduleDays,
tz: scheduleTz,
}
: null;
onSave(
allowAll ? [] : selectedModels,
noLogEnabled,
allowAllConnections ? [] : selectedConnections,
autoResolveEnabled,
keyIsActive,
schedule
);
}, [
onSave,
allowAll,
selectedModels,
noLogEnabled,
allowAllConnections,
selectedConnections,
autoResolveEnabled,
keyIsActive,
scheduleEnabled,
scheduleFrom,
scheduleUntil,
scheduleDays,
scheduleTz,
]);
const selectedCount = selectedModels.length;
const totalModels = allModels.length;
@@ -833,6 +986,129 @@ const PermissionsModal = memo(function PermissionsModal({
</p>
</div>
{/* Key Active Toggle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("keyActive")}</p>
<p className="text-xs text-text-muted">{t("keyActiveDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={keyIsActive}
onClick={() => setKeyIsActive((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
keyIsActive
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30"
: "bg-red-500/15 text-red-700 dark:text-red-300 border border-red-500/30"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{keyIsActive ? "check_circle" : "block"}
</span>
{keyIsActive ? tc("enabled") : tc("disabled")}
</button>
</div>
{/* Access Schedule */}
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("accessSchedule")}</p>
<p className="text-xs text-text-muted">{t("accessScheduleDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={scheduleEnabled}
onClick={() => setScheduleEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors shrink-0 ${
scheduleEnabled
? "bg-orange-500/15 text-orange-700 dark:text-orange-300 border border-orange-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">schedule</span>
{scheduleEnabled ? tc("enabled") : tc("disabled")}
</button>
</div>
{scheduleEnabled && (
<div className="flex flex-col gap-3 pt-1">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-text-muted mb-1 block">{t("scheduleFrom")}</label>
<input
type="time"
value={scheduleFrom}
onChange={(e) => setScheduleFrom(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">{t("scheduleUntil")}</label>
<input
type="time"
value={scheduleUntil}
onChange={(e) => setScheduleUntil(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main"
/>
</div>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block">{t("scheduleDays")}</label>
<div className="flex gap-1 flex-wrap">
{(
[
[0, t("daySun")],
[1, t("dayMon")],
[2, t("dayTue")],
[3, t("dayWed")],
[4, t("dayThu")],
[5, t("dayFri")],
[6, t("daySat")],
] as [number, string][]
).map(([dayIdx, label]) => {
const selected = scheduleDays.includes(dayIdx);
return (
<button
key={dayIdx}
type="button"
onClick={() =>
setScheduleDays((prev) =>
prev.includes(dayIdx)
? prev.filter((d) => d !== dayIdx)
: [...prev, dayIdx].sort((a, b) => a - b)
)
}
className={`px-2 py-1 text-[11px] font-medium rounded transition-all ${
selected
? "bg-primary text-white"
: "bg-surface border border-border text-text-muted hover:border-primary/50"
}`}
>
{label}
</button>
);
})}
</div>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">
{t("scheduleTimezone")}
</label>
<input
type="text"
value={scheduleTz}
onChange={(e) => setScheduleTz(e.target.value)}
placeholder="America/Sao_Paulo"
className="w-full px-2 py-1.5 text-sm border border-border rounded-md bg-background text-text-main font-mono"
/>
<p className="text-[10px] text-text-muted mt-1">{t("scheduleTimezoneHint")}</p>
</div>
</div>
)}
</div>
{/* Privacy Toggle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
@@ -859,6 +1135,30 @@ const PermissionsModal = memo(function PermissionsModal({
</button>
</div>
{/* Auto-Resolve Toggle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("autoResolve")}</p>
<p className="text-xs text-text-muted">{t("autoResolveDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={autoResolveEnabled}
onClick={() => setAutoResolveEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
autoResolveEnabled
? "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border border-cyan-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{autoResolveEnabled ? "auto_fix_high" : "auto_fix_normal"}
</span>
{autoResolveEnabled ? tc("enabled") : tc("disabled")}
</button>
</div>
{/* Selected Models Summary (only in restrict mode) */}
{!allowAll && selectedCount > 0 && (
<div className="flex flex-col gap-1.5 p-2 bg-primary/5 rounded-lg border border-primary/20">
@@ -1024,6 +1324,97 @@ const PermissionsModal = memo(function PermissionsModal({
</>
)}
{/* Allowed Connections Section */}
{allConnections.length > 0 && (
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-text-main">Allowed Connections</p>
<div className="flex gap-1 p-0.5 bg-surface rounded-md">
<button
onClick={() => {
setAllowAllConnections(true);
setSelectedConnections([]);
}}
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
allowAllConnections
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
All
</button>
<button
onClick={() => setAllowAllConnections(false)}
className={`px-2 py-1 rounded text-xs font-medium transition-all ${
!allowAllConnections
? "bg-primary text-white"
: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
Restrict
</button>
</div>
</div>
<p className="text-xs text-text-muted">
{allowAllConnections
? "This key can use any active connection."
: `Restricted to ${selectedConnections.length} connection${selectedConnections.length !== 1 ? "s" : ""}.`}
</p>
{!allowAllConnections && (
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
{Object.entries(
allConnections.reduce<Record<string, ProviderConnection[]>>((acc, conn) => {
const p = conn.provider || "Other";
if (!acc[p]) acc[p] = [];
acc[p].push(conn);
return acc;
}, {})
)
.sort(([a], [b]) => a.localeCompare(b))
.map(([provider, conns]) => (
<div key={provider}>
<p className="text-[10px] font-semibold text-text-muted uppercase tracking-wider px-1 py-0.5">
{provider}
</p>
{conns.map((conn) => {
const isSelected = selectedConnections.includes(conn.id);
return (
<button
key={conn.id}
onClick={() => handleToggleConnection(conn.id)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs transition-all ${
isSelected
? "bg-primary/10 text-primary"
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
}`}
>
<div
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
isSelected ? "bg-primary border-primary" : "border-border"
}`}
>
{isSelected && (
<span className="material-symbols-outlined text-white text-[10px]">
check
</span>
)}
</div>
<span className="truncate flex-1">
{conn.name || conn.id.slice(0, 8)}
</span>
{!conn.isActive && (
<span className="text-[9px] text-red-400 shrink-0">inactive</span>
)}
</button>
);
})}
</div>
))}
</div>
)}
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Button onClick={handleSave} fullWidth>
+73 -1
View File
@@ -27,6 +27,14 @@ const STRATEGY_OPTIONS = [
{ value: "random", labelKey: "random", descKey: "randomDesc", icon: "shuffle" },
{ value: "least-used", labelKey: "leastUsed", descKey: "leastUsedDesc", icon: "low_priority" },
{ value: "cost-optimized", labelKey: "costOpt", descKey: "costOptimizedDesc", icon: "savings" },
{
value: "fill-first",
labelKey: "fillFirst",
descKey: "fillFirstDesc",
icon: "stacked_bar_chart",
},
{ value: "p2c", labelKey: "p2c", descKey: "p2cDesc", icon: "compare_arrows" },
{ value: "strict-random", labelKey: "strictRandom", descKey: "strictRandomDesc", icon: "casino" },
];
const STRATEGY_GUIDANCE_FALLBACK = {
@@ -60,6 +68,21 @@ const STRATEGY_GUIDANCE_FALLBACK = {
avoid: "Avoid when pricing data is missing or outdated.",
example: "Example: Batch or background jobs where lower cost matters most.",
},
"fill-first": {
when: "Use when you want to drain one provider's quota fully before moving to the next.",
avoid: "Avoid when you need request-level load balancing across providers.",
example: "Example: Use all $200 Deepgram credits before falling to Groq.",
},
p2c: {
when: "Use when you want low-latency selection using Power-of-Two-Choices algorithm.",
avoid: "Avoid for small combos with 2 or fewer models — no benefit over round-robin.",
example: "Example: High-throughput inference across 4+ equivalent model endpoints.",
},
"strict-random": {
when: "Use when you want perfectly even spread — each model used once before repeating.",
avoid: "Avoid when models have different quality or latency and order matters.",
example: "Example: Multiple accounts of the same model to distribute usage evenly.",
},
};
const ADVANCED_FIELD_HELP_FALLBACK = {
@@ -126,6 +149,34 @@ const STRATEGY_RECOMMENDATIONS_FALLBACK = {
"Use for batch/background jobs where cost is the main KPI.",
],
},
"fill-first": {
title: "Quota drain strategy",
description: "Exhausts one provider's quota before moving to the next in chain.",
tips: [
"Order models by free quota size — biggest first.",
"Enable health checks to skip drained providers.",
"Ideal for free-tier stacking (Deepgram → Groq → NIM).",
],
},
p2c: {
title: "Power-of-Two-Choices",
description:
"Picks the less-loaded of two random candidates per request — low latency at scale.",
tips: [
"Use with 4+ models for best effect.",
"Requires latency telemetry enabled in Settings.",
"Great replacement for round-robin in high-throughput combos.",
],
},
"strict-random": {
title: "Shuffle deck distribution",
description: "Each model is used exactly once per cycle before reshuffling.",
tips: [
"Use at least 2 models for meaningful distribution.",
"Ideal for same-model accounts to evenly spread quota.",
"Guarantees no model is skipped or repeated within a cycle.",
],
},
};
const COMBO_USAGE_GUIDE_STORAGE_KEY = "omniroute:combos:hide-usage-guide";
@@ -227,6 +278,8 @@ function getStrategyBadgeClass(strategy) {
if (strategy === "random") return "bg-purple-500/15 text-purple-600 dark:text-purple-400";
if (strategy === "least-used") return "bg-cyan-500/15 text-cyan-600 dark:text-cyan-400";
if (strategy === "cost-optimized") return "bg-teal-500/15 text-teal-600 dark:text-teal-400";
if (strategy === "fill-first") return "bg-orange-500/15 text-orange-600 dark:text-orange-400";
if (strategy === "p2c") return "bg-indigo-500/15 text-indigo-600 dark:text-indigo-400";
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
}
@@ -1365,10 +1418,24 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
);
};
const FREE_STACK_PRESET_MODELS = [
{ model: "gc/gemini-3-flash-preview", weight: 0 },
{ model: "kr/claude-sonnet-4.5", weight: 0 },
{ model: "if/kimi-k2-thinking", weight: 0 },
{ model: "if/qwen3-coder-plus", weight: 0 },
{ model: "qw/qwen3-coder-plus", weight: 0 },
{ model: "nvidia/llama-3.3-70b-instruct", weight: 0 },
{ model: "groq/llama-3.3-70b-versatile", weight: 0 },
];
const applyTemplate = (template) => {
setStrategy(template.strategy);
setConfig((prev) => ({ ...prev, ...template.config }));
if (!name.trim()) setName(template.suggestedName);
// Pre-fill Free Stack with 7 real free provider models
if (template.id === "free-stack") {
setModels(FREE_STACK_PRESET_MODELS);
}
};
// Format model display name with readable provider name
@@ -1473,7 +1540,12 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
return (
<>
<Modal isOpen={isOpen} onClose={onClose} title={isEdit ? t("editCombo") : t("createCombo")}>
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEdit ? t("editCombo") : t("createCombo")}
size="full"
>
<div className="flex flex-col gap-3">
{/* Name */}
<div>
@@ -12,8 +12,8 @@ export default function LimitsPage() {
<Suspense fallback={<CardSkeleton />}>
<ProviderLimits />
</Suspense>
<RateLimitStatus />
<SessionsTab />
<RateLimitStatus />
</div>
);
}
@@ -32,6 +32,17 @@ import {
import { getModelsByProviderId } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } {
const record =
policy && typeof policy === "object" && !Array.isArray(policy)
? (policy as Record<string, unknown>)
: {};
return {
use5h: typeof record.use5h === "boolean" ? record.use5h : true,
useWeekly: typeof record.useWeekly === "boolean" ? record.useWeekly : true,
};
}
export default function ProviderDetailPage() {
const params = useParams();
const router = useRouter();
@@ -49,6 +60,7 @@ export default function ProviderDetailPage() {
const [headerImgError, setHeaderImgError] = useState(false);
const { copied, copy } = useCopyToClipboard();
const t = useTranslations("providers");
const notify = useNotificationStore();
const hasAutoOpened = useRef(false);
const userDismissed = useRef(false);
const [proxyTarget, setProxyTarget] = useState(null);
@@ -311,6 +323,63 @@ export default function ProviderDetailPage() {
}
};
const handleToggleCodexLimit = async (connectionId, field, enabled) => {
try {
const target = connections.find((connection) => connection.id === connectionId);
if (!target) return;
const providerSpecificData =
target.providerSpecificData && typeof target.providerSpecificData === "object"
? target.providerSpecificData
: {};
const existingPolicy =
providerSpecificData.codexLimitPolicy &&
typeof providerSpecificData.codexLimitPolicy === "object"
? providerSpecificData.codexLimitPolicy
: {};
const nextPolicy = {
...normalizeCodexLimitPolicy(existingPolicy),
[field]: enabled,
};
const res = await fetch(`/api/providers/${connectionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerSpecificData: {
...providerSpecificData,
codexLimitPolicy: nextPolicy,
},
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
notify.error(data.error || "Failed to update Codex limit policy");
return;
}
setConnections((prev) =>
prev.map((connection) =>
connection.id === connectionId
? {
...connection,
providerSpecificData: {
...(connection.providerSpecificData || {}),
codexLimitPolicy: nextPolicy,
},
}
: connection
)
);
notify.success("Codex limit policy updated");
} catch (error) {
console.error("Error toggling Codex quota policy:", error);
notify.error("Failed to update Codex limit policy");
}
};
const handleRetestConnection = async (connectionId) => {
if (!connectionId || retestingId) return;
setRetestingId(connectionId);
@@ -331,7 +400,6 @@ export default function ProviderDetailPage() {
// T12: Manual token refresh
const [refreshingId, setRefreshingId] = useState<string | null>(null);
const notify = useNotificationStore();
const handleRefreshToken = async (connectionId: string) => {
if (refreshingId) return;
setRefreshingId(connectionId);
@@ -941,6 +1009,11 @@ export default function ProviderDetailPage() {
onMoveDown={() => handleSwapPriority(conn, connections[index + 1])}
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
isCodex={providerId === "codex"}
onToggleCodex5h={(enabled) => handleToggleCodexLimit(conn.id, "use5h", enabled)}
onToggleCodexWeekly={(enabled) =>
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
}
onRetest={() => handleRetestConnection(conn.id)}
isRetesting={retestingId === conn.id}
onEdit={() => {
@@ -2175,12 +2248,15 @@ function getStatusPresentation(connection, effectiveStatus, isCooldown, t) {
function ConnectionRow({
connection,
isOAuth,
isCodex,
isFirst,
isLast,
onMoveUp,
onMoveDown,
onToggleActive,
onToggleRateLimit,
onToggleCodex5h,
onToggleCodexWeekly,
onRetest,
isRetesting,
onEdit,
@@ -2242,6 +2318,16 @@ function ConnectionRow({
const statusPresentation = getStatusPresentation(connection, effectiveStatus, isCooldown, t);
const rateLimitEnabled = !!connection.rateLimitProtection;
const codexPolicy =
connection.providerSpecificData &&
typeof connection.providerSpecificData === "object" &&
connection.providerSpecificData.codexLimitPolicy &&
typeof connection.providerSpecificData.codexLimitPolicy === "object"
? connection.providerSpecificData.codexLimitPolicy
: {};
const normalizedCodexPolicy = normalizeCodexLimitPolicy(codexPolicy);
const codex5hEnabled = normalizedCodexPolicy.use5h;
const codexWeeklyEnabled = normalizedCodexPolicy.useWeekly;
return (
<div
@@ -2331,6 +2417,35 @@ function ConnectionRow({
<span className="material-symbols-outlined text-[13px]">shield</span>
{rateLimitEnabled ? t("rateLimitProtected") : t("rateLimitUnprotected")}
</button>
{isCodex && (
<>
<span className="text-text-muted/30 select-none">|</span>
<button
onClick={() => onToggleCodex5h?.(!codex5hEnabled)}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
codex5hEnabled
? "bg-blue-500/15 text-blue-500 hover:bg-blue-500/25"
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
}`}
title="Toggle Codex 5h limit policy"
>
<span className="material-symbols-outlined text-[13px]">timer</span>
5h {codex5hEnabled ? "ON" : "OFF"}
</button>
<button
onClick={() => onToggleCodexWeekly?.(!codexWeeklyEnabled)}
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
codexWeeklyEnabled
? "bg-violet-500/15 text-violet-500 hover:bg-violet-500/25"
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
}`}
title="Toggle Codex weekly limit policy"
>
<span className="material-symbols-outlined text-[13px]">date_range</span>
Weekly {codexWeeklyEnabled ? "ON" : "OFF"}
</button>
</>
)}
{hasProxy &&
(() => {
const colorClass =
@@ -2411,6 +2526,7 @@ function ConnectionRow({
<button
onClick={onEdit}
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary"
title={t("edit")}
>
<span className="material-symbols-outlined text-[18px]">edit</span>
</button>
@@ -2421,7 +2537,11 @@ function ConnectionRow({
>
<span className="material-symbols-outlined text-[18px]">vpn_lock</span>
</button>
<button onClick={onDelete} className="p-2 hover:bg-red-500/10 rounded text-red-500">
<button
onClick={onDelete}
className="p-2 hover:bg-red-500/10 rounded text-red-500"
title={t("delete")}
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
</div>
@@ -2446,14 +2566,18 @@ ConnectionRow.propTypes = {
lastErrorSource: PropTypes.string,
errorCode: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
globalPriority: PropTypes.number,
providerSpecificData: PropTypes.object,
}).isRequired,
isOAuth: PropTypes.bool.isRequired,
isCodex: PropTypes.bool,
isFirst: PropTypes.bool.isRequired,
isLast: PropTypes.bool.isRequired,
onMoveUp: PropTypes.func.isRequired,
onMoveDown: PropTypes.func.isRequired,
onToggleActive: PropTypes.func.isRequired,
onToggleRateLimit: PropTypes.func.isRequired,
onToggleCodex5h: PropTypes.func,
onToggleCodexWeekly: PropTypes.func,
onRetest: PropTypes.func.isRequired,
isRetesting: PropTypes.bool,
onEdit: PropTypes.func.isRequired,
@@ -2644,6 +2768,8 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [saving, setSaving] = useState(false);
const [extraApiKeys, setExtraApiKeys] = useState<string[]>([]);
const [newExtraKey, setNewExtraKey] = useState("");
useEffect(() => {
if (connection) {
@@ -2653,6 +2779,10 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
apiKey: "",
healthCheckInterval: connection.healthCheckInterval ?? 60,
});
// Load existing extra keys from providerSpecificData
const existing = connection.providerSpecificData?.extraApiKeys;
setExtraApiKeys(Array.isArray(existing) ? existing : []);
setNewExtraKey("");
setTestResult(null);
setValidationResult(null);
}
@@ -2739,6 +2869,13 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
updates.rateLimitedUntil = null;
}
}
// Persist extra API keys in providerSpecificData
if (!isOAuth) {
updates.providerSpecificData = {
...(connection.providerSpecificData || {}),
extraApiKeys: extraApiKeys.filter((k) => k.trim().length > 0),
};
}
await onSave(updates);
} finally {
setSaving(false);
@@ -2823,6 +2960,68 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
</>
)}
{/* T07: Extra API Keys for round-robin rotation */}
{!isOAuth && (
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-text-main">
Extra API Keys
<span className="ml-2 text-[11px] font-normal text-text-muted">
(round-robin rotation optional)
</span>
</label>
{extraApiKeys.length > 0 && (
<div className="flex flex-col gap-1.5">
{extraApiKeys.map((key, idx) => (
<div key={idx} className="flex items-center gap-2">
<span className="flex-1 font-mono text-xs bg-sidebar/50 px-3 py-2 rounded border border-border text-text-muted truncate">
{`Key #${idx + 2}: ${key.slice(0, 6)}...${key.slice(-4)}`}
</span>
<button
onClick={() => setExtraApiKeys(extraApiKeys.filter((_, i) => i !== idx))}
className="p-1.5 rounded hover:bg-red-500/10 text-red-400 hover:text-red-500"
title="Remove this key"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
))}
</div>
)}
<div className="flex gap-2">
<input
type="password"
value={newExtraKey}
onChange={(e) => setNewExtraKey(e.target.value)}
placeholder="Add another API key..."
className="flex-1 text-sm bg-sidebar/50 border border-border rounded px-3 py-2 text-text-main placeholder:text-text-muted focus:ring-1 focus:ring-primary outline-none"
onKeyDown={(e) => {
if (e.key === "Enter" && newExtraKey.trim()) {
setExtraApiKeys([...extraApiKeys, newExtraKey.trim()]);
setNewExtraKey("");
}
}}
/>
<button
onClick={() => {
if (newExtraKey.trim()) {
setExtraApiKeys([...extraApiKeys, newExtraKey.trim()]);
setNewExtraKey("");
}
}}
disabled={!newExtraKey.trim()}
className="px-3 py-2 rounded bg-primary/10 text-primary hover:bg-primary/20 disabled:opacity-40 text-sm font-medium"
>
Add
</button>
</div>
{extraApiKeys.length > 0 && (
<p className="text-[11px] text-text-muted">
{extraApiKeys.length + 1} keys total rotating round-robin on each request.
</p>
)}
</div>
)}
{/* Test Connection */}
{!isCompatible && (
<div className="flex items-center gap-3">
@@ -0,0 +1,100 @@
"use client";
import { useEffect, useState } from "react";
import { Card } from "@/shared/components";
export default function CodexServiceTierTab() {
const [enabled, setEnabled] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<"" | "saved" | "error">("");
useEffect(() => {
fetch("/api/settings/codex-service-tier")
.then((res) => res.json())
.then((data) => {
setEnabled(Boolean(data.enabled));
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
const save = async (nextEnabled: boolean) => {
setEnabled(nextEnabled);
setSaving(true);
setStatus("");
try {
const res = await fetch("/api/settings/codex-service-tier", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: nextEnabled }),
});
if (res.ok) {
setStatus("saved");
setTimeout(() => setStatus(""), 2000);
} else {
setStatus("error");
setEnabled(!nextEnabled);
}
} catch {
setStatus("error");
setEnabled(!nextEnabled);
} finally {
setSaving(false);
}
};
return (
<Card>
<div className="flex items-center gap-3 mb-5">
<div className="p-2 rounded-lg bg-sky-500/10 text-sky-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
bolt
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">Codex Fast Service Tier</h3>
<p className="text-sm text-text-muted">
Inject `service_tier=fast` into Codex requests when the client leaves it unset.
</p>
</div>
{status === "saved" && (
<span className="text-xs font-medium text-emerald-500 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">check_circle</span>
Saved
</span>
)}
{status === "error" && (
<span className="text-xs font-medium text-rose-500 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">error</span>
Failed to save
</span>
)}
</div>
<div className="flex items-center justify-between p-4 rounded-lg bg-surface/30 border border-border/30">
<div>
<p className="text-sm font-medium">Force fast tier for Codex</p>
<p className="text-xs text-text-muted mt-0.5">
Off by default. Applies only to Codex requests and does not override an explicit tier.
</p>
</div>
<button
onClick={() => save(!enabled)}
disabled={loading || saving}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? "bg-sky-500" : "bg-white/10"
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
enabled ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
</Card>
);
}
@@ -12,6 +12,7 @@ import ComboDefaultsTab from "./components/ComboDefaultsTab";
import ProxyTab from "./components/ProxyTab";
import AppearanceTab from "./components/AppearanceTab";
import ThinkingBudgetTab from "./components/ThinkingBudgetTab";
import CodexServiceTierTab from "./components/CodexServiceTierTab";
import SystemPromptTab from "./components/SystemPromptTab";
import ModelAliasesTab from "./components/ModelAliasesTab";
import BackgroundDegradationTab from "./components/BackgroundDegradationTab";
@@ -85,6 +86,7 @@ export default function SettingsPage() {
{activeTab === "ai" && (
<div className="flex flex-col gap-6">
<ThinkingBudgetTab />
<CodexServiceTierTab />
<SystemPromptTab />
<CacheStatsCard />
</div>
@@ -10,6 +10,10 @@ import Badge from "@/shared/components/Badge";
import { CardSkeleton } from "@/shared/components/Loading";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
const LS_GROUP_BY = "omniroute:limits:groupBy";
const LS_AUTO_REFRESH = "omniroute:limits:autoRefresh";
const LS_EXPANDED_GROUPS = "omniroute:limits:expandedGroups";
const REFRESH_INTERVAL_MS = 120000;
const MIN_FETCH_INTERVAL_MS = 30000; // Debounce per-connection fetches
@@ -20,6 +24,7 @@ const PROVIDER_CONFIG = {
kiro: { label: "Kiro AI", color: "#FF6B35" },
codex: { label: "OpenAI Codex", color: "#10A37F" },
claude: { label: "Claude Code", color: "#D97757" },
glm: { label: "GLM (Z.AI)", color: "#4A90D9" },
"kimi-coding": { label: "Kimi Coding", color: "#1E3A8A" },
};
@@ -89,12 +94,30 @@ export default function ProviderLimits() {
const [quotaData, setQuotaData] = useState({});
const [loading, setLoading] = useState({});
const [errors, setErrors] = useState({});
const [autoRefresh, setAutoRefresh] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem(LS_AUTO_REFRESH) === "true";
});
const [lastUpdated, setLastUpdated] = useState(null);
const [refreshingAll, setRefreshingAll] = useState(false);
const [countdown, setCountdown] = useState(120);
const [initialLoading, setInitialLoading] = useState(true);
const [tierFilter, setTierFilter] = useState("all");
const [groupBy, setGroupBy] = useState<"none" | "environment">(() => {
if (typeof window === "undefined") return "none";
const saved = localStorage.getItem(LS_GROUP_BY);
if (saved === "environment" || saved === "none") return saved;
return "none";
});
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {
if (typeof window === "undefined") return new Set();
try {
const saved = localStorage.getItem(LS_EXPANDED_GROUPS);
return saved ? new Set(JSON.parse(saved)) : new Set();
} catch {
return new Set();
}
});
const intervalRef = useRef(null);
const countdownRef = useRef(null);
@@ -175,10 +198,12 @@ export default function ProviderLimits() {
setCountdown(120);
try {
const conns = await fetchConnections();
const oauthConnections = conns.filter(
(conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
const usageConnections = conns.filter(
(conn) =>
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
(conn.authType === "oauth" || conn.authType === "apikey")
);
await Promise.all(oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider)));
await Promise.all(usageConnections.map((conn) => fetchQuota(conn.id, conn.provider)));
setLastUpdated(new Date());
} catch (error) {
console.error("Error refreshing all:", error);
@@ -231,13 +256,23 @@ export default function ProviderLimits() {
const filteredConnections = useMemo(
() =>
connections.filter(
(conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
(conn) =>
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) &&
(conn.authType === "oauth" || conn.authType === "apikey")
),
[connections]
);
const sortedConnections = useMemo(() => {
const priority = { antigravity: 1, github: 2, codex: 3, claude: 4, kiro: 5, "kimi-coding": 6 };
const priority = {
antigravity: 1,
github: 2,
codex: 3,
claude: 4,
kiro: 5,
glm: 6,
"kimi-coding": 7,
};
return [...filteredConnections].sort(
(a, b) => (priority[a.provider] || 9) - (priority[b.provider] || 9)
);
@@ -276,6 +311,50 @@ export default function ProviderLimits() {
);
}, [sortedConnections, tierByConnection, tierFilter]);
const groupedConnections = useMemo(() => {
if (groupBy !== "environment") return null;
const groups = new Map();
for (const conn of visibleConnections) {
const key = conn.group || t("ungrouped");
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(conn);
}
return groups;
}, [groupBy, visibleConnections, t]);
const handleSetGroupBy = (value: "none" | "environment") => {
setGroupBy(value);
localStorage.setItem(LS_GROUP_BY, value);
};
const toggleGroup = (groupName: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
next.has(groupName) ? next.delete(groupName) : next.add(groupName);
localStorage.setItem(LS_EXPANDED_GROUPS, JSON.stringify([...next]));
return next;
});
};
// Default inteligente: se não há preferência salva e há connections com grupo, abre em Por Ambiente
useEffect(() => {
if (typeof window === "undefined") return;
const hasSaved = localStorage.getItem(LS_GROUP_BY) !== null;
if (!hasSaved && connections.some((c) => c.group)) {
setGroupBy("environment");
}
}, [connections]);
// Quando entra em modo environment pela primeira vez sem estado salvo, abre todos os grupos
useEffect(() => {
if (groupBy !== "environment" || !groupedConnections) return;
if (expandedGroups.size === 0) {
const allGroups = new Set([...groupedConnections.keys()]);
setExpandedGroups(allGroups);
localStorage.setItem(LS_EXPANDED_GROUPS, JSON.stringify([...allGroups]));
}
}, [groupBy, groupedConnections]); // eslint-disable-line react-hooks/exhaustive-deps
if (initialLoading) {
return (
<div className="flex flex-col gap-4">
@@ -313,8 +392,37 @@ export default function ProviderLimits() {
</div>
<div className="flex items-center gap-2">
{/* Group by toggle */}
<div className="flex rounded-lg border border-white/[0.08] overflow-hidden">
<button
onClick={() => handleSetGroupBy("none")}
className="px-2.5 py-1.5 text-[12px] font-medium cursor-pointer border-none"
style={{
background: groupBy === "none" ? "rgba(255,255,255,0.1)" : "transparent",
color: groupBy === "none" ? "var(--text-main)" : "var(--text-muted)",
}}
>
{t("viewFlat")}
</button>
<button
onClick={() => handleSetGroupBy("environment")}
className="px-2.5 py-1.5 text-[12px] font-medium cursor-pointer border-none border-l border-white/[0.08]"
style={{
background: groupBy === "environment" ? "rgba(255,255,255,0.1)" : "transparent",
color: groupBy === "environment" ? "var(--text-main)" : "var(--text-muted)",
borderLeft: "1px solid rgba(255,255,255,0.08)",
}}
>
{t("viewByEnvironment")}
</button>
</div>
<button
onClick={() => setAutoRefresh((p) => !p)}
onClick={() => {
const next = !autoRefresh;
setAutoRefresh(next);
localStorage.setItem(LS_AUTO_REFRESH, String(next));
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-white/[0.08] bg-transparent cursor-pointer text-text-main text-[13px]"
>
<span
@@ -382,157 +490,196 @@ export default function ProviderLimits() {
<div className="text-center">{t("actions")}</div>
</div>
{visibleConnections.map((conn, idx) => {
const quota = quotaData[conn.id];
const isLoading = loading[conn.id];
const error = errors[conn.id];
const config = PROVIDER_CONFIG[conn.provider] || { label: conn.provider, color: "#666" };
const tierMeta = tierByConnection[conn.id] || normalizePlanTier(null);
{(() => {
const renderRow = (conn, isLast) => {
const quota = quotaData[conn.id];
const isLoading = loading[conn.id];
const error = errors[conn.id];
const config = PROVIDER_CONFIG[conn.provider] || {
label: conn.provider,
color: "#666",
};
const tierMeta = tierByConnection[conn.id] || normalizePlanTier(null);
return (
<div
key={conn.id}
className="items-center px-4 py-3.5 transition-[background] duration-150 hover:bg-white/[0.02]"
style={{
display: "grid",
gridTemplateColumns: "280px 1fr 100px 48px",
borderBottom:
idx < visibleConnections.length - 1 ? "1px solid rgba(255,255,255,0.04)" : "none",
}}
>
{/* Account Info */}
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-lg flex items-center justify-center overflow-hidden shrink-0">
<Image
src={`/providers/${conn.provider}.png`}
alt={conn.provider}
width={32}
height={32}
className="object-contain"
sizes="32px"
/>
</div>
<div className="min-w-0">
<div className="text-[13px] font-semibold text-text-main truncate">
{conn.name || config.label}
return (
<div
key={conn.id}
className="items-center px-4 py-3.5 transition-[background] duration-150 hover:bg-white/[0.02]"
style={{
display: "grid",
gridTemplateColumns: "280px 1fr 100px 48px",
borderBottom: !isLast ? "1px solid rgba(255,255,255,0.04)" : "none",
}}
>
{/* Account Info */}
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-lg flex items-center justify-center overflow-hidden shrink-0">
<Image
src={`/providers/${conn.provider}.png`}
alt={conn.provider}
width={32}
height={32}
className="object-contain"
sizes="32px"
/>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span
title={
quota?.plan
? t("rawPlanWithValue", { plan: quota.plan })
: t("noPlanFromProvider")
}
>
<Badge variant={tierMeta.variant} size="sm" dot>
{tierMeta.label}
</Badge>
</span>
<span className="text-[11px] text-text-muted">{config.label}</span>
<div className="min-w-0">
<div className="text-[13px] font-semibold text-text-main truncate">
{conn.name || config.label}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span
title={
quota?.plan
? t("rawPlanWithValue", { plan: quota.plan })
: t("noPlanFromProvider")
}
>
<Badge variant={tierMeta.variant} size="sm" dot>
{tierMeta.label}
</Badge>
</span>
<span className="text-[11px] text-text-muted">{config.label}</span>
</div>
</div>
</div>
</div>
{/* Quota Bars */}
<div className="flex flex-wrap gap-x-3 gap-y-1.5 pr-3">
{isLoading ? (
<div className="flex items-center gap-1.5 text-text-muted text-xs">
<span className="material-symbols-outlined animate-spin text-[14px]">
progress_activity
</span>
{t("loadingQuotas")}
</div>
) : error ? (
<div className="flex items-center gap-1.5 text-xs text-red-500">
<span className="material-symbols-outlined text-[14px]">error</span>
<span className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px]">
{error}
</span>
</div>
) : quota?.message && (!quota.quotas || quota.quotas.length === 0) ? (
<div className="text-xs text-text-muted italic">{quota.message}</div>
) : quota?.quotas?.length > 0 ? (
quota.quotas.map((q, i) => {
const remaining =
q.remainingPercentage !== undefined
? Math.round(q.remainingPercentage)
: calculatePercentage(q.used, q.total);
const colors = getBarColor(remaining);
const cd = formatCountdown(q.resetAt);
const shortName = getShortModelName(q.name);
{/* Quota Bars */}
<div className="flex flex-wrap gap-x-3 gap-y-1.5 pr-3">
{isLoading ? (
<div className="flex items-center gap-1.5 text-text-muted text-xs">
<span className="material-symbols-outlined animate-spin text-[14px]">
progress_activity
</span>
{t("loadingQuotas")}
</div>
) : error ? (
<div className="flex items-center gap-1.5 text-xs text-red-500">
<span className="material-symbols-outlined text-[14px]">error</span>
<span className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px]">
{error}
</span>
</div>
) : quota?.message && (!quota.quotas || quota.quotas.length === 0) ? (
<div className="text-xs text-text-muted italic">{quota.message}</div>
) : quota?.quotas?.length > 0 ? (
quota.quotas.map((q, i) => {
const remaining =
q.remainingPercentage !== undefined
? Math.round(q.remainingPercentage)
: calculatePercentage(q.used, q.total);
const colors = getBarColor(remaining);
const cd = formatCountdown(q.resetAt);
const shortName = getShortModelName(q.name);
return (
<div key={i} className="flex items-center gap-1.5 min-w-[200px] shrink-0">
{/* Model label */}
<span
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap min-w-[60px] text-center"
style={{ background: colors.bg, color: colors.text }}
>
{shortName}
</span>
{/* Countdown */}
{cd && (
<span className="text-[10px] text-text-muted whitespace-nowrap">
{cd}
return (
<div key={i} className="flex items-center gap-1.5 min-w-[200px] shrink-0">
{/* Model label */}
<span
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap min-w-[60px] text-center"
style={{ background: colors.bg, color: colors.text }}
>
{shortName}
</span>
)}
{/* Progress bar */}
<div className="flex-1 h-1.5 rounded-sm bg-white/[0.06] min-w-[60px] overflow-hidden">
<div
className="h-full rounded-sm transition-[width] duration-300 ease-out"
style={{
width: `${Math.min(remaining, 100)}%`,
background: colors.bar,
}}
/>
{/* Countdown */}
{cd && (
<span className="text-[10px] text-text-muted whitespace-nowrap">
{cd}
</span>
)}
{/* Progress bar */}
<div className="flex-1 h-1.5 rounded-sm bg-white/[0.06] min-w-[60px] overflow-hidden">
<div
className="h-full rounded-sm transition-[width] duration-300 ease-out"
style={{
width: `${Math.min(remaining, 100)}%`,
background: colors.bar,
}}
/>
</div>
{/* Percentage */}
<span
className="text-[11px] font-semibold min-w-[32px] text-right"
style={{ color: colors.text }}
>
{remaining}%
</span>
</div>
);
})
) : (
<div className="text-xs text-text-muted italic">{t("noQuotaData")}</div>
)}
</div>
{/* Percentage */}
<span
className="text-[11px] font-semibold min-w-[32px] text-right"
style={{ color: colors.text }}
>
{remaining}%
</span>
</div>
);
})
) : (
<div className="text-xs text-text-muted italic">{t("noQuotaData")}</div>
)}
</div>
{/* Last Used */}
<div className="text-center text-[11px] text-text-muted">
{lastUpdated ? (
<span>
{lastUpdated.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
) : (
"-"
)}
</div>
{/* Last Used */}
<div className="text-center text-[11px] text-text-muted">
{lastUpdated ? (
<span>
{lastUpdated.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
) : (
"-"
)}
</div>
{/* Actions */}
<div className="flex justify-center gap-0.5">
<button
onClick={() => refreshProvider(conn.id, conn.provider)}
disabled={isLoading}
title={t("refreshQuota")}
className="p-1 rounded-md border-none bg-transparent cursor-pointer disabled:cursor-not-allowed disabled:opacity-30 opacity-60 hover:opacity-100 flex items-center justify-center transition-opacity duration-150"
>
<span
className={`material-symbols-outlined text-[16px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
{/* Actions */}
<div className="flex justify-center gap-0.5">
<button
onClick={() => refreshProvider(conn.id, conn.provider)}
disabled={isLoading}
title={t("refreshQuota")}
className="p-1 rounded-md border-none bg-transparent cursor-pointer disabled:cursor-not-allowed disabled:opacity-30 opacity-60 hover:opacity-100 flex items-center justify-center transition-opacity duration-150"
>
refresh
<span
className={`material-symbols-outlined text-[16px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
>
refresh
</span>
</button>
</div>
</div>
);
};
if (groupedConnections) {
const entries = [...groupedConnections.entries()];
return entries.map(([groupName, conns]) => (
<div
key={groupName}
className="border border-white/[0.08] rounded-lg overflow-hidden mb-2"
>
<button
onClick={() => toggleGroup(groupName)}
className="w-full flex items-center gap-2 px-4 py-2.5 bg-white/[0.03] hover:bg-white/[0.05] transition-colors text-left border-none cursor-pointer"
>
<span className="material-symbols-outlined text-[16px] text-text-muted">
{expandedGroups.has(groupName) ? "expand_less" : "expand_more"}
</span>
<span className="material-symbols-outlined text-[16px] text-text-muted">
folder
</span>
<span className="text-[12px] font-semibold text-text-main uppercase tracking-wider flex-1">
{groupName}
</span>
<span className="text-[11px] text-text-muted bg-white/[0.06] px-2 py-0.5 rounded-full">
{conns.length}
</span>
</button>
{expandedGroups.has(groupName) && (
<div>{conns.map((conn, idx) => renderRow(conn, idx === conns.length - 1))}</div>
)}
</div>
</div>
));
}
return visibleConnections.map((conn, idx) =>
renderRow(conn, idx === visibleConnections.length - 1)
);
})}
})()}
{visibleConnections.length === 0 && (
<div className="py-6 px-4 text-center text-text-muted text-[13px]">
+7 -2
View File
@@ -49,6 +49,7 @@ export async function POST(request) {
const startTime = Date.now();
try {
// Send a minimal chat request to the internal SSE handler
// Use OpenAI-compatible format — universally accepted by all providers via the translator
const testBody = {
model: modelStr,
messages: [{ role: "user", content: "Hi" }],
@@ -58,11 +59,15 @@ export async function POST(request) {
const internalUrl = `${getBaseUrl(request)}/v1/chat/completions`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000); // 15s timeout
const timeout = setTimeout(() => controller.abort(), 20000); // 20s timeout (was 15s, slow providers need more)
const res = await fetch(internalUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
// Fix #350: bypass REQUIRE_API_KEY for internal admin combo tests
"X-Internal-Test": "combo-health-check",
},
body: JSON.stringify(testBody),
signal: controller.signal,
});
+48
View File
@@ -0,0 +1,48 @@
/**
* API Route: /api/pricing/sync
*
* POST Trigger a manual pricing sync from external sources.
* GET Get current sync status.
* DELETE Clear all synced pricing data.
*/
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const sources = Array.isArray(body.sources)
? body.sources.filter((s: unknown): s is string => typeof s === "string")
: undefined;
const dryRun = body.dryRun === true;
const { syncPricingFromSources } = await import("@/lib/pricingSync");
const result = await syncPricingFromSources({ sources, dryRun });
return NextResponse.json(result, { status: result.success ? 200 : 502 });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function GET() {
try {
const { getSyncStatus } = await import("@/lib/pricingSync");
return NextResponse.json(getSyncStatus());
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function DELETE() {
try {
const { clearSyncedPricing } = await import("@/lib/pricingSync");
clearSyncedPricing();
return NextResponse.json({ success: true, message: "Synced pricing data cleared" });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+38 -1
View File
@@ -10,6 +10,30 @@ import { syncToCloud } from "@/lib/cloudSync";
import { updateProviderConnectionSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
function normalizeCodexLimitPolicy(
incoming: unknown,
existing: unknown
): { use5h: boolean; useWeekly: boolean } {
const incomingRecord =
incoming && typeof incoming === "object" && !Array.isArray(incoming)
? (incoming as Record<string, unknown>)
: {};
const existingRecord =
existing && typeof existing === "object" && !Array.isArray(existing)
? (existing as Record<string, unknown>)
: {};
const existingUse5h = typeof existingRecord.use5h === "boolean" ? existingRecord.use5h : true;
const existingUseWeekly =
typeof existingRecord.useWeekly === "boolean" ? existingRecord.useWeekly : true;
return {
use5h: typeof incomingRecord.use5h === "boolean" ? incomingRecord.use5h : existingUse5h,
useWeekly:
typeof incomingRecord.useWeekly === "boolean" ? incomingRecord.useWeekly : existingUseWeekly,
};
}
// GET /api/providers/[id] - Get single connection
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
@@ -105,7 +129,20 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
existing.providerSpecificData && typeof existing.providerSpecificData === "object"
? existing.providerSpecificData
: {};
updateData.providerSpecificData = { ...existingPsd, ...incomingPsd };
const mergedPsd = { ...existingPsd, ...incomingPsd };
// Deep-merge and normalize Codex limit policy defaults.
if (existing.provider === "codex") {
const incomingRecord = incomingPsd as Record<string, unknown>;
if ("codexLimitPolicy" in incomingRecord || "codexLimitPolicy" in existingPsd) {
mergedPsd.codexLimitPolicy = normalizeCodexLimitPolicy(
incomingRecord.codexLimitPolicy,
(existingPsd as Record<string, unknown>).codexLimitPolicy
);
}
}
updateData.providerSpecificData = mergedPsd;
}
const updated = await updateProviderConnection(id, updateData);
@@ -0,0 +1,55 @@
import { NextResponse, type Request } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import { setDefaultFastServiceTierEnabled } from "@omniroute/open-sse/executors/codex.ts";
import { updateCodexServiceTierSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
export async function GET() {
try {
const settings = await getSettings();
const persisted =
typeof settings.codexServiceTier === "string"
? JSON.parse(settings.codexServiceTier)
: settings.codexServiceTier;
return NextResponse.json({
enabled: typeof persisted?.enabled === "boolean" ? persisted.enabled : false,
});
} catch (error) {
console.error("[API ERROR] /api/settings/codex-service-tier GET:", error);
return NextResponse.json({ error: "Failed to get config" }, { status: 500 });
}
}
export async function PUT(request: 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(updateCodexServiceTierSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const config = validation.data;
await updateSettings({ codexServiceTier: config });
setDefaultFastServiceTierEnabled(config.enabled);
return NextResponse.json(config);
} catch (error) {
console.error("[API ERROR] /api/settings/codex-service-tier PUT:", error);
return NextResponse.json({ error: "Failed to update config" }, { status: 500 });
}
}
+11 -2
View File
@@ -1,6 +1,11 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleAudioSpeech } from "@omniroute/open-sse/handlers/audioSpeech.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { parseSpeechModel, getSpeechProvider } from "@omniroute/open-sse/config/audioRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
@@ -70,5 +75,9 @@ export async function POST(request) {
}
}
return handleAudioSpeech({ body, credentials });
const response = await handleAudioSpeech({ body, credentials });
if (response?.ok) {
await clearRecoveredProviderState(credentials);
}
return response;
}
+11 -2
View File
@@ -1,6 +1,11 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleAudioTranscription } from "@omniroute/open-sse/handlers/audioTranscription.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { parseTranscriptionModel, getTranscriptionProvider } from "@omniroute/open-sse/config/audioRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
@@ -68,5 +73,9 @@ export async function POST(request) {
}
}
return handleAudioTranscription({ formData, credentials });
const response = await handleAudioTranscription({ formData, credentials });
if (response?.ok) {
await clearRecoveredProviderState(credentials);
}
return response;
}
+7 -1
View File
@@ -1,6 +1,11 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleEmbedding } from "@omniroute/open-sse/handlers/embeddings.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import {
parseEmbeddingModel,
getAllEmbeddingModels,
@@ -126,6 +131,7 @@ export async function POST(request) {
const result = await handleEmbedding({ body, credentials, log });
if (result.success) {
await clearRecoveredProviderState(credentials);
return new Response(JSON.stringify(result.data), {
status: 200,
headers: { "Content-Type": "application/json" },
+7 -1
View File
@@ -1,6 +1,11 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import {
parseImageModel,
getAllImageModels,
@@ -170,6 +175,7 @@ export async function POST(request) {
});
if (result.success) {
await clearRecoveredProviderState(credentials);
return new Response(JSON.stringify((result as any).data), {
status: 200,
headers: { "Content-Type": "application/json" },
+11 -2
View File
@@ -1,6 +1,11 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleModeration } from "@omniroute/open-sse/handlers/moderations.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { parseModerationModel } from "@omniroute/open-sse/config/moderationRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
@@ -64,5 +69,9 @@ export async function POST(request) {
);
}
return handleModeration({ body: { ...body, model }, credentials });
const response = await handleModeration({ body: { ...body, model }, credentials });
if (response?.ok) {
await clearRecoveredProviderState(credentials);
}
return response;
}
+7 -1
View File
@@ -1,6 +1,11 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleMusicGeneration } from "@omniroute/open-sse/handlers/musicGeneration.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import {
parseMusicModel,
getAllMusicModels,
@@ -110,6 +115,7 @@ export async function POST(request) {
const result = await handleMusicGeneration({ body, credentials, log });
if (result.success) {
await clearRecoveredProviderState(credentials);
return new Response(JSON.stringify((result as any).data), {
status: 200,
headers: { "Content-Type": "application/json" },
@@ -2,7 +2,12 @@ import { CORS_ORIGIN } from "@/shared/utils/cors";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { handleEmbedding } from "@omniroute/open-sse/handlers/embeddings.ts";
import * as log from "@/sse/utils/logger";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
@@ -84,6 +89,7 @@ export async function POST(request, { params }) {
const result = await handleEmbedding({ body, credentials, log });
if (result.success) {
await clearRecoveredProviderState(credentials);
return new Response(JSON.stringify(result.data), {
status: 200,
headers: { "Content-Type": "application/json" },
@@ -2,7 +2,12 @@ import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleImageGeneration } from "@omniroute/open-sse/handlers/imageGeneration.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts";
import * as log from "@/sse/utils/logger";
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
@@ -84,6 +89,7 @@ export async function POST(request, { params }) {
const result = await handleImageGeneration({ body, credentials, log });
if (result.success) {
await clearRecoveredProviderState(credentials);
return new Response(JSON.stringify((result as any).data), {
status: 200,
headers: { "Content-Type": "application/json" },
+11 -2
View File
@@ -1,6 +1,11 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleRerank } from "@omniroute/open-sse/handlers/rerank.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { parseRerankModel } from "@omniroute/open-sse/config/rerankRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
@@ -66,7 +71,7 @@ export async function POST(request) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
return handleRerank({
const response = await handleRerank({
model: body.model,
query: body.query,
documents: body.documents,
@@ -74,4 +79,8 @@ export async function POST(request) {
return_documents: body.return_documents,
credentials,
});
if (response?.ok) {
await clearRecoveredProviderState(credentials);
}
return response;
}
+7 -1
View File
@@ -1,6 +1,11 @@
import { CORS_ORIGIN } from "@/shared/utils/cors";
import { handleVideoGeneration } from "@omniroute/open-sse/handlers/videoGeneration.ts";
import { getProviderCredentials, extractApiKey, isValidApiKey } from "@/sse/services/auth";
import {
getProviderCredentials,
clearRecoveredProviderState,
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import {
parseVideoModel,
getAllVideoModels,
@@ -110,6 +115,7 @@ export async function POST(request) {
const result = await handleVideoGeneration({ body, credentials, log });
if (result.success) {
await clearRecoveredProviderState(credentials);
return new Response(JSON.stringify((result as any).data), {
status: 200,
headers: { "Content-Type": "application/json" },
+51
View File
@@ -35,6 +35,13 @@ interface QuotaCacheEntry {
windowDurationMs?: number | null; // T08: optional rolling window duration
}
interface QuotaWindowStatus {
remainingPercentage: number;
usedPercentage: number;
resetAt: string | null;
reachedThreshold: boolean;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const ACTIVE_TTL_MS = 5 * 60 * 1000; // 5 minutes for active accounts
@@ -89,6 +96,11 @@ function parseDate(value: string): number | null {
return Number.isNaN(ms) ? null : ms;
}
function clampPercent(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, value));
}
function earliestResetAt(quotas: Record<string, QuotaInfo>): string | null {
let earliest: string | null = null;
let earliestMs = Infinity;
@@ -175,6 +187,45 @@ export function isAccountQuotaExhausted(connectionId: string): boolean {
return true;
}
/**
* Return quota window status for a connection (e.g., session/weekly).
* Returns null when no cache or no window data is available.
*/
export function getQuotaWindowStatus(
connectionId: string,
windowName: string,
thresholdPercent = 90
): QuotaWindowStatus | null {
const entry = cache.get(connectionId);
if (!entry) return null;
const now = Date.now();
const window = entry.quotas[windowName];
if (!window) return null;
const remainingPercentage = clampPercent(window.remainingPercentage);
const usedPercentage = clampPercent(100 - remainingPercentage);
let resetAt = window.resetAt || null;
let windowExpired = false;
if (resetAt) {
const resetMs = parseDate(resetAt);
if (resetMs !== null && resetMs <= now) {
resetAt = null;
windowExpired = true;
}
}
return {
remainingPercentage,
usedPercentage,
resetAt,
// If reset time has already passed, avoid stale cached percentages blocking selection.
reachedThreshold: windowExpired ? false : usedPercentage >= thresholdPercent,
};
}
/**
* Mark an account as quota-exhausted from a 429 response (no quota data available).
* Uses 5-minute fixed TTL since we don't know the actual resetAt.
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "اختيار عشوائي موحد، ثم الرجوع إلى النماذج المتبقية",
"leastUsedDesc": "يختار النموذج الذي يحتوي على أقل عدد من الطلبات، مع موازنة الحمل مع مرور الوقت",
"costOptimizedDesc": "الطرق إلى النموذج الأرخص تعتمد أولاً على التسعير",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "نماذج",
"autoBalance": "التوازن التلقائي",
"advancedSettings": "الإعدادات المتقدمة",
@@ -1378,6 +1380,8 @@
"email": "البريد الإلكتروني",
"healthCheckMinutes": "فحص الصحة (دقيقة)",
"healthCheckHint": "الفاصل الزمني لتحديث الرمز المميز. 0 = معطل.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "فشل في اختبار الاتصال",
"failed": "فشل",
"leaveBlankKeepCurrentApiKey": "اتركه فارغًا للاحتفاظ بمفتاح API الحالي.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "اختر الحساب الأقل استخدامًا مؤخرًا",
"costOpt": "خيار التكلفة",
"costOptDesc": "تفضل أرخص حساب متاح",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "الحد اللزج",
"stickyLimitDesc": "المكالمات لكل حساب قبل التبديل",
"modelAliases": "الأسماء المستعارة النموذجية",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "الخطة الأولية: {plan}",
"noPlanFromProvider": "لا توجد خطة من المزود",
"noQuotaData": "لا توجد بيانات الحصص",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "لا توجد بيانات الحصص المتاحة",
"noAccountsForTierFilter": "لم يتم العثور على حسابات لمرشح الطبقة",
"tierAll": "الكل",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Единен случаен избор, след което се връща към останалите модели",
"leastUsedDesc": "Избира модела с най-малко заявки, като балансира натоварването във времето",
"costOptimizedDesc": "Първо маршрути към най-евтиния модел въз основа на ценообразуването",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Модели",
"autoBalance": "Автоматичен баланс",
"advancedSettings": "Разширени настройки",
@@ -1378,6 +1380,8 @@
"email": "Имейл",
"healthCheckMinutes": "Проверка на здравето (мин.)",
"healthCheckHint": "Интервал за опресняване на проактивен токен. 0 = забранено.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Неуспешно тестване на връзката",
"failed": "Неуспешно",
"leaveBlankKeepCurrentApiKey": "Оставете празно, за да запазите текущия API ключ.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Изберете най-малко използван акаунт",
"costOpt": "Цена Опт",
"costOptDesc": "Предпочитайте най-евтиния наличен акаунт",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Лепкава граница",
"stickyLimitDesc": "Обаждания на акаунт преди превключване",
"modelAliases": "Псевдоними на модела",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Необработен план: {plan}",
"noPlanFromProvider": "Няма план от доставчика",
"noQuotaData": "Няма данни за квоти",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Няма налични данни за квота",
"noAccountsForTierFilter": "Няма намерени акаунти за филтър за ниво",
"tierAll": "Всички",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Ensartet tilfældig udvælgelse, derefter tilbagevenden til de resterende modeller",
"leastUsedDesc": "Vælger modellen med færrest anmodninger, balancerer belastningen over tid",
"costOptimizedDesc": "Ruter til den billigste model først baseret på priser",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modeller",
"autoBalance": "Auto-balance",
"advancedSettings": "Avancerede indstillinger",
@@ -1378,6 +1380,8 @@
"email": "E-mail",
"healthCheckMinutes": "Sundhedstjek (min)",
"healthCheckHint": "Proaktivt token-opdateringsinterval. 0 = deaktiveret.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Forbindelsen kunne ikke testes",
"failed": "Mislykkedes",
"leaveBlankKeepCurrentApiKey": "Lad stå tomt for at beholde den aktuelle API-nøgle.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Vælg den mindst brugte konto",
"costOpt": "Omkostningsopt",
"costOptDesc": "Foretrækker den billigste tilgængelige konto",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Sticky Limit",
"stickyLimitDesc": "Opkald pr. konto før skift",
"modelAliases": "Model aliaser",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Rå plan: {plan}",
"noPlanFromProvider": "Ingen plan fra udbyderen",
"noQuotaData": "Ingen kvotedata",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Ingen tilgængelige kvotedata",
"noAccountsForTierFilter": "Der blev ikke fundet nogen konti til niveaufilter",
"tierAll": "Alle",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Einheitliche Zufallsauswahl, dann Rückgriff auf verbleibende Modelle",
"leastUsedDesc": "Wählt das Modell mit den wenigsten Anfragen aus und gleicht die Last über die Zeit aus",
"costOptimizedDesc": "Leitet basierend auf dem Preis zuerst zum günstigsten Modell weiter",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modelle",
"autoBalance": "Automatischer Ausgleich",
"advancedSettings": "Erweiterte Einstellungen",
@@ -1378,6 +1380,8 @@
"email": "E-Mail",
"healthCheckMinutes": "Gesundheitscheck (Min.)",
"healthCheckHint": "Proaktives Token-Aktualisierungsintervall. 0 = deaktiviert.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Die Verbindung konnte nicht getestet werden",
"failed": "Fehlgeschlagen",
"leaveBlankKeepCurrentApiKey": "Lassen Sie das Feld leer, um den aktuellen API-Schlüssel beizubehalten.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Wählen Sie das zuletzt verwendete Konto aus",
"costOpt": "Kosten Opt",
"costOptDesc": "Bevorzugen Sie das günstigste verfügbare Konto",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Sticky-Limit",
"stickyLimitDesc": "Anrufe pro Konto vor dem Wechsel",
"modelAliases": "Modell-Aliase",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Rohplan: {plan}",
"noPlanFromProvider": "Kein Plan vom Anbieter",
"noQuotaData": "Keine Quotendaten",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Keine Quotendaten verfügbar",
"noAccountsForTierFilter": "Für den Stufenfilter wurden keine Konten gefunden",
"tierAll": "Alle",
+30
View File
@@ -63,6 +63,7 @@
"dashboard": "Dashboard",
"providers": "Providers",
"combos": "Combos",
"autoCombo": "Auto Combo",
"usage": "Usage",
"analytics": "Analytics",
"costs": "Costs",
@@ -235,6 +236,26 @@
"keyCreatedNote": "Copy and store this key now — it won't be shown again.",
"done": "Done",
"savePermissions": "Save Permissions",
"autoResolve": "Auto-Resolve",
"autoResolveDesc": "Auto-resolve ambiguous model names to native provider for this API key.",
"keyActive": "Key Active",
"keyActiveDesc": "Enable or disable this API key. Disabled keys are immediately rejected with 403.",
"accessSchedule": "Access Schedule",
"accessScheduleDesc": "Restrict access to specific hours and days of the week.",
"scheduleFrom": "From",
"scheduleUntil": "Until",
"scheduleDays": "Days",
"scheduleTimezone": "Timezone",
"scheduleTimezoneHint": "Use IANA timezone names, e.g. America/New_York, Europe/Berlin",
"scheduleActive": "Schedule",
"disabled": "Disabled",
"daySun": "Sun",
"dayMon": "Mon",
"dayTue": "Tue",
"dayWed": "Wed",
"dayThu": "Thu",
"dayFri": "Fri",
"daySat": "Sat",
"allowAll": "Allow All",
"restrict": "Restrict",
"allowAllInfo": "This key can access all available models.",
@@ -604,6 +625,8 @@
"randomDesc": "Uniform random selection, then fallback to remaining models",
"leastUsedDesc": "Picks the model with fewest requests, balancing load over time",
"costOptimizedDesc": "Routes to the cheapest model first based on pricing",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Models",
"autoBalance": "Auto-balance",
"advancedSettings": "Advanced Settings",
@@ -1391,6 +1414,8 @@
"email": "Email",
"healthCheckMinutes": "Health Check (min)",
"healthCheckHint": "Proactive token refresh interval. 0 = disabled.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Failed to test connection",
"failed": "Failed",
"leaveBlankKeepCurrentApiKey": "Leave blank to keep the current API key.",
@@ -1562,6 +1587,8 @@
"leastUsedDesc": "Pick least recently used account",
"costOpt": "Cost Opt",
"costOptDesc": "Prefer cheapest available account",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Sticky Limit",
"stickyLimitDesc": "Calls per account before switching",
"modelAliases": "Model Aliases",
@@ -2086,6 +2113,9 @@
"rawPlanWithValue": "Raw plan: {plan}",
"noPlanFromProvider": "No plan from provider",
"noQuotaData": "No quota data",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "No quota data available",
"noAccountsForTierFilter": "No accounts found for tier filter",
"tierAll": "All",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Selección aleatoria uniforme y luego recurrir a los modelos restantes.",
"leastUsedDesc": "Elige el modelo con menos solicitudes y equilibra la carga a lo largo del tiempo.",
"costOptimizedDesc": "Rutas al modelo más barato primero según el precio",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modelos",
"autoBalance": "Equilibrio automático",
"advancedSettings": "Configuración avanzada",
@@ -1378,6 +1380,8 @@
"email": "Correo electrónico",
"healthCheckMinutes": "Control de salud (min)",
"healthCheckHint": "Intervalo de actualización de token proactivo. 0 = deshabilitado.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "No se pudo probar la conexión",
"failed": "Fallido",
"leaveBlankKeepCurrentApiKey": "Déjelo en blanco para conservar la clave API actual.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Elija la cuenta utilizada menos recientemente",
"costOpt": "Opción de costo",
"costOptDesc": "Prefiere la cuenta más barata disponible",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Límite fijo",
"stickyLimitDesc": "Llamadas por cuenta antes de cambiar",
"modelAliases": "Alias de modelo",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Plan sin formato: {plan}",
"noPlanFromProvider": "Sin plan del proveedor",
"noQuotaData": "Sin datos de cuota",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "No hay datos de cuota disponibles",
"noAccountsForTierFilter": "No se encontraron cuentas para el filtro de niveles",
"tierAll": "Todos",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Yhtenäinen satunnainen valinta, sitten takaisin muihin malleihin",
"leastUsedDesc": "Valitsee mallin, jolla on vähiten pyyntöjä ja tasapainottaa kuormitusta ajan myötä",
"costOptimizedDesc": "Reitit edullisimpaan malliin ensin hinnoittelun perusteella",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Mallit",
"autoBalance": "Automaattinen tasapainotus",
"advancedSettings": "Lisäasetukset",
@@ -1378,6 +1380,8 @@
"email": "Sähköposti",
"healthCheckMinutes": "Terveystarkastus (min)",
"healthCheckHint": "Ennakoiva tunnuksen päivitysväli. 0 = pois käytöstä.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Yhteyden testaus epäonnistui",
"failed": "Epäonnistui",
"leaveBlankKeepCurrentApiKey": "Jätä tyhjäksi, jos haluat säilyttää nykyisen API-avaimen.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Valitse vähiten käytetty tili",
"costOpt": "Kustannusopt",
"costOptDesc": "Valitse halvin saatavilla oleva tili",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Sticky Limit",
"stickyLimitDesc": "Puhelut tilikohtaisesti ennen vaihtamista",
"modelAliases": "Mallin aliakset",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Raakasuunnitelma: {plan}",
"noPlanFromProvider": "Ei suunnitelmaa palveluntarjoajalta",
"noQuotaData": "Ei kiintiötietoja",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Kiintiötietoja ei ole saatavilla",
"noAccountsForTierFilter": "Tasosuodattimelle ei löytynyt tilejä",
"tierAll": "Kaikki",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Sélection aléatoire uniforme, puis retour aux modèles restants",
"leastUsedDesc": "Sélectionne le modèle avec le moins de demandes, en équilibrant la charge au fil du temps",
"costOptimizedDesc": "Itinéraires vers le modèle le moins cher en premier en fonction du prix",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modèles",
"autoBalance": "Équilibre automatique",
"advancedSettings": "Paramètres avancés",
@@ -1378,6 +1380,8 @@
"email": "Courriel",
"healthCheckMinutes": "Bilan de santé (min)",
"healthCheckHint": "Intervalle dactualisation proactif des jetons. 0 = désactivé.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Échec du test de connexion",
"failed": "Échec",
"leaveBlankKeepCurrentApiKey": "Laissez vide pour conserver la clé API actuelle.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Choisissez le compte le moins récemment utilisé",
"costOpt": "Option de coût",
"costOptDesc": "Préférer le compte disponible le moins cher",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Limite collante",
"stickyLimitDesc": "Appels par compte avant de changer",
"modelAliases": "Alias de modèle",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Plan brut : {plan}",
"noPlanFromProvider": "Aucun plan du fournisseur",
"noQuotaData": "Aucune donnée de quota",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Aucune donnée de quota disponible",
"noAccountsForTierFilter": "Aucun compte trouvé pour le filtre de niveau",
"tierAll": "Tout",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "בחירה אקראית אחידה, ואז חזרה לדגמים שנותרו",
"leastUsedDesc": "בוחר את הדגם עם הכי פחות בקשות, מאזן עומס לאורך זמן",
"costOptimizedDesc": "מסלולים לדגם הזול ביותר לפי תמחור",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "דגמים",
"autoBalance": "איזון אוטומטי",
"advancedSettings": "הגדרות מתקדמות",
@@ -1378,6 +1380,8 @@
"email": "דוא\"ל",
"healthCheckMinutes": "בדיקת בריאות (דקה)",
"healthCheckHint": "מרווח רענון אסימון פרואקטיבי. 0 = מושבת.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "בדיקת החיבור נכשלה",
"failed": "נכשל",
"leaveBlankKeepCurrentApiKey": "השאר ריק כדי לשמור את מפתח ה-API הנוכחי.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "בחר חשבון שנעשה בו שימוש לפחות לאחרונה",
"costOpt": "אופטימיזציית עלות",
"costOptDesc": "העדיפו את החשבון הזול ביותר הזמין",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "גבול דביק",
"stickyLimitDesc": "שיחות לכל חשבון לפני המעבר",
"modelAliases": "כינויי דגם",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "תוכנית גולמית: {plan}",
"noPlanFromProvider": "אין תוכנית מספק",
"noQuotaData": "אין נתוני מכסה",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "אין נתוני מכסה זמינים",
"noAccountsForTierFilter": "לא נמצאו חשבונות עבור מסנן שכבות",
"tierAll": "הכל",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Egységes véletlenszerű kiválasztás, majd visszaállás a többi modellhez",
"leastUsedDesc": "A legkevesebb kéréssel rendelkező modellt választja, idővel kiegyensúlyozva a terhelést",
"costOptimizedDesc": "Először a legolcsóbb modellhez vezet az árak alapján",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modellek",
"autoBalance": "Automatikus egyensúly",
"advancedSettings": "Speciális beállítások",
@@ -1378,6 +1380,8 @@
"email": "E-mail",
"healthCheckMinutes": "állapotfelmérés (perc)",
"healthCheckHint": "Proaktív token frissítési időköz. 0 = letiltva.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Nem sikerült tesztelni a kapcsolatot",
"failed": "Sikertelen",
"leaveBlankKeepCurrentApiKey": "Hagyja üresen az aktuális API-kulcs megtartásához.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Válassza ki a legutóbb használt fiókot",
"costOpt": "Költségopt",
"costOptDesc": "A legolcsóbb elérhető fiók előnyben részesítése",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Ragadós határ",
"stickyLimitDesc": "Hívások fiókonként váltás előtt",
"modelAliases": "Modell álnevek",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Nyers terv: {plan}",
"noPlanFromProvider": "Nincs terv a szolgáltatótól",
"noQuotaData": "Nincsenek kvótaadatok",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Nem állnak rendelkezésre kvótaadatok",
"noAccountsForTierFilter": "Nem található fiók a rétegszűrőhöz",
"tierAll": "Mind",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Pemilihan acak seragam, lalu kembali ke model lainnya",
"leastUsedDesc": "Memilih model dengan permintaan paling sedikit, menyeimbangkan beban dari waktu ke waktu",
"costOptimizedDesc": "Rutekan ke model termurah terlebih dahulu berdasarkan harga",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Model",
"autoBalance": "Keseimbangan otomatis",
"advancedSettings": "Pengaturan Lanjutan",
@@ -1378,6 +1380,8 @@
"email": "Surel",
"healthCheckMinutes": "Pemeriksaan Kesehatan (menit)",
"healthCheckHint": "Interval penyegaran token proaktif. 0 = dinonaktifkan.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Gagal menguji koneksi",
"failed": "Gagal",
"leaveBlankKeepCurrentApiKey": "Biarkan kosong untuk mempertahankan kunci API saat ini.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Pilih akun yang paling jarang digunakan",
"costOpt": "Pilihan Biaya",
"costOptDesc": "Lebih suka akun termurah yang tersedia",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Batas Lengket",
"stickyLimitDesc": "Panggilan per akun sebelum beralih",
"modelAliases": "Alias Model",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Paket mentah: {plan}",
"noPlanFromProvider": "Tidak ada rencana dari penyedia",
"noQuotaData": "Tidak ada data kuota",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Tidak ada data kuota yang tersedia",
"noAccountsForTierFilter": "Tidak ditemukan akun untuk filter tingkat",
"tierAll": "Semua",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "समान यादृच्छिक चयन, फिर शेष मॉडलों पर वापस लौटना",
"leastUsedDesc": "समय के साथ लोड को संतुलित करते हुए, सबसे कम अनुरोधों वाला मॉडल चुनता है",
"costOptimizedDesc": "मूल्य निर्धारण के आधार पर सबसे पहले सबसे सस्ते मॉडल पर रूट करें",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "मॉडल",
"autoBalance": "स्वत: संतुलन",
"advancedSettings": "उन्नत सेटिंग्स",
@@ -1378,6 +1380,8 @@
"email": "ईमेल",
"healthCheckMinutes": "स्वास्थ्य जांच (न्यूनतम)",
"healthCheckHint": "प्रोएक्टिव टोकन ताज़ा अंतराल। 0 = अक्षम.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "कनेक्शन का परीक्षण करने में विफल",
"failed": "असफल",
"leaveBlankKeepCurrentApiKey": "वर्तमान एपीआई कुंजी रखने के लिए खाली छोड़ दें।",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "कम से कम हाल ही में उपयोग किया गया खाता चुनें",
"costOpt": "लागत विकल्प",
"costOptDesc": "सबसे सस्ते उपलब्ध खाते को प्राथमिकता दें",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "चिपचिपी सीमा",
"stickyLimitDesc": "स्विच करने से पहले प्रति खाता कॉल",
"modelAliases": "मॉडल उपनाम",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "कच्ची योजना: {plan}",
"noPlanFromProvider": "प्रदाता की ओर से कोई योजना नहीं",
"noQuotaData": "कोई कोटा डेटा नहीं",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "कोई कोटा डेटा उपलब्ध नहीं है",
"noAccountsForTierFilter": "टियर फ़िल्टर के लिए कोई खाता नहीं मिला",
"tierAll": "सब",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Selezione casuale uniforme, quindi fallback sui modelli rimanenti",
"leastUsedDesc": "Sceglie il modello con meno richieste, bilanciando il carico nel tempo",
"costOptimizedDesc": "Percorsi prima verso il modello più economico in base al prezzo",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modelli",
"autoBalance": "Bilanciamento automatico",
"advancedSettings": "Impostazioni avanzate",
@@ -1378,6 +1380,8 @@
"email": "E-mail",
"healthCheckMinutes": "Controllo dello stato (min)",
"healthCheckHint": "Intervallo di aggiornamento del token proattivo. 0 = disabilitato.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Impossibile testare la connessione",
"failed": "Fallito",
"leaveBlankKeepCurrentApiKey": "Lascia vuoto per mantenere la chiave API corrente.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Scegli l'account utilizzato meno di recente",
"costOpt": "Opzione costo",
"costOptDesc": "Preferisci il conto più economico disponibile",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Limite appiccicoso",
"stickyLimitDesc": "Chiamate per account prima del cambio",
"modelAliases": "Alias del modello",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Piano grezzo: {plan}",
"noPlanFromProvider": "Nessun piano dal fornitore",
"noQuotaData": "Nessun dato sulle quote",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Nessun dato sulle quote disponibile",
"noAccountsForTierFilter": "Nessun account trovato per il filtro del livello",
"tierAll": "Tutto",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "均一なランダム選択、その後残りのモデルへのフォールバック",
"leastUsedDesc": "リクエストが最も少ないモデルを選択し、時間の経過とともに負荷のバランスをとります",
"costOptimizedDesc": "価格に基づいて最初に最も安価なモデルにルーティングします",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "モデル",
"autoBalance": "オートバランス",
"advancedSettings": "詳細設定",
@@ -1378,6 +1380,8 @@
"email": "電子メール",
"healthCheckMinutes": "ヘルスチェック (分)",
"healthCheckHint": "プロアクティブなトークンの更新間隔。 0 = 無効。",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "接続のテストに失敗しました",
"failed": "失敗しました",
"leaveBlankKeepCurrentApiKey": "現在の API キーを保持するには、空白のままにします。",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "最も最近使用されていないアカウントを選択する",
"costOpt": "コストオプション",
"costOptDesc": "利用可能な最も安いアカウントを優先する",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "スティッキー制限",
"stickyLimitDesc": "切り替える前のアカウントごとの通話数",
"modelAliases": "モデルのエイリアス",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "未加工のプラン: {plan}",
"noPlanFromProvider": "プロバイダーからのプランなし",
"noQuotaData": "クォータ データがありません",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "利用可能なクォータ データがありません",
"noAccountsForTierFilter": "層フィルターのアカウントが見つかりませんでした",
"tierAll": "すべて",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "균일한 무작위 선택 후 나머지 모델로 대체",
"leastUsedDesc": "시간이 지남에 따라 로드 밸런싱을 통해 요청이 가장 적은 모델을 선택합니다.",
"costOptimizedDesc": "가격을 기준으로 가장 저렴한 모델로 먼저 라우팅",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "모델",
"autoBalance": "자동 균형",
"advancedSettings": "고급 설정",
@@ -1378,6 +1380,8 @@
"email": "이메일",
"healthCheckMinutes": "상태 점검(분)",
"healthCheckHint": "사전 토큰 새로 고침 간격. 0 = 비활성화됨.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "연결을 테스트하지 못했습니다.",
"failed": "실패",
"leaveBlankKeepCurrentApiKey": "현재 API 키를 유지하려면 비워 두세요.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "최근에 가장 적게 사용한 계정 선택",
"costOpt": "비용 선택",
"costOptDesc": "가장 저렴한 계정을 선호합니다",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "고정 한도",
"stickyLimitDesc": "전환 전 계정당 통화",
"modelAliases": "모델 별칭",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "기본 계획: {plan}",
"noPlanFromProvider": "공급자의 계획 없음",
"noQuotaData": "할당량 데이터 없음",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "사용 가능한 할당량 데이터가 없습니다.",
"noAccountsForTierFilter": "등급 필터에 대한 계정을 찾을 수 없습니다.",
"tierAll": "모두",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Pemilihan rawak seragam, kemudian sandarkan kepada model yang tinggal",
"leastUsedDesc": "Memilih model dengan permintaan paling sedikit, mengimbangi beban dari semasa ke semasa",
"costOptimizedDesc": "Laluan ke model termurah dahulu berdasarkan harga",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "model",
"autoBalance": "Imbangan automatik",
"advancedSettings": "Tetapan Lanjutan",
@@ -1378,6 +1380,8 @@
"email": "E-mel",
"healthCheckMinutes": "Pemeriksaan Kesihatan (min)",
"healthCheckHint": "Selang penyegaran token proaktif. 0 = kurang upaya.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Gagal menguji sambungan",
"failed": "gagal",
"leaveBlankKeepCurrentApiKey": "Biarkan kosong untuk mengekalkan kunci API semasa.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Pilih akaun yang paling kurang digunakan baru-baru ini",
"costOpt": "Pilihan Kos",
"costOptDesc": "Pilih akaun termurah yang tersedia",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Had Melekit",
"stickyLimitDesc": "Panggilan setiap akaun sebelum bertukar",
"modelAliases": "Alias Model",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Pelan mentah: {plan}",
"noPlanFromProvider": "Tiada pelan daripada pembekal",
"noQuotaData": "Tiada data kuota",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Tiada data kuota tersedia",
"noAccountsForTierFilter": "Tiada akaun ditemui untuk penapis peringkat",
"tierAll": "Semua",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Uniforme willekeurige selectie en vervolgens terugvallen op de resterende modellen",
"leastUsedDesc": "Kiest het model met de minste verzoeken, waarbij de belasting in de loop van de tijd wordt verdeeld",
"costOptimizedDesc": "Routes eerst naar het goedkoopste model op basis van prijzen",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modellen",
"autoBalance": "Automatische balans",
"advancedSettings": "Geavanceerde instellingen",
@@ -1378,6 +1380,8 @@
"email": "E-mail",
"healthCheckMinutes": "Gezondheidscontrole (min)",
"healthCheckHint": "Proactief tokenvernieuwingsinterval. 0 = uitgeschakeld.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Kan de verbinding niet testen",
"failed": "Mislukt",
"leaveBlankKeepCurrentApiKey": "Laat dit leeg om de huidige API-sleutel te behouden.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Kies het minst recent gebruikte account",
"costOpt": "Kosten opt",
"costOptDesc": "Geef de voorkeur aan het goedkoopste beschikbare account",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Kleverige limiet",
"stickyLimitDesc": "Gesprekken per account voordat u overstapt",
"modelAliases": "Modelaliassen",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Ruw plan: {plan}",
"noPlanFromProvider": "Geen abonnement van aanbieder",
"noQuotaData": "Geen quotagegevens",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Geen quotagegevens beschikbaar",
"noAccountsForTierFilter": "Er zijn geen accounts gevonden voor niveaufilter",
"tierAll": "Allemaal",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Ensartet tilfeldig valg, deretter fallback til gjenværende modeller",
"leastUsedDesc": "Velger modellen med færrest forespørsler, og balanserer belastningen over tid",
"costOptimizedDesc": "Ruter til den billigste modellen først basert på priser",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modeller",
"autoBalance": "Autobalanse",
"advancedSettings": "Avanserte innstillinger",
@@ -1378,6 +1380,8 @@
"email": "E-post",
"healthCheckMinutes": "Helsesjekk (min)",
"healthCheckHint": "Proaktivt token-oppdateringsintervall. 0 = deaktivert.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Kunne ikke teste tilkoblingen",
"failed": "Mislyktes",
"leaveBlankKeepCurrentApiKey": "La stå tomt for å beholde gjeldende API-nøkkel.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Velg minst nylig brukte konto",
"costOpt": "Kostnad Opt",
"costOptDesc": "Foretrekker den billigste tilgjengelige kontoen",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Sticky Limit",
"stickyLimitDesc": "Anrop per konto før bytte",
"modelAliases": "Modellaliaser",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Rå plan: {plan}",
"noPlanFromProvider": "Ingen plan fra leverandøren",
"noQuotaData": "Ingen kvotedata",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Ingen kvotedata tilgjengelig",
"noAccountsForTierFilter": "Fant ingen kontoer for nivåfilter",
"tierAll": "Alle",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Uniform random selection, pagkatapos ay fallback sa natitirang mga modelo",
"leastUsedDesc": "Pinipili ang modelo na may kaunting mga kahilingan, binabalanse ang pagkarga sa paglipas ng panahon",
"costOptimizedDesc": "Mga ruta muna sa pinakamurang modelo batay sa pagpepresyo",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Mga modelo",
"autoBalance": "Awtomatikong balanse",
"advancedSettings": "Mga Advanced na Setting",
@@ -1378,6 +1380,8 @@
"email": "Email",
"healthCheckMinutes": "Health Check (min)",
"healthCheckHint": "Proactive token refresh interval. 0 = hindi pinagana.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Nabigong subukan ang koneksyon",
"failed": "Nabigo",
"leaveBlankKeepCurrentApiKey": "Iwanang blangko upang mapanatili ang kasalukuyang API key.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Pumili ng hindi bababa sa kamakailang ginamit na account",
"costOpt": "Cost Opt",
"costOptDesc": "Mas gusto ang pinakamurang available na account",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Malagkit na Limitasyon",
"stickyLimitDesc": "Mga tawag sa bawat account bago lumipat",
"modelAliases": "Mga Alyas ng Modelo",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Raw plan: {plan}",
"noPlanFromProvider": "Walang plano mula sa provider",
"noQuotaData": "Walang data ng quota",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Walang available na data ng quota",
"noAccountsForTierFilter": "Walang nahanap na account para sa tier filter",
"tierAll": "Lahat",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Jednolity wybór losowy, a następnie powrót do pozostałych modeli",
"leastUsedDesc": "Wybiera model z najmniejszą liczbą żądań, równoważąc obciążenie w czasie",
"costOptimizedDesc": "Najpierw wybiera najtańszy model na podstawie ceny",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modele",
"autoBalance": "Automatyczne balansowanie",
"advancedSettings": "Ustawienia zaawansowane",
@@ -1378,6 +1380,8 @@
"email": "E-mail",
"healthCheckMinutes": "Kontrola stanu (min)",
"healthCheckHint": "Proaktywny interwał odświeżania tokenu. 0 = wyłączone.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Nie udało się przetestować połączenia",
"failed": "Nie udało się",
"leaveBlankKeepCurrentApiKey": "Pozostaw puste, aby zachować bieżący klucz API.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Wybierz ostatnio używane konto",
"costOpt": "Opcja kosztowa",
"costOptDesc": "Preferuj najtańsze dostępne konto",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Lepki limit",
"stickyLimitDesc": "Połączenia na konto przed zmianą",
"modelAliases": "Aliasy modeli",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Surowy plan: {plan}",
"noPlanFromProvider": "Brak planu od dostawcy",
"noQuotaData": "Brak danych dotyczących kwot",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Brak dostępnych danych dotyczących kwot",
"noAccountsForTierFilter": "Nie znaleziono kont dla filtra poziomów",
"tierAll": "Wszystko",
+72 -30
View File
@@ -63,6 +63,7 @@
"dashboard": "Painel",
"providers": "Provedores",
"combos": "Combos",
"autoCombo": "Auto Combo",
"usage": "Uso",
"analytics": "Análises",
"costs": "Custos",
@@ -235,6 +236,26 @@
"keyCreatedNote": "Copie e armazene esta chave agora — ela não será mostrada novamente.",
"done": "Pronto",
"savePermissions": "Salvar Permissões",
"autoResolve": "Auto-Resolve",
"autoResolveDesc": "Resolve automaticamente nomes ambíguos de modelo para o provedor nativo desta API key.",
"keyActive": "Chave Ativa",
"keyActiveDesc": "Ativa ou desativa esta API key. Chaves desativadas são bloqueadas com 403.",
"accessSchedule": "Horário de Acesso",
"accessScheduleDesc": "Restrinja o acesso a horários e dias da semana específicos.",
"scheduleFrom": "Das",
"scheduleUntil": "Até",
"scheduleDays": "Dias",
"scheduleTimezone": "Fuso Horário",
"scheduleTimezoneHint": "Use nomes IANA, ex: America/Sao_Paulo",
"scheduleActive": "Agenda",
"disabled": "Desativada",
"daySun": "Dom",
"dayMon": "Seg",
"dayTue": "Ter",
"dayWed": "Qua",
"dayThu": "Qui",
"dayFri": "Sex",
"daySat": "Sáb",
"allowAll": "Permitir Tudo",
"restrict": "Restringir",
"allowAllInfo": "Esta chave pode acessar todos os modelos disponíveis.",
@@ -604,6 +625,8 @@
"randomDesc": "Seleção aleatória uniforme, depois fallback para modelos restantes",
"leastUsedDesc": "Escolhe o modelo com menos requisições, equilibrando carga ao longo do tempo",
"costOptimizedDesc": "Roteia para o modelo mais barato primeiro baseado em preços",
"strictRandom": "Aleatório Estrito",
"strictRandomDesc": "Baralho embaralhado — usa cada modelo uma vez antes de reembaralhar",
"models": "Modelos",
"autoBalance": "Auto-balancear",
"advancedSettings": "Configurações Avançadas",
@@ -652,6 +675,11 @@
"when": "Redução de custo é a prioridade principal.",
"avoid": "A base de preços está ausente ou desatualizada.",
"example": "Jobs em lote ou segundo plano focados em menor custo."
},
"strict-random": {
"when": "Você quer distribuição perfeitamente uniforme — cada modelo é usado uma vez antes de repetir.",
"avoid": "Os modelos têm qualidade ou latência muito diferentes e a ordem importa.",
"example": "Múltiplas contas do mesmo modelo para distribuir uso de forma equilibrada."
}
},
"advancedHelp": {
@@ -705,46 +733,53 @@
"recommendationsApplied": "Recommendations applied to this combo.",
"strategyRecommendations": {
"priority": {
"title": "Fail-safe baseline",
"description": "Use one primary model and keep fallback chain short and reliable.",
"tip1": "Put your most reliable model first.",
"tip2": "Keep 1-2 backup models with similar quality.",
"tip3": "Use safe retries to absorb transient provider failures."
"title": "Fail-safe básico",
"description": "Use um modelo principal e mantenha a cadeia de fallback curta e confiável.",
"tip1": "Coloque o modelo mais confiável em primeiro.",
"tip2": "Mantenha 1-2 modelos de backup com qualidade similar.",
"tip3": "Use retries seguros para absorver falhas transitórias do provedor."
},
"weighted": {
"title": "Controlled traffic split",
"description": "Great for canary rollouts and gradual migration between models.",
"tip1": "Start with conservative split like 90/10.",
"tip2": "Keep the total at 100% and auto-balance after changes.",
"tip3": "Monitor success and latency before increasing canary weight."
"title": "Divisão controlada de tráfego",
"description": "Ótimo para rollouts canário e migração gradual entre modelos.",
"tip1": "Comece com divisão conservadora tipo 90/10.",
"tip2": "Mantenha o total em 100% e rebalanceie após mudanças.",
"tip3": "Monitore sucesso e latência antes de aumentar o peso canário."
},
"round-robin": {
"title": "Predictable load sharing",
"description": "Best when models are equivalent and you need smooth distribution.",
"tip1": "Use at least 2 models.",
"tip2": "Set concurrency limits to avoid burst overload.",
"tip3": "Use queue timeout to fail fast under saturation."
"title": "Distribuição previsível de carga",
"description": "Melhor quando os modelos são equivalentes e você precisa de distribuição uniforme.",
"tip1": "Use pelo menos 2 modelos.",
"tip2": "Configure limites de concorrência para evitar sobrecarga.",
"tip3": "Use timeout de fila para falhar rápido sob saturação."
},
"random": {
"title": "Quick spread with low setup",
"description": "Use when you need simple distribution without strict guarantees.",
"tip1": "Use models with similar latency profiles.",
"tip2": "Keep retries enabled to absorb random misses.",
"tip3": "Prefer this for experimentation, not strict SLAs."
"title": "Distribuição rápida com baixa configuração",
"description": "Use quando precisar de distribuição simples sem garantias rígidas.",
"tip1": "Use modelos com perfis de latência semelhantes.",
"tip2": "Mantenha retries habilitados para absorver falhas aleatórias.",
"tip3": "Prefira para experimentação, não para SLAs rígidos."
},
"least-used": {
"title": "Adaptive balancing",
"description": "Routes to less-used models to reduce hotspots over time.",
"tip1": "Works better under continuous traffic.",
"tip2": "Combine with health checks for safer balancing.",
"tip3": "Track per-model usage to validate distribution gains."
"title": "Balanceamento adaptativo",
"description": "Roteia para modelos menos usados para reduzir hotspots ao longo do tempo.",
"tip1": "Funciona melhor sob tráfego contínuo.",
"tip2": "Combine com health checks para balanceamento mais seguro.",
"tip3": "Acompanhe uso por modelo para validar ganhos na distribuição."
},
"cost-optimized": {
"title": "Budget-first routing",
"description": "Routes to lower-cost models when pricing metadata is available.",
"tip1": "Ensure pricing coverage for all selected models.",
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
"title": "Roteamento por orçamento",
"description": "Roteia para modelos mais baratos quando metadados de preço estão disponíveis.",
"tip1": "Garanta cobertura de preços para todos os modelos selecionados.",
"tip2": "Mantenha um fallback de qualidade para prompts difíceis.",
"tip3": "Use para jobs em lote/background onde custo é o KPI principal."
},
"strict-random": {
"title": "Distribuição estritamente uniforme",
"description": "Cada modelo é usado exatamente uma vez antes de reembaralhar o baralho.",
"tip1": "Ideal para múltiplas contas do mesmo modelo.",
"tip2": "Garante que nenhuma conta é repetida antes de todas serem usadas.",
"tip3": "Combine com health checks para pular contas indisponíveis sem quebrar o ciclo."
}
},
"templateFreeStack": "Free Stack ($0)",
@@ -1378,6 +1413,8 @@
"email": "Email",
"healthCheckMinutes": "Health Check (min)",
"healthCheckHint": "Intervalo proativo de renovação de token. 0 = desativado.",
"groupLabel": "Ambiente",
"groupPlaceholder": "ex: eKaizen, Pessoal",
"failedTestConnection": "Falha ao testar conexão",
"failed": "Falhou",
"leaveBlankKeepCurrentApiKey": "Deixe em branco para manter a chave de API atual.",
@@ -1548,6 +1585,8 @@
"leastUsedDesc": "Escolher a conta usada menos recentemente",
"costOpt": "Custo Otimizado",
"costOptDesc": "Preferir conta mais barata disponível",
"strictRandom": "Aleatório Estrito",
"strictRandomDesc": "Baralho embaralhado — usa cada conta uma vez antes de reembaralhar",
"stickyLimit": "Limite Fixo",
"stickyLimitDesc": "Chamadas por conta antes de trocar",
"modelAliases": "Aliases de Modelo",
@@ -2072,6 +2111,9 @@
"rawPlanWithValue": "Plano bruto: {plan}",
"noPlanFromProvider": "Sem plano do provedor",
"noQuotaData": "Sem dados de cota",
"ungrouped": "Sem grupo",
"viewFlat": "Lista",
"viewByEnvironment": "Por Ambiente",
"noQuotaDataAvailable": "Nenhum dado de cota disponível",
"noAccountsForTierFilter": "Nenhuma conta encontrada para o filtro de plano",
"tierAll": "Todos",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Seleção aleatória uniforme e, em seguida, retorno aos modelos restantes",
"leastUsedDesc": "Escolhe o modelo com menos solicitações, equilibrando a carga ao longo do tempo",
"costOptimizedDesc": "Rotas para o modelo mais barato primeiro com base no preço",
"strictRandom": "Aleatório Estrito",
"strictRandomDesc": "Baralho embaralhado — usa cada modelo uma vez antes de reembaralhar",
"models": "Modelos",
"autoBalance": "Equilíbrio automático",
"advancedSettings": "Configurações avançadas",
@@ -1390,6 +1392,8 @@
"email": "E-mail",
"healthCheckMinutes": "Verificação de integridade (min)",
"healthCheckHint": "Intervalo de atualização de token proativo. 0 = desabilitado.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Falha ao testar a conexão",
"failed": "Falha",
"leaveBlankKeepCurrentApiKey": "Deixe em branco para manter a chave API atual.",
@@ -1555,6 +1559,8 @@
"leastUsedDesc": "Escolha a conta usada menos recentemente",
"costOpt": "Opção de custo",
"costOptDesc": "Prefira a conta mais barata disponível",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Limite pegajoso",
"stickyLimitDesc": "Chamadas por conta antes de mudar",
"modelAliases": "Aliases de modelo",
@@ -2084,6 +2090,9 @@
"rawPlanWithValue": "Plano bruto: {plan}",
"noPlanFromProvider": "Nenhum plano do provedor",
"noQuotaData": "Sem dados de cota",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Não há dados de cota disponíveis",
"noAccountsForTierFilter": "Nenhuma conta encontrada para filtro de nível",
"tierAll": "Todos",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Selectare aleatorie uniformă, apoi revenire la modelele rămase",
"leastUsedDesc": "Alege modelul cu cele mai puține solicitări, echilibrând sarcina în timp",
"costOptimizedDesc": "Rute către cel mai ieftin model mai întâi pe baza prețului",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modele",
"autoBalance": "Auto-echilibrare",
"advancedSettings": "Setări avansate",
@@ -1378,6 +1380,8 @@
"email": "E-mail",
"healthCheckMinutes": "Verificare de sănătate (min)",
"healthCheckHint": "Interval proactiv de reîmprospătare a simbolului. 0 = dezactivat.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Nu s-a testat conexiunea",
"failed": "A eșuat",
"leaveBlankKeepCurrentApiKey": "Lăsați necompletat pentru a păstra cheia API curentă.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Alegeți contul cel mai puțin utilizat recent",
"costOpt": "Cost Opt",
"costOptDesc": "Prefer cel mai ieftin cont disponibil",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Limită lipicioasă",
"stickyLimitDesc": "Apeluri pe cont înainte de a comuta",
"modelAliases": "Aliasuri de model",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Plan brut: {plan}",
"noPlanFromProvider": "Niciun plan de la furnizor",
"noQuotaData": "Fără date de cotă",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Nu sunt disponibile date privind cotele",
"noAccountsForTierFilter": "Nu s-au găsit conturi pentru filtrul de nivel",
"tierAll": "Toate",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Равномерный случайный выбор, затем возврат к оставшимся моделям",
"leastUsedDesc": "Выбирает модель с наименьшим количеством запросов, балансируя нагрузку с течением времени.",
"costOptimizedDesc": "Маршруты к самой дешевой модели в первую очередь на основе цены",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Модели",
"autoBalance": "Автобаланс",
"advancedSettings": "Расширенные настройки",
@@ -1378,6 +1380,8 @@
"email": "электронная почта",
"healthCheckMinutes": "Проверка здоровья (мин)",
"healthCheckHint": "Интервал обновления упреждающего токена. 0 = отключено.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Не удалось проверить соединение",
"failed": "Не удалось",
"leaveBlankKeepCurrentApiKey": "Оставьте пустым, чтобы сохранить текущий ключ API.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Выберите наименее использованную учетную запись",
"costOpt": "Опция стоимости",
"costOptDesc": "Предпочитаю самый дешевый доступный аккаунт",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Липкий лимит",
"stickyLimitDesc": "Звонки на аккаунт до переключения",
"modelAliases": "Псевдонимы моделей",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Необработанный план: {plan}",
"noPlanFromProvider": "Нет плана от провайдера",
"noQuotaData": "Нет данных о квотах",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Нет данных о квотах",
"noAccountsForTierFilter": "Аккаунты для фильтра уровня не найдены",
"tierAll": "Все",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Jednotný náhodný výber, potom návrat k zostávajúcim modelom",
"leastUsedDesc": "Vyberie model s najmenším počtom požiadaviek, čím vyrovná zaťaženie v priebehu času",
"costOptimizedDesc": "Najprv sa presmeruje na najlacnejší model na základe ceny",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modelky",
"autoBalance": "Automatické vyváženie",
"advancedSettings": "Rozšírené nastavenia",
@@ -1378,6 +1380,8 @@
"email": "Email",
"healthCheckMinutes": "Kontrola stavu (min)",
"healthCheckHint": "Interval proaktívneho obnovenia tokenu. 0 = vypnuté.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Nepodarilo sa otestovať pripojenie",
"failed": "Nepodarilo sa",
"leaveBlankKeepCurrentApiKey": "Ak chcete zachovať aktuálny kľúč API, nechajte pole prázdne.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Vyberte najmenej nedávno používaný účet",
"costOpt": "Opt",
"costOptDesc": "Uprednostnite najlacnejší dostupný účet",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Sticky Limit",
"stickyLimitDesc": "Hovory na účet pred prepnutím",
"modelAliases": "Aliasy modelov",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Nespracovaný plán: {plan}",
"noPlanFromProvider": "Žiadny plán od poskytovateľa",
"noQuotaData": "Žiadne údaje o kvóte",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Nie sú k dispozícii žiadne údaje o kvóte",
"noAccountsForTierFilter": "Pre filter úrovne sa nenašli žiadne účty",
"tierAll": "Všetky",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Enhetligt slumpmässigt urval, sedan fallback till återstående modeller",
"leastUsedDesc": "Väljer modellen med minst förfrågningar, balanserar belastningen över tiden",
"costOptimizedDesc": "Rutter till den billigaste modellen först baserat på prissättning",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Modeller",
"autoBalance": "Automatisk balansering",
"advancedSettings": "Avancerade inställningar",
@@ -1378,6 +1380,8 @@
"email": "E-post",
"healthCheckMinutes": "Hälsokontroll (min)",
"healthCheckHint": "Proaktivt uppdateringsintervall för token. 0 = inaktiverad.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Det gick inte att testa anslutningen",
"failed": "Misslyckades",
"leaveBlankKeepCurrentApiKey": "Lämna tomt för att behålla den aktuella API-nyckeln.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Välj minst senast använda konto",
"costOpt": "Kostnad Opt",
"costOptDesc": "Föredrar billigaste tillgängliga konto",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Sticky Limit",
"stickyLimitDesc": "Samtal per konto innan byte",
"modelAliases": "Modellalias",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Rå plan: {plan}",
"noPlanFromProvider": "Ingen plan från leverantören",
"noQuotaData": "Inga kvotdata",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Inga kvotdata tillgängliga",
"noAccountsForTierFilter": "Inga konton hittades för nivåfilter",
"tierAll": "Alla",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "การเลือกแบบสุ่มแบบสม่ำเสมอ จากนั้นจึงย้อนกลับไปยังโมเดลที่เหลือ",
"leastUsedDesc": "เลือกโมเดลที่มีคำขอน้อยที่สุด โดยจะปรับสมดุลการโหลดเมื่อเวลาผ่านไป",
"costOptimizedDesc": "กำหนดเส้นทางไปยังรุ่นที่ถูกที่สุดก่อนตามราคา",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "โมเดล",
"autoBalance": "ปรับสมดุลอัตโนมัติ",
"advancedSettings": "การตั้งค่าขั้นสูง",
@@ -1378,6 +1380,8 @@
"email": "อีเมล",
"healthCheckMinutes": "ตรวจสุขภาพ (ขั้นต่ำ)",
"healthCheckHint": "ช่วงเวลาการรีเฟรชโทเค็นเชิงรุก 0 = ปิดการใช้งาน",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "ทดสอบการเชื่อมต่อไม่สำเร็จ",
"failed": "ล้มเหลว",
"leaveBlankKeepCurrentApiKey": "เว้นว่างไว้เพื่อเก็บคีย์ API ปัจจุบันไว้",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "เลือกบัญชีที่ใช้ล่าสุดน้อยที่สุด",
"costOpt": "การเลือกใช้ต้นทุน",
"costOptDesc": "ต้องการบัญชีที่ถูกที่สุด",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "ขีด จำกัด เหนียว",
"stickyLimitDesc": "โทรต่อบัญชีก่อนที่จะเปลี่ยน",
"modelAliases": "นามแฝงของโมเดล",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "แผนดิบ: {plan}",
"noPlanFromProvider": "ไม่มีแผนจากผู้ให้บริการ",
"noQuotaData": "ไม่มีข้อมูลโควต้า",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "ไม่มีข้อมูลโควต้า",
"noAccountsForTierFilter": "ไม่พบบัญชีสำหรับตัวกรองระดับ",
"tierAll": "ทั้งหมด",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Рівномірний випадковий вибір, а потім повернення до інших моделей",
"leastUsedDesc": "Вибирає модель із найменшою кількістю запитів, балансуючи навантаження за часом",
"costOptimizedDesc": "Маршрути до найдешевшої моделі на основі ціни",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Моделі",
"autoBalance": "Автобаланс",
"advancedSettings": "Розширені налаштування",
@@ -1378,6 +1380,8 @@
"email": "Електронна пошта",
"healthCheckMinutes": "Перевірка стану (хв.)",
"healthCheckHint": "Проактивний інтервал оновлення маркера. 0 = вимкнено.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Не вдалося перевірити з’єднання",
"failed": "Не вдалося",
"leaveBlankKeepCurrentApiKey": "Залиште поле порожнім, щоб зберегти поточний ключ API.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Виберіть нещодавно використовуваний обліковий запис",
"costOpt": "Вартість Opt",
"costOptDesc": "Віддайте перевагу найдешевшому доступному обліковому запису",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Sticky Limit",
"stickyLimitDesc": "Дзвінки на обліковий запис перед переходом",
"modelAliases": "Псевдоніми моделі",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Необроблений план: {plan}",
"noPlanFromProvider": "Без плану від провайдера",
"noQuotaData": "Немає даних про квоти",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Немає даних про квоти",
"noAccountsForTierFilter": "Не знайдено облікових записів для фільтра рівня",
"tierAll": "всі",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "Lựa chọn ngẫu nhiên thống nhất, sau đó dự phòng cho các mô hình còn lại",
"leastUsedDesc": "Chọn mô hình có ít yêu cầu nhất, cân bằng tải theo thời gian",
"costOptimizedDesc": "Hướng tới mô hình rẻ nhất trước tiên dựa trên giá cả",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Người mẫu",
"autoBalance": "Tự động cân bằng",
"advancedSettings": "Cài đặt nâng cao",
@@ -1378,6 +1380,8 @@
"email": "Email",
"healthCheckMinutes": "Kiểm tra sức khỏe (phút)",
"healthCheckHint": "Khoảng thời gian làm mới mã thông báo chủ động. 0 = bị vô hiệu hóa.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Không thể kiểm tra kết nối",
"failed": "thất bại",
"leaveBlankKeepCurrentApiKey": "Để trống để giữ khóa API hiện tại.",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "Chọn tài khoản ít được sử dụng gần đây nhất",
"costOpt": "Lựa chọn chi phí",
"costOptDesc": "Ưu tiên tài khoản có sẵn rẻ nhất",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Giới hạn dính",
"stickyLimitDesc": "Cuộc gọi trên mỗi tài khoản trước khi chuyển đổi",
"modelAliases": "Bí danh mẫu",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "Gói thô: {plan}",
"noPlanFromProvider": "Không có kế hoạch từ nhà cung cấp",
"noQuotaData": "Không có dữ liệu hạn ngạch",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Không có sẵn dữ liệu hạn ngạch",
"noAccountsForTierFilter": "Không tìm thấy tài khoản nào cho bộ lọc cấp độ",
"tierAll": "Tất cả",
+9
View File
@@ -604,6 +604,8 @@
"randomDesc": "统一随机选择,然后回退到剩余模型",
"leastUsedDesc": "选择请求最少的模型,随着时间的推移平衡负载",
"costOptimizedDesc": "首先根据定价路由至最便宜的型号",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "型号",
"autoBalance": "自动平衡",
"advancedSettings": "高级设置",
@@ -1378,6 +1380,8 @@
"email": "电子邮件",
"healthCheckMinutes": "健康检查(分钟)",
"healthCheckHint": "主动令牌刷新间隔。 0 = 禁用。",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "测试连接失败",
"failed": "失败",
"leaveBlankKeepCurrentApiKey": "留空以保留当前的 API 密钥。",
@@ -1543,6 +1547,8 @@
"leastUsedDesc": "选择最近最少使用的帐户",
"costOpt": "成本选择",
"costOptDesc": "更喜欢最便宜的可用帐户",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "粘性限制",
"stickyLimitDesc": "切换前每个账户的通话次数",
"modelAliases": "模型别名",
@@ -2072,6 +2078,9 @@
"rawPlanWithValue": "原始计划:{plan}",
"noPlanFromProvider": "提供商没有计划",
"noQuotaData": "无配额数据",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "无可用配额数据",
"noAccountsForTierFilter": "未找到适用于层过滤器的帐户",
"tierAll": "全部",
+13 -1
View File
@@ -52,7 +52,9 @@ export async function register() {
try {
const { getSettings } = await import("@/lib/db/settings");
const { setCustomAliases } = await import("@omniroute/open-sse/services/modelDeprecation.ts");
const { setDefaultFastServiceTierEnabled } = await import("@omniroute/open-sse/executors/codex.ts");
const settings = await getSettings();
if (settings.modelAliases) {
const aliases =
typeof settings.modelAliases === "string"
@@ -65,9 +67,19 @@ export async function register() {
);
}
}
const persisted =
typeof settings.codexServiceTier === "string"
? JSON.parse(settings.codexServiceTier)
: settings.codexServiceTier;
if (typeof persisted?.enabled === "boolean") {
setDefaultFastServiceTierEnabled(persisted.enabled);
console.log(`[STARTUP] Restored Codex fast service tier: ${persisted.enabled ? "on" : "off"}`);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn("[STARTUP] Could not restore model aliases:", msg);
console.warn("[STARTUP] Could not restore runtime settings:", msg);
}
// Compliance: Initialize audit_log table + cleanup expired logs
+175 -4
View File
@@ -20,12 +20,24 @@ interface CacheEntry<TValue> {
value: TValue;
}
export interface AccessSchedule {
enabled: boolean;
from: string;
until: string;
days: number[];
tz: string;
}
interface ApiKeyMetadata {
id: string;
name: string;
machineId: string | null;
allowedModels: string[];
allowedConnections: string[];
noLog: boolean;
autoResolve: boolean;
isActive: boolean;
accessSchedule: AccessSchedule | null;
}
interface ApiKeyRow extends JsonRecord {
@@ -36,8 +48,16 @@ interface ApiKeyRow extends JsonRecord {
machineId?: unknown;
allowed_models?: unknown;
allowedModels?: unknown;
allowed_connections?: unknown;
allowedConnections?: unknown;
no_log?: unknown;
noLog?: unknown;
auto_resolve?: unknown;
autoResolve?: unknown;
is_active?: unknown;
isActive?: unknown;
access_schedule?: unknown;
accessSchedule?: unknown;
}
interface StatementLike<TRow = unknown> {
@@ -63,7 +83,11 @@ interface ApiKeysStatements {
interface ApiKeyView extends JsonRecord {
id?: string;
allowedModels: string[];
allowedConnections: string[];
noLog: boolean;
autoResolve: boolean;
isActive: boolean;
accessSchedule: AccessSchedule | null;
}
// LRU cache for API key validation (valid keys only)
@@ -147,6 +171,22 @@ function ensureApiKeysColumns(db: ApiKeysDbLike) {
db.exec("ALTER TABLE api_keys ADD COLUMN no_log INTEGER NOT NULL DEFAULT 0");
console.log("[DB] Added api_keys.no_log column");
}
if (!columnNames.has("allowed_connections")) {
db.exec("ALTER TABLE api_keys ADD COLUMN allowed_connections TEXT");
console.log("[DB] Added api_keys.allowed_connections column");
}
if (!columnNames.has("auto_resolve")) {
db.exec("ALTER TABLE api_keys ADD COLUMN auto_resolve INTEGER NOT NULL DEFAULT 0");
console.log("[DB] Added api_keys.auto_resolve column");
}
if (!columnNames.has("is_active")) {
db.exec("ALTER TABLE api_keys ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1");
console.log("[DB] Added api_keys.is_active column");
}
if (!columnNames.has("access_schedule")) {
db.exec("ALTER TABLE api_keys ADD COLUMN access_schedule TEXT");
console.log("[DB] Added api_keys.access_schedule column");
}
_schemaChecked = true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -172,7 +212,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
_stmtGetKeyById = db.prepare<ApiKeyRow>("SELECT * FROM api_keys WHERE id = ?");
_stmtValidateKey = db.prepare<JsonRecord>("SELECT 1 FROM api_keys WHERE key = ?");
_stmtGetKeyMetadata = db.prepare<ApiKeyRow>(
"SELECT id, name, machine_id, allowed_models, no_log FROM api_keys WHERE key = ?"
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule FROM api_keys WHERE key = ?"
);
_stmtInsertKey = db.prepare(
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
@@ -208,7 +248,11 @@ export async function getApiKeys() {
return rows.map((row) => {
const camelRow = toRecord(rowToCamel(row)) as ApiKeyView;
camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels);
camelRow.allowedConnections = parseAllowedConnections(camelRow.allowedConnections);
camelRow.noLog = parseNoLog(camelRow.noLog);
camelRow.autoResolve = parseAutoResolve(camelRow.autoResolve);
camelRow.isActive = parseIsActive(camelRow.isActive);
camelRow.accessSchedule = parseAccessSchedule(camelRow.accessSchedule);
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
setNoLog(camelRow.id, camelRow.noLog === true);
}
@@ -223,7 +267,11 @@ export async function getApiKeyById(id: string) {
if (!row) return null;
const camelRow = toRecord(rowToCamel(row)) as ApiKeyView;
camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels);
camelRow.allowedConnections = parseAllowedConnections(camelRow.allowedConnections);
camelRow.noLog = parseNoLog(camelRow.noLog);
camelRow.autoResolve = parseAutoResolve(camelRow.autoResolve);
camelRow.isActive = parseIsActive(camelRow.isActive);
camelRow.accessSchedule = parseAccessSchedule(camelRow.accessSchedule);
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
setNoLog(camelRow.id, camelRow.noLog === true);
}
@@ -251,6 +299,63 @@ function parseNoLog(value: unknown): boolean {
return value === true || value === 1 || value === "1";
}
function parseAutoResolve(value: unknown): boolean {
return value === true || value === 1 || value === "1";
}
function parseIsActive(value: unknown): boolean {
// DEFAULT 1 — active unless explicitly set to 0
if (value === 0 || value === "0" || value === false) return false;
return true;
}
function parseAccessSchedule(value: unknown): AccessSchedule | null {
if (!value || typeof value !== "string" || value.trim() === "") return null;
try {
const parsed: unknown = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
const obj = parsed as Record<string, unknown>;
if (
typeof obj["enabled"] !== "boolean" ||
typeof obj["from"] !== "string" ||
typeof obj["until"] !== "string" ||
!Array.isArray(obj["days"]) ||
typeof obj["tz"] !== "string"
) {
return null;
}
const days = (obj["days"] as unknown[]).filter(
(d): d is number => typeof d === "number" && Number.isInteger(d) && d >= 0 && d <= 6
);
return {
enabled: obj["enabled"],
from: obj["from"],
until: obj["until"],
days,
tz: obj["tz"],
};
} catch {
return null;
}
}
/**
* Helper function to safely parse allowed_connections JSON
*/
function parseAllowedConnections(value: unknown): string[] {
if (!value || typeof value !== "string" || value.trim() === "") {
return [];
}
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((entry): entry is string => typeof entry === "string")
: [];
} catch {
return [];
}
}
export async function createApiKey(name: string, machineId: string) {
if (!machineId) {
throw new Error("machineId is required");
@@ -268,6 +373,7 @@ export async function createApiKey(name: string, machineId: string) {
key: result.key,
machineId: machineId,
allowedModels: [], // Empty array means all models allowed
allowedConnections: [], // Empty array means all connections allowed
noLog: false,
createdAt: now,
};
@@ -290,7 +396,17 @@ export async function createApiKey(name: string, machineId: string) {
export async function updateApiKeyPermissions(
id: string,
update: string[] | { allowedModels?: string[]; noLog?: boolean }
update:
| string[]
| {
name?: string;
allowedModels?: string[];
allowedConnections?: string[];
noLog?: boolean;
autoResolve?: boolean;
isActive?: boolean;
accessSchedule?: AccessSchedule | null;
}
) {
const db = getDbInstance() as ApiKeysDbLike;
getPreparedStatements(db);
@@ -299,16 +415,43 @@ export async function updateApiKeyPermissions(
Array.isArray(update) || update === undefined
? { allowedModels: update || [] }
: {
name: update.name,
allowedModels: update.allowedModels,
allowedConnections: update.allowedConnections,
noLog: update.noLog,
autoResolve: update.autoResolve,
isActive: update.isActive,
accessSchedule: update.accessSchedule,
};
if (normalized.allowedModels === undefined && normalized.noLog === undefined) {
if (
normalized.name === undefined &&
normalized.allowedModels === undefined &&
normalized.allowedConnections === undefined &&
normalized.noLog === undefined &&
normalized.autoResolve === undefined &&
normalized.isActive === undefined &&
normalized.accessSchedule === undefined
) {
return false;
}
const updates: string[] = [];
const params: { id: string; allowedModels?: string; noLog?: number } = { id };
const params: {
id: string;
name?: string;
allowedModels?: string;
allowedConnections?: string;
noLog?: number;
autoResolve?: number;
isActive?: number;
accessSchedule?: string | null;
} = { id };
if (normalized.name !== undefined) {
updates.push("name = @name");
params.name = normalized.name;
}
if (normalized.allowedModels !== undefined) {
// Empty array means all models are allowed
@@ -316,11 +459,33 @@ export async function updateApiKeyPermissions(
params.allowedModels = JSON.stringify(normalized.allowedModels || []);
}
if (normalized.allowedConnections !== undefined) {
// Empty array means all connections are allowed
updates.push("allowed_connections = @allowedConnections");
params.allowedConnections = JSON.stringify(normalized.allowedConnections || []);
}
if (normalized.noLog !== undefined) {
updates.push("no_log = @noLog");
params.noLog = normalized.noLog ? 1 : 0;
}
if (normalized.autoResolve !== undefined) {
updates.push("auto_resolve = @autoResolve");
params.autoResolve = normalized.autoResolve ? 1 : 0;
}
if (normalized.isActive !== undefined) {
updates.push("is_active = @isActive");
params.isActive = normalized.isActive ? 1 : 0;
}
if (normalized.accessSchedule !== undefined) {
updates.push("access_schedule = @accessSchedule");
params.accessSchedule =
normalized.accessSchedule !== null ? JSON.stringify(normalized.accessSchedule) : null;
}
const result = db.prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`).run(params);
if (result.changes === 0) return false;
@@ -414,7 +579,13 @@ export async function getApiKeyMetadata(
name: metadataName,
machineId: metadataMachineId,
allowedModels: parseAllowedModels(record.allowed_models ?? record.allowedModels),
allowedConnections: parseAllowedConnections(
record.allowed_connections ?? record.allowedConnections
),
noLog: parseNoLog(record.no_log ?? record.noLog),
autoResolve: parseAutoResolve(record.auto_resolve ?? record.autoResolve),
isActive: parseIsActive(record.is_active ?? record.isActive),
accessSchedule: parseAccessSchedule(record.access_schedule ?? record.accessSchedule),
};
if (!metadata.id) {
+5
View File
@@ -80,6 +80,7 @@ const SCHEMA_SQL = `
consecutive_use_count INTEGER DEFAULT 0,
rate_limit_protection INTEGER DEFAULT 0,
last_used_at TEXT,
"group" TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -316,6 +317,10 @@ function ensureProviderConnectionsColumns(db: SqliteDatabase) {
db.exec("ALTER TABLE provider_connections ADD COLUMN last_used_at TEXT");
console.log("[DB] Added provider_connections.last_used_at column");
}
if (!columnNames.has("group")) {
db.exec('ALTER TABLE provider_connections ADD COLUMN "group" TEXT');
console.log('[DB] Added provider_connections."group" column');
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.warn("[DB] Failed to verify provider_connections schema:", message);
+16 -2
View File
@@ -185,6 +185,7 @@ export async function createProviderConnection(data: JsonRecord) {
"errorCode",
"consecutiveUseCount",
"rateLimitProtection",
"group",
];
for (const field of optionalFields) {
if (data[field] !== undefined && data[field] !== null) {
@@ -217,7 +218,7 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
rate_limited_until, health_check_interval, last_health_check_at,
last_tested, api_key, id_token, provider_specific_data,
expires_in, display_name, global_priority, default_model,
token_type, consecutive_use_count, rate_limit_protection, last_used_at, created_at, updated_at
token_type, consecutive_use_count, rate_limit_protection, last_used_at, "group", created_at, updated_at
) VALUES (
@id, @provider, @authType, @name, @email, @priority, @isActive,
@accessToken, @refreshToken, @expiresAt, @tokenExpiresAt,
@@ -226,7 +227,7 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
@rateLimitedUntil, @healthCheckInterval, @lastHealthCheckAt,
@lastTested, @apiKey, @idToken, @providerSpecificData,
@expiresIn, @displayName, @globalPriority, @defaultModel,
@tokenType, @consecutiveUseCount, @rateLimitProtection, @lastUsedAt, @createdAt, @updatedAt
@tokenType, @consecutiveUseCount, @rateLimitProtection, @lastUsedAt, @group, @createdAt, @updatedAt
)
`
).run({
@@ -268,6 +269,7 @@ function _insertConnectionRow(db: DbLike, conn: JsonRecord) {
rateLimitProtection:
conn.rateLimitProtection === true || conn.rateLimitProtection === 1 ? 1 : 0,
lastUsedAt: conn.lastUsedAt || null,
group: conn.group || null,
createdAt: conn.createdAt,
updatedAt: conn.updatedAt,
});
@@ -292,6 +294,7 @@ function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) {
consecutive_use_count = @consecutiveUseCount,
rate_limit_protection = @rateLimitProtection,
last_used_at = @lastUsedAt,
"group" = @group,
updated_at = @updatedAt
WHERE id = @id
`
@@ -334,6 +337,7 @@ function _updateConnectionRow(db: DbLike, id: string, data: JsonRecord) {
rateLimitProtection:
data.rateLimitProtection === true || data.rateLimitProtection === 1 ? 1 : 0,
lastUsedAt: data.lastUsedAt || null,
group: data.group || null,
updatedAt: now,
});
}
@@ -407,6 +411,16 @@ export async function cleanupProviderConnections() {
return 0;
}
export async function getDistinctGroups(): Promise<string[]> {
const db = getDbInstance() as unknown as DbLike;
const rows = db
.prepare(
'SELECT DISTINCT "group" FROM provider_connections WHERE "group" IS NOT NULL ORDER BY "group"'
)
.all() as Array<{ group?: string }>;
return rows.map((r) => String(r.group ?? "")).filter(Boolean);
}
// ──────────────── Provider Nodes ────────────────
export async function getProviderNodes(filter: JsonRecord = {}) {
+32 -17
View File
@@ -94,6 +94,25 @@ export async function isCloudEnabled() {
export async function getPricing() {
const db = getDbInstance();
// Layer 1: Hardcoded defaults (lowest priority)
const { getDefaultPricing } = await import("@/shared/constants/pricing");
const defaultPricing = getDefaultPricing();
// Layer 2: Synced external pricing (middle priority)
const syncedRows = db
.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing_synced'")
.all();
const syncedPricing: PricingByProvider = {};
for (const row of syncedRows) {
const record = toRecord(row);
const key = typeof record.key === "string" ? record.key : null;
const rawValue = typeof record.value === "string" ? record.value : null;
if (!key || rawValue === null) continue;
syncedPricing[key] = toRecord(JSON.parse(rawValue)) as PricingModels;
}
// Layer 3: User overrides (highest priority)
const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all();
const userPricing: PricingByProvider = {};
for (const row of rows) {
@@ -104,28 +123,24 @@ export async function getPricing() {
userPricing[key] = toRecord(JSON.parse(rawValue)) as PricingModels;
}
const { getDefaultPricing } = await import("@/shared/constants/pricing");
const defaultPricing = getDefaultPricing();
// Merge: defaults → synced → user (each layer overrides the previous)
const mergedPricing: PricingByProvider = {};
// Start with defaults
for (const [provider, models] of Object.entries(defaultPricing) as Array<[string, unknown]>) {
mergedPricing[provider] = { ...(toRecord(models) as PricingModels) };
if (userPricing[provider]) {
for (const [model, pricing] of Object.entries(userPricing[provider])) {
mergedPricing[provider][model] = mergedPricing[provider][model]
? { ...(mergedPricing[provider][model] || {}), ...toRecord(pricing) }
: pricing;
}
}
}
for (const [provider, models] of Object.entries(userPricing)) {
if (!mergedPricing[provider]) {
mergedPricing[provider] = { ...models };
} else {
for (const [model, pricing] of Object.entries(models)) {
if (!mergedPricing[provider][model]) {
mergedPricing[provider][model] = pricing;
// Layer synced then user on top (each higher-priority layer overrides)
for (const layer of [syncedPricing, userPricing]) {
for (const [provider, models] of Object.entries(layer)) {
if (!mergedPricing[provider]) {
mergedPricing[provider] = { ...models };
} else {
for (const [model, pricing] of Object.entries(models)) {
mergedPricing[provider][model] = mergedPricing[provider][model]
? { ...(mergedPricing[provider][model] || {}), ...toRecord(pricing) }
: pricing;
}
}
}
+12
View File
@@ -89,6 +89,18 @@ export {
setProxyConfig,
} from "./db/settings";
export {
// Pricing Sync
getSyncedPricing,
saveSyncedPricing,
clearSyncedPricing,
syncPricingFromSources,
getSyncStatus,
initPricingSync,
startPeriodicSync,
stopPeriodicSync,
} from "./pricingSync";
export {
// Backup Management
backupDbFile,
+397
View File
@@ -0,0 +1,397 @@
/**
* pricingSync.ts External pricing sync engine.
*
* Fetches pricing data from external sources (LiteLLM) and stores it
* in a separate namespace (`pricing_synced`) so user overrides are
* never touched.
*
* Resolution order: user overrides > synced external > hardcoded defaults
*
* Opt-in via PRICING_SYNC_ENABLED=true (default: false).
*/
import { getDbInstance } from "./db/core";
import { invalidateDbCache } from "./db/readCache";
import { backupDbFile } from "./db/backup";
// ─── Types ───────────────────────────────────────────────
type PricingEntry = {
input: number;
output: number;
cached?: number;
cache_creation?: number;
};
type PricingModels = Record<string, PricingEntry>;
type PricingByProvider = Record<string, PricingModels>;
interface LiteLLMModelInfo {
input_cost_per_token?: number;
output_cost_per_token?: number;
cache_read_input_token_cost?: number;
cache_creation_input_token_cost?: number;
litellm_provider?: string;
mode?: string;
}
interface SyncStatus {
enabled: boolean;
lastSync: string | null;
lastSyncModelCount: number;
nextSync: string | null;
intervalMs: number;
sources: string[];
}
interface SyncResult {
success: boolean;
modelCount: number;
providerCount: number;
source: string;
dryRun: boolean;
data?: PricingByProvider;
error?: string;
}
// ─── Configuration ───────────────────────────────────────
const SUPPORTED_SOURCES = ["litellm"] as const;
type SupportedSource = (typeof SUPPORTED_SOURCES)[number];
const parsedInterval = parseInt(process.env.PRICING_SYNC_INTERVAL || "86400", 10);
const SYNC_INTERVAL_MS =
Number.isFinite(parsedInterval) && parsedInterval > 0 ? parsedInterval * 1000 : 86400 * 1000;
const SYNC_SOURCES = (process.env.PRICING_SYNC_SOURCES || "litellm")
.split(",")
.map((s) => s.trim())
.filter((s): s is SupportedSource => SUPPORTED_SOURCES.includes(s as SupportedSource));
const LITELLM_PRICING_URL =
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
// ─── Provider mapping: LiteLLM provider → OmniRoute aliases ─────
const LITELLM_PROVIDER_MAP: Record<string, string[]> = {
openai: ["openai", "cx"],
anthropic: ["anthropic", "cc"],
vertex_ai: ["gemini", "gc"],
"vertex_ai-anthropic_models": ["anthropic"],
google: ["gemini", "gc"],
deepseek: ["if"],
groq: ["groq"],
together_ai: ["openrouter"],
bedrock: ["kiro"],
fireworks_ai: ["fireworks"],
cerebras: ["cerebras"],
nvidia_nim: ["nvidia"],
siliconflow: ["siliconflow"],
};
// ─── Periodic sync state ─────────────────────────────────
let syncTimer: ReturnType<typeof setInterval> | null = null;
let lastSyncTime: string | null = null;
let lastSyncModelCount = 0;
let activeSyncIntervalMs = SYNC_INTERVAL_MS;
// ─── Core: Fetch + Transform ─────────────────────────────
/**
* Fetch raw pricing data from LiteLLM GitHub.
*/
export async function fetchLiteLLMPricing(): Promise<Record<string, LiteLLMModelInfo>> {
const response = await fetch(LITELLM_PRICING_URL, {
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
throw new Error(`LiteLLM fetch failed [${response.status}]: ${response.statusText}`);
}
const text = await response.text();
try {
return JSON.parse(text) as Record<string, LiteLLMModelInfo>;
} catch {
throw new Error(`LiteLLM returned invalid JSON (${text.slice(0, 100)}...)`);
}
}
/**
* Transform LiteLLM raw data OmniRoute PricingByProvider format.
*
* Conversion: cost_per_token × 1_000_000 $/1M tokens (OmniRoute format)
* Filters: only chat/completion modes (skip image/audio/embedding)
*/
export function transformToOmniRoute(raw: Record<string, LiteLLMModelInfo>): PricingByProvider {
const result: PricingByProvider = {};
for (const [modelKey, info] of Object.entries(raw)) {
// Skip non-chat models
if (info.mode && !["chat", "completion"].includes(info.mode)) continue;
// Must have at least input pricing
if (!info.input_cost_per_token && info.input_cost_per_token !== 0) continue;
const inputCost = (info.input_cost_per_token || 0) * 1_000_000;
const outputCost = (info.output_cost_per_token || 0) * 1_000_000;
const entry: PricingEntry = {
input: Math.round(inputCost * 1000) / 1000,
output: Math.round(outputCost * 1000) / 1000,
};
if (info.cache_read_input_token_cost != null) {
entry.cached = Math.round(info.cache_read_input_token_cost * 1_000_000 * 1000) / 1000;
}
if (info.cache_creation_input_token_cost != null) {
entry.cache_creation =
Math.round(info.cache_creation_input_token_cost * 1_000_000 * 1000) / 1000;
}
// Extract model name (strip provider prefix from key)
// LiteLLM keys look like: "openai/gpt-4o", "anthropic/claude-3-opus"
const slashIdx = modelKey.indexOf("/");
const modelName = slashIdx >= 0 ? modelKey.slice(slashIdx + 1) : modelKey;
// Map to OmniRoute providers
const litellmProvider = info.litellm_provider || "";
const omniRouteProviders = LITELLM_PROVIDER_MAP[litellmProvider];
if (omniRouteProviders) {
for (const provider of omniRouteProviders) {
if (!result[provider]) result[provider] = {};
result[provider][modelName] = entry;
}
} else if (litellmProvider) {
// Use litellm_provider as-is for unknown providers
if (!result[litellmProvider]) result[litellmProvider] = {};
result[litellmProvider][modelName] = entry;
}
}
return result;
}
// ─── DB: Synced pricing namespace ────────────────────────
function toRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
/**
* Read synced pricing from `pricing_synced` namespace.
*/
export function getSyncedPricing(): PricingByProvider {
const db = getDbInstance();
const rows = db
.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing_synced'")
.all();
const synced: PricingByProvider = {};
for (const row of rows) {
const record = toRecord(row);
const key = typeof record.key === "string" ? record.key : null;
const rawValue = typeof record.value === "string" ? record.value : null;
if (!key || rawValue === null) continue;
try {
synced[key] = JSON.parse(rawValue) as PricingModels;
} catch {
console.warn(`[PRICING_SYNC] Corrupted data for provider "${key}", skipping`);
}
}
return synced;
}
/**
* Save synced pricing to `pricing_synced` namespace (full replace).
*/
export function saveSyncedPricing(data: PricingByProvider): void {
const db = getDbInstance();
const del = db.prepare("DELETE FROM key_value WHERE namespace = 'pricing_synced'");
const insert = db.prepare(
"INSERT INTO key_value (namespace, key, value) VALUES ('pricing_synced', ?, ?)"
);
const tx = db.transaction(() => {
del.run();
for (const [provider, models] of Object.entries(data)) {
insert.run(provider, JSON.stringify(models));
}
});
tx();
backupDbFile("pre-write");
invalidateDbCache("pricing");
}
/**
* Clear all synced pricing data.
*/
export function clearSyncedPricing(): void {
const db = getDbInstance();
db.prepare("DELETE FROM key_value WHERE namespace = 'pricing_synced'").run();
backupDbFile("pre-write");
invalidateDbCache("pricing");
}
// ─── Main sync function ─────────────────────────────────
/**
* Fetch, transform, and save pricing from external sources.
*/
export async function syncPricingFromSources(opts?: {
sources?: string[];
dryRun?: boolean;
}): Promise<SyncResult> {
const requestedSources = opts?.sources || SYNC_SOURCES;
const dryRun = opts?.dryRun ?? false;
// Validate sources
const validSources = requestedSources.filter((s): s is SupportedSource =>
SUPPORTED_SOURCES.includes(s as SupportedSource)
);
const invalidSources = requestedSources.filter(
(s) => !SUPPORTED_SOURCES.includes(s as SupportedSource)
);
if (validSources.length === 0) {
const supported = SUPPORTED_SOURCES.join(", ");
return {
success: false,
modelCount: 0,
providerCount: 0,
source: requestedSources.join(","),
dryRun,
error: `No valid sources provided. Supported: ${supported}. Invalid: ${invalidSources.join(", ")}`,
};
}
try {
const aggregated: PricingByProvider = {};
for (const source of validSources) {
if (source === "litellm") {
const raw = await fetchLiteLLMPricing();
const transformed = transformToOmniRoute(raw);
for (const [provider, models] of Object.entries(transformed)) {
if (!aggregated[provider]) aggregated[provider] = {};
Object.assign(aggregated[provider], models);
}
}
}
const modelCount = Object.values(aggregated).reduce(
(sum, models) => sum + Object.keys(models).length,
0
);
const providerCount = Object.keys(aggregated).length;
if (!dryRun) {
saveSyncedPricing(aggregated);
lastSyncTime = new Date().toISOString();
lastSyncModelCount = modelCount;
}
return {
success: true,
modelCount,
providerCount,
source: validSources.join(","),
dryRun,
...(invalidSources.length > 0
? { warnings: [`Unknown sources ignored: ${invalidSources.join(", ")}`] }
: {}),
...(dryRun ? { data: aggregated } : {}),
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn("[PRICING_SYNC] Sync failed:", message);
return {
success: false,
modelCount: 0,
providerCount: 0,
source: requestedSources.join(","),
dryRun,
error: message,
};
}
}
// ─── Periodic sync ───────────────────────────────────────
/**
* Start periodic pricing sync (non-blocking).
*/
export function startPeriodicSync(intervalMs?: number): void {
if (syncTimer) return; // Already running
const interval = intervalMs ?? SYNC_INTERVAL_MS;
activeSyncIntervalMs = interval;
console.log(`[PRICING_SYNC] Starting periodic sync every ${interval / 1000}s`);
// Initial sync (non-blocking)
syncPricingFromSources()
.then((result) => {
if (result.success) {
console.log(
`[PRICING_SYNC] Initial sync complete: ${result.modelCount} models from ${result.providerCount} providers`
);
}
})
.catch((err) => {
console.warn("[PRICING_SYNC] Initial sync error:", err instanceof Error ? err.message : err);
});
syncTimer = setInterval(() => {
syncPricingFromSources()
.then((result) => {
if (result.success) {
console.log(`[PRICING_SYNC] Periodic sync complete: ${result.modelCount} models`);
}
})
.catch((err) => {
console.warn(
"[PRICING_SYNC] Periodic sync error:",
err instanceof Error ? err.message : err
);
});
}, interval);
}
/**
* Stop periodic sync and cleanup timer.
*/
export function stopPeriodicSync(): void {
if (syncTimer) {
clearInterval(syncTimer);
syncTimer = null;
console.log("[PRICING_SYNC] Periodic sync stopped");
}
}
/**
* Get current sync status.
*/
export function getSyncStatus(): SyncStatus {
const enabled = process.env.PRICING_SYNC_ENABLED === "true";
return {
enabled,
lastSync: lastSyncTime,
lastSyncModelCount,
nextSync:
syncTimer && lastSyncTime
? new Date(new Date(lastSyncTime).getTime() + activeSyncIntervalMs).toISOString()
: null,
intervalMs: activeSyncIntervalMs,
sources: SYNC_SOURCES,
};
}
// ─── Init (called from server-init.ts) ───────────────────
/**
* Initialize pricing sync if enabled.
*/
export async function initPricingSync(): Promise<void> {
if (process.env.PRICING_SYNC_ENABLED !== "true") {
console.log("[PRICING_SYNC] Disabled (set PRICING_SYNC_ENABLED=true to enable)");
return;
}
startPeriodicSync();
}
+13
View File
@@ -42,6 +42,19 @@ async function startServer() {
console.error("[FATAL] Error initializing cloud sync:", error);
process.exit(1);
}
// Pricing sync: opt-in external pricing data (non-blocking, never fatal)
if (process.env.PRICING_SYNC_ENABLED === "true") {
try {
const { initPricingSync } = await import("./lib/pricingSync");
await initPricingSync();
} catch (err) {
console.warn(
"[PRICING_SYNC] Could not initialize:",
err instanceof Error ? err.message : err
);
}
}
}
// Start the server initialization
+17 -19
View File
@@ -8,38 +8,36 @@
* comes back online.
*/
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect } from "react";
export default function MaintenanceBanner() {
const [show, setShow] = useState(false);
const [message, setMessage] = useState("");
const checkHealth = useCallback(async () => {
try {
const res = await fetch("/api/monitoring/health", {
signal: AbortSignal.timeout(3000),
});
if (res.ok) {
// Server is healthy — hide banner if shown
if (show) {
useEffect(() => {
const checkHealth = async () => {
try {
const res = await fetch("/api/monitoring/health", {
signal: AbortSignal.timeout(3000),
});
if (res.ok) {
setShow(false);
setMessage("");
} else {
setShow(true);
setMessage("Server is experiencing issues. Some features may be unavailable.");
}
} else {
} catch {
setShow(true);
setMessage("Server is experiencing issues. Some features may be unavailable.");
setMessage("Server is unreachable. Reconnecting...");
}
} catch {
setShow(true);
setMessage("Server is unreachable. Reconnecting...");
}
}, [show]);
};
useEffect(() => {
// Check health every 10 seconds
// Run immediately on mount, then every 10 seconds
checkHealth();
const interval = setInterval(checkHealth, 10000);
return () => clearInterval(interval);
}, [checkHealth]);
}, []); // empty deps — checkHealth is defined inside effect, no stale closure
if (!show) return null;
+104 -2
View File
@@ -15,14 +15,92 @@ import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import * as log from "@/sse/utils/logger";
interface AccessSchedule {
enabled: boolean;
from: string;
until: string;
days: number[];
tz: string;
}
/** Metadata stored for an API key in the local database. */
export interface ApiKeyMetadata {
id: string;
name?: string;
allowedModels?: string[];
allowedConnections?: string[];
noLog?: boolean;
autoResolve?: boolean;
budget?: number;
usedBudget?: number;
isActive?: boolean;
accessSchedule?: AccessSchedule | null;
}
/**
* Returns true if the current time (in the schedule's timezone) is within
* the configured window.
* Supports overnight ranges (e.g. 22:00 until 06:00).
*/
function isWithinSchedule(schedule: AccessSchedule): boolean {
if (!schedule.enabled) return true;
const now = new Date();
// Convert current UTC time to the configured timezone
let localTimeStr: string;
try {
localTimeStr = new Intl.DateTimeFormat("en-US", {
timeZone: schedule.tz,
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(now);
} catch {
// Invalid timezone — fail open (don't block)
return true;
}
// Intl may return "24:xx" instead of "00:xx" — normalize
const normalizedTime = localTimeStr.replace(/^24:/, "00:");
const [localHour, localMin] = normalizedTime.split(":").map(Number);
const localMinutes = localHour * 60 + localMin;
// Determine current weekday in the configured timezone
let localDayStr: string;
try {
localDayStr = new Intl.DateTimeFormat("en-US", {
timeZone: schedule.tz,
weekday: "short",
}).format(now);
} catch {
return true;
}
const dayMap: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
const localDay = dayMap[localDayStr] ?? now.getDay();
if (!schedule.days.includes(localDay)) return false;
const [fromHour, fromMin] = schedule.from.split(":").map(Number);
const [untilHour, untilMin] = schedule.until.split(":").map(Number);
const fromMinutes = fromHour * 60 + fromMin;
const untilMinutes = untilHour * 60 + untilMin;
// Overnight window (e.g. 22:00 → 06:00)
if (untilMinutes < fromMinutes) {
return localMinutes >= fromMinutes || localMinutes < untilMinutes;
}
return localMinutes >= fromMinutes && localMinutes < untilMinutes;
}
export interface ApiKeyPolicyResult {
@@ -82,7 +160,31 @@ export async function enforceApiKeyPolicy(
return { apiKey, apiKeyInfo: null, rejection: null };
}
// ── Check 1: Model restriction ──
// ── Check 1: is_active — hard block regardless of schedule ──
if (apiKeyInfo.isActive === false) {
return {
apiKey,
apiKeyInfo,
rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key is disabled"),
};
}
// ── Check 2: access_schedule — time-based access window ──
if (apiKeyInfo.accessSchedule && apiKeyInfo.accessSchedule.enabled) {
if (!isWithinSchedule(apiKeyInfo.accessSchedule)) {
const { from, until, tz } = apiKeyInfo.accessSchedule;
return {
apiKey,
apiKeyInfo,
rejection: errorResponse(
HTTP_STATUS.FORBIDDEN,
`Access denied outside allowed hours (${from}${until} ${tz})`
),
};
}
}
// ── Check 3: Model restriction ──
if (modelStr && apiKeyInfo.allowedModels && apiKeyInfo.allowedModels.length > 0) {
const allowed = await isModelAllowedForKey(apiKey, modelStr);
if (!allowed) {
@@ -97,7 +199,7 @@ export async function enforceApiKeyPolicy(
}
}
// ── Check 2: Budget limit ──
// ── Check 4: Budget limit ──
if (apiKeyInfo.id) {
try {
const budgetOk = checkBudget(apiKeyInfo.id);
+145
View File
@@ -0,0 +1,145 @@
/**
* Shared shuffle deck utility Fisher-Yates shuffle with anti-repeat guarantee.
* Used by both combo model rotation and credential connection selection.
*
* Thread-safe: each deck namespace gets its own promise-based mutex to prevent
* race conditions when concurrent requests hit the same deck simultaneously.
*/
// ─── Types ──────────────────────────────────────────────────────────────────
interface ShuffleDeck {
order: readonly string[];
index: number;
idsKey: string;
}
// ─── State ──────────────────────────────────────────────────────────────────
const decks = new Map<string, ShuffleDeck>();
const mutexes = new Map<string, Promise<void>>();
// ─── Fisher-Yates Shuffle ───────────────────────────────────────────────────
/**
* Fisher-Yates shuffle returns a new shuffled copy of the array.
* Does NOT mutate the original.
*/
export function fisherYatesShuffle<T>(arr: readonly T[]): T[] {
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = result[i];
result[i] = result[j];
result[j] = tmp;
}
return result;
}
// ─── Deck Operations ────────────────────────────────────────────────────────
/**
* Get next item from a namespaced shuffle deck.
*
* - Namespace isolates decks (e.g. "combo:myCombo" vs "conn:openai").
* - Uses each item exactly once per cycle before reshuffling.
* - Guarantees the last item of a cycle is not the first of the next.
* - Resets deck when the item set changes (detected via sorted key).
* - Serialized per namespace via promise-based mutex (no race conditions).
*/
export async function getNextFromDeck(
namespace: string,
itemIds: readonly string[]
): Promise<string> {
if (itemIds.length === 0) return "";
if (itemIds.length === 1) return itemIds[0];
// Acquire per-namespace mutex
const currentMutex = mutexes.get(namespace) ?? Promise.resolve();
let resolveMutex: (() => void) | undefined;
mutexes.set(
namespace,
new Promise<void>((resolve) => {
resolveMutex = resolve;
})
);
try {
await currentMutex;
const idsKey = [...itemIds].sort().join(",");
const existing = decks.get(namespace);
// If deck exists, same item set, and not exhausted — advance
if (existing && existing.idsKey === idsKey && existing.index < existing.order.length) {
const id = existing.order[existing.index];
decks.set(namespace, { ...existing, index: existing.index + 1 });
return id;
}
// Reshuffle — ensure last of previous cycle is not first of new cycle
const lastUsedId =
existing && existing.idsKey === idsKey && existing.order.length > 0
? existing.order[existing.order.length - 1]
: undefined;
const newOrder = fisherYatesShuffle(itemIds);
if (lastUsedId !== undefined && newOrder[0] === lastUsedId && newOrder.length > 1) {
const swapIdx = 1 + Math.floor(Math.random() * (newOrder.length - 1));
const tmp = newOrder[0];
newOrder[0] = newOrder[swapIdx];
newOrder[swapIdx] = tmp;
}
decks.set(namespace, { order: newOrder, index: 1, idsKey });
return newOrder[0];
} finally {
resolveMutex?.();
}
}
// ─── Sync version (backwards compat for non-concurrent callers) ─────────────
/**
* Synchronous version of getNextFromDeck NO mutex protection.
* Only safe when the caller already holds a mutex (e.g. auth.ts getProviderCredentials).
*/
export function getNextFromDeckSync(namespace: string, itemIds: readonly string[]): string {
if (itemIds.length === 0) return "";
if (itemIds.length === 1) return itemIds[0];
const idsKey = [...itemIds].sort().join(",");
const existing = decks.get(namespace);
if (existing && existing.idsKey === idsKey && existing.index < existing.order.length) {
const id = existing.order[existing.index];
decks.set(namespace, { ...existing, index: existing.index + 1 });
return id;
}
const lastUsedId =
existing && existing.idsKey === idsKey && existing.order.length > 0
? existing.order[existing.order.length - 1]
: undefined;
const newOrder = fisherYatesShuffle(itemIds);
if (lastUsedId !== undefined && newOrder[0] === lastUsedId && newOrder.length > 1) {
const swapIdx = 1 + Math.floor(Math.random() * (newOrder.length - 1));
const tmp = newOrder[0];
newOrder[0] = newOrder[swapIdx];
newOrder[swapIdx] = tmp;
}
decks.set(namespace, { order: newOrder, index: 1, idsKey });
return newOrder[0];
}
// ─── Test helpers ───────────────────────────────────────────────────────────
/** Reset all decks — for testing only. */
export function _resetAllDecks(): void {
decks.clear();
mutexes.clear();
}
+43 -3
View File
@@ -51,6 +51,7 @@ const comboStrategySchema = z.enum([
"random",
"least-used",
"cost-optimized",
"strict-random",
]);
const comboRuntimeConfigSchema = z
@@ -77,6 +78,7 @@ export const createComboSchema = z.object({
models: z.array(comboModelEntry).optional().default([]),
strategy: comboStrategySchema.optional().default("priority"),
config: comboConfigSchema,
allowedProviders: z.array(z.string().max(200)).optional(),
});
// ──── Auto-Combo Schemas ────
@@ -125,7 +127,15 @@ export const updateSettingsSchema = z.object({
hideHealthCheckLogs: z.boolean().optional(),
// Routing settings (#134)
fallbackStrategy: z
.enum(["fill-first", "round-robin", "p2c", "random", "least-used", "cost-optimized"])
.enum([
"fill-first",
"round-robin",
"p2c",
"random",
"least-used",
"cost-optimized",
"strict-random",
])
.optional(),
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
@@ -439,6 +449,12 @@ export const updateThinkingBudgetSchema = z
}
});
export const updateCodexServiceTierSchema = z
.object({
enabled: z.boolean(),
})
.strict();
const ipFilterModeSchema = z.enum(["blacklist", "whitelist"]);
const tempBanSchema = z.object({
ip: z.string().trim().min(1),
@@ -676,6 +692,7 @@ export const updateComboSchema = z
strategy: comboStrategySchema.optional(),
config: comboRuntimeConfigSchema.optional(),
isActive: z.boolean().optional(),
allowedProviders: z.array(z.string().max(200)).optional(),
})
.superRefine((value, ctx) => {
if (
@@ -683,7 +700,8 @@ export const updateComboSchema = z
value.models === undefined &&
value.strategy === undefined &&
value.config === undefined &&
value.isActive === undefined
value.isActive === undefined &&
value.allowedProviders === undefined
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -706,13 +724,34 @@ export const evalRunSuiteSchema = z.object({
outputs: z.record(z.string(), z.string()),
});
const accessScheduleSchema = z.object({
enabled: z.boolean(),
from: z.string().regex(/^\d{2}:\d{2}$/, "Time must be in HH:MM format"),
until: z.string().regex(/^\d{2}:\d{2}$/, "Time must be in HH:MM format"),
days: z.array(z.number().int().min(0).max(6)).min(1, "At least one day is required").max(7),
tz: z.string().min(1).max(100),
});
export const updateKeyPermissionsSchema = z
.object({
name: z.string().trim().min(1).max(200).optional(),
allowedModels: z.array(z.string().trim().min(1)).max(1000).optional(),
allowedConnections: z.array(z.string().uuid()).max(100).optional(),
noLog: z.boolean().optional(),
autoResolve: z.boolean().optional(),
isActive: z.boolean().optional(),
accessSchedule: z.union([accessScheduleSchema, z.null()]).optional(),
})
.superRefine((value, ctx) => {
if (value.allowedModels === undefined && value.noLog === undefined) {
if (
value.name === undefined &&
value.allowedModels === undefined &&
value.allowedConnections === undefined &&
value.noLog === undefined &&
value.autoResolve === undefined &&
value.isActive === undefined &&
value.accessSchedule === undefined
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "No valid fields to update",
@@ -770,6 +809,7 @@ export const updateProviderConnectionSchema = z
rateLimitedUntil: z.union([z.string(), z.null()]).optional(),
lastTested: z.union([z.string(), z.null()]).optional(),
healthCheckInterval: z.coerce.number().int().min(0).optional(),
group: z.union([z.string().max(100), z.null()]).optional(),
// Partial patch of per-connection provider-specific settings (e.g. quota toggles)
providerSpecificData: z.record(z.string(), z.unknown()).optional(),
})
+13 -3
View File
@@ -133,7 +133,9 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
// Optional strict API key mode for /v1 endpoints.
// Keep disabled by default to preserve local-mode compatibility.
if (process.env.REQUIRE_API_KEY === "true") {
// Exception: X-Internal-Test header bypasses auth for admin-side combo health checks (#350)
const isInternalTest = request.headers?.get?.("x-internal-test") === "combo-health-check";
if (process.env.REQUIRE_API_KEY === "true" && !isInternalTest) {
if (!apiKey) {
log.warn("AUTH", "Missing API key while REQUIRE_API_KEY=true");
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
@@ -207,7 +209,11 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
return false;
}
const creds = await getProviderCredentials(provider);
const creds = await getProviderCredentials(
provider,
null,
apiKeyInfo?.allowedConnections ?? null
);
if (!creds || creds.allRateLimited) return false;
return true;
};
@@ -291,7 +297,11 @@ async function handleSingleModelChat(
let lastStatus = null;
while (true) {
const credentials = await getProviderCredentials(provider, excludeConnectionId);
const credentials = await getProviderCredentials(
provider,
excludeConnectionId,
apiKeyInfo?.allowedConnections ?? null
);
if (!credentials || credentials.allRateLimited) {
if (lastStatus === 429 || lastStatus === 503) {
+167 -9
View File
@@ -4,7 +4,7 @@ import {
updateProviderConnection,
getSettings,
} from "@/lib/localDb";
import { isAccountQuotaExhausted } from "@/domain/quotaCache";
import { getQuotaWindowStatus, isAccountQuotaExhausted } from "@/domain/quotaCache";
import {
isAccountUnavailable,
getUnavailableUntil,
@@ -15,6 +15,7 @@ import {
lockModel,
} from "@omniroute/open-sse/services/accountFallback.ts";
import * as log from "../utils/logger";
import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck";
type JsonRecord = Record<string, unknown>;
@@ -34,10 +35,24 @@ interface ProviderConnectionView {
consecutiveUseCount: number;
priority: number;
lastError: string | null;
lastErrorType: string | null;
lastErrorSource: string | null;
errorCode: string | number | null;
backoffLevel: number;
}
interface RecoverableConnectionState {
connectionId: string;
testStatus?: string | null;
lastError?: string | null;
rateLimitedUntil?: string | null;
errorCode?: string | number | null;
lastErrorType?: string | null;
lastErrorSource?: string | null;
}
const CODEX_QUOTA_THRESHOLD_PERCENT = 90;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
@@ -73,12 +88,48 @@ function toProviderConnection(value: unknown): ProviderConnectionView {
consecutiveUseCount: toNumber(row.consecutiveUseCount, 0),
priority: toNumber(row.priority, 999),
lastError: toStringOrNull(row.lastError),
lastErrorType: toStringOrNull(row.lastErrorType),
lastErrorSource: toStringOrNull(row.lastErrorSource),
errorCode:
typeof row.errorCode === "string" || typeof row.errorCode === "number" ? row.errorCode : null,
backoffLevel: toNumber(row.backoffLevel, 0),
};
}
function toBooleanOrDefault(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function getCodexLimitPolicy(providerSpecificData: JsonRecord): {
use5h: boolean;
useWeekly: boolean;
} {
const policy = asRecord(providerSpecificData.codexLimitPolicy);
return {
use5h: toBooleanOrDefault(policy.use5h, true),
useWeekly: toBooleanOrDefault(policy.useWeekly, true),
};
}
function parseFutureDateMs(value: string | null): number | null {
if (!value) return null;
const ms = new Date(value).getTime();
if (!Number.isFinite(ms) || ms <= Date.now()) return null;
return ms;
}
function getEarliestFutureDate(candidates: Array<string | null>): string | null {
return (
candidates
.map((candidate) => ({
raw: candidate,
ms: parseFutureDateMs(candidate),
}))
.filter((entry) => entry.ms !== null)
.sort((a, b) => (a.ms as number) - (b.ms as number))[0]?.raw || null
);
}
// Mutex to prevent race conditions during account selection
let selectionMutex = Promise.resolve();
@@ -87,6 +138,11 @@ let selectionMutex = Promise.resolve();
// unavailable in parallel, which was the root cause of cascading 502 lockouts.
const markMutexes = new Map<string, Promise<void>>();
// Strict-Random shuffle deck moved to src/shared/utils/shuffleDeck.ts
// auth.ts uses getNextFromDeckSync (already inside selectionMutex).
// Re-export for backwards compat with existing test imports.
export { fisherYatesShuffle, getNextFromDeckSync as getNextFromDeck };
/**
* Get provider credentials from localDb
* Filters out unavailable accounts and returns the selected account based on strategy
@@ -95,7 +151,8 @@ const markMutexes = new Map<string, Promise<void>>();
*/
export async function getProviderCredentials(
provider: string,
excludeConnectionId: string | null = null
excludeConnectionId: string | null = null,
allowedConnections: string[] | null = null
) {
// Acquire mutex to prevent race conditions
const currentMutex = selectionMutex;
@@ -108,9 +165,13 @@ export async function getProviderCredentials(
await currentMutex;
const connectionsRaw = await getProviderConnections({ provider, isActive: true });
const connections = (Array.isArray(connectionsRaw) ? connectionsRaw : [])
let connections = (Array.isArray(connectionsRaw) ? connectionsRaw : [])
.map(toProviderConnection)
.filter((conn) => conn.id.length > 0);
// allowedConnections: restrict to specific connection IDs (from API key policy, #363)
if (allowedConnections && allowedConnections.length > 0) {
connections = connections.filter((conn) => allowedConnections.includes(conn.id));
}
log.debug(
"AUTH",
`${provider} | total connections: ${connections.length}, excludeId: ${excludeConnectionId || "none"}`
@@ -198,11 +259,84 @@ export async function getProviderCredentials(
return null;
}
let policyEligibleConnections = availableConnections;
if (provider === "codex") {
const blockedByPolicy: Array<{
id: string;
reasons: string[];
resetAt: string | null;
}> = [];
policyEligibleConnections = availableConnections.filter((connection) => {
const policy = getCodexLimitPolicy(connection.providerSpecificData);
const sessionStatus = policy.use5h
? getQuotaWindowStatus(connection.id, "session", CODEX_QUOTA_THRESHOLD_PERCENT)
: null;
const weeklyStatus = policy.useWeekly
? getQuotaWindowStatus(connection.id, "weekly", CODEX_QUOTA_THRESHOLD_PERCENT)
: null;
const reasons: string[] = [];
const resetCandidates: Array<string | null> = [];
if (policy.use5h && sessionStatus?.reachedThreshold) {
reasons.push(`5h usage ${Math.round(sessionStatus.usedPercentage)}%`);
resetCandidates.push(sessionStatus.resetAt);
}
if (policy.useWeekly && weeklyStatus?.reachedThreshold) {
reasons.push(`weekly usage ${Math.round(weeklyStatus.usedPercentage)}%`);
resetCandidates.push(weeklyStatus.resetAt);
}
if (reasons.length > 0) {
const nextResetAt = getEarliestFutureDate(resetCandidates);
blockedByPolicy.push({
id: connection.id,
reasons,
resetAt: nextResetAt,
});
return false;
}
return true;
});
if (blockedByPolicy.length > 0) {
log.info(
"AUTH",
`${provider} | quota policy filtered ${blockedByPolicy.length} account(s): ${blockedByPolicy
.map((entry) => `${entry.id.slice(0, 8)}(${entry.reasons.join(", ")})`)
.join("; ")}`
);
}
if (policyEligibleConnections.length === 0 && availableConnections.length > 0) {
const earliestResetAt = getEarliestFutureDate(
blockedByPolicy.map((entry) => entry.resetAt)
);
const earliestResetMs = parseFutureDateMs(earliestResetAt);
const retryAfter = earliestResetMs
? new Date(earliestResetMs).toISOString()
: new Date(Date.now() + 5 * 60 * 1000).toISOString();
return {
allRateLimited: true,
retryAfter,
retryAfterHuman: formatRetryAfter(retryAfter),
lastError: "All Codex accounts reached configured quota threshold",
lastErrorCode: 429,
};
}
}
// Quota-aware: prioritize accounts with available quota
const withQuota = availableConnections.filter((c) => !isAccountQuotaExhausted(c.id));
const exhaustedQuota = availableConnections.filter((c) => isAccountQuotaExhausted(c.id));
const withQuota = policyEligibleConnections.filter((c) => !isAccountQuotaExhausted(c.id));
const exhaustedQuota = policyEligibleConnections.filter((c) => isAccountQuotaExhausted(c.id));
const orderedConnections =
withQuota.length > 0 ? [...withQuota, ...exhaustedQuota] : availableConnections;
withQuota.length > 0 ? [...withQuota, ...exhaustedQuota] : policyEligibleConnections;
if (exhaustedQuota.length > 0) {
log.debug(
@@ -324,6 +458,11 @@ export async function getProviderCredentials(
(a, b) => (a.priority || 999) - (b.priority || 999)
);
connection = sorted[0];
} else if (strategy === "strict-random") {
// Strict Random: shuffle deck — uses each account once before reshuffling
const ids = orderedConnections.map((c) => c.id);
const selectedId = getNextFromDeckSync(`conn:${provider}`, ids);
connection = orderedConnections.find((c) => c.id === selectedId) || orderedConnections[0];
} else {
// Default: fill-first (already sorted by priority in getProviderConnections)
connection = orderedConnections[0];
@@ -344,6 +483,9 @@ export async function getProviderCredentials(
// Include current status for optimization check
testStatus: connection.testStatus,
lastError: connection.lastError,
lastErrorType: connection.lastErrorType,
lastErrorSource: connection.lastErrorSource,
errorCode: connection.errorCode,
rateLimitedUntil: connection.rateLimitedUntil,
};
} finally {
@@ -444,12 +586,18 @@ export async function markAccountUnavailable(
* Clear account error status (only if currently has error)
* Optimized to avoid unnecessary DB updates
*/
export async function clearAccountError(connectionId: string, currentConnection: any) {
export async function clearAccountError(
connectionId: string,
currentConnection: Partial<RecoverableConnectionState>
) {
// Only update if currently has error status
const hasError =
currentConnection.testStatus === "unavailable" ||
(currentConnection.testStatus && currentConnection.testStatus !== "active") ||
currentConnection.lastError ||
currentConnection.rateLimitedUntil;
currentConnection.rateLimitedUntil ||
currentConnection.errorCode ||
currentConnection.lastErrorType ||
currentConnection.lastErrorSource;
if (!hasError) return; // Skip if already clean
@@ -457,12 +605,22 @@ export async function clearAccountError(connectionId: string, currentConnection:
testStatus: "active",
lastError: null,
lastErrorAt: null,
lastErrorType: null,
lastErrorSource: null,
errorCode: null,
rateLimitedUntil: null,
backoffLevel: 0,
});
log.info("AUTH", `Account ${connectionId.slice(0, 8)} error cleared`);
}
export async function clearRecoveredProviderState(
credentials: Partial<RecoverableConnectionState> | null
) {
if (!credentials?.connectionId) return;
await clearAccountError(credentials.connectionId, credentials);
}
/**
* Extract API key from request headers
*/
+2 -1
View File
@@ -10,7 +10,8 @@ export interface Settings {
| "p2c"
| "random"
| "least-used"
| "cost-optimized";
| "cost-optimized"
| "strict-random";
stickyRoundRobinLimit: number;
jwtSecret?: string;
}
+313
View File
@@ -0,0 +1,313 @@
/**
* Unit tests for API key policy helpers:
* - parseIsActive (via parseAccessSchedule indirect coverage)
* - isWithinSchedule logic (tested directly via a re-export or by mocking Date)
*
* Because isWithinSchedule is module-private, we test it through observable
* behavior: feeding real Date overrides via globalThis.Date stubbing.
*
* Strategy:
* 1. Extract the schedule-check logic into a standalone helper exported only
* for tests OR test it end-to-end through enforceApiKeyPolicy.
* 2. Since enforceApiKeyPolicy needs a full DB + HTTP Request, we isolate
* isWithinSchedule by copying its logic into this test file and verifying
* the exact same algorithm.
*/
import test from "node:test";
import assert from "node:assert/strict";
// ─── Replicate the isWithinSchedule logic for pure unit testing ───────────────
//
// This mirrors the implementation in apiKeyPolicy.ts exactly.
// If the production code changes, update this copy too.
/**
* @param {{ enabled: boolean; from: string; until: string; days: number[]; tz: string }} schedule
* @param {Date} now injectable "current time"
* @returns {boolean}
*/
function isWithinSchedule(schedule, now = new Date()) {
if (!schedule.enabled) return true;
let localTimeStr;
try {
localTimeStr = new Intl.DateTimeFormat("en-US", {
timeZone: schedule.tz,
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(now);
} catch {
return true;
}
const normalizedTime = localTimeStr.replace(/^24:/, "00:");
const [localHour, localMin] = normalizedTime.split(":").map(Number);
const localMinutes = localHour * 60 + localMin;
let localDayStr;
try {
localDayStr = new Intl.DateTimeFormat("en-US", {
timeZone: schedule.tz,
weekday: "short",
}).format(now);
} catch {
return true;
}
const dayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
const localDay = dayMap[localDayStr] ?? now.getDay();
if (!schedule.days.includes(localDay)) return false;
const [fromHour, fromMin] = schedule.from.split(":").map(Number);
const [untilHour, untilMin] = schedule.until.split(":").map(Number);
const fromMinutes = fromHour * 60 + fromMin;
const untilMinutes = untilHour * 60 + untilMin;
if (untilMinutes < fromMinutes) {
return localMinutes >= fromMinutes || localMinutes < untilMinutes;
}
return localMinutes >= fromMinutes && localMinutes < untilMinutes;
}
// ─── parseIsActive helper (mirrors production code) ──────────────────────────
function parseIsActive(value) {
if (value === 0 || value === "0" || value === false) return false;
return true;
}
// ─── parseAccessSchedule helper (mirrors production code) ────────────────────
function parseAccessSchedule(value) {
if (!value || typeof value !== "string" || value.trim() === "") return null;
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
if (
typeof parsed.enabled !== "boolean" ||
typeof parsed.from !== "string" ||
typeof parsed.until !== "string" ||
!Array.isArray(parsed.days) ||
typeof parsed.tz !== "string"
)
return null;
const days = parsed.days.filter(
(d) => typeof d === "number" && Number.isInteger(d) && d >= 0 && d <= 6
);
return { enabled: parsed.enabled, from: parsed.from, until: parsed.until, days, tz: parsed.tz };
} catch {
return null;
}
}
// ─── parseIsActive ────────────────────────────────────────────────────────────
test("parseIsActive: undefined → true (default active)", () => {
assert.equal(parseIsActive(undefined), true);
});
test("parseIsActive: null → true", () => {
assert.equal(parseIsActive(null), true);
});
test("parseIsActive: 1 → true", () => {
assert.equal(parseIsActive(1), true);
});
test("parseIsActive: true → true", () => {
assert.equal(parseIsActive(true), true);
});
test("parseIsActive: 0 → false", () => {
assert.equal(parseIsActive(0), false);
});
test("parseIsActive: false → false", () => {
assert.equal(parseIsActive(false), false);
});
test("parseIsActive: '0' → false", () => {
assert.equal(parseIsActive("0"), false);
});
// ─── parseAccessSchedule ──────────────────────────────────────────────────────
test("parseAccessSchedule: null/empty → null", () => {
assert.equal(parseAccessSchedule(null), null);
assert.equal(parseAccessSchedule(""), null);
assert.equal(parseAccessSchedule(" "), null);
});
test("parseAccessSchedule: valid JSON → object", () => {
const input = JSON.stringify({
enabled: true,
from: "08:00",
until: "18:00",
days: [1, 2, 3, 4, 5],
tz: "America/Sao_Paulo",
});
const result = parseAccessSchedule(input);
assert.deepEqual(result, {
enabled: true,
from: "08:00",
until: "18:00",
days: [1, 2, 3, 4, 5],
tz: "America/Sao_Paulo",
});
});
test("parseAccessSchedule: invalid day values are filtered out", () => {
const input = JSON.stringify({
enabled: true,
from: "08:00",
until: "18:00",
days: [1, 7, -1, 5],
tz: "UTC",
});
const result = parseAccessSchedule(input);
assert.deepEqual(result.days, [1, 5]);
});
test("parseAccessSchedule: missing required field → null", () => {
const input = JSON.stringify({ enabled: true, from: "08:00", until: "18:00", days: [1] });
assert.equal(parseAccessSchedule(input), null); // tz missing
});
test("parseAccessSchedule: invalid JSON → null", () => {
assert.equal(parseAccessSchedule("{broken json}"), null);
});
// ─── isWithinSchedule ────────────────────────────────────────────────────────
// Helper: create a Date at a specific UTC datetime
function utc(y, m, d, h, min) {
return new Date(Date.UTC(y, m - 1, d, h, min));
}
test("isWithinSchedule: enabled=false → always true", () => {
const schedule = { enabled: false, from: "00:00", until: "00:01", days: [1], tz: "UTC" };
// Even a time that would be blocked
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 12, 0)), true);
});
test("isWithinSchedule: time within window → true", () => {
// Monday 2024-03-11, 09:00 UTC (UTC timezone)
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 9, 0)), true);
});
test("isWithinSchedule: time before window → false", () => {
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
// 07:59
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 7, 59)), false);
});
test("isWithinSchedule: time exactly at 'from' → true (inclusive)", () => {
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 8, 0)), true);
});
test("isWithinSchedule: time exactly at 'until' → false (exclusive)", () => {
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 18, 0)), false);
});
test("isWithinSchedule: time after window → false", () => {
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 20, 0)), false);
});
test("isWithinSchedule: wrong weekday → false", () => {
// Monday (day 1) schedule, but 2024-03-12 is Tuesday (day 2)
const schedule = { enabled: true, from: "08:00", until: "18:00", days: [1], tz: "UTC" };
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 12, 10, 0)), false);
});
test("isWithinSchedule: multiple days — matching day → true", () => {
// Mon-Fri schedule, Wednesday (3)
const schedule = {
enabled: true,
from: "09:00",
until: "17:00",
days: [1, 2, 3, 4, 5],
tz: "UTC",
};
// 2024-03-13 is Wednesday
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 13, 12, 0)), true);
});
test("isWithinSchedule: multiple days — Saturday blocked", () => {
const schedule = {
enabled: true,
from: "09:00",
until: "17:00",
days: [1, 2, 3, 4, 5],
tz: "UTC",
};
// 2024-03-09 is Saturday (day 6)
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 9, 12, 0)), false);
});
// ─── Overnight schedule tests ─────────────────────────────────────────────────
test("isWithinSchedule: overnight window — time after midnight → true", () => {
// 22:00 → 06:00, Monday
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
// 02:30 Monday
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 2, 30)), true);
});
test("isWithinSchedule: overnight window — time before start → false", () => {
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
// 21:59 Monday
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 21, 59)), false);
});
test("isWithinSchedule: overnight window — time after end → false", () => {
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
// 06:01 Monday
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 6, 1)), false);
});
test("isWithinSchedule: overnight window — time exactly at start → true", () => {
const schedule = { enabled: true, from: "22:00", until: "06:00", days: [1], tz: "UTC" };
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 22, 0)), true);
});
test("isWithinSchedule: invalid timezone → fail-open (true)", () => {
const schedule = {
enabled: true,
from: "08:00",
until: "18:00",
days: [1, 2, 3, 4, 5],
tz: "Invalid/Zone",
};
// Should not throw, should return true (fail-open)
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 12, 0)), true);
});
test("isWithinSchedule: America/Sao_Paulo timezone conversion", () => {
// UTC 2024-03-11 15:00 = BRT (UTC-3) 12:00, Monday
const schedule = {
enabled: true,
from: "08:00",
until: "18:00",
days: [1],
tz: "America/Sao_Paulo",
};
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 15, 0)), true);
});
test("isWithinSchedule: America/Sao_Paulo — outside window", () => {
// UTC 2024-03-11 22:00 = BRT 19:00, Monday — after 18:00
const schedule = {
enabled: true,
from: "08:00",
until: "18:00",
days: [1],
tz: "America/Sao_Paulo",
};
assert.equal(isWithinSchedule(schedule, utc(2024, 3, 11, 22, 0)), false);
});
@@ -0,0 +1,59 @@
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-auth-clear-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const auth = await import("../../src/sse/services/auth.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("clearAccountError clears stale provider error metadata after recovery", async () => {
await resetStorage();
core.getDbInstance().exec('ALTER TABLE provider_connections ADD COLUMN "group" TEXT');
const created = await providersDb.createProviderConnection({
provider: "codex",
authType: "oauth",
email: "recover@example.com",
accessToken: "access",
refreshToken: "refresh",
testStatus: "active",
lastError: null,
lastErrorType: "token_refresh_failed",
lastErrorSource: "oauth",
errorCode: "refresh_failed",
rateLimitedUntil: null,
backoffLevel: 2,
});
const credentials = await auth.getProviderCredentials("codex");
assert.equal(credentials.connectionId, created.id);
assert.equal(credentials.errorCode, "refresh_failed");
assert.equal(credentials.lastErrorType, "token_refresh_failed");
assert.equal(credentials.lastErrorSource, "oauth");
await auth.clearAccountError(created.id, credentials);
const updated = await providersDb.getProviderConnectionById(created.id);
assert.equal(updated.testStatus, "active");
assert.equal(updated.lastError, undefined);
assert.equal(updated.lastErrorType, undefined);
assert.equal(updated.lastErrorSource, undefined);
assert.equal(updated.errorCode, undefined);
assert.equal(updated.rateLimitedUntil, undefined);
assert.equal(updated.backoffLevel, 0);
});
@@ -0,0 +1,109 @@
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-auth-routes-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const moderationRoute = await import("../../src/app/api/v1/moderations/route.ts");
const embeddingsRoute = await import("../../src/app/api/v1/embeddings/route.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
core.getDbInstance().exec('ALTER TABLE provider_connections ADD COLUMN "group" TEXT');
}
async function seedOpenAIConnection(email) {
return await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
email,
name: email,
apiKey: "sk-test",
testStatus: "active",
lastError: null,
lastErrorType: "token_refresh_failed",
lastErrorSource: "oauth",
errorCode: "refresh_failed",
rateLimitedUntil: null,
backoffLevel: 2,
});
}
async function readConnection(id) {
return await providersDb.getProviderConnectionById(id);
}
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("moderations route clears stale provider error metadata on success", async () => {
await resetStorage();
const created = await seedOpenAIConnection("moderation@example.com");
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
Response.json({
id: "modr-1",
model: "omni-moderation-latest",
results: [{ flagged: false }],
});
try {
const request = new Request("http://localhost/v1/moderations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: "hello" }),
});
const response = await moderationRoute.POST(request);
assert.equal(response.status, 200);
const updated = await readConnection(created.id);
assert.equal(updated.testStatus, "active");
assert.equal(updated.errorCode, undefined);
assert.equal(updated.lastErrorType, undefined);
assert.equal(updated.lastErrorSource, undefined);
} finally {
globalThis.fetch = originalFetch;
}
});
test("embeddings route clears stale provider error metadata on success", async () => {
await resetStorage();
const created = await seedOpenAIConnection("embeddings@example.com");
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
Response.json({
data: [{ object: "embedding", index: 0, embedding: [0.1, 0.2] }],
usage: { prompt_tokens: 3, total_tokens: 3 },
});
try {
const request = new Request("http://localhost/v1/embeddings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: "openai/text-embedding-3-small", input: "hello" }),
});
const response = await embeddingsRoute.POST(request);
assert.equal(response.status, 200);
const updated = await readConnection(created.id);
assert.equal(updated.testStatus, "active");
assert.equal(updated.errorCode, undefined);
assert.equal(updated.lastErrorType, undefined);
assert.equal(updated.lastErrorSource, undefined);
} finally {
globalThis.fetch = originalFetch;
}
});
+113
View File
@@ -0,0 +1,113 @@
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";
import Database from "better-sqlite3";
import { bootstrapEnv } from "../../scripts/bootstrap-env.mjs";
function withTempEnv(fn) {
const originalCwd = process.cwd();
const originalEnv = { ...process.env };
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-bootstrap-test-"));
const tempCwd = path.join(tempRoot, "cwd");
const tempHome = path.join(tempRoot, "home");
fs.mkdirSync(tempCwd, { recursive: true });
fs.mkdirSync(tempHome, { recursive: true });
delete process.env.DATA_DIR;
delete process.env.XDG_CONFIG_HOME;
delete process.env.APPDATA;
delete process.env.JWT_SECRET;
delete process.env.STORAGE_ENCRYPTION_KEY;
delete process.env.STORAGE_ENCRYPTION_KEY_VERSION;
delete process.env.API_KEY_SECRET;
delete process.env.INITIAL_PASSWORD;
process.env.HOME = tempHome;
process.chdir(tempCwd);
try {
fn({ tempRoot, tempCwd, tempHome, dataDir: path.join(tempHome, ".omniroute") });
} finally {
process.chdir(originalCwd);
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) delete process.env[key];
}
for (const [key, value] of Object.entries(originalEnv)) {
process.env[key] = value;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
test("bootstrapEnv prefers ~/.omniroute/.env over server.env", () => {
withTempEnv(({ dataDir }) => {
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(
path.join(dataDir, ".env"),
"STORAGE_ENCRYPTION_KEY=from-dot-env\nJWT_SECRET=jwt-from-dot-env\n",
"utf8"
);
fs.writeFileSync(
path.join(dataDir, "server.env"),
"STORAGE_ENCRYPTION_KEY=from-server-env\nJWT_SECRET=jwt-from-server-env\n",
"utf8"
);
const env = bootstrapEnv({ quiet: true });
assert.equal(env.STORAGE_ENCRYPTION_KEY, "from-dot-env");
assert.equal(env.JWT_SECRET, "jwt-from-dot-env");
});
});
test("bootstrapEnv refuses to generate a new key over encrypted data", () => {
withTempEnv(({ dataDir }) => {
fs.mkdirSync(dataDir, { recursive: true });
const db = new Database(path.join(dataDir, "storage.sqlite"));
try {
db.exec(`
CREATE TABLE provider_connections (
id TEXT PRIMARY KEY,
access_token TEXT,
refresh_token TEXT,
api_key TEXT,
id_token TEXT
);
`);
db.prepare("INSERT INTO provider_connections (id, access_token) VALUES (?, ?)")
.run("conn-1", "enc:v1:deadbeef:feedface:cafebabe");
} finally {
db.close();
}
assert.throws(
() => bootstrapEnv({ quiet: true }),
/Refusing to auto-generate STORAGE_ENCRYPTION_KEY/
);
});
});
test("bootstrapEnv fails closed when existing database cannot be inspected", () => {
withTempEnv(({ dataDir }) => {
fs.mkdirSync(path.join(dataDir, "storage.sqlite"), { recursive: true });
assert.throws(
() => bootstrapEnv({ quiet: true }),
/Unable to inspect existing database/
);
});
});
test("bootstrapEnv ignores blank dataDirOverride values", () => {
withTempEnv(({ dataDir }) => {
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(path.join(dataDir, ".env"), "JWT_SECRET=jwt-from-dot-env\n", "utf8");
const env = bootstrapEnv({ dataDirOverride: " ", quiet: true });
assert.equal(env.JWT_SECRET, "jwt-from-dot-env");
});
});
+57
View File
@@ -141,6 +141,63 @@ test("provider connection persists rateLimitProtection across reopen", async ()
assert.equal(secondRead.rateLimitProtection, true);
});
test('provider connection migration adds "group" column for existing databases', async () => {
await resetStorage();
const sqlitePath = core.SQLITE_FILE;
core.resetDbInstance();
const Database = (await import("better-sqlite3")).default;
const db = new Database(sqlitePath);
db.exec(`
CREATE TABLE provider_connections (
id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
auth_type TEXT,
name TEXT,
email TEXT,
priority INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
access_token TEXT,
refresh_token TEXT,
expires_at TEXT,
token_expires_at TEXT,
scope TEXT,
project_id TEXT,
test_status TEXT,
error_code TEXT,
last_error TEXT,
last_error_at TEXT,
last_error_type TEXT,
last_error_source TEXT,
backoff_level INTEGER DEFAULT 0,
rate_limited_until TEXT,
health_check_interval INTEGER,
last_health_check_at TEXT,
last_tested TEXT,
api_key TEXT,
id_token TEXT,
provider_specific_data TEXT,
expires_in INTEGER,
display_name TEXT,
global_priority INTEGER,
default_model TEXT,
token_type TEXT,
consecutive_use_count INTEGER DEFAULT 0,
rate_limit_protection INTEGER DEFAULT 0,
last_used_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
`);
db.close();
const reopened = core.getDbInstance();
const columns = reopened.prepare("PRAGMA table_info(provider_connections)").all();
const names = new Set(columns.map((column) => column.name));
assert.equal(names.has("group"), true);
});
test("resolveProxyForConnection applies combo proxy for object/string model entries", async () => {
await resetStorage();
+54 -1
View File
@@ -6,7 +6,10 @@ import { getModelInfoCore } from "../../open-sse/services/model.ts";
import { detectFormat } from "../../open-sse/services/provider.ts";
import { translateRequest } from "../../open-sse/translator/index.ts";
import { GithubExecutor } from "../../open-sse/executors/github.ts";
import { CodexExecutor } from "../../open-sse/executors/codex.ts";
import {
CodexExecutor,
setDefaultFastServiceTierEnabled,
} from "../../open-sse/executors/codex.ts";
import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts";
import { extractUsageFromResponse } from "../../open-sse/handlers/usageExtractor.ts";
import { parseSSEToResponsesOutput } from "../../open-sse/handlers/sseParser.ts";
@@ -23,6 +26,12 @@ test("getModelInfoCore keeps openai fallback for gpt-4o", async () => {
assert.equal(info.model, "gpt-4o");
});
test("getModelInfoCore resolves gpt-5.4 to codex", async () => {
const info = await getModelInfoCore("gpt-5.4", {});
assert.equal(info.provider, "codex");
assert.equal(info.model, "gpt-5.4");
});
test("getModelInfoCore returns explicit ambiguity metadata for ambiguous unprefixed model", async () => {
const info = await getModelInfoCore("claude-haiku-4.5", {});
assert.equal(info.provider, null);
@@ -60,6 +69,32 @@ test("CodexExecutor forces stream=true for upstream compatibility", () => {
assert.equal(transformed.stream, true);
});
test("CodexExecutor maps fast service tier to priority", () => {
const executor = new CodexExecutor();
const transformed = executor.transformRequest(
"gpt-5.1-codex",
{ model: "gpt-5.1-codex", input: [], service_tier: "fast" },
true
);
assert.equal(transformed.service_tier, "priority");
});
test("CodexExecutor can force fast service tier from settings", () => {
setDefaultFastServiceTierEnabled(true);
try {
const executor = new CodexExecutor();
const transformed = executor.transformRequest(
"gpt-5.1-codex",
{ model: "gpt-5.1-codex", input: [] },
true
);
assert.equal(transformed.service_tier, "priority");
} finally {
setDefaultFastServiceTierEnabled(false);
}
});
test("CodexExecutor always requests SSE accept header", () => {
const executor = new CodexExecutor();
const headers = executor.buildHeaders({ accessToken: "test-token" }, false);
@@ -166,6 +201,24 @@ test("translateRequest normalizes openai-responses input string into list payloa
assert.equal(translated.input[0].content[0].text, "hello from responses");
});
test("translateRequest preserves service_tier when converting openai to openai-responses", () => {
const translated = translateRequest(
FORMATS.OPENAI,
FORMATS.OPENAI_RESPONSES,
"gpt-5.1-codex",
{
model: "gpt-5.1-codex",
messages: [{ role: "user", content: "hello from chat completions" }],
service_tier: "fast",
stream: false,
},
false
);
assert.equal(translated.service_tier, "fast");
assert.ok(Array.isArray(translated.input));
});
test("parseSSEToResponsesOutput parses completed response from SSE payload", () => {
const rawSSE = [
"event: response.created",
+226
View File
@@ -0,0 +1,226 @@
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { transformToOmniRoute } from "../../src/lib/pricingSync.ts";
// ─── transformToOmniRoute ────────────────────────────────
describe("transformToOmniRoute", () => {
test("converts LiteLLM per-token pricing to OmniRoute per-million format", () => {
const raw = {
"openai/gpt-4o": {
input_cost_per_token: 0.0000025,
output_cost_per_token: 0.00001,
litellm_provider: "openai",
mode: "chat",
},
};
const result = transformToOmniRoute(raw);
assert.ok(result.openai, "Should have openai provider");
assert.ok(result.openai["gpt-4o"], "Should have gpt-4o model");
assert.strictEqual(result.openai["gpt-4o"].input, 2.5);
assert.strictEqual(result.openai["gpt-4o"].output, 10);
});
test("maps anthropic provider to cc alias", () => {
const raw = {
"anthropic/claude-sonnet-4-20250514": {
input_cost_per_token: 0.000003,
output_cost_per_token: 0.000015,
litellm_provider: "anthropic",
mode: "chat",
},
};
const result = transformToOmniRoute(raw);
assert.ok(result.cc, "Should map to cc alias");
assert.ok(result.cc["claude-sonnet-4-20250514"]);
assert.strictEqual(result.cc["claude-sonnet-4-20250514"].input, 3);
assert.strictEqual(result.cc["claude-sonnet-4-20250514"].output, 15);
});
test("maps vertex_ai provider to gemini and gc aliases", () => {
const raw = {
"vertex_ai/gemini-2.5-flash": {
input_cost_per_token: 0.0000003,
output_cost_per_token: 0.0000025,
litellm_provider: "vertex_ai",
mode: "chat",
},
};
const result = transformToOmniRoute(raw);
assert.ok(result.gemini, "Should map to gemini alias");
assert.ok(result.gc, "Should map to gc alias");
assert.strictEqual(result.gemini["gemini-2.5-flash"].input, 0.3);
assert.strictEqual(result.gc["gemini-2.5-flash"].input, 0.3);
});
test("skips non-chat models (embedding, image, audio)", () => {
const raw = {
"openai/text-embedding-3-small": {
input_cost_per_token: 0.00000002,
output_cost_per_token: 0,
litellm_provider: "openai",
mode: "embedding",
},
"openai/dall-e-3": {
input_cost_per_token: 0,
output_cost_per_token: 0,
litellm_provider: "openai",
mode: "image_generation",
},
};
const result = transformToOmniRoute(raw);
// openai key should not exist since all models were filtered
const openaiModels = result.openai || {};
assert.strictEqual(Object.keys(openaiModels).length, 0, "Should skip non-chat models");
});
test("includes cache pricing when available", () => {
const raw = {
"anthropic/claude-sonnet-4-20250514": {
input_cost_per_token: 0.000003,
output_cost_per_token: 0.000015,
cache_read_input_token_cost: 0.0000003,
cache_creation_input_token_cost: 0.00000375,
litellm_provider: "anthropic",
mode: "chat",
},
};
const result = transformToOmniRoute(raw);
const model = result.anthropic["claude-sonnet-4-20250514"];
assert.ok(model, "Should have model");
assert.strictEqual(model.cached, 0.3);
assert.strictEqual(model.cache_creation, 3.75);
});
test("handles models without explicit mode (treated as chat)", () => {
const raw = {
"deepseek/deepseek-chat": {
input_cost_per_token: 0.00000014,
output_cost_per_token: 0.00000028,
litellm_provider: "deepseek",
},
};
const result = transformToOmniRoute(raw);
// deepseek maps to "if" alias
assert.ok(result.if, "Should map deepseek to if alias");
assert.ok(result.if["deepseek-chat"]);
});
test("skips entries without input cost", () => {
const raw = {
"unknown/model": {
litellm_provider: "unknown",
mode: "chat",
},
};
const result = transformToOmniRoute(raw);
const unknownModels = result.unknown || {};
assert.strictEqual(Object.keys(unknownModels).length, 0);
});
test("handles zero-cost (free) models", () => {
const raw = {
"groq/llama-3.3-70b-versatile": {
input_cost_per_token: 0,
output_cost_per_token: 0,
litellm_provider: "groq",
mode: "chat",
},
};
const result = transformToOmniRoute(raw);
assert.ok(result.groq, "Should have groq provider");
assert.strictEqual(result.groq["llama-3.3-70b-versatile"].input, 0);
assert.strictEqual(result.groq["llama-3.3-70b-versatile"].output, 0);
});
test("uses litellm_provider as-is for unmapped providers", () => {
const raw = {
"newprovider/some-model": {
input_cost_per_token: 0.000001,
output_cost_per_token: 0.000002,
litellm_provider: "newprovider",
mode: "chat",
},
};
const result = transformToOmniRoute(raw);
assert.ok(result.newprovider, "Should use litellm_provider as-is");
assert.ok(result.newprovider["some-model"]);
});
test("strips provider prefix from model key", () => {
const raw = {
"openai/gpt-4o-mini": {
input_cost_per_token: 0.00000015,
output_cost_per_token: 0.0000006,
litellm_provider: "openai",
mode: "chat",
},
};
const result = transformToOmniRoute(raw);
assert.ok(result.openai["gpt-4o-mini"], "Should strip openai/ prefix");
assert.strictEqual(result.openai["gpt-4o-mini"].input, 0.15);
});
test("rounds pricing to 3 decimal places", () => {
const raw = {
"test/model": {
input_cost_per_token: 0.00000033333,
output_cost_per_token: 0.00000066666,
litellm_provider: "openai",
mode: "chat",
},
};
const result = transformToOmniRoute(raw);
// 0.00000033333 * 1e6 = 0.33333 → rounded to 0.333
assert.strictEqual(result.openai.model.input, 0.333);
assert.strictEqual(result.openai.model.output, 0.667);
});
});
// ─── Merge precedence ────────────────────────────────────
describe("pricing merge precedence", () => {
test("user overrides > synced > defaults conceptual order", () => {
// This test validates the conceptual model.
// The actual merge is tested via integration with settings.ts.
// Here we verify transform doesn't lose data needed for merge.
const raw = {
"openai/gpt-4o": {
input_cost_per_token: 0.0000025,
output_cost_per_token: 0.00001,
litellm_provider: "openai",
mode: "chat",
},
};
const synced = transformToOmniRoute(raw);
const userOverride = { openai: { "gpt-4o": { input: 999 } } };
// Simulate merge: synced then user
const merged = { ...synced.openai["gpt-4o"], ...userOverride.openai["gpt-4o"] };
assert.strictEqual(merged.input, 999, "User override should win");
assert.strictEqual(merged.output, 10, "Non-overridden fields from synced should remain");
});
});
+167
View File
@@ -0,0 +1,167 @@
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";
// ── Env vars BEFORE dynamic imports ──────────────────────────────────────────
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-strict-random-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "strict-random-test-secret";
const { fisherYatesShuffle, getNextFromDeck } = await import("../../src/sse/services/auth.ts");
test.after(() => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ─── fisherYatesShuffle ──────────────────────────────────────────────────────
test("fisherYatesShuffle: returns array with same elements", () => {
const input = ["a", "b", "c", "d", "e"];
const result = fisherYatesShuffle(input);
assert.equal(result.length, input.length);
for (const item of input) {
assert.ok(result.includes(item), `Missing item: ${item}`);
}
});
test("fisherYatesShuffle: does not mutate original array", () => {
const input = Object.freeze(["a", "b", "c"]);
const result = fisherYatesShuffle(input);
assert.deepStrictEqual([...input], ["a", "b", "c"]);
assert.equal(result.length, 3);
});
test("fisherYatesShuffle: single element returns same element", () => {
const result = fisherYatesShuffle(["only"]);
assert.deepStrictEqual(result, ["only"]);
});
test("fisherYatesShuffle: empty array returns empty array", () => {
const result = fisherYatesShuffle([]);
assert.deepStrictEqual(result, []);
});
// ─── getNextFromDeck ─────────────────────────────────────────────────────────
test("getNextFromDeck: uses all connections before repeating", () => {
const provider = "test-full-cycle";
const ids = ["c1", "c2", "c3", "c4"];
const seen = new Set();
for (let i = 0; i < ids.length; i++) {
const id = getNextFromDeck(provider, ids);
assert.ok(!seen.has(id), `Duplicate before full cycle: ${id} at step ${i}`);
seen.add(id);
}
assert.equal(seen.size, ids.length, "Should have used every connection exactly once");
});
test("getNextFromDeck: reshuffles after exhausting deck", () => {
const provider = "test-reshuffle";
const ids = ["c1", "c2", "c3"];
// Exhaust first cycle
for (let i = 0; i < ids.length; i++) {
getNextFromDeck(provider, ids);
}
// Next call should start a new cycle (reshuffle)
const firstOfNewCycle = getNextFromDeck(provider, ids);
assert.ok(ids.includes(firstOfNewCycle), "New cycle should return a valid connection");
// Complete the new cycle
const newCycleSeen = new Set([firstOfNewCycle]);
for (let i = 1; i < ids.length; i++) {
const id = getNextFromDeck(provider, ids);
assert.ok(!newCycleSeen.has(id), `Duplicate in new cycle: ${id}`);
newCycleSeen.add(id);
}
assert.equal(newCycleSeen.size, ids.length, "New cycle should use all connections");
});
test("getNextFromDeck: last of previous cycle is not first of next cycle", () => {
const provider = "test-no-repeat-boundary";
const ids = ["c1", "c2", "c3", "c4", "c5"];
// Run multiple full cycles and check the boundary condition
let violations = 0;
const totalCycles = 50;
for (let cycle = 0; cycle < totalCycles; cycle++) {
let lastId = "";
for (let i = 0; i < ids.length; i++) {
lastId = getNextFromDeck(provider, ids);
}
// First of next cycle
const firstOfNext = getNextFromDeck(provider, ids);
if (firstOfNext === lastId) violations++;
// Consume rest of cycle
for (let i = 1; i < ids.length; i++) {
getNextFromDeck(provider, ids);
}
}
assert.equal(
violations,
0,
`Last of cycle matched first of next cycle ${violations}/${totalCycles} times`
);
});
test("getNextFromDeck: connection list change resets deck", () => {
const provider = "test-reset-on-change";
const originalIds = ["c1", "c2", "c3", "c4"];
// Use 2 from original deck
getNextFromDeck(provider, originalIds);
getNextFromDeck(provider, originalIds);
// Now change the connection list (simulates quota exhaustion removing a connection)
const newIds = ["c1", "c2", "c3"]; // c4 removed
const seen = new Set();
for (let i = 0; i < newIds.length; i++) {
const id = getNextFromDeck(provider, newIds);
assert.ok(newIds.includes(id), `Got invalid id ${id} after reset`);
assert.ok(!seen.has(id), `Duplicate after reset: ${id}`);
seen.add(id);
}
assert.equal(seen.size, newIds.length, "Should use all new connections after reset");
});
test("getNextFromDeck: single connection always returns that connection", () => {
const provider = "test-single";
const ids = ["only-one"];
for (let i = 0; i < 10; i++) {
const id = getNextFromDeck(provider, ids);
assert.equal(id, "only-one");
}
});
test("getNextFromDeck: empty array returns empty string", () => {
const provider = "test-empty";
const id = getNextFromDeck(provider, []);
assert.equal(id, "");
});
test("getNextFromDeck: different providers have independent decks", () => {
const idsA = ["a1", "a2", "a3"];
const idsB = ["b1", "b2"];
const firstA = getNextFromDeck("providerA", idsA);
const firstB = getNextFromDeck("providerB", idsB);
assert.ok(idsA.includes(firstA));
assert.ok(idsB.includes(firstB));
// Exhaust providerB deck
getNextFromDeck("providerB", idsB);
// providerA should still have remaining items from its deck
const secondA = getNextFromDeck("providerA", idsA);
assert.ok(idsA.includes(secondA));
assert.notEqual(firstA, secondA, "providerA deck should advance independently");
});
+149
View File
@@ -0,0 +1,149 @@
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";
import { fileURLToPath } from "node:url";
// ── Env vars ANTES dos imports dinâmicos ─────────────────────────
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-t08-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "t08-test-secret-key";
const core = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const schemas = await import("../../src/shared/validation/schemas.ts");
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, "../..");
async function resetStorage() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ══════════════════════════════════════════════════════════════════
// Bloco 1 — Schema Zod (updateKeyPermissionsSchema)
// ══════════════════════════════════════════════════════════════════
test("1.1 — allowedConnections com UUID válido é aceito", () => {
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
allowedConnections: ["550e8400-e29b-41d4-a716-446655440000"],
});
assert.equal(result.success, true);
});
test("1.2 — allowedConnections com string não-UUID é rejeitado", () => {
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
allowedConnections: ["nao-e-uuid"],
});
assert.equal(result.success, false);
});
test("1.3 — allowedConnections + noLog combinados são aceitos", () => {
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
allowedConnections: ["550e8400-e29b-41d4-a716-446655440000"],
noLog: true,
});
assert.equal(result.success, true);
});
test("1.4 — payload vazio é rejeitado pelo superRefine", () => {
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {});
assert.equal(result.success, false);
});
test("1.5 — allowedConnections array vazio é aceito (não é undefined, superRefine não dispara)", () => {
const result = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
allowedConnections: [],
});
assert.equal(result.success, true);
});
// ══════════════════════════════════════════════════════════════════
// Bloco 2 — DB (apiKeys.ts)
// ══════════════════════════════════════════════════════════════════
test("2.1 — key criada tem allowedConnections: [] por padrão", async () => {
const created = await apiKeysDb.createApiKey("test-key", "machine-t08");
assert.deepEqual(created.allowedConnections, []);
});
test("2.2 — updateApiKeyPermissions persiste array de UUIDs", async () => {
const created = await apiKeysDb.createApiKey("conn-key", "machine-t08");
const uuid1 = "550e8400-e29b-41d4-a716-446655440000";
const uuid2 = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
const updated = await apiKeysDb.updateApiKeyPermissions(created.id, {
allowedConnections: [uuid1, uuid2],
});
assert.equal(updated, true);
const row = await apiKeysDb.getApiKeyById(created.id);
assert.deepEqual(row?.allowedConnections, [uuid1, uuid2]);
});
test("2.3 — getApiKeyById retorna allowedConnections corretamente", async () => {
const created = await apiKeysDb.createApiKey("by-id-key", "machine-t08");
const uuid = "550e8400-e29b-41d4-a716-446655440000";
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [uuid] });
const row = await apiKeysDb.getApiKeyById(created.id);
assert.ok(Array.isArray(row?.allowedConnections));
assert.equal(row?.allowedConnections[0], uuid);
});
test("2.4 — getApiKeyMetadata retorna allowedConnections corretamente", async () => {
const created = await apiKeysDb.createApiKey("meta-key", "machine-t08");
const uuid = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [uuid] });
const meta = await apiKeysDb.getApiKeyMetadata(created.key);
assert.ok(Array.isArray(meta?.allowedConnections));
assert.equal(meta?.allowedConnections[0], uuid);
});
test("2.5 — allowedConnections: [] persiste como array vazio (não null)", async () => {
const created = await apiKeysDb.createApiKey("empty-conn-key", "machine-t08");
const uuid = "550e8400-e29b-41d4-a716-446655440000";
// Primeiro adiciona um UUID
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [uuid] });
// Reseta para array vazio
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [] });
const row = await apiKeysDb.getApiKeyById(created.id);
assert.ok(Array.isArray(row?.allowedConnections));
assert.deepEqual(row?.allowedConnections, []);
});
test("2.6 — cache é invalidado após update (2ª chamada ao metadata reflete novo valor)", async () => {
const created = await apiKeysDb.createApiKey("cache-test-key", "machine-t08");
const uuid = "550e8400-e29b-41d4-a716-446655440000";
// Primeira chamada popula cache (allowedConnections vazio)
const before = await apiKeysDb.getApiKeyMetadata(created.key);
assert.deepEqual(before?.allowedConnections, []);
// Atualiza — deve invalidar cache
await apiKeysDb.updateApiKeyPermissions(created.id, { allowedConnections: [uuid] });
// Segunda chamada deve retornar novo valor (não o cache antigo)
const after = await apiKeysDb.getApiKeyMetadata(created.key);
assert.deepEqual(after?.allowedConnections, [uuid]);
});