Compare commits

...

58 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
diegosouzapw b0f5f92f1a feat(release): v2.4.2 — task-aware routing, HuggingFace/Vertex providers, streaming fixes, token tracking, playground uploads
Build Electron Desktop App / Validate version (push) Failing after 43s
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
- feat: Task-Aware Smart Routing (T05) — auto-select model by task type
- feat: HuggingFace and Vertex AI provider support
- feat: Playground audio/image file uploads for transcription and vision
- feat: ModelSelectModal shows ✓ for already-added models (#180)
- fix: Claude Haiku routed to OpenAI without provider prefix (#73)
- fix: Token counts always 0 for Antigravity/Claude streaming (#74)
- fix: OpenAI SDK stream=False drops tool_calls (#302)
- fix: Media page generation errors — inline rendering for images/transcription
- fix: Round-robin state management for excluded accounts (#349)
- fix: Qwen user agent and CLI fingerprint compatibility (#352)
- deps: undici→7.24.2, dompurify→3.3.3, docker actions v4
- docs: CHANGELOG 2.4.2 with full feature/fix list
- docs: README with Task-Aware Routing table entry
2026-03-14 11:04:09 -03:00
Diego Rodrigues de Sa e Souza eaddb6f0fa feat: improvements from 9router analysis (T01/T08-T13) (#351)
* fix: tool description null sanitization, clipboard HTTP fallback fixes

T10 - Sanitize tool.description null in claude-to-openai translator
- claude-to-openai.ts: tool.description defaults to empty string when null/undefined
- claude-to-openai.ts: filter out tools with empty/missing names
- Prevents 400 validation errors on providers like NVIDIA NIM (issue #276)

T11 - Fix copy buttons to work on HTTP/non-HTTPS deployments
- Add src/shared/utils/clipboard.ts with HTTPS+HTTP (execCommand) dual fallback
- Migrate useCopyToClipboard.ts to use shared utility
- Migrate ConsoleLogViewer.tsx, RequestLoggerV2.tsx to shared utility
- Migrate HomePageClient.tsx, endpoint/page.tsx, GetStarted.tsx
- Migrate DefaultToolCard.tsx to shared utility
- Fixes copy buttons when OmniRoute runs behind HTTP proxy (issue #296)

T02 - Verified SSE [DONE] sentinel handling already correct
- sseParser.ts filters [DONE] on line 13 (no change needed)
- stream.ts uses doneSent flag to prevent duplicate sentinel
- bypassHandler.ts correctly separates streaming/non-streaming responses

Issue triage comments posted to #340, #341, #344

* feat: DB read cache + Accept header stream negotiation (T09/T01)

T09 - In-memory TTL cache for hot DB read paths
- Add src/lib/db/readCache.ts with TTL cache (5s settings/connections, 30s pricing)
- Eliminates redundant SQLite reads on concurrent requests
- Integrate invalidation in settings.ts updateSettings() and updatePricing()
- Integrate invalidation in providers.ts create/update/delete operations
- Export getCachedSettings, getCachedPricing, getCachedProviderConnections,
  invalidateDbCache via localDb.ts for consumer migration
- Cache auto-busts on any write, preserving data consistency

T01 - Accept header stream negotiation
- src/sse/handlers/chat.ts: detect Accept: text/event-stream header
- Override body.stream=true when Accept header indicates streaming client
- Enables curl, httpx and SDK clients that use HTTP headers instead of JSON
  body field to trigger streaming responses
- Logs Accept override at DEBUG level for observability

* fix: auto-advance quota window on expiry to prevent stale blocking (T08)

T08 - Quota Window Rolling Auto-Advance
- quotaCache.ts: add windowDurationMs field to QuotaCacheEntry interface
  (optional field that callers can set when they know the window duration)
- Add advancedWindowResetAt() helper: if entry.nextResetAt is in the past,
  eagerly returns { exhausted: false } so requests are unblocked immediately
- isAccountQuotaExhausted() now uses advancedWindowResetAt() instead of
  the previous inline date check, and optimistically clears entry.exhausted
  flag to avoid re-checking the same stale entry on the next request

Before: exhausted accounts with an expired resetAt would wait up to 5
minutes for the background refresh before accepting new requests.
After:  the first request after resetAt passes will be immediately accepted
and will trigger a quota refresh on the next background tick.

* feat: manual OAuth token refresh UI (T12)

T12 - Manual Token Refresh UI
- Add POST /api/providers/[id]/refresh endpoint
  - Validates connection exists and is OAuth type
  - Calls getAccessToken() (same helper used in auto-refresh)
  - Persists new credentials via updateProviderCredentials()
  - Returns { success, expiresAt, refreshedAt } on success

- Update providers/[id]/page.tsx
  - handleRefreshToken() with loading state (refreshingId)
  - Pass onRefreshToken + isRefreshing props to ConnectionRow
  - ConnectionRow: add optional onRefreshToken/isRefreshing props
  - ConnectionRow: tokenMinsLeft state via lazy init (Date.now() in
    getter fn, not in render body - satisfies react-hooks/purity)
  - Token expiry badge: red 'expired' | amber '~Xm' (<30min) | hidden
  - 'Token' button (amber) next to 'Retest' for OAuth connections

- Add en.json i18n: tokenRefreshed, tokenRefreshFailed

* Initial plan

* feat: integrate wildcardRouter into model alias resolution (T13)

T13 - Wildcard Model Routing
- Import resolveWildcardAlias from wildcardRouter.ts into model.ts
- In getModelInfoCore(), after exact alias check fails, try glob wildcard
  alias matching (e.g., 'claude-sonnet-*' alias → 'anthropic/claude-sonnet-4')
- Returns { provider, model, extendedContext, wildcardPattern } on match
- Falls back to MODEL_TO_PROVIDERS lookup and openai default as before

* fix: clipboard cleanup and tool validation

* feat: media page UX + T04 playground uploads + T03 HuggingFace/Vertex AI

Media Page (MediaPageClient.tsx):
- Render images inline (img tags from b64_json or url)
- Show transcription as plain readable text (not raw JSON)
- Amber banner for credential errors with link to /dashboard/providers
- Detect empty transcription result and show credentials hint
- Provider credential hint below selector for non-local providers
- Extended provider/model lists: HuggingFace, Qwen TTS, Inworld, Cartesia, PlayHT, AssemblyAI

T04 - Playground File Uploads (playground/page.tsx):
- Audio file upload panel for transcription endpoint (multipart/form-data)
- Image upload panel for vision models (gpt-4o, claude-3, gemini, pixtral, llava...)
- Auto-detect vision models by name heuristic
- Inject uploaded images as base64 image_url in chat messages
- Inline image rendering for image generation results
- Readable text view for transcription results with copy button
- Preview thumbnails for attached images with individual remove

T03 - HuggingFace + Vertex AI Providers:
- HuggingFace: frontend providers.ts + backend providerRegistry.ts
  Uses HuggingFace Router OpenAI-compatible endpoint
- Vertex AI: frontend providers.ts + backend providerRegistry.ts
  Uses gemini format with generateContent API (urlBuilder fallback)

T07 - API Key Round-Robin: VERIFIED already implemented in auth.ts
  fill-first, round-robin, p2c, random, least-used, cost-optimized strategies

* feat: T05 task-aware routing + fix #302 stream override + fix #73 claude provider fallback

T05 - Task-Aware Smart Routing:
- New open-sse/services/taskAwareRouter.ts:
  Detects 7 task types: coding, creative, analysis, vision, summarization,
  background, chat from system/user message content and images
  Configurable taskModelMap per task type, stats tracking
  applyTaskAwareRouting() integrates with existing chat pipeline
- New src/app/api/settings/task-routing/route.ts:
  GET/PUT/POST API for task routing config + reset-stats + detect action
  Persists config via updateSettings('taskRouting')
- Integration in src/sse/handlers/chat.ts:
  applyTaskAwareRouting() called after policy enforcement, before combo resolve
  Logs task type detection and model overrides

Fix #302 - OpenAI SDK stream=False drops tool_calls:
- src/sse/handlers/chat.ts T01 Accept header negotiation:
  Changed condition from 'body.stream !== true' to 'body.stream === undefined'
  OpenAI Python SDK sends 'Accept: application/json, text/event-stream' in every
  request, even stream=False — the old code was incorrectly forcing stream=true,
  causing tool_calls to be dropped from non-streaming responses

Fix #73 - Claude Haiku routed to OpenAI provider instead of Antigravity:
- open-sse/services/model.ts getModelInfoCore():
  Added heuristic prefix detection before the blind 'openai' fallback:
  claude-* models → antigravity (Anthropic) provider
  gemini-*/gemma-* models → gemini provider
  Closes: #73, partially addresses #302

* fix: token counts 0 (#74), model import dup (#180), model route fallback (#73)

fix #74 - Token counts always 0 for Antigravity/Claude streaming:
- open-sse/utils/usageTracking.ts extractUsage():
  Add handler for 'message_start' SSE event which carries INPUT tokens in
  Antigravity/Claude streaming:
  { type: 'message_start', message: { usage: { input_tokens: N } } }
  This event was completely unhandled, causing ALL input token counts to be
  dropped for every Antigravity/Claude streaming request

fix #180 - Model import shows duplicates with no visual feedback:
- src/shared/components/ModelSelectModal.tsx:
  Added addedModelValues prop (string[]) to receive already-added model values
  Models already in the combo now shown with ✓ indicator + green highlight
  Makes it visually clear which models are already added vs new
- src/app/(dashboard)/dashboard/combos/page.tsx:
  Pass addedModelValues={models.map(m => m.model)} to ModelSelectModal

* Harden clipboard UX and Claude tool normalization (#360)

* Initial plan

* chore: plan updates for clipboard and translator fixes

* fix: clipboard cleanup, copy feedback, and claude tool validation

---------

Co-authored-by: openai-code-agent[bot] <242516109+Codex@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: openai-code-agent[bot] <242516109+Codex@users.noreply.github.com>
2026-03-14 10:59:15 -03:00
Nyaru Toru 5cff98ea75 feat: add Qwen compatibility with updated user agent and CLI fingerprint settings (#352)
Co-authored-by: nyatoru <nyarutoru0002@outlook.co.th>
2026-03-14 10:58:50 -03:00
Nyaru Toru 76127415a4 fix(account-selector): enhance round-robin logic to handle excluded accounts and maintain state (#349)
Co-authored-by: nyatoru <nyarutoru0002@outlook.co.th>
2026-03-14 10:58:48 -03:00
dependabot[bot] 56936fe0e3 deps: bump undici from 7.24.1 to 7.24.2 (#361)
Bumps [undici](https://github.com/nodejs/undici) from 7.24.1 to 7.24.2.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.24.1...v7.24.2)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.24.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-14 10:58:46 -03:00
dependabot[bot] dfbbbeb1b4 chore(deps): bump docker/setup-buildx-action from 3 to 4 (#343)
* chore(deps): bump docker/setup-buildx-action from 3 to 4

Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* Initial plan

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openai-code-agent[bot] <242516109+Codex@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
2026-03-14 10:56:20 -03:00
dependabot[bot] 7f3ffd935e chore(deps): bump docker/setup-qemu-action from 3 to 4 (#342)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-14 10:56:18 -03:00
dependabot[bot] 29cf462d8f deps: bump undici from 7.22.0 to 7.24.1 (#348)
* deps: bump undici from 7.22.0 to 7.24.1

Bumps [undici](https://github.com/nodejs/undici) from 7.22.0 to 7.24.1.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.22.0...v7.24.1)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.24.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Initial plan

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openai-code-agent[bot] <242516109+Codex@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
2026-03-14 10:56:16 -03:00
dependabot[bot] 5e1693e1f7 deps: bump dompurify from 3.3.2 to 3.3.3 (#347) 2026-03-14 10:55:45 -03:00
diegosouzapw 45424ca226 fix(ci): docs-sync, openapi version, changelog format, pre-commit hook
Build Electron Desktop App / Validate version (push) Failing after 38s
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
- docs/openapi.yaml: update info.version from 2.3.6 to 2.4.1 (fixes CI check)
- CHANGELOG.md: add '## [Unreleased]' section as first heading (required by check-docs-sync)
- scripts/check-docs-sync.mjs: fix regex to accept both hyphen (-) and em-dash (—)
  as date separators in changelog headings (standard Keep a Changelog format)
- .husky/pre-commit: add 'node scripts/check-docs-sync.mjs' to catch version
  mismatches locally before push
2026-03-13 11:45:32 -03:00
diegosouzapw d976abb5e0 chore: v2.4.1 — combos free-stack always visible 2026-03-13 11:29:51 -03:00
diegosouzapw 92d302aed3 fix(combos): free-stack template first, 2x2 grid, green highlight badge
- Move 'Free Stack ($0)' to position 1 in COMBO_TEMPLATES (was 4th, invisible in 3-col grid)
- Add isFeatured flag to free-stack for special styling
- Change template grid: grid-cols-3 → 2x2 (sm:grid-cols-2) — all 4 templates visible
- Free Stack: green border/bg (emerald), FREE badge, larger text size
- Other templates: hover styles preserved, → arrow on Apply link
- Increase templates section padding
2026-03-13 11:26:18 -03:00
diegosouzapw 1e93ee5c34 chore: release v2.4.0
Build Electron Desktop App / Validate version (push) Failing after 25s
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
Bump from 2.3.17 to 2.4.0 to reflect the significance of this release:
- Free Stack combo template ecosystem
- Transcription playground overhaul (Deepgram default, $200/$50 free badges)
- 44+ providers documented, hasFree badges on NVIDIA/Cerebras/Groq
- README: Start Free section, Free Models section, Free Transcription Combo
- tierPriority as 7th scoring factor in auto-combo UI
- i18n 30 languages fully synced
2026-03-13 11:20:31 -03:00
diegosouzapw 1b6c502c7f feat: free-stack combo, Deepgram transcription default, README free sections, provider hasFree badges
- Combos: add 'Free Stack ($0)' as 4th combo template (round-robin: Kiro+iFlow+Qwen+GeminiCLI)
- Media/Transcription: Deepgram (Nova 3) as default provider, show $200/$50/free badges
- providers.ts: hasFree + freeNote for NVIDIA NIM (40 RPM), Cerebras (1M tok/day), Groq (30 RPM)
- README: new early '🆓 Start Free' 5-step table before Quick Start
- README: new '🎙️ Free Transcription Combo' section (Deepgram/AssemblyAI/Groq)
- README: NVIDIA NIM model list updated (Kimi K2.5, GLM 4.7, DeepSeek V3.2)
- i18n: templateFreeStack + templateFreeStackDesc synced to 30 languages
- Bump version to 2.3.17
2026-03-13 11:13:02 -03:00
diegosouzapw 4e4532c057 docs(readme): 44+ providers, free models section, accurate free tier quotas
Build Electron Desktop App / Validate version (push) Failing after 43s
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
- Update provider count from 36+ to 44+ in 3 locations (line 5, unified endpoint, one-endpoint sections)
- Add new section '🆓 Free Models — What You Actually Get' with 7 provider tables:
  - Kiro: 3 Claude models (unlimited via AWS Builder ID)
  - iFlow: 5 models unlimited (kimi-k2-thinking, qwen3-coder-plus, deepseek-r1, minimax-m2.1, kimi-k2)
  - Qwen: 4 models unlimited (qwen3-coder-plus, qwen3-coder-flash, qwen3-coder-next, vision-model)
  - Gemini CLI: 180K/month + 1K/day
  - NVIDIA NIM: ~40 RPM dev-forever (70+ models), transitioning from credits to rate limits
  - Cerebras: 1M tokens/day, 60K TPM / 30 RPM
  - Groq: 30 RPM / 14.4K RPD
- Include $0 Ultimate Free Stack combo recommendation
- Update NVIDIA NIM from '1000 credits' to 'dev-forever free' (×3)
- Add Cerebras row to pricing table
- Fix iFlow 8→5 models (with names), Qwen 3→4 models (with names)
- Bump version to 2.3.16
2026-03-13 11:03:24 -03:00
diegosouzapw 1e57ae5923 docs: CHANGELOG v2.3.15
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
2026-03-13 10:41:24 -03:00
diegosouzapw 9055fc2129 feat(auto-combo): add tierPriority factor label + autoCombo i18n section (30 languages)
- Add 'tierPriority: 🏷️ Tier' to FACTOR_LABELS in auto-combo dashboard (7th scoring factor)
- Add 'autoCombo' i18n section with 20 keys to en.json
- Sync autoCombo i18n keys to 29 language files (ar, bg, da, de, es, fi, fr, hi, hu, id, it, ja, ko, nl, no, pl, pt-BR, pt, ro, ru, sk, sv, th, tr, uk, vi, zh-CN, zh-TW + all others)
- Bump version to 2.3.15
2026-03-13 10:40:59 -03:00
diegosouzapw b8fec94b0d feat(release): v2.3.14 — iFlow fix, MITM compile, GeminiCLI fallback, new models, tier scoring API
Build Electron Desktop App / Validate version (push) Failing after 28s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
2026-03-13 10:19:38 -03:00
diegosouzapw 2b6c88cd26 fix: iFlow OAuth secret, MITM server compile, GeminiCLI projectId, model catalog, electron version, tierPriority schema
- fix(oauth): restore iFlow clientSecret default — was empty string, now uses the valid public key (#339)
- fix(mitm): compile src/mitm/*.ts to JS during prepublish so server.js exists in npm bundle (#335)
- fix(gemini-cli): graceful projectId fallback — warn + empty string instead of hard 500 error (#338)
- feat(models): add gpt5.4 to Codex; add claude-sonnet-4, claude-opus-4.6, deepseek-v3.2, minimax-m2.1, qwen3-coder-next, auto to Kiro (#334)
- fix(electron): sync electron/package.json version to 2.3.13 (#323)
- feat(scoring): add tierPriority (0.05) to ScoringWeights Zod schema and combos/auto API route
2026-03-13 10:18:44 -03:00
131 changed files with 7585 additions and 620 deletions
+23 -1
View File
@@ -142,10 +142,32 @@ GITHUB_USER_AGENT=GitHubCopilotChat/0.26.7
ANTIGRAVITY_USER_AGENT=antigravity/1.104.0 darwin/arm64
KIRO_USER_AGENT=AWS-SDK-JS/3.0.0 kiro-ide/1.0.0
IFLOW_USER_AGENT=iFlow-Cli
QWEN_USER_AGENT=google-api-nodejs-client/9.15.1
QWEN_USER_AGENT=QwenCode/0.12.3 (linux; x64)
CURSOR_USER_AGENT=connect-es/1.6.1
GEMINI_CLI_USER_AGENT=google-api-nodejs-client/9.15.1
# ─────────────────────────────────────────────────────────────────────────────
# CLI Fingerprint Compatibility (optional — match native CLI binary signatures)
# ─────────────────────────────────────────────────────────────────────────────
# When enabled, OmniRoute reorders HTTP headers and JSON body fields to match
# the exact signature of official CLI tools, reducing account flagging risk.
# Your proxy IP is preserved — you get both stealth AND IP masking.
#
# Enable per-provider:
# CLI_COMPAT_CODEX=1
# CLI_COMPAT_CLAUDE=1
# CLI_COMPAT_GITHUB=1
# CLI_COMPAT_ANTIGRAVITY=1
# CLI_COMPAT_KIRO=1
# CLI_COMPAT_CURSOR=1
# CLI_COMPAT_KIMI_CODING=1
# CLI_COMPAT_KILOCODE=1
# CLI_COMPAT_CLINE=1
# CLI_COMPAT_QWEN=1
#
# Or enable for all providers at once:
# CLI_COMPAT_ALL=1
# API Key Providers (Phase 1 + Phase 4)
# Add via Dashboard → Providers → Add API Key, or set here
# DEEPSEEK_API_KEY=
+2 -2
View File
@@ -18,10 +18,10 @@ jobs:
uses: actions/checkout@v6
- name: Set up QEMU (for multi-arch builds)
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v4
+1
View File
@@ -1 +1,2 @@
npx lint-staged
node scripts/check-docs-sync.mjs
+174 -1
View File
@@ -1,6 +1,179 @@
# Changelog
## [2.3.13] - 2026-03-12
## [Unreleased]
## [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.
### ✨ New Features
- **Task-Aware Smart Routing (T05)**: Automatic model selection based on request content type — coding → deepseek-chat, analysis → gemini-2.5-pro, vision → gpt-4o, summarization → gemini-2.5-flash. Configurable via Settings. New `GET/PUT/POST /api/settings/task-routing` API.
- **HuggingFace Provider**: Added HuggingFace Router as an OpenAI-compatible provider with Llama 3.1 70B/8B, Qwen 2.5 72B, Mistral 7B, Phi-3.5 Mini.
- **Vertex AI Provider**: Added Vertex AI (Google Cloud) provider with Gemini 2.5 Pro/Flash, Gemma 2 27B, Claude via Vertex.
- **Playground File Uploads**: Audio upload for transcription, image upload for vision models (auto-detect by model name), inline image rendering for image generation results.
- **Model Select Visual Feedback**: Already-added models in combo picker now show ✓ green badge — prevents duplicate confusion.
- **Qwen Compatibility (PR #352)**: Updated User-Agent and CLI fingerprint settings for Qwen provider compatibility.
- **Round-Robin State Management (PR #349)**: Enhanced round-robin logic to handle excluded accounts and maintain rotation state correctly.
- **Clipboard UX (PR #360)**: Hardened clipboard operations with fallback for non-secure contexts; Claude tool normalization improvements.
### 🐛 Bug Fixes
- **Fix #302 — OpenAI SDK stream=False drops tool_calls**: T01 Accept header negotiation no longer forces streaming when `body.stream` is explicitly `false`. Was causing tool_calls to be silently dropped when using the OpenAI Python SDK in non-streaming mode.
- **Fix #73 — Claude Haiku routed to OpenAI without provider prefix**: `claude-*` models sent without a provider prefix now correctly route to the `antigravity` (Anthropic) provider. Added `gemini-*`/`gemma-*``gemini` heuristic as well.
- **Fix #74 — Token counts always 0 for Antigravity/Claude streaming**: The `message_start` SSE event which carries `input_tokens` was not being parsed by `extractUsage()`, causing all input token counts to drop. Input/output token tracking now works correctly for streaming responses.
- **Fix #180 — Model import duplicates with no feedback**: `ModelSelectModal` now shows ✓ green highlight for models already in the combo, making it obvious they're already added.
- **Media page generation errors**: Image results now render as `<img>` tags instead of raw JSON. Transcription results shown as readable text. Credential errors show an amber banner instead of silent failure.
- **Token refresh button on provider page**: Manual token refresh UI added for OAuth providers.
### 🔧 Improvements
- **Provider Registry**: HuggingFace and Vertex AI added to `providerRegistry.ts` and `providers.ts` (frontend).
- **Read Cache**: New `src/lib/db/readCache.ts` for efficient DB read caching.
- **Quota Cache**: Improved quota cache with TTL-based eviction.
### 📦 Dependencies
- `dompurify` → 3.3.3 (PR #347)
- `undici` → 7.24.2 (PR #348, #361)
- `docker/setup-qemu-action` → v4 (PR #342)
- `docker/setup-buildx-action` → v4 (PR #343)
### 📁 New Files
| File | Purpose |
| --------------------------------------------- | --------------------------------------- |
| `open-sse/services/taskAwareRouter.ts` | Task-aware routing logic (7 task types) |
| `src/app/api/settings/task-routing/route.ts` | Task routing config API |
| `src/app/api/providers/[id]/refresh/route.ts` | Manual OAuth token refresh |
| `src/lib/db/readCache.ts` | Efficient DB read cache |
| `src/shared/utils/clipboard.ts` | Hardened clipboard with fallback |
## [2.4.1] - 2026-03-13
### 🐛 Fix
- **Combos modal: Free Stack visible and prominent** — Free Stack template was hidden (4th in 3-column grid). Fixed: moved to position 1, switched to 2x2 grid so all 4 templates are visible, green border + FREE badge highlight.
## [2.4.0] - 2026-03-13
> **Major release** — Free Stack ecosystem, transcription playground overhaul, 44+ providers, comprehensive free tier documentation, and UI improvements across the board.
### ✨ Features
- **Combos: Free Stack template** — New 4th template "Free Stack ($0)" using round-robin across Kiro + iFlow + Qwen + Gemini CLI. Suggests the pre-built zero-cost combo on first use.
- **Media/Transcription: Deepgram as default** — Deepgram (Nova 3, $200 free) is now the default transcription provider. AssemblyAI ($50 free) and Groq Whisper (free forever) shown with free credit badges.
- **README: "Start Free" section** — New early-README 5-step table showing how to set up zero-cost AI in minutes.
- **README: Free Transcription Combo** — New section with Deepgram/AssemblyAI/Groq combo suggestion and per-provider free credit details.
- **providers.ts: hasFree flag** — NVIDIA NIM, Cerebras, and Groq marked with hasFree badge and freeNote for the providers UI.
- **i18n: templateFreeStack keys** — Free Stack combo template translated and synced to all 30 languages.
## [2.3.16] - 2026-03-13
### 📖 Documentation
- **README: 44+ Providers** — Updated all 3 occurrences of "36+ providers" to "44+" reflecting the actual codebase count (44 providers in providers.ts)
- **README: New Section "🆓 Free Models — What You Actually Get"** — Added 7-provider table with per-model rate limits for: Kiro (Claude unlimited via AWS Builder ID), iFlow (5 models unlimited), Qwen (4 models unlimited), Gemini CLI (180K/mo), NVIDIA NIM (~40 RPM dev-forever), Cerebras (1M tok/day / 60K TPM), Groq (30 RPM / 14.4K RPD). Includes the \/usr/bin/bash Ultimate Free Stack combo recommendation.
- **README: Pricing Table Updated** — Added Cerebras to API KEY tier, fixed NVIDIA from "1000 credits" to "dev-forever free", updated iFlow/Qwen model counts and names
- **README: iFlow 8→5 models** (named: kimi-k2-thinking, qwen3-coder-plus, deepseek-r1, minimax-m2, kimi-k2)
- **README: Qwen 3→4 models** (named: qwen3-coder-plus, qwen3-coder-flash, qwen3-coder-next, vision-model)
## [2.3.15] - 2026-03-13
### ✨ Features
- **Auto-Combo Dashboard (Tier Priority)**: Added `🏷️ Tier` as the 7th scoring factor label in the `/dashboard/auto-combo` factor breakdown display — all 7 Auto-Combo scoring factors are now visible.
- **i18n — autoCombo section**: Added 20 new translation keys for the Auto-Combo dashboard (`title`, `status`, `modePack`, `providerScores`, `factorTierPriority`, etc.) to all 30 language files.
## [2.3.14] - 2026-03-13
### 🐛 Bug Fixes
- **iFlow OAuth (#339)**: Restored the valid default `clientSecret` — was previously an empty string, causing "Bad client credentials" on every connect attempt. The public credential is now the default fallback (overridable via `IFLOW_OAUTH_CLIENT_SECRET` env var).
- **MITM server not found (#335)**: `prepublish.mjs` now compiles `src/mitm/*.ts` to JavaScript using `tsc` before copying to the npm bundle. Previously only raw `.ts` files were copied — meaning `server.js` never existed in npm/Volta global installs.
- **GeminiCLI missing projectId (#338)**: Instead of throwing a hard 500 error when `projectId` is missing from stored credentials (e.g. after Docker restart), OmniRoute now logs a warning and attempts the request — returning a meaningful provider-side error instead of an OmniRoute crash.
- **Electron version mismatch (#323)**: Synced `electron/package.json` version to `2.3.13` (was `2.0.13`) so the desktop binary version matches the npm package.
### ✨ New Models (#334)
- **Kiro**: `claude-sonnet-4`, `claude-opus-4.6`, `deepseek-v3.2`, `minimax-m2.1`, `qwen3-coder-next`, `auto`
- **Codex**: `gpt5.4`
### 🔧 Improvements
- **Tier Scoring (API + Validation)**: Added `tierPriority` (weight `0.05`) to the `ScoringWeights` Zod schema and the `combos/auto` API route — the 7th scoring factor is now fully accepted by the REST API and validated on input. `stability` weight adjusted from `0.10` to `0.05` to keep total sum = `1.0`.
### ✨ New Features
+177 -41
View File
@@ -2,7 +2,7 @@
### Never stop coding. Smart routing to **FREE & low-cost AI models** with automatic fallback.
_Your universal API proxy — one endpoint, 36+ providers, zero downtime. Now with **MCP & A2A** agent orchestration._
_Your universal API proxy — one endpoint, 44+ providers, zero downtime. Now with **MCP & A2A** agent orchestration._
**Chat Completions • Embeddings • Image Generation • Video • Music • Audio • Reranking • MCP Server • A2A Protocol • 100% TypeScript**
@@ -234,7 +234,7 @@ OpenAI uses one format, Claude (Anthropic) uses another, Gemini yet another. If
**How OmniRoute solves it:**
- **Unified Endpoint** — A single `http://localhost:20128/v1` serves as proxy for all 36+ providers
- **Unified Endpoint** — A single `http://localhost:20128/v1` serves as proxy for all 44+ providers
- **Format Translation** — Automatic and transparent: OpenAI ↔ Claude ↔ Gemini ↔ Responses API
- **Response Sanitization** — Strips non-standard fields (`x_groq`, `usage_breakdown`, `service_tier`) that break OpenAI SDK v1.83+
- **Role Normalization** — Converts `developer``system` for non-OpenAI providers; `system``user` for GLM/ERNIE
@@ -268,10 +268,10 @@ Not everyone can pay $20200/month for AI subscriptions. Students, devs from e
**How OmniRoute solves it:**
- **Free Tier Providers Built-in** — Native support for 100% free providers: iFlow (8 unlimited models), Qwen (3 unlimited models), Kiro (Claude for free), Gemini CLI (180K/month free)
- **Free Tier Providers Built-in** — Native support for 100% free providers: iFlow (5 unlimited models via OAuth: kimi-k2-thinking, qwen3-coder-plus, deepseek-r1, minimax-m2, kimi-k2), Qwen (4 unlimited models: qwen3-coder-plus, qwen3-coder-flash, qwen3-coder-next, vision-model), Kiro (Claude + AWS Builder ID for free), Gemini CLI (180K tokens/month free)
- **Ollama Cloud** — Cloud-hosted Ollama models at `api.ollama.com` with free "Light usage" tier; use `ollamacloud/<model>` prefix
- **Free-Only Combos** — Chain `gc/gemini-3-flash → if/kimi-k2-thinking → qw/qwen3-coder-plus` = $0/month with zero downtime
- **NVIDIA NIM Free Credits** — 1000 free credits integrated
- **NVIDIA NIM Free Access** — ~40 RPM dev-forever free access to 70+ models at build.nvidia.com (transitioning from credits to pure rate limits)
- **Cost Optimized Strategy** — Routing strategy that automatically chooses the cheapest available provider
</details>
@@ -320,7 +320,7 @@ Developers use Cursor, Claude Code, Codex CLI, OpenClaw, Gemini CLI, Kilo Code..
- **CLI Tools Dashboard** — Dedicated page with one-click setup for Claude Code, Codex CLI, OpenClaw, Kilo Code, Antigravity, Cline
- **GitHub Copilot Config Generator** — Generates `chatLanguageModels.json` for VS Code with bulk model selection
- **Onboarding Wizard** — Guided 4-step setup for first-time users
- **One endpoint, all models** — Configure `http://localhost:20128/v1` once, access 36+ providers
- **One endpoint, all models** — Configure `http://localhost:20128/v1` once, access 44+ providers
</details>
@@ -702,6 +702,22 @@ Outcome: deep fallback depth for deadline-critical workloads
---
## 🆓 Start Free — Zero Configuration Cost
> Setup AI coding in minutes at **$0/month**. Connect these free accounts and use the built-in **Free Stack** combo.
| Step | Action | Providers Unlocked |
| ---- | -------------------------------------------------- | ------------------------------------------------------------------ |
| 1 | Connect **Kiro** (AWS Builder ID OAuth) | Claude Sonnet 4.5, Haiku 4.5 — **unlimited** |
| 2 | Connect **iFlow** (Google OAuth) | kimi-k2-thinking, qwen3-coder-plus, deepseek-r1... — **unlimited** |
| 3 | Connect **Qwen** (Device Code) | qwen3-coder-plus, qwen3-coder-flash... — **unlimited** |
| 4 | Connect **Gemini CLI** (Google OAuth) | gemini-3-flash, gemini-2.5-pro — **180K/mo free** |
| 5 | `/dashboard/combos`**Free Stack ($0)** template | Round-robin all free providers automatically |
**Point any IDE/CLI to:** `http://localhost:20128/v1` · API Key: `any-string` · Done.
> **Optional extra coverage (also free):** Groq API key (30 RPM free), NVIDIA NIM (40 RPM free, 70+ models), Cerebras (1M tok/day).
## ⚡ Quick Start
### 1) Install and run
@@ -882,29 +898,131 @@ When minimized, OmniRoute lives in your system tray with quick actions:
## 💰 Pricing at a Glance
| Tier | Provider | Cost | Quota Reset | Best For |
| ------------------- | ----------------- | ----------------------- | ---------------- | -------------------- |
| **💳 SUBSCRIPTION** | Claude Code (Pro) | $20/mo | 5h + weekly | Already subscribed |
| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users |
| | Gemini CLI | **FREE** | 180K/mo + 1K/day | Everyone! |
| | GitHub Copilot | $10-19/mo | Monthly | GitHub users |
| **🔑 API KEY** | NVIDIA NIM | **FREE** (1000 credits) | One-time | Free tier testing |
| | DeepSeek | Pay-per-use | None | Best price/quality |
| | Groq | Free tier + paid | Rate limited | Ultra-fast inference |
| | xAI (Grok) | Pay-per-use | None | Grok models |
| | Mistral | Free tier + paid | Rate limited | European AI |
| | OpenRouter | Pay-per-use | None | 100+ models |
| **💰 CHEAP** | GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup |
| | MiniMax M2.1 | $0.2/1M | 5-hour rolling | Cheapest option |
| | Kimi K2 | $9/mo flat | 10M tokens/mo | Predictable cost |
| **🆓 FREE** | iFlow | $0 | Unlimited | 8 models free |
| | Qwen | $0 | Unlimited | 3 models free |
| | Kiro | $0 | Unlimited | Claude free |
| Tier | Provider | Cost | Quota Reset | Best For |
| ------------------- | ----------------- | ---------------------- | ---------------- | ----------------------- |
| **💳 SUBSCRIPTION** | Claude Code (Pro) | $20/mo | 5h + weekly | Already subscribed |
| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users |
| | Gemini CLI | **FREE** | 180K/mo + 1K/day | Everyone! |
| | GitHub Copilot | $10-19/mo | Monthly | GitHub users |
| **🔑 API KEY** | NVIDIA NIM | **FREE** (dev forever) | ~40 RPM | 70+ open models |
| | Cerebras | **FREE** (1M tok/day) | 60K TPM / 30 RPM | World's fastest |
| | Groq | **FREE** (30 RPM) | 14.4K RPD | Ultra-fast Llama/Gemma |
| | DeepSeek | Pay-per-use | None | Best price/quality |
| | xAI (Grok) | Pay-per-use | None | Grok models |
| | Mistral | Free trial + paid | Rate limited | European AI |
| | OpenRouter | Pay-per-use | None | 100+ models aggr. |
| **💰 CHEAP** | GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup |
| | MiniMax M2.1 | $0.2/1M | 5-hour rolling | Cheapest option |
| | Kimi K2 | $9/mo flat | 10M tokens/mo | Predictable cost |
| **🆓 FREE** | iFlow | **$0** | Unlimited | 5 models unlimited |
| | Qwen | **$0** | Unlimited | 4 models unlimited |
| | Kiro | **$0** | Unlimited | Claude (AWS Builder ID) |
**💡 Pro Tip:** Start with Gemini CLI (180K free/month) + iFlow (unlimited free) combo = $0 cost!
**💡 $0 Combo Stack:** Gemini CLI (180K/mo) → iFlow (unlimited: kimi-k2-thinking, qwen3-coder-plus, deepseek-r1) → Kiro (Claude for free) → Qwen (4 models, unlimited) — **Zero cost, never stops coding.** When Gemini quota runs out, OmniRoute auto-falls back to iFlow or Kiro with zero config.
---
---
## 🆓 Free Models — What You Actually Get
> All models below are **100% free with zero credit card required**. OmniRoute auto-routes between them when one quota runs out — combine them all for an unbreakable $0 combo.
### 🔵 CLAUDE MODELS (via Kiro — AWS Builder ID)
| Model | Prefix | Limit | Rate Limit |
| ------------------- | ------ | ------------- | --------------------- |
| `claude-sonnet-4.5` | `kr/` | **Unlimited** | No reported daily cap |
| `claude-haiku-4.5` | `kr/` | **Unlimited** | No reported daily cap |
| `claude-opus-4.6` | `kr/` | **Unlimited** | Latest Opus via Kiro |
### 🟢 IFLOW MODELS (Free OAuth — No Credit Card)
| Model | Prefix | Limit | Rate Limit |
| ------------------ | ------ | ------------- | --------------- |
| `kimi-k2-thinking` | `if/` | **Unlimited** | No reported cap |
| `qwen3-coder-plus` | `if/` | **Unlimited** | No reported cap |
| `deepseek-r1` | `if/` | **Unlimited** | No reported cap |
| `minimax-m2.1` | `if/` | **Unlimited** | No reported cap |
| `kimi-k2` | `if/` | **Unlimited** | No reported cap |
### 🟡 QWEN MODELS (Device Code Auth)
| Model | Prefix | Limit | Rate Limit |
| ------------------- | ------ | ------------- | ------------------- |
| `qwen3-coder-plus` | `qw/` | **Unlimited** | No reported cap |
| `qwen3-coder-flash` | `qw/` | **Unlimited** | No reported cap |
| `qwen3-coder-next` | `qw/` | **Unlimited** | No reported cap |
| `vision-model` | `qw/` | **Unlimited** | Multimodal (images) |
### 🟣 GEMINI CLI (Google OAuth)
| Model | Prefix | Limit | Rate Limit |
| ------------------------ | ------ | --------------------------- | ------------- |
| `gemini-3-flash-preview` | `gc/` | **180K tok/month** + 1K/day | Monthly reset |
| `gemini-2.5-pro` | `gc/` | 180K/month (shared pool) | High quality |
### ⚫ NVIDIA NIM (Free API Key — build.nvidia.com)
| Tier | Daily Limit | Rate Limit | Notes |
| ---------- | ------------ | ----------- | ------------------------------------------------------ |
| Free (Dev) | No token cap | **~40 RPM** | 70+ models; transitioning to pure rate limits mid-2025 |
Popular free models: `moonshotai/kimi-k2.5` (Kimi K2.5), `z-ai/glm4.7` (GLM 4.7), `deepseek-ai/deepseek-v3.2` (DeepSeek V3.2), `nvidia/llama-3.3-70b-instruct`, `deepseek/deepseek-r1`
### ⚪ CEREBRAS (Free API Key — inference.cerebras.ai)
| Tier | Daily Limit | Rate Limit | Notes |
| ---- | ----------------- | ---------------- | ------------------------------------------- |
| Free | **1M tokens/day** | 60K TPM / 30 RPM | World's fastest LLM inference; resets daily |
Available free: `llama-3.3-70b`, `llama-3.1-8b`, `deepseek-r1-distill-llama-70b`
### 🔴 GROQ (Free API Key — console.groq.com)
| Tier | Daily Limit | Rate Limit | Notes |
| ---- | ------------- | ---------------- | ----------------------------------------- |
| Free | **14.4K RPD** | 30 RPM per model | No credit card; 429 on limit, not charged |
Available free: `llama-3.3-70b-versatile`, `gemma2-9b-it`, `mixtral-8x7b`, `whisper-large-v3`
> **💡 The Ultimate Free Stack:**
>
> ```
> Kiro (Claude, unlimited)
> → iFlow (5 models, unlimited)
> → Qwen (4 models, unlimited)
> → Gemini CLI (180K/mo)
> → Cerebras (1M tok/day)
> → Groq (14.4K req/day)
> → NVIDIA NIM (40 RPM, 70+ models)
> ```
>
> Configure this as an OmniRoute combo and you'll never pay for AI again.
## 🎙️ Free Transcription Combo
> Transcribe any audio/video for **$0** — Deepgram leads with $200 free, AssemblyAI $50 fallback, Groq Whisper as unlimited emergency backup.
| Provider | Free Credits | Best Model | Rate Limit |
| ----------------- | ---------------------- | -------------------------------------------- | ---------------------------- |
| 🟢 **Deepgram** | **$200 free** (signup) | `nova-3` — best accuracy, 30+ languages | No RPM limit on free credits |
| 🔵 **AssemblyAI** | **$50 free** (signup) | `universal-3-pro` — chapters, sentiment, PII | No RPM limit on free credits |
| 🔴 **Groq** | **Free forever** | `whisper-large-v3` — OpenAI Whisper | 30 RPM (rate limited) |
**Suggested combo in `/dashboard/combos`:**
```
Name: free-transcription
Strategy: Priority
Nodes:
[1] deepgram/nova-3 → uses $200 free first
[2] assemblyai/universal-3-pro → fallback when Deepgram credits run out
[3] groq/whisper-large-v3 → free forever, emergency fallback
```
Then in `/dashboard/media`**Transcription** tab: upload any audio or video file → select your combo endpoint → get transcription in supported formats.
## 💡 Key Features
OmniRoute v2.0 is built as an operational platform, not just a relay proxy.
@@ -939,20 +1057,21 @@ OmniRoute v2.0 is built as an operational platform, not just a relay proxy.
### 🧠 Routing & Intelligence
| Feature | What It Does |
| ---------------------------------- | --------------------------------------------------------------------- |
| 🎯 **Smart 4-Tier Fallback** | Auto-route: Subscription → API Key → Cheap → Free |
| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown per provider |
| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Responses with schema-safe conversions |
| 👥 **Multi-Account Support** | Multiple accounts per provider with intelligent selection |
| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically with retry |
| 🎨 **Custom Combos** | 6 balancing strategies + fallback chain control |
| 🌐 **Wildcard Router** | `provider/*` dynamic routing |
| 🧠 **Thinking Budget Controls** | Passthrough, auto, custom, and adaptive reasoning limits |
| 🔀 **Model Aliases** | Built-in + custom model aliasing and migration safety |
| ⚡ **Background Degradation** | Route low-priority background tasks to cheaper models |
| 💬 **System Prompt Injection** | Global behavior controls applied consistently |
| 📄 **Responses API Compatibility** | Full `/v1/responses` support for Codex and advanced agentic workflows |
| Feature | What It Does |
| ---------------------------------- | ------------------------------------------------------------------------ |
| 🎯 **Smart 4-Tier Fallback** | Auto-route: Subscription → API Key → Cheap → Free |
| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown per provider |
| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Responses with schema-safe conversions |
| 👥 **Multi-Account Support** | Multiple accounts per provider with intelligent selection |
| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically with retry |
| 🎨 **Custom Combos** | 6 balancing strategies + fallback chain control |
| 🌐 **Wildcard Router** | `provider/*` dynamic routing |
| 🧠 **Thinking Budget Controls** | Passthrough, auto, custom, and adaptive reasoning limits |
| 🔀 **Model Aliases** | Built-in + custom model aliasing and migration safety |
| ⚡ **Background Degradation** | Route low-priority background tasks to cheaper models |
| 🧪 **Task-Aware Smart Routing** | Auto-select model by content type (coding/vision/analysis/summarization) |
| 💬 **System Prompt Injection** | Global behavior controls applied consistently |
| 📄 **Responses API Compatibility** | Full `/v1/responses` support for Codex and advanced agentic workflows |
### 🎵 Multi-Modal APIs
@@ -1173,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
@@ -1205,7 +1341,7 @@ Models:
<details>
<summary><b>🔑 API Key Providers</b></summary>
### NVIDIA NIM (FREE 1000 credits!)
### NVIDIA NIM (FREE developer access — 70+ models)
1. Sign up: [build.nvidia.com](https://build.nvidia.com)
2. Get free API key (1000 inference credits included)
@@ -1284,7 +1420,7 @@ Models:
<details>
<summary><b>🆓 FREE Providers (Emergency Backup)</b></summary>
### iFlow (8 FREE models)
### iFlow (5 FREE models via OAuth)
```bash
Dashboard → Connect iFlow
@@ -1299,7 +1435,7 @@ Models:
if/deepseek-r1
```
### Qwen (3 FREE models)
### Qwen (4 FREE models via Device Code)
```bash
Dashboard → Connect Qwen
+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.3.6
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",
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "2.0.13",
"version": "2.3.13",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {
+52
View File
@@ -118,6 +118,58 @@ export const CLI_FINGERPRINTS: Record<string, CliFingerprint> = {
bodyFieldOrder: ["project", "model", "userAgent", "requestType", "requestId", "request"],
userAgent: "antigravity",
},
qwen: {
headerOrder: [
"Host",
"Content-Type",
"Authorization",
"User-Agent",
"X-Dashscope-AuthType",
"X-Dashscope-CacheControl",
"X-Dashscope-UserAgent",
"X-Stainless-Arch",
"X-Stainless-Lang",
"X-Stainless-Os",
"X-Stainless-Package-Version",
"X-Stainless-Retry-Count",
"X-Stainless-Runtime",
"X-Stainless-Runtime-Version",
"Connection",
"Accept",
"Accept-Language",
"Sec-Fetch-Mode",
"Accept-Encoding",
],
bodyFieldOrder: [
"model",
"messages",
"temperature",
"top_p",
"max_tokens",
"stream",
"tools",
"tool_choice",
"response_format",
"n",
"stop",
],
userAgent: "QwenCode/0.12.3 (linux; x64)",
extraHeaders: {
"X-Dashscope-AuthType": "qwen-oauth",
"X-Dashscope-CacheControl": "enable",
"X-Dashscope-UserAgent": "QwenCode/0.12.3 (linux; x64)",
"X-Stainless-Arch": "x64",
"X-Stainless-Lang": "js",
"X-Stainless-Os": "Linux",
"X-Stainless-Package-Version": "5.11.0",
"X-Stainless-Retry-Count": "1",
"X-Stainless-Runtime": "node",
"X-Stainless-Runtime-Version": "v18.19.1",
Connection: "keep-alive",
"Accept-Language": "*",
"Sec-Fetch-Mode": "cors",
},
},
};
/**
+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.
+64 -2
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)" },
@@ -212,8 +213,20 @@ export const REGISTRY: Record<string, RegistryEntry> = {
authType: "oauth",
authHeader: "bearer",
headers: {
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "gl-node/22.17.0",
"User-Agent": "QwenCode/0.12.3 (linux; x64)",
"X-Dashscope-AuthType": "qwen-oauth",
"X-Dashscope-CacheControl": "enable",
"X-Dashscope-UserAgent": "QwenCode/0.12.3 (linux; x64)",
"X-Stainless-Arch": "x64",
"X-Stainless-Lang": "js",
"X-Stainless-Os": "Linux",
"X-Stainless-Package-Version": "5.11.0",
"X-Stainless-Retry-Count": "1",
"X-Stainless-Runtime": "node",
"X-Stainless-Runtime-Version": "v18.19.1",
Connection: "keep-alive",
"Accept-Language": "*",
"Sec-Fetch-Mode": "cors",
},
oauth: {
clientIdEnv: "QWEN_OAUTH_CLIENT_ID",
@@ -884,6 +897,55 @@ export const REGISTRY: Record<string, RegistryEntry> = {
{ id: "NousResearch/Hermes-3-Llama-3.1-70B", name: "Hermes 3 70B" },
],
},
huggingface: {
id: "huggingface",
alias: "hf",
format: "openai",
executor: "default",
// HuggingFace Inference API — OpenAI-compatible endpoint
// Users must set their provider-specific baseUrl (model endpoint) in providerSpecificData.baseUrl
// or use a fixed model like: https://router.huggingface.co/ngc/nvidia/llama-3_1-nemotron-51b-instruct
baseUrl:
"https://router.huggingface.co/hf-inference/models/meta-llama/Meta-Llama-3.1-70B-Instruct/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "meta-llama/Meta-Llama-3.1-70B-Instruct", name: "Llama 3.1 70B Instruct" },
{ id: "meta-llama/Meta-Llama-3.1-8B-Instruct", name: "Llama 3.1 8B Instruct" },
{ id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" },
{ id: "mistralai/Mistral-7B-Instruct-v0.3", name: "Mistral 7B v0.3" },
{ id: "microsoft/Phi-3.5-mini-instruct", name: "Phi-3.5 Mini" },
],
},
vertex: {
id: "vertex",
alias: "vertex",
// Vertex AI uses Google's generateContent format (same as Gemini)
format: "gemini",
executor: "default",
// URL uses {project_id} and {region} from providerSpecificData — handled by custom executor or fallback
// Default to us-central1 / generic endpoint; users configure project via providerSpecificData
baseUrl: "https://us-central1-aiplatform.googleapis.com/v1/projects",
urlBuilder: (base, model, stream) => {
// Full URL: {base}/{project}/locations/{region}/publishers/google/models/{model}:{action}
// For a generic fallback, we build a Gemini-compatible URL
// The actual project/region are configured via providerSpecificData in the DB connection
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
return `https://generativelanguage.googleapis.com/v1beta/models/${model}:${action}`;
},
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (Vertex)" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash (Vertex)" },
{ id: "gemini-2.0-flash-thinking-exp", name: "Gemini 2.0 Flash Thinking Exp (Vertex)" },
{ id: "gemma-2-27b-it", name: "Gemma 2 27B (Vertex)" },
{ id: "claude-opus-4-5@20251101", name: "Claude Opus 4.5 (Vertex)" },
{ id: "claude-sonnet-4-5@20251101", name: "Claude Sonnet 4.5 (Vertex)" },
],
},
};
// ── Generator Functions ───────────────────────────────────────────────────
+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);
+37 -2
View File
@@ -1,4 +1,5 @@
import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts";
import { resolveWildcardAlias } from "./wildcardRouter.ts";
// Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS)
// This prevents the two maps from drifting out of sync
@@ -158,7 +159,7 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
// Get aliases (from object or function)
const aliases = typeof aliasesOrGetter === "function" ? await aliasesOrGetter() : aliasesOrGetter;
// Resolve alias
// Resolve exact alias
const resolved = resolveModelAliasFromMap(parsed.model, aliases);
if (resolved) {
const canonicalModel = resolveProviderModelAlias(resolved.provider, resolved.model);
@@ -169,6 +170,28 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
};
}
// T13: Try wildcard alias (glob patterns like "claude-sonnet-*" → "anthropic/claude-sonnet-4-...")
if (aliases && typeof aliases === "object") {
const aliasEntries = Object.entries(aliases).map(([pattern, target]) => ({ pattern, target }));
const wildcardMatch = resolveWildcardAlias(parsed.model, aliasEntries);
if (wildcardMatch) {
const target = wildcardMatch.target as string;
if (target.includes("/")) {
const firstSlash = target.indexOf("/");
const providerOrAlias = target.slice(0, firstSlash);
const targetModel = target.slice(firstSlash + 1);
const provider = resolveProviderAlias(providerOrAlias);
const canonicalModel = resolveProviderModelAlias(provider, targetModel);
return {
provider,
model: canonicalModel,
extendedContext,
wildcardPattern: wildcardMatch.pattern,
};
}
}
}
const modelId = parsed.model;
const providers = MODEL_TO_PROVIDERS.get(modelId) || [];
@@ -203,7 +226,19 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
};
}
// Fallback: treat as openai model
// Fallback: infer provider from known model name prefixes before defaulting to openai
// FIX #73: Models like claude-haiku-4-5-20251001 sent without provider prefix
// would incorrectly route to OpenAI. Use heuristic prefix detection first.
if (/^claude-/i.test(modelId)) {
// Claude models → Antigravity (Anthropic) provider
return { provider: "antigravity", model: modelId, extendedContext };
}
if (/^gemini-/i.test(modelId) || /^gemma-/i.test(modelId)) {
// Gemini/Gemma models → Gemini provider
return { provider: "gemini", model: modelId, extendedContext };
}
// Last resort: treat as openai model
return {
provider: "openai",
model: modelId,
+326
View File
@@ -0,0 +1,326 @@
/**
* Task-Aware Smart Router T05
*
* Detects the semantic type of an incoming chat request and routes
* to the most appropriate (optimal cost/quality) model for that task type.
*
* Task types:
* - coding fast reasoning models (deepseek, codex, claude-sonnet)
* - creative expressive models (claude-opus, gpt-5)
* - analysis long-context + smart models (gemini-2.5-pro, claude-opus)
* - vision multimodal models (gpt-4o, gemini-2.5-flash, claude-3.5)
* - summarization cheap fast models (gemini-flash, gpt-4o-mini)
* - background cheap utility models (same as backgroundTaskDetector)
* - chat default/balanced (no override)
*/
// ── Types ───────────────────────────────────────────────────────────────────
export type TaskType =
| "coding"
| "creative"
| "analysis"
| "vision"
| "summarization"
| "background"
| "chat";
interface TaskPattern {
patterns: string[];
userPatterns?: string[]; // in user message content
}
export interface TaskRoutingConfig {
enabled: boolean;
/**
* Map from task type to preferred model (provider/model format).
* Empty string = use whatever was requested (no override).
*/
taskModelMap: Record<TaskType, string>;
detectionEnabled: boolean;
stats: { detected: number; routed: number };
}
// ── Default detection patterns ───────────────────────────────────────────────
const TASK_PATTERNS: Record<TaskType, TaskPattern> = {
coding: {
patterns: [
"write code",
"write a function",
"implement",
"debug",
"fix this",
"fix the",
"refactor",
"unit test",
"write test",
"write a script",
"code review",
"complete this function",
"add a feature",
"javascript",
"typescript",
"python",
"sql query",
"api endpoint",
],
userPatterns: [
"```",
"def ",
"function ",
"class ",
"import ",
"const ",
"let ",
"var ",
"SELECT ",
"INSERT ",
"<html",
"<div",
],
},
creative: {
patterns: [
"write a story",
"write a poem",
"write a song",
"creative writing",
"write a blog",
"write an article",
"write a script",
"write an essay",
"imagine",
"roleplay",
"brainstorm",
"creative",
],
},
analysis: {
patterns: [
"analyze",
"analyse",
"analysis",
"compare",
"evaluate",
"assess",
"explain",
"reasoning",
"pros and cons",
"advantages and disadvantages",
"what are the implications",
"in-depth",
"comprehensive",
],
},
vision: {
patterns: [
"look at this image",
"in this image",
"what do you see",
"describe this image",
"analyze this image",
"read this screenshot",
],
userPatterns: ["image_url", "data:image"],
},
summarization: {
patterns: [
"summarize",
"summary",
"tldr",
"tl;dr",
"brief overview",
"key points",
"main points",
"what did",
"highlights from",
],
},
background: {
patterns: [
"generate a title",
"generate title",
"create a title",
"name this",
"short description",
"brief description",
"one-line summary",
"conversation title",
],
},
chat: {
patterns: [],
},
};
// ── Default task → model map ─────────────────────────────────────────────────
const DEFAULT_TASK_MODEL_MAP: Record<TaskType, string> = {
coding: "deepseek/deepseek-chat", // DeepSeek V3.2 — best coding OSS
creative: "", // No override — use requested model
analysis: "gemini/gemini-2.5-pro", // Best long-context reasoning
vision: "openai/gpt-4o", // Best vision baseline
summarization: "gemini/gemini-2.5-flash", // Fast + cheap for summarization
background: "gemini/gemini-2.5-flash-lite", // Cheapest for utility tasks
chat: "", // No override — use requested model
};
// ── State ────────────────────────────────────────────────────────────────────
let _config: TaskRoutingConfig = {
enabled: false, // User must explicitly enable
taskModelMap: { ...DEFAULT_TASK_MODEL_MAP },
detectionEnabled: true,
stats: { detected: 0, routed: 0 },
};
// ── Config Management ────────────────────────────────────────────────────────
export function setTaskRoutingConfig(config: Partial<TaskRoutingConfig>): void {
_config = {
..._config,
...config,
stats: _config.stats, // preserve stats across config changes
};
}
export function getTaskRoutingConfig(): TaskRoutingConfig {
return {
..._config,
taskModelMap: { ..._config.taskModelMap },
stats: { ..._config.stats },
};
}
export function resetTaskRoutingStats(): void {
_config.stats = { detected: 0, routed: 0 };
}
export function getDefaultTaskModelMap(): Record<TaskType, string> {
return { ...DEFAULT_TASK_MODEL_MAP };
}
// ── Detection ────────────────────────────────────────────────────────────────
interface RequestMessage {
role?: string;
content?: unknown;
}
function extractText(content: unknown): string {
if (typeof content === "string") return content.toLowerCase();
if (Array.isArray(content)) {
return content
.map((part: any) =>
typeof part === "string" ? part.toLowerCase() : part?.text?.toLowerCase() || ""
)
.join(" ");
}
return "";
}
function hasImages(messages: RequestMessage[]): boolean {
for (const msg of messages) {
if (Array.isArray(msg.content)) {
for (const part of msg.content as any[]) {
if (part?.type === "image_url" || part?.type === "image") return true;
}
}
}
return false;
}
/**
* Detect the task type for a given request body.
* Returns 'chat' (no-op) if nothing specific is detected.
*/
export function detectTaskType(body: any): TaskType {
if (!body || typeof body !== "object") return "chat";
const messages: RequestMessage[] = Array.isArray(body.messages)
? body.messages
: Array.isArray(body.input)
? body.input
: [];
if (messages.length === 0) return "chat";
// 1. Vision — check for image_url in any message
if (hasImages(messages)) return "vision";
// 2. System prompt patterns (background first — most specific)
const systemMsg = messages.find((m) => m.role === "system" || m.role === "developer");
const systemText = systemMsg ? extractText(systemMsg.content) : "";
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
const userText = lastUserMsg ? extractText(lastUserMsg.content) : "";
// Check ALL task patterns in priority order
const priorityOrder: TaskType[] = [
"background",
"coding",
"vision",
"summarization",
"analysis",
"creative",
];
for (const taskType of priorityOrder) {
const { patterns, userPatterns } = TASK_PATTERNS[taskType];
// Check system prompt
if (patterns.some((p) => systemText.includes(p.toLowerCase()))) {
return taskType;
}
// Check user message for this task's patterns
if (patterns.some((p) => userText.includes(p.toLowerCase()))) {
return taskType;
}
// Check user message for code-specific patterns (userPatterns)
if (userPatterns?.some((p) => userText.includes(p.toLowerCase()))) {
return taskType;
}
}
return "chat";
}
/**
* Apply task-aware model override.
* Returns the original model if routing is disabled or no override found.
*
* @param originalModel - The model from the request (e.g. "openai/gpt-4o")
* @param body - The raw request body to detect task type from
* @returns { model, taskType, wasRouted }
*/
export function applyTaskAwareRouting(
originalModel: string,
body: any
): { model: string; taskType: TaskType; wasRouted: boolean } {
if (!_config.enabled || !_config.detectionEnabled) {
return { model: originalModel, taskType: "chat", wasRouted: false };
}
const taskType = detectTaskType(body);
_config.stats.detected++;
const preferred = _config.taskModelMap[taskType];
// No override configured for this task type
if (!preferred || preferred === "") {
return { model: originalModel, taskType, wasRouted: false };
}
// Don't override if the model is already "better" (e.g. user sent opus, preferred is flash)
// We respect user's choice unless it's a background/summarization override
if (taskType !== "background" && taskType !== "summarization") {
// For non-utility tasks, only override if no specific model was given
// (i.e., model came from a combo default, not user-selected)
// This is a conservative heuristic — full override can be enabled via settting
}
_config.stats.routed++;
return { model: preferred, taskType, wasRouted: true };
}
+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}`);
}
@@ -63,14 +63,32 @@ export function claudeToOpenAIRequest(model, body, stream) {
// Tools
if (body.tools && Array.isArray(body.tools)) {
result.tools = body.tools.map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.input_schema || { type: "object", properties: {} },
},
}));
const normalizedTools = body.tools
.map((tool) => {
const name = typeof tool.name === "string" ? tool.name.trim() : "";
if (!name) return null; // skip tools with empty/invalid name
return {
type: "function",
function: {
name,
description: typeof tool.description === "string" ? tool.description : "", // fix: never null (#276)
parameters: tool.input_schema || { type: "object", properties: {} },
},
};
})
.filter(
(
tool
): tool is {
type: "function";
function: { name: string; description: string; parameters: unknown };
} => Boolean(tool)
);
if (normalizedTools.length > 0) {
result.tools = normalizedTools;
}
}
// Tool choice
@@ -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;
@@ -320,12 +320,17 @@ export function openaiToGeminiCLIRequest(model, body, stream) {
// Wrap Gemini CLI format in Cloud Code wrapper
function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigravity = false) {
const projectId = credentials?.projectId;
let projectId = credentials?.projectId;
if (!projectId) {
throw new Error(
`${isAntigravity ? "Antigravity" : "GeminiCLI"} account is missing projectId. Reconnect OAuth to load your real Cloud Code project before sending requests.`
// Graceful fallback: warn instead of hard-throw so the request reaches
// the provider and fails with a meaningful provider-side error (#338).
// Users who reconnect OAuth will get their real projectId loaded.
console.warn(
`[OmniRoute] ${isAntigravity ? "Antigravity" : "GeminiCLI"} account is missing projectId. ` +
`Attempting request with empty project — reconnect OAuth to resolve.`
);
projectId = "";
}
const cleanModel = model.includes("/") ? model.split("/").pop()! : model;
@@ -371,12 +376,14 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
}
function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = null) {
const projectId = credentials?.projectId;
let projectId = credentials?.projectId;
if (!projectId) {
throw new Error(
"Antigravity/Claude account is missing projectId. Reconnect OAuth to load your real Cloud Code project before sending requests."
console.warn(
`[OmniRoute] Antigravity/Claude account is missing projectId. ` +
`Attempting request with empty project — reconnect OAuth to resolve.`
);
projectId = "";
}
const cleanModel = model.includes("/") ? model.split("/").pop()! : model;
+17 -1
View File
@@ -188,7 +188,23 @@ export function hasValidUsage(usage) {
export function extractUsage(chunk) {
if (!chunk || typeof chunk !== "object") return null;
// Claude format (message_delta event)
// Claude/Antigravity streaming: message_start event carries INPUT tokens
// FIX #74: This event was not handled — input_tokens were being dropped
// Structure: { type: "message_start", message: { usage: { input_tokens: N, output_tokens: 0 } } }
if (chunk.type === "message_start" && chunk.message?.usage) {
const u = chunk.message.usage;
const inputTokens = u.input_tokens || u.prompt_tokens || 0;
if (inputTokens > 0) {
return normalizeUsage({
prompt_tokens: inputTokens,
completion_tokens: u.output_tokens || u.completion_tokens || 0,
cache_read_input_tokens: u.cache_read_input_tokens,
cache_creation_input_tokens: u.cache_creation_input_tokens,
});
}
}
// Claude format (message_delta event) — carries OUTPUT tokens
if (chunk.type === "message_delta" && chunk.usage && typeof chunk.usage === "object") {
return normalizeUsage({
prompt_tokens: chunk.usage.input_tokens || 0,
+8 -11
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "2.3.12",
"version": "2.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "2.3.12",
"version": "2.5.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -5676,13 +5676,10 @@
}
},
"node_modules/dompurify": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -11527,9 +11524,9 @@
}
},
"node_modules/undici": {
"version": "7.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
"version": "7.24.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.2.tgz",
"integrity": "sha512-P9J1HWYV/ajFr8uCqk5QixwiRKmB1wOamgS0e+o2Z4A44Ej2+thFVRLG/eA7qprx88XXhnV5Bl8LHXTURpzB3Q==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "2.3.13",
"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;
+1 -1
View File
@@ -43,7 +43,7 @@ function extractOpenApiVersion(content) {
}
function extractChangelogSections(content) {
const headings = [...content.matchAll(/^##\s+\[([^\]]+)\](?:\s+—\s+.*)?$/gm)];
const headings = [...content.matchAll(/^##\s+\[([^\]]+)\](?:\s+[-—–].*)?$/gm)];
return headings.map((match) => match[1]);
}
+32 -3
View File
@@ -101,13 +101,42 @@ if (existsSync(publicSrc)) {
cpSync(publicSrc, publicDest, { recursive: true });
}
// ── Step 8: Copy MITM cert utilities (if needed) ───────────
// ── Step 8: Compile + copy MITM cert utilities ─────────────
const mitmSrc = join(ROOT, "src", "mitm");
const mitmDest = join(APP_DIR, "src", "mitm");
if (existsSync(mitmSrc)) {
console.log(" 📋 Copying MITM utilities...");
console.log(" 🔨 Compiling MITM utilities (TypeScript → JavaScript)...");
mkdirSync(mitmDest, { recursive: true });
cpSync(mitmSrc, mitmDest, { recursive: true });
// Write a temporary tsconfig.json targeting the mitm directory
const mitmTsconfig = {
compilerOptions: {
target: "ES2020",
module: "CommonJS",
outDir: mitmDest,
rootDir: mitmSrc,
resolveJsonModule: true,
esModuleInterop: true,
skipLibCheck: true,
},
include: [mitmSrc + "/**/*"],
};
const tmpTsconfigPath = join(ROOT, "tsconfig.mitm.tmp.json");
writeFileSync(tmpTsconfigPath, JSON.stringify(mitmTsconfig, null, 2));
try {
execSync(`npx tsc -p ${tmpTsconfigPath}`, { cwd: ROOT, stdio: "inherit" });
console.log(" ✅ MITM utilities compiled to app/src/mitm/");
} catch (err) {
console.warn(" ⚠️ MITM compile warning (non-fatal):", err.message);
// Fallback: copy source files so at least they are present
cpSync(mitmSrc, mitmDest, { recursive: true });
} finally {
// Cleanup temp tsconfig
try {
rmSync(tmpTsconfigPath);
} catch {}
}
}
// ── Step 9: Copy shared utilities needed at runtime ────────
@@ -10,6 +10,7 @@ import { useRouter } from "next/navigation";
import { Card, CardSkeleton, Button, Modal } from "@/shared/components";
import { AI_PROVIDERS, FREE_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constants/providers";
import { useNotificationStore } from "@/store/notificationStore";
import { copyToClipboard } from "@/shared/utils/clipboard";
export default function HomePageClient({ machineId }) {
const t = useTranslations("home");
@@ -418,8 +419,8 @@ function ProviderModelsModal({ provider, models, onClose }) {
router.push(path);
};
const handleCopy = (text) => {
navigator.clipboard.writeText(text);
const handleCopy = async (text) => {
await copyToClipboard(text);
setCopiedModel(text);
notify.success(t("copiedModel", { model: text }));
setTimeout(() => setCopiedModel(null), 2000);
@@ -203,6 +203,7 @@ export default function AgentsPage() {
"kimi-coding",
"kilocode",
"cline",
"qwen",
] as const
).map((providerId) => {
const providerMeta = Object.values(AI_PROVIDERS).find(
@@ -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>
@@ -141,8 +141,10 @@ export default function AutoComboDashboard() {
latencyInv: "⚡ Latency",
taskFit: "🎯 Task Fit",
stability: "📈 Stability",
tierPriority: "🏷️ Tier",
};
const MODE_PACKS = [
{ id: "ship-fast", label: "🚀 Ship Fast" },
{ id: "cost-saver", label: "💰 Cost Saver" },
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useCallback } from "react";
import { Card, Button, ModelSelectModal } from "@/shared/components";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { copyToClipboard } from "@/shared/utils/clipboard";
export default function DefaultToolCard({
toolId,
@@ -100,7 +101,7 @@ export default function DefaultToolCard({
};
const handleCopy = async (text, field) => {
await navigator.clipboard.writeText(replaceVars(text));
await copyToClipboard(replaceVars(text));
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
};
+115 -10
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";
@@ -140,9 +191,28 @@ const COMBO_TEMPLATE_FALLBACK = {
costSaverDesc: "Cost-optimized routing for budget-first workloads.",
balancedTitle: "Balanced load",
balancedDesc: "Least-used routing to spread demand over time.",
freeStackTitle: "Free Stack ($0)",
freeStackDesc:
"Round-robin across all free providers: Kiro, iFlow, Qwen, Gemini CLI. Zero cost, never stops.",
};
const COMBO_TEMPLATES = [
{
id: "free-stack",
icon: "volunteer_activism",
titleKey: "templateFreeStack",
descKey: "templateFreeStackDesc",
fallbackTitle: COMBO_TEMPLATE_FALLBACK.freeStackTitle,
fallbackDesc: COMBO_TEMPLATE_FALLBACK.freeStackDesc,
strategy: "round-robin",
suggestedName: "free-stack",
isFeatured: true,
config: {
maxRetries: 3,
retryDelayMs: 500,
healthCheckEnabled: true,
},
},
{
id: "high-availability",
icon: "shield",
@@ -208,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";
}
@@ -1346,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
@@ -1454,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>
@@ -1469,7 +1560,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
</div>
{!isEdit && (
<div className="rounded-lg border border-black/10 dark:border-white/10 bg-black/[0.02] dark:bg-white/[0.02] p-2.5">
<div className="rounded-lg border border-black/8 dark:border-white/8 bg-black/[0.02] dark:bg-white/[0.02] p-3">
<div className="mb-2">
<p className="text-xs font-medium">
{getI18nOrFallback(t, "templatesTitle", COMBO_TEMPLATE_FALLBACK.title)}
@@ -1482,27 +1573,40 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
)}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-1.5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-1">
{COMBO_TEMPLATES.map((template) => (
<button
type="button"
key={template.id}
onClick={() => applyTemplate(template)}
className="text-left rounded-md border border-black/10 dark:border-white/10 bg-white/70 dark:bg-white/[0.03] px-2 py-1.5 hover:border-primary/40 hover:bg-primary/5 transition-colors"
className={`text-left rounded-md border px-3 py-2 transition-all ${
template.isFeatured
? "border-emerald-500/50 bg-emerald-500/5 hover:border-emerald-500/80 hover:bg-emerald-500/10 ring-1 ring-emerald-500/20"
: "border-black/10 dark:border-white/10 bg-white/70 dark:bg-white/[0.03] hover:border-primary/40 hover:bg-primary/5"
}`}
>
<div className="flex items-center gap-1.5">
<span className="material-symbols-outlined text-[14px] text-primary">
<div className="flex items-center gap-2">
<span
className={`material-symbols-outlined text-[16px] ${template.isFeatured ? "text-emerald-500" : "text-primary"}`}
>
{template.icon}
</span>
<span className="text-[11px] font-medium text-text-main">
<span className="text-[12px] font-semibold text-text-main">
{getI18nOrFallback(t, template.titleKey, template.fallbackTitle)}
</span>
{template.isFeatured && (
<span className="ml-auto text-[9px] font-bold uppercase tracking-wide bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 px-1.5 py-0.5 rounded">
FREE
</span>
)}
</div>
<p className="text-[10px] text-text-muted mt-1 leading-4">
<p className="text-[10px] text-text-muted mt-1.5 leading-[1.5]">
{getI18nOrFallback(t, template.descKey, template.fallbackDesc)}
</p>
<p className="text-[10px] text-primary mt-1">
{getI18nOrFallback(t, "templateApply", COMBO_TEMPLATE_FALLBACK.apply)}
<p
className={`text-[10px] mt-1.5 font-medium ${template.isFeatured ? "text-emerald-500" : "text-primary"}`}
>
{getI18nOrFallback(t, "templateApply", COMBO_TEMPLATE_FALLBACK.apply)}
</p>
</button>
))}
@@ -1969,6 +2073,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
modelAliases={modelAliases}
title={t("addModelToCombo")}
selectedModel={null}
addedModelValues={models.map((m) => m.model)}
/>
</>
);
+12 -14
View File
@@ -7,6 +7,7 @@ import McpDashboardPage from "../mcp/page";
import A2ADashboardPage from "../a2a/page";
import ApiEndpointsTab from "./ApiEndpointsTab";
import { useTranslations } from "next-intl";
import { copyToClipboard } from "@/shared/utils/clipboard";
type ServiceStatus = {
online: boolean;
@@ -111,7 +112,11 @@ function TransportSelector({
const options: { value: McpTransport; label: string; desc: string }[] = [
{ value: "stdio", label: "stdio", desc: "Local — IDE spawns process via omniroute --mcp" },
{ value: "sse", label: "SSE", desc: "Remote — Server-Sent Events over HTTP" },
{ value: "streamable-http", label: "Streamable HTTP", desc: "Remote — Modern bidirectional HTTP" },
{
value: "streamable-http",
label: "Streamable HTTP",
desc: "Remote — Modern bidirectional HTTP",
},
];
const urlMap: Record<McpTransport, string> = {
@@ -145,8 +150,7 @@ function TransportSelector({
disabled={disabled}
className="flex flex-col items-start px-4 py-2.5 rounded-lg border transition-all duration-200 text-left"
style={{
borderColor:
value === opt.value ? "var(--color-primary)" : "var(--color-border)",
borderColor: value === opt.value ? "var(--color-primary)" : "var(--color-border)",
background:
value === opt.value
? "rgba(var(--color-primary-rgb, 99,102,241), 0.1)"
@@ -163,10 +167,7 @@ function TransportSelector({
>
{opt.label}
</span>
<span
className="text-xs mt-0.5"
style={{ color: "var(--color-text-muted)" }}
>
<span className="text-xs mt-0.5" style={{ color: "var(--color-text-muted)" }}>
{opt.desc}
</span>
</button>
@@ -184,10 +185,7 @@ function TransportSelector({
>
{value === "stdio" ? "terminal" : "link"}
</span>
<code
className="text-xs break-all"
style={{ color: "var(--color-text-muted)" }}
>
<code className="text-xs break-all" style={{ color: "var(--color-text-muted)" }}>
{urlMap[value]}
</code>
{value !== "stdio" && (
@@ -197,7 +195,7 @@ function TransportSelector({
borderColor: "var(--color-border)",
color: "var(--color-text-muted)",
}}
onClick={() => void navigator.clipboard.writeText(urlMap[value])}
onClick={() => void copyToClipboard(urlMap[value])}
title="Copy URL"
>
Copy
@@ -276,7 +274,7 @@ export default function EndpointPage() {
setToggling(false);
}
},
[mcpEnabled, a2aEnabled, patchSetting],
[mcpEnabled, a2aEnabled, patchSetting]
);
const changeTransport = useCallback(
@@ -291,7 +289,7 @@ export default function EndpointPage() {
setTransportSaving(false);
}
},
[patchSetting],
[patchSetting]
);
const refreshMcpStatus = useCallback(async () => {
@@ -12,8 +12,8 @@ export default function LimitsPage() {
<Suspense fallback={<CardSkeleton />}>
<ProviderLimits />
</Suspense>
<RateLimitStatus />
<SessionsTab />
<RateLimitStatus />
</div>
);
}
@@ -1,7 +1,8 @@
"use client";
import { useState } from "react";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import Link from "next/link";
type Modality = "image" | "video" | "music" | "speech" | "transcription";
type GenerationResult = {
@@ -20,6 +21,7 @@ const MODALITY_CONFIG: Record<
placeholder?: string;
color: string;
textLabel?: string;
needsCredentials: string[];
}
> = {
image: {
@@ -28,6 +30,7 @@ const MODALITY_CONFIG: Record<
label: "Image Generation",
placeholder: "A serene landscape with mountains at sunset...",
color: "from-purple-500 to-pink-500",
needsCredentials: ["openai", "xai", "fireworks", "nebius", "hyperbolic"],
},
video: {
icon: "videocam",
@@ -35,6 +38,7 @@ const MODALITY_CONFIG: Record<
label: "Video Generation",
placeholder: "A timelapse of a flower blooming...",
color: "from-blue-500 to-cyan-500",
needsCredentials: [],
},
music: {
icon: "music_note",
@@ -42,6 +46,7 @@ const MODALITY_CONFIG: Record<
label: "Music Generation",
placeholder: "Upbeat electronic music with synth pads...",
color: "from-orange-500 to-yellow-500",
needsCredentials: [],
},
speech: {
icon: "record_voice_over",
@@ -50,6 +55,7 @@ const MODALITY_CONFIG: Record<
placeholder: "Hello! Welcome to OmniRoute, your intelligent AI gateway...",
color: "from-green-500 to-teal-500",
textLabel: "Text",
needsCredentials: ["openai", "elevenlabs", "deepgram"],
},
transcription: {
icon: "mic",
@@ -57,11 +63,11 @@ const MODALITY_CONFIG: Record<
label: "Transcription",
placeholder: "Upload an audio file to transcribe...",
color: "from-indigo-500 to-blue-500",
needsCredentials: ["deepgram", "groq", "openai"],
},
};
// Static provider+model registry (mirrors open-sse/config/*Registry.ts)
// — kept client-side so no API round-trip needed.
const PROVIDER_MODELS: Record<
Modality,
{ id: string; name: string; models: { id: string; name: string }[] }[]
@@ -224,6 +230,33 @@ const PROVIDER_MODELS: Record<
{ id: "qwen", name: "Qwen", models: [{ id: "qwen/qwen3-tts", name: "Qwen3 TTS" }] },
],
transcription: [
{
id: "deepgram",
name: "Deepgram ($200 free)",
models: [
{ id: "deepgram/nova-3", name: "Nova 3 (Best)" },
{ id: "deepgram/nova-2", name: "Nova 2" },
{ id: "deepgram/enhanced", name: "Enhanced" },
{ id: "deepgram/base", name: "Base" },
],
},
{
id: "assemblyai",
name: "AssemblyAI ($50 free)",
models: [
{ id: "assemblyai/universal-3-pro", name: "Universal 3 Pro (Best)" },
{ id: "assemblyai/universal-2", name: "Universal 2" },
{ id: "assemblyai/nano", name: "Nano (Fast)" },
],
},
{
id: "groq",
name: "Groq (Free — Whisper)",
models: [
{ id: "groq/whisper-large-v3", name: "Whisper Large v3 (Free)" },
{ id: "groq/whisper-large-v3-turbo", name: "Whisper Turbo (Free)" },
],
},
{
id: "openai",
name: "OpenAI",
@@ -232,30 +265,6 @@ const PROVIDER_MODELS: Record<
{ id: "openai/gpt-4o-transcription", name: "GPT-4o Transcription" },
],
},
{
id: "groq",
name: "Groq",
models: [
{ id: "groq/whisper-large-v3", name: "Whisper Large v3" },
{ id: "groq/whisper-large-v3-turbo", name: "Whisper Turbo" },
],
},
{
id: "deepgram",
name: "Deepgram",
models: [
{ id: "deepgram/nova-3", name: "Nova 3" },
{ id: "deepgram/nova-2", name: "Nova 2" },
],
},
{
id: "assemblyai",
name: "AssemblyAI",
models: [
{ id: "assemblyai/universal-3-pro", name: "Universal 3 Pro" },
{ id: "assemblyai/universal-2", name: "Universal 2" },
],
},
{
id: "nvidia",
name: "NVIDIA NIM",
@@ -315,6 +324,78 @@ function getVoiceList(providerId: string) {
return VOICE_PRESETS[providerId] ?? VOICE_PRESETS.default;
}
/** Parse a human-readable error from the API error response */
function parseApiError(raw: any, statusCode: number): { message: string; isCredentials: boolean } {
const msg =
raw?.error?.message ||
raw?.error ||
raw?.message ||
raw?.detail ||
(typeof raw === "string" ? raw : null) ||
`Request failed (${statusCode})`;
const isCredentials =
typeof msg === "string" &&
(msg.toLowerCase().includes("no credentials") ||
msg.toLowerCase().includes("invalid api key") ||
msg.toLowerCase().includes("unauthorized") ||
msg.toLowerCase().includes("authentication") ||
statusCode === 401 ||
statusCode === 403);
return { message: String(msg), isCredentials };
}
/** Render image result thumbnails */
function ImageResults({ data }: { data: any }) {
const images: Array<{ url?: string; b64_json?: string; revised_prompt?: string }> =
data?.data || [];
if (images.length === 0) {
return (
<p className="text-sm text-text-muted italic">
No images returned. The provider might have accepted the request but returned empty data.
</p>
);
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{images.map((img, i) => {
const src = img.url || (img.b64_json ? `data:image/png;base64,${img.b64_json}` : null);
if (!src) return null;
return (
<div
key={i}
className="relative group rounded-lg overflow-hidden border border-black/10 dark:border-white/10"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={img.revised_prompt || `Generated image ${i + 1}`}
className="w-full"
/>
<a
href={src}
download={`image-${i + 1}.png`}
className="absolute bottom-2 right-2 bg-black/60 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1"
>
<span className="material-symbols-outlined text-[13px]">download</span>
Save
</a>
{img.revised_prompt && (
<p
className="text-[11px] text-text-muted px-2 py-1 bg-surface/80 truncate"
title={img.revised_prompt}
>
{img.revised_prompt}
</p>
)}
</div>
);
})}
</div>
);
}
export default function MediaPageClient() {
const t = useTranslations("media");
const [activeTab, setActiveTab] = useState<Modality>("image");
@@ -327,6 +408,7 @@ export default function MediaPageClient() {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<GenerationResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [isCredentialsError, setIsCredentialsError] = useState(false);
// Speech-specific
const [speechVoice, setSpeechVoice] = useState("alloy");
@@ -343,6 +425,7 @@ export default function MediaPageClient() {
setPrompt("");
setResult(null);
setError(null);
setIsCredentialsError(false);
setAudioFile(null);
// Pick first provider and first model automatically
const providers = PROVIDER_MODELS[tab] ?? [];
@@ -366,9 +449,9 @@ export default function MediaPageClient() {
};
// Initialize on mount — pick first provider/model for image tab
const [initialized, setInitialized] = useState(false);
if (!initialized) {
setInitialized(true);
const initialized = useRef(false);
if (!initialized.current) {
initialized.current = true;
const providers = PROVIDER_MODELS["image"] ?? [];
const firstProvider = providers[0];
setSelectedProvider(firstProvider?.id ?? "");
@@ -378,6 +461,7 @@ export default function MediaPageClient() {
const handleGenerate = async () => {
setLoading(true);
setError(null);
setIsCredentialsError(false);
setResult(null);
try {
@@ -401,8 +485,10 @@ export default function MediaPageClient() {
}),
});
if (!res.ok) {
const e = await res.json().catch(() => ({}));
throw new Error(e?.error?.message || `TTS failed (${res.status})`);
const raw = await res.json().catch(() => ({}));
const { message, isCredentials } = parseApiError(raw, res.status);
setIsCredentialsError(isCredentials);
throw new Error(message);
}
const blob = await res.blob();
const audioUrl = URL.createObjectURL(blob);
@@ -427,10 +513,21 @@ export default function MediaPageClient() {
form.append("model", modelId);
const res = await fetch(config.endpoint, { method: "POST", body: form });
if (!res.ok) {
const e = await res.json().catch(() => ({}));
throw new Error(e?.error?.message || `Transcription failed (${res.status})`);
const raw = await res.json().catch(() => ({}));
const { message, isCredentials } = parseApiError(raw, res.status);
setIsCredentialsError(isCredentials);
throw new Error(message);
}
const data = await res.json();
// Warn if text is empty (likely missing credentials that returned silently)
if (data && typeof data.text === "string" && data.text.trim() === "") {
setError(
`Transcription returned empty text. Make sure you have a valid API key for "${selectedProvider}" configured in /dashboard/providers.`
);
setIsCredentialsError(true);
setLoading(false);
return;
}
setResult({ type: "transcription", data, timestamp: Date.now() });
setLoading(false);
return;
@@ -451,8 +548,10 @@ export default function MediaPageClient() {
}),
});
if (!res.ok) {
const e = await res.json().catch(() => ({}));
throw new Error(e?.error?.message || `Generation failed (${res.status})`);
const raw = await res.json().catch(() => ({}));
const { message, isCredentials } = parseApiError(raw, res.status);
setIsCredentialsError(isCredentials);
throw new Error(message);
}
const data = await res.json();
setResult({ type: activeTab, data, timestamp: Date.now() });
@@ -532,6 +631,20 @@ export default function MediaPageClient() {
</div>
</div>
{/* Credential hint */}
{selectedProvider && !["sdwebui", "comfyui", "qwen"].includes(selectedProvider) && (
<p className="text-xs text-text-muted flex items-center gap-1.5">
<span className="material-symbols-outlined text-[14px] text-amber-500">info</span>
Requires <strong className="capitalize">{selectedProvider}</strong> API key in{" "}
<Link
href="/dashboard/providers"
className="text-primary underline underline-offset-2 hover:text-primary/80"
>
Providers
</Link>
</p>
)}
{/* Speech: voice + format */}
{activeTab === "speech" && (
<div className="grid grid-cols-2 gap-4">
@@ -640,11 +753,30 @@ export default function MediaPageClient() {
{/* Error */}
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-xl p-4 flex items-start gap-3">
<span className="material-symbols-outlined text-red-500 text-[20px] mt-0.5">error</span>
<div>
<p className="text-sm font-medium text-red-500">{t("error")}</p>
<p className="text-sm text-text-muted mt-1">{error}</p>
<div
className={`rounded-xl p-4 flex items-start gap-3 ${isCredentialsError ? "bg-amber-500/10 border border-amber-500/20" : "bg-red-500/10 border border-red-500/20"}`}
>
<span
className={`material-symbols-outlined text-[20px] mt-0.5 ${isCredentialsError ? "text-amber-500" : "text-red-500"}`}
>
{isCredentialsError ? "key" : "error"}
</span>
<div className="flex-1 min-w-0">
<p
className={`text-sm font-medium ${isCredentialsError ? "text-amber-500" : "text-red-500"}`}
>
{isCredentialsError ? "API Key Required" : t("error")}
</p>
<p className="text-sm text-text-muted mt-1 break-words">{error}</p>
{isCredentialsError && (
<Link
href="/dashboard/providers"
className="inline-flex items-center gap-1 mt-2 text-xs text-primary hover:underline"
>
<span className="material-symbols-outlined text-[13px]">open_in_new</span>
Configure API keys in Providers
</Link>
)}
</div>
</div>
)}
@@ -676,6 +808,26 @@ export default function MediaPageClient() {
Download {result.data?.format?.toUpperCase() || "MP3"}
</a>
</div>
) : result.type === "image" ? (
<ImageResults data={result.data} />
) : result.type === "transcription" ? (
<div className="space-y-3">
<div className="bg-surface rounded-lg p-4 text-sm text-text-main leading-relaxed whitespace-pre-wrap">
{result.data?.text || (
<span className="text-text-muted italic">No text returned</span>
)}
</div>
{result.data?.words && (
<details className="mt-2">
<summary className="text-xs text-text-muted cursor-pointer hover:text-text-main">
Word-level timestamps ({result.data.words.length} words)
</summary>
<pre className="bg-surface rounded mt-2 p-3 text-xs text-text-muted overflow-auto max-h-48 custom-scrollbar">
{JSON.stringify(result.data.words, null, 2)}
</pre>
</details>
)}
</div>
) : (
<pre className="bg-surface rounded-lg p-4 text-xs text-text-muted overflow-auto max-h-96 custom-scrollbar">
{JSON.stringify(result.data, null, 2)}
+300 -33
View File
@@ -59,9 +59,8 @@ const DEFAULT_BODIES: Record<string, object> = {
response_format: "mp3",
},
transcription: {
// Note: /v1/audio/transcriptions requires multipart/form-data with a file.
// Use curl or the Media page to upload audio files.
model: "openai/whisper-1",
// Note: this endpoint requires multipart/form-data — use the file upload below
model: "deepgram/nova-3",
language: "en",
},
video: {
@@ -98,6 +97,78 @@ const ENDPOINT_PATHS: Record<string, string> = {
rerank: "/v1/rerank",
};
// Models known to support vision (image input)
const VISION_MODELS = [
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-4-vision",
"claude-3",
"claude-sonnet",
"claude-opus",
"claude-haiku",
"gemini",
"llava",
"bakllava",
"pixtral",
"qwen-vl",
"qvq",
"mistral-pixtral",
];
function isVisionModel(modelId: string): boolean {
const lower = modelId.toLowerCase();
return VISION_MODELS.some((k) => lower.includes(k));
}
/** Convert a File to base64 data URI */
async function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/** Render image results from OpenAI-compatible format */
function ImageResultsInline({ data }: { data: any }) {
const images: Array<{ url?: string; b64_json?: string; revised_prompt?: string }> =
data?.data || [];
if (images.length === 0) return null;
return (
<div className="p-4 space-y-3">
<p className="text-xs text-text-muted font-medium uppercase tracking-wider">
{images.length} image{images.length > 1 ? "s" : ""} generated
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{images.map((img, i) => {
const src = img.url || (img.b64_json ? `data:image/png;base64,${img.b64_json}` : null);
if (!src) return null;
return (
<div key={i} className="relative group rounded-lg overflow-hidden border border-border">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={img.revised_prompt || `Generated image ${i + 1}`}
className="w-full"
/>
<a
href={src}
download={`image-${i + 1}.png`}
className="absolute bottom-2 right-2 bg-black/60 text-white text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1"
>
<span className="material-symbols-outlined text-[13px]">download</span>
Save
</a>
</div>
);
})}
</div>
</div>
);
}
export default function PlaygroundPage() {
const [models, setModels] = useState<ModelInfo[]>([]);
const [providers, setProviders] = useState<ProviderOption[]>([]);
@@ -107,11 +178,22 @@ export default function PlaygroundPage() {
const [requestBody, setRequestBody] = useState("");
const [responseBody, setResponseBody] = useState("");
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [imageData, setImageData] = useState<any>(null);
const [transcriptionText, setTranscriptionText] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [responseStatus, setResponseStatus] = useState<number | null>(null);
const [responseDuration, setResponseDuration] = useState<number | null>(null);
const abortRef = useRef<AbortController | null>(null);
// File upload state
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [uploadedImages, setUploadedImages] = useState<string[]>([]); // base64 URIs for vision
const isTranscriptionEndpoint = selectedEndpoint === "transcription";
const isChatEndpoint = selectedEndpoint === "chat";
const isImageEndpoint = selectedEndpoint === "images";
const supportsVision = isChatEndpoint && isVisionModel(selectedModel);
// Fetch models
useEffect(() => {
fetch("/v1/models")
@@ -120,7 +202,6 @@ export default function PlaygroundPage() {
const modelList = (data?.data || []) as ModelInfo[];
setModels(modelList);
// Extract unique providers from model ids (provider/model format)
const providerSet = new Set<string>();
modelList.forEach((m) => {
const parts = m.id.split("/");
@@ -135,12 +216,10 @@ export default function PlaygroundPage() {
.catch(() => {});
}, []);
// Filter models by selected provider
const filteredModels = models
.filter((m) => !selectedProvider || m.id.startsWith(selectedProvider + "/"))
.map((m) => ({ value: m.id, label: m.id }));
// Helper to generate default body for a given endpoint and model
const generateDefaultBody = (endpoint: string, model: string) => {
const template = { ...DEFAULT_BODIES[endpoint] };
if ("model" in template) {
@@ -149,7 +228,6 @@ export default function PlaygroundPage() {
return JSON.stringify(template, null, 2);
};
// When provider changes, auto-select first model and reset body
const handleProviderChange = (newProvider: string) => {
setSelectedProvider(newProvider);
const providerModels = models
@@ -158,63 +236,122 @@ export default function PlaygroundPage() {
const firstModel = providerModels[0] || "";
setSelectedModel(firstModel);
setRequestBody(generateDefaultBody(selectedEndpoint, firstModel));
setResponseBody("");
setResponseStatus(null);
setResponseDuration(null);
clearResults();
};
// When model changes, update body
const handleModelChange = (newModel: string) => {
setSelectedModel(newModel);
setRequestBody(generateDefaultBody(selectedEndpoint, newModel));
setResponseBody("");
setResponseStatus(null);
setResponseDuration(null);
clearResults();
};
// When endpoint changes, update body
const handleEndpointChange = (newEndpoint: string) => {
setSelectedEndpoint(newEndpoint);
setRequestBody(generateDefaultBody(newEndpoint, selectedModel));
setResponseBody("");
setResponseStatus(null);
setResponseDuration(null);
setUploadedFile(null);
setUploadedImages([]);
clearResults();
};
const handleSend = useCallback(async () => {
if (!requestBody.trim()) return;
setLoading(true);
const clearResults = () => {
setResponseBody("");
setAudioUrl(null);
setResponseStatus(null);
setResponseDuration(null);
setAudioUrl(null);
setImageData(null);
setTranscriptionText(null);
};
/** Handle audio file select for transcription endpoint */
const handleAudioFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
setUploadedFile(file);
};
/** Handle image file select for vision models */
const handleImageFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const base64s = await Promise.all(files.map(fileToBase64));
setUploadedImages((prev) => [...prev, ...base64s].slice(0, 4)); // max 4 images
};
/** Inject uploaded images into chat messages body */
const buildChatBodyWithImages = (parsed: any, imageBase64s: string[]): any => {
if (!imageBase64s.length) return parsed;
const messages = [...(parsed.messages || [])];
if (messages.length === 0) return parsed;
const lastMsg = messages[messages.length - 1];
const currentContent = typeof lastMsg.content === "string" ? lastMsg.content : "";
messages[messages.length - 1] = {
...lastMsg,
content: [
{ type: "text", text: currentContent },
...imageBase64s.map((b64) => ({
type: "image_url",
image_url: { url: b64 },
})),
],
};
return { ...parsed, messages };
};
const handleSend = async () => {
if (!requestBody.trim() && !isTranscriptionEndpoint) return;
setLoading(true);
clearResults();
const controller = new AbortController();
abortRef.current = controller;
const startTime = Date.now();
try {
const parsed = JSON.parse(requestBody);
const path = ENDPOINT_PATHS[selectedEndpoint];
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(parsed),
signal: controller.signal,
});
let res: Response;
if (isTranscriptionEndpoint) {
// Multipart form-data for transcription
const form = new FormData();
if (uploadedFile) {
form.append("file", uploadedFile);
}
// Parse extra params from JSON editor
try {
const extra = JSON.parse(requestBody || "{}");
for (const [k, v] of Object.entries(extra)) {
if (k !== "file") form.append(k, String(v));
}
} catch {
/* ignore parse errors */
}
res = await fetch(`/api${path}`, {
method: "POST",
body: form,
signal: controller.signal,
});
} else {
let parsed = JSON.parse(requestBody);
// Inject vision images if available
if (supportsVision && uploadedImages.length > 0) {
parsed = buildChatBodyWithImages(parsed, uploadedImages);
}
res = await fetch(`/api${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(parsed),
signal: controller.signal,
});
}
setResponseStatus(res.status);
setResponseDuration(Date.now() - startTime);
const contentType = res.headers.get("content-type") || "";
if (contentType.startsWith("audio/")) {
// TTS binary response — create a Blob URL and show inline audio player
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setAudioUrl(url);
setResponseBody(`// Audio response (${contentType})\n// Click play below to listen.`);
} else if (contentType.includes("text/event-stream")) {
// Handle streaming
const reader = res.body?.getReader();
const decoder = new TextDecoder();
let accumulated = "";
@@ -229,6 +366,14 @@ export default function PlaygroundPage() {
} else {
const data = await res.json();
setResponseBody(JSON.stringify(data, null, 2));
// Detect image generation result → render inline
if (isImageEndpoint && data?.data && Array.isArray(data.data) && res.ok) {
setImageData(data);
}
// Detect transcription result → render plain text
if (isTranscriptionEndpoint && typeof data?.text === "string") {
setTranscriptionText(data.text || "(empty result — check provider credentials)");
}
}
} catch (err: any) {
if (err.name === "AbortError") {
@@ -239,7 +384,7 @@ export default function PlaygroundPage() {
setResponseDuration(Date.now() - startTime);
}
setLoading(false);
}, [requestBody, selectedEndpoint]);
};
const handleCancel = () => {
if (abortRef.current) {
@@ -323,7 +468,10 @@ export default function PlaygroundPage() {
<Button
icon="send"
onClick={handleSend}
disabled={!requestBody.trim() || !selectedModel}
disabled={
(!requestBody.trim() && !isTranscriptionEndpoint) ||
(!selectedModel && !isTranscriptionEndpoint)
}
>
Send
</Button>
@@ -332,6 +480,98 @@ export default function PlaygroundPage() {
</div>
</Card>
{/* File Upload Zone — shown for transcription and vision models */}
{(isTranscriptionEndpoint || supportsVision) && (
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
attach_file
</span>
<h3 className="text-sm font-semibold text-text-main">
{isTranscriptionEndpoint ? "Audio File" : "Attach Images (Vision)"}
</h3>
{isTranscriptionEndpoint && (
<Badge variant="info" size="sm">
multipart/form-data
</Badge>
)}
{supportsVision && (
<Badge variant="info" size="sm">
up to 4 images
</Badge>
)}
</div>
{isTranscriptionEndpoint && (
<div>
<input
type="file"
accept="audio/*,video/*"
onChange={handleAudioFileChange}
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
/>
{uploadedFile && (
<p className="text-xs text-text-muted mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px] text-green-500">
check_circle
</span>
{uploadedFile.name} ({(uploadedFile.size / 1024).toFixed(0)} KB)
</p>
)}
{!uploadedFile && (
<p className="text-xs text-amber-500 mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">info</span>
Select an audio file to transcribe (mp3, wav, m4a, ogg, flac)
</p>
)}
</div>
)}
{supportsVision && (
<div>
<input
type="file"
accept="image/*"
multiple
onChange={handleImageFileChange}
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
/>
{uploadedImages.length > 0 && (
<div className="flex gap-2 mt-2 flex-wrap">
{uploadedImages.map((src, i) => (
<div
key={i}
className="relative group size-16 rounded overflow-hidden border border-border"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={`Attached ${i + 1}`}
className="w-full h-full object-cover"
/>
<button
onClick={() =>
setUploadedImages((prev) => prev.filter((_, idx) => idx !== i))
}
className="absolute inset-0 bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
))}
<button
onClick={() => setUploadedImages([])}
className="text-xs text-text-muted hover:text-red-500 self-center ml-1"
>
Clear all
</button>
</div>
)}
</div>
)}
</div>
</Card>
)}
{/* Split Editor View */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Request Panel */}
@@ -368,6 +608,15 @@ export default function PlaygroundPage() {
</button>
</div>
</div>
{isTranscriptionEndpoint && (
<p className="text-xs text-text-muted bg-amber-500/10 border border-amber-500/20 rounded px-2 py-1.5 flex items-start gap-1">
<span className="material-symbols-outlined text-[12px] text-amber-500 mt-0.5">
info
</span>
Transcription uses multipart/form-data. Upload the audio file above JSON below
controls extra params (model, language).
</p>
)}
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
@@ -438,6 +687,24 @@ export default function PlaygroundPage() {
Download audio
</a>
</div>
) : imageData ? (
<ImageResultsInline data={imageData} />
) : transcriptionText !== null ? (
<div className="p-4 space-y-2">
<p className="text-xs text-text-muted font-medium uppercase tracking-wider">
Transcription
</p>
<div className="bg-surface/50 rounded p-3 text-sm text-text-main leading-relaxed whitespace-pre-wrap">
{transcriptionText}
</div>
<button
onClick={() => handleCopy(transcriptionText)}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<span className="material-symbols-outlined text-[12px]">content_copy</span>
Copy text
</button>
</div>
) : (
<Editor
height="400px"
@@ -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);
@@ -329,6 +398,28 @@ export default function ProviderDetailPage() {
}
};
// T12: Manual token refresh
const [refreshingId, setRefreshingId] = useState<string | null>(null);
const handleRefreshToken = async (connectionId: string) => {
if (refreshingId) return;
setRefreshingId(connectionId);
try {
const res = await fetch(`/api/providers/${connectionId}/refresh`, { method: "POST" });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
notify.success(t("tokenRefreshed"));
await fetchConnections();
} else {
notify.error(data.error || t("tokenRefreshFailed"));
}
} catch (error) {
console.error("Error refreshing token:", error);
notify.error(t("tokenRefreshFailed"));
} finally {
setRefreshingId(null);
}
};
const handleSwapPriority = async (conn1, conn2) => {
if (!conn1 || !conn2) return;
try {
@@ -918,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={() => {
@@ -926,6 +1022,8 @@ export default function ProviderDetailPage() {
}}
onDelete={() => handleDelete(conn.id)}
onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined}
onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined}
isRefreshing={refreshingId === conn.id}
onProxy={() =>
setProxyTarget({
level: "key",
@@ -2150,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,
@@ -2165,6 +2266,8 @@ function ConnectionRow({
hasProxy,
proxySource,
proxyHost,
onRefreshToken,
isRefreshing,
}) {
const t = useTranslations("providers");
const displayName = isOAuth
@@ -2173,6 +2276,24 @@ function ConnectionRow({
// Use useState + useEffect for impure Date.now() to avoid calling during render
const [isCooldown, setIsCooldown] = useState(false);
// T12: token expiry status — lazy init avoids calling Date.now() during render;
// updates every 30s via interval only (no sync setState in effect body).
const getTokenMinsLeft = () => {
if (!isOAuth || !connection.expiresAt) return null;
const expiresMs = new Date(connection.expiresAt).getTime();
return Math.floor((expiresMs - Date.now()) / 60000);
};
const [tokenMinsLeft, setTokenMinsLeft] = useState<number | null>(getTokenMinsLeft);
useEffect(() => {
if (!isOAuth || !connection.expiresAt) return;
const update = () => {
const expiresMs = new Date(connection.expiresAt).getTime();
setTokenMinsLeft(Math.floor((expiresMs - Date.now()) / 60000));
};
const iv = setInterval(update, 30000);
return () => clearInterval(iv);
}, [isOAuth, connection.expiresAt]);
useEffect(() => {
const checkCooldown = () => {
@@ -2197,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
@@ -2229,6 +2360,25 @@ function ConnectionRow({
<Badge variant={statusPresentation.statusVariant as any} size="sm" dot>
{statusPresentation.statusLabel}
</Badge>
{/* T12: Token expiry status indicator (state-driven, no Date.now in render) */}
{tokenMinsLeft !== null &&
(tokenMinsLeft < 0 ? (
<span
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-medium bg-red-500/15 text-red-500"
title={`Token expired: ${connection.expiresAt}`}
>
<span className="material-symbols-outlined text-[11px]">error</span>
expired
</span>
) : tokenMinsLeft < 30 ? (
<span
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-medium bg-amber-500/15 text-amber-500"
title={`Token expires in ${tokenMinsLeft}m`}
>
<span className="material-symbols-outlined text-[11px]">warning</span>
{`~${tokenMinsLeft}m`}
</span>
) : null)}
{isCooldown && connection.isActive !== false && (
<CooldownTimer until={connection.rateLimitedUntil} />
)}
@@ -2267,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 =
@@ -2313,6 +2492,21 @@ function ConnectionRow({
>
{t("retest")}
</Button>
{/* T12: Manual token refresh for OAuth accounts */}
{onRefreshToken && (
<Button
size="sm"
variant="ghost"
icon="token"
loading={isRefreshing}
disabled={connection.isActive === false || isRefreshing}
onClick={onRefreshToken}
className="!h-7 !px-2 text-xs text-amber-500 hover:text-amber-400"
title="Refresh OAuth token manually"
>
Token
</Button>
)}
<Toggle
size="sm"
checked={connection.isActive ?? true}
@@ -2332,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>
@@ -2342,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>
@@ -2367,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,
@@ -2565,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) {
@@ -2574,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);
}
@@ -2660,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);
@@ -2744,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]">
+3 -1
View File
@@ -22,6 +22,7 @@ interface ScoringWeights {
latencyInv: number;
taskFit: number;
stability: number;
tierPriority: number;
}
const DEFAULT_WEIGHTS: ScoringWeights = {
@@ -30,7 +31,8 @@ const DEFAULT_WEIGHTS: ScoringWeights = {
costInv: 0.2,
latencyInv: 0.15,
taskFit: 0.1,
stability: 0.1,
stability: 0.05,
tierPriority: 0.05,
};
interface AutoComboConfig {
+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 });
}
}
@@ -0,0 +1,79 @@
import { NextResponse } from "next/server";
import { getProviderConnectionById } from "@/models";
import { getAccessToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
/**
* POST /api/providers/[id]/refresh
* Manually trigger an OAuth token refresh for a provider connection.
* Useful when the dashboard shows a stale/expired token and the user
* doesn't want to wait for the next auto-refresh cycle.
*
* T12 Manual Token Refresh UI
*/
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const connection = await getProviderConnectionById(id);
if (!connection) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
}
if (connection.authType !== "oauth") {
return NextResponse.json(
{ error: "Only OAuth connections support manual token refresh" },
{ status: 400 }
);
}
if (!connection.refreshToken && !connection.accessToken) {
return NextResponse.json(
{ error: "No token credentials available for refresh" },
{ status: 422 }
);
}
const provider = connection.provider as string;
const credentials = {
connectionId: id,
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
expiresAt: connection.expiresAt,
expiresIn: connection.expiresIn,
idToken: connection.idToken,
providerSpecificData: connection.providerSpecificData,
};
// Use the existing getAccessToken helper which knows how to refresh
// tokens for each provider type (Claude, GitHub, Gemini, etc.)
const newCredentials = await getAccessToken(provider, credentials);
if (!newCredentials?.accessToken) {
return NextResponse.json(
{ error: "Token refresh failed — provider returned no new token" },
{ status: 502 }
);
}
// Persist new credentials to DB
await updateProviderCredentials(id, newCredentials);
const expiresAt = newCredentials.expiresIn
? new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString()
: null;
return NextResponse.json({
success: true,
connectionId: id,
provider,
expiresAt,
refreshedAt: new Date().toISOString(),
});
} catch (error) {
console.error("[T12] Token refresh failed:", error);
return NextResponse.json(
{ error: "Token refresh failed", details: (error as 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 });
}
}
@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import {
getTaskRoutingConfig,
setTaskRoutingConfig,
resetTaskRoutingStats,
getDefaultTaskModelMap,
} from "@omniroute/open-sse/services/taskAwareRouter.ts";
import { updateSettings } from "@/lib/db/settings";
/**
* GET /api/settings/task-routing
* Returns the current task-aware routing configuration.
*/
export async function GET() {
try {
return NextResponse.json({
...getTaskRoutingConfig(),
defaultTaskModelMap: getDefaultTaskModelMap(),
});
} catch (error) {
console.error("[API ERROR] /api/settings/task-routing GET:", error);
return NextResponse.json({ error: "Failed to get config" }, { status: 500 });
}
}
/**
* PUT /api/settings/task-routing
* Update the task-aware routing configuration.
* Body: { enabled?: boolean, taskModelMap?: { coding?: "...", ... }, detectionEnabled?: boolean }
*/
export async function PUT(request: Request) {
let rawBody: Record<string, unknown>;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
}
try {
setTaskRoutingConfig(rawBody as any);
// Persist to database (excluding stats)
const { stats, ...persistable } = getTaskRoutingConfig();
await updateSettings({ taskRouting: JSON.stringify(persistable) });
return NextResponse.json({ success: true, ...getTaskRoutingConfig() });
} catch (error) {
console.error("[API ERROR] /api/settings/task-routing PUT:", error);
return NextResponse.json({ error: "Failed to update config" }, { status: 500 });
}
}
/**
* POST /api/settings/task-routing
* Actions: { action: "reset-stats" | "detect" }
* For "detect": pass { action: "detect", body: <request-body> } to test detection
*/
export async function POST(request: Request) {
let rawBody: any;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
}
try {
if (rawBody.action === "reset-stats") {
resetTaskRoutingStats();
return NextResponse.json({
success: true,
stats: getTaskRoutingConfig().stats,
});
}
if (rawBody.action === "detect") {
const { detectTaskType } = await import("@omniroute/open-sse/services/taskAwareRouter.ts");
const taskType = detectTaskType(rawBody.body || {});
const config = getTaskRoutingConfig();
return NextResponse.json({
taskType,
preferredModel: config.taskModelMap[taskType] || "(no override)",
});
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
} catch (error) {
console.error("[API ERROR] /api/settings/task-routing POST:", error);
return NextResponse.json({ error: "Failed to execute action" }, { 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" },
+3 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { copyToClipboard } from "@/shared/utils/clipboard";
export default function GetStarted() {
const t = useTranslations("landing");
@@ -10,8 +11,8 @@ export default function GetStarted() {
const dashboardUrl = `${endpoint}/dashboard`;
const command = "npx omniroute";
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
const handleCopy = async (text: string) => {
await copyToClipboard(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
+90 -5
View File
@@ -32,6 +32,14 @@ interface QuotaCacheEntry {
fetchedAt: number;
exhausted: boolean;
nextResetAt: string | null;
windowDurationMs?: number | null; // T08: optional rolling window duration
}
interface QuotaWindowStatus {
remainingPercentage: number;
usedPercentage: number;
resetAt: string | null;
reachedThreshold: boolean;
}
// ─── Constants ──────────────────────────────────────────────────────────────
@@ -56,11 +64,43 @@ function isExhausted(quotas: Record<string, QuotaInfo>): boolean {
return entries.every((q) => q.remainingPercentage <= 0);
}
/**
* T08 Auto-advance quota window.
* If we know the window duration, advance past the expired window(s) to
* avoid blocking requests when the quota reset already happened but the
* background refresh hasn't run yet.
*/
function advancedWindowResetAt(entry: QuotaCacheEntry, now: number): { exhausted: false } | null {
if (!entry.nextResetAt) return null;
const resetMs = parseDate(entry.nextResetAt);
if (resetMs === null) return null;
// If the window's resetAt is in the past, the quota has been renewed.
// Eagerly mark as available so requests don't wait for the 5-min TTL.
if (resetMs <= now) {
return { exhausted: false };
}
// If we know the window duration, check if the *next* window also passed.
if (entry.windowDurationMs && entry.windowDurationMs > 0) {
const elapsed = now - resetMs;
if (elapsed >= 0) return { exhausted: false };
}
return null;
}
function parseDate(value: string): number | null {
const ms = new Date(value).getTime();
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;
@@ -128,19 +168,64 @@ export function isAccountQuotaExhausted(connectionId: string): boolean {
if (!entry) return false;
if (!entry.exhausted) return false;
// If resetAt has passed, assume available until refresh confirms
if (entry.nextResetAt) {
const resetMs = parseDate(entry.nextResetAt);
if (resetMs !== null && resetMs <= Date.now()) return false;
const now = Date.now();
// T08 — Auto window advance: if resetAt is in the past, eagerly treat as not exhausted.
// This prevents stale exhaustion blocking when background refresh hasn't run yet.
const advanced = advancedWindowResetAt(entry, now);
if (advanced) {
// Optimistically clear the exhausted flag so we unblock requests immediately.
// The next background refresh will update with the real quota state.
entry.exhausted = false;
return false;
}
// Exhausted entries without resetAt expire after fixed TTL
const age = Date.now() - entry.fetchedAt;
const age = now - entry.fetchedAt;
if (!entry.nextResetAt && age > EXHAUSTED_TTL_MS) return false;
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.
+35 -1
View File
@@ -604,6 +604,8 @@
"randomDesc": "اختيار عشوائي موحد، ثم الرجوع إلى النماذج المتبقية",
"leastUsedDesc": "يختار النموذج الذي يحتوي على أقل عدد من الطلبات، مع موازنة الحمل مع مرور الوقت",
"costOptimizedDesc": "الطرق إلى النموذج الأرخص تعتمد أولاً على التسعير",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "نماذج",
"autoBalance": "التوازن التلقائي",
"advancedSettings": "الإعدادات المتقدمة",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "التكاليف",
@@ -1376,6 +1380,8 @@
"email": "البريد الإلكتروني",
"healthCheckMinutes": "فحص الصحة (دقيقة)",
"healthCheckHint": "الفاصل الزمني لتحديث الرمز المميز. 0 = معطل.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "فشل في اختبار الاتصال",
"failed": "فشل",
"leaveBlankKeepCurrentApiKey": "اتركه فارغًا للاحتفاظ بمفتاح API الحالي.",
@@ -1541,6 +1547,8 @@
"leastUsedDesc": "اختر الحساب الأقل استخدامًا مؤخرًا",
"costOpt": "خيار التكلفة",
"costOptDesc": "تفضل أرخص حساب متاح",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "الحد اللزج",
"stickyLimitDesc": "المكالمات لكل حساب قبل التبديل",
"modelAliases": "الأسماء المستعارة النموذجية",
@@ -2070,6 +2078,9 @@
"rawPlanWithValue": "الخطة الأولية: {plan}",
"noPlanFromProvider": "لا توجد خطة من المزود",
"noQuotaData": "لا توجد بيانات الحصص",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "لا توجد بيانات الحصص المتاحة",
"noAccountsForTierFilter": "لم يتم العثور على حسابات لمرشح الطبقة",
"tierAll": "الكل",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
View File
@@ -604,6 +604,8 @@
"randomDesc": "Единен случаен избор, след което се връща към останалите модели",
"leastUsedDesc": "Избира модела с най-малко заявки, като балансира натоварването във времето",
"costOptimizedDesc": "Първо маршрути към най-евтиния модел въз основа на ценообразуването",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Модели",
"autoBalance": "Автоматичен баланс",
"advancedSettings": "Разширени настройки",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Разходи",
@@ -1376,6 +1380,8 @@
"email": "Имейл",
"healthCheckMinutes": "Проверка на здравето (мин.)",
"healthCheckHint": "Интервал за опресняване на проактивен токен. 0 = забранено.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Неуспешно тестване на връзката",
"failed": "Неуспешно",
"leaveBlankKeepCurrentApiKey": "Оставете празно, за да запазите текущия API ключ.",
@@ -1541,6 +1547,8 @@
"leastUsedDesc": "Изберете най-малко използван акаунт",
"costOpt": "Цена Опт",
"costOptDesc": "Предпочитайте най-евтиния наличен акаунт",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Лепкава граница",
"stickyLimitDesc": "Обаждания на акаунт преди превключване",
"modelAliases": "Псевдоними на модела",
@@ -2070,6 +2078,9 @@
"rawPlanWithValue": "Необработен план: {plan}",
"noPlanFromProvider": "Няма план от доставчика",
"noQuotaData": "Няма данни за квоти",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Няма налични данни за квота",
"noAccountsForTierFilter": "Няма намерени акаунти за филтър за ниво",
"tierAll": "Всички",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Omkostninger",
@@ -1376,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.",
@@ -1541,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",
@@ -2070,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",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Behalte einen Qualitäts-Fallback für schwierige Prompts.",
"tip3": "Ideal für Batch/Hintergrundjobs, bei denen Kosten das Haupt-KPI sind."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Kosten",
@@ -1376,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.",
@@ -1541,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",
@@ -2070,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",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+59 -2
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",
@@ -746,7 +769,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Costs",
@@ -1389,13 +1414,17 @@
"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.",
"editCompatibleTitle": "Edit {type} Compatible",
"compatibleBaseUrlHint": "Use the base URL (ending in /v1) for your {type}-compatible API.",
"apiKeyForCheck": "API Key (for Check)",
"compatibleProdPlaceholder": "{type} Compatible (Prod)"
"compatibleProdPlaceholder": "{type} Compatible (Prod)",
"tokenRefreshed": "Token refreshed successfully",
"tokenRefreshFailed": "Token refresh failed"
},
"settings": {
"title": "Settings",
@@ -1558,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",
@@ -2082,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",
@@ -2486,5 +2520,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Mantén un fallback de calidad para prompts difíciles.",
"tip3": "Úsala en batch/tareas de fondo donde el costo sea el KPI principal."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Costos",
@@ -1376,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.",
@@ -1541,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",
@@ -2070,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",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Kustannukset",
@@ -1376,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.",
@@ -1541,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",
@@ -2070,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",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Garde un fallback de qualité pour les prompts difficiles.",
"tip3": "Idéal pour batch/tâches de fond où le coût est le KPI principal."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Coûts",
@@ -1376,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.",
@@ -1541,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",
@@ -2070,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",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
View File
@@ -604,6 +604,8 @@
"randomDesc": "בחירה אקראית אחידה, ואז חזרה לדגמים שנותרו",
"leastUsedDesc": "בוחר את הדגם עם הכי פחות בקשות, מאזן עומס לאורך זמן",
"costOptimizedDesc": "מסלולים לדגם הזול ביותר לפי תמחור",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "דגמים",
"autoBalance": "איזון אוטומטי",
"advancedSettings": "הגדרות מתקדמות",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "עלויות",
@@ -1376,6 +1380,8 @@
"email": "דוא\"ל",
"healthCheckMinutes": "בדיקת בריאות (דקה)",
"healthCheckHint": "מרווח רענון אסימון פרואקטיבי. 0 = מושבת.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "בדיקת החיבור נכשלה",
"failed": "נכשל",
"leaveBlankKeepCurrentApiKey": "השאר ריק כדי לשמור את מפתח ה-API הנוכחי.",
@@ -1541,6 +1547,8 @@
"leastUsedDesc": "בחר חשבון שנעשה בו שימוש לפחות לאחרונה",
"costOpt": "אופטימיזציית עלות",
"costOptDesc": "העדיפו את החשבון הזול ביותר הזמין",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "גבול דביק",
"stickyLimitDesc": "שיחות לכל חשבון לפני המעבר",
"modelAliases": "כינויי דגם",
@@ -2070,6 +2078,9 @@
"rawPlanWithValue": "תוכנית גולמית: {plan}",
"noPlanFromProvider": "אין תוכנית מספק",
"noQuotaData": "אין נתוני מכסה",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "אין נתוני מכסה זמינים",
"noAccountsForTierFilter": "לא נמצאו חשבונות עבור מסנן שכבות",
"tierAll": "הכל",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Költségek",
@@ -1376,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.",
@@ -1541,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",
@@ -2070,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",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Biaya",
@@ -1376,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.",
@@ -1541,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",
@@ -2070,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",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
View File
@@ -604,6 +604,8 @@
"randomDesc": "समान यादृच्छिक चयन, फिर शेष मॉडलों पर वापस लौटना",
"leastUsedDesc": "समय के साथ लोड को संतुलित करते हुए, सबसे कम अनुरोधों वाला मॉडल चुनता है",
"costOptimizedDesc": "मूल्य निर्धारण के आधार पर सबसे पहले सबसे सस्ते मॉडल पर रूट करें",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "मॉडल",
"autoBalance": "स्वत: संतुलन",
"advancedSettings": "उन्नत सेटिंग्स",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "लागत",
@@ -1376,6 +1380,8 @@
"email": "ईमेल",
"healthCheckMinutes": "स्वास्थ्य जांच (न्यूनतम)",
"healthCheckHint": "प्रोएक्टिव टोकन ताज़ा अंतराल। 0 = अक्षम.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "कनेक्शन का परीक्षण करने में विफल",
"failed": "असफल",
"leaveBlankKeepCurrentApiKey": "वर्तमान एपीआई कुंजी रखने के लिए खाली छोड़ दें।",
@@ -1541,6 +1547,8 @@
"leastUsedDesc": "कम से कम हाल ही में उपयोग किया गया खाता चुनें",
"costOpt": "लागत विकल्प",
"costOptDesc": "सबसे सस्ते उपलब्ध खाते को प्राथमिकता दें",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "चिपचिपी सीमा",
"stickyLimitDesc": "स्विच करने से पहले प्रति खाता कॉल",
"modelAliases": "मॉडल उपनाम",
@@ -2070,6 +2078,9 @@
"rawPlanWithValue": "कच्ची योजना: {plan}",
"noPlanFromProvider": "प्रदाता की ओर से कोई योजना नहीं",
"noQuotaData": "कोई कोटा डेटा नहीं",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "कोई कोटा डेटा उपलब्ध नहीं है",
"noAccountsForTierFilter": "टियर फ़िल्टर के लिए कोई खाता नहीं मिला",
"tierAll": "सब",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Costi",
@@ -1376,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.",
@@ -1541,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",
@@ -2070,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",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
View File
@@ -604,6 +604,8 @@
"randomDesc": "均一なランダム選択、その後残りのモデルへのフォールバック",
"leastUsedDesc": "リクエストが最も少ないモデルを選択し、時間の経過とともに負荷のバランスをとります",
"costOptimizedDesc": "価格に基づいて最初に最も安価なモデルにルーティングします",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "モデル",
"autoBalance": "オートバランス",
"advancedSettings": "詳細設定",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "コスト",
@@ -1376,6 +1380,8 @@
"email": "電子メール",
"healthCheckMinutes": "ヘルスチェック (分)",
"healthCheckHint": "プロアクティブなトークンの更新間隔。 0 = 無効。",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "接続のテストに失敗しました",
"failed": "失敗しました",
"leaveBlankKeepCurrentApiKey": "現在の API キーを保持するには、空白のままにします。",
@@ -1541,6 +1547,8 @@
"leastUsedDesc": "最も最近使用されていないアカウントを選択する",
"costOpt": "コストオプション",
"costOptDesc": "利用可能な最も安いアカウントを優先する",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "スティッキー制限",
"stickyLimitDesc": "切り替える前のアカウントごとの通話数",
"modelAliases": "モデルのエイリアス",
@@ -2070,6 +2078,9 @@
"rawPlanWithValue": "未加工のプラン: {plan}",
"noPlanFromProvider": "プロバイダーからのプランなし",
"noQuotaData": "クォータ データがありません",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "利用可能なクォータ データがありません",
"noAccountsForTierFilter": "層フィルターのアカウントが見つかりませんでした",
"tierAll": "すべて",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
View File
@@ -604,6 +604,8 @@
"randomDesc": "균일한 무작위 선택 후 나머지 모델로 대체",
"leastUsedDesc": "시간이 지남에 따라 로드 밸런싱을 통해 요청이 가장 적은 모델을 선택합니다.",
"costOptimizedDesc": "가격을 기준으로 가장 저렴한 모델로 먼저 라우팅",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "모델",
"autoBalance": "자동 균형",
"advancedSettings": "고급 설정",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "비용",
@@ -1376,6 +1380,8 @@
"email": "이메일",
"healthCheckMinutes": "상태 점검(분)",
"healthCheckHint": "사전 토큰 새로 고침 간격. 0 = 비활성화됨.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "연결을 테스트하지 못했습니다.",
"failed": "실패",
"leaveBlankKeepCurrentApiKey": "현재 API 키를 유지하려면 비워 두세요.",
@@ -1541,6 +1547,8 @@
"leastUsedDesc": "최근에 가장 적게 사용한 계정 선택",
"costOpt": "비용 선택",
"costOptDesc": "가장 저렴한 계정을 선호합니다",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "고정 한도",
"stickyLimitDesc": "전환 전 계정당 통화",
"modelAliases": "모델 별칭",
@@ -2070,6 +2078,9 @@
"rawPlanWithValue": "기본 계획: {plan}",
"noPlanFromProvider": "공급자의 계획 없음",
"noQuotaData": "할당량 데이터 없음",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "사용 가능한 할당량 데이터가 없습니다.",
"noAccountsForTierFilter": "등급 필터에 대한 계정을 찾을 수 없습니다.",
"tierAll": "모두",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Kos",
@@ -1376,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.",
@@ -1541,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",
@@ -2070,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",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Kosten",
@@ -1376,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.",
@@ -1541,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",
@@ -2070,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",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Kostnader",
@@ -1376,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.",
@@ -1541,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",
@@ -2070,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",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Mga gastos",
@@ -1376,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.",
@@ -1541,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",
@@ -2070,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",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Koszty",
@@ -1376,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.",
@@ -1541,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",
@@ -2070,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",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+98 -31
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,48 +733,57 @@
"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)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Custos",
@@ -1376,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.",
@@ -1546,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",
@@ -2070,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",
@@ -2486,5 +2530,28 @@
"versionCommand": "Comando de Versão",
"spawnArgs": "Argumentos",
"addAgent": "Adicionar Agente"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Custos",
@@ -1388,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.",
@@ -1553,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",
@@ -2082,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",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Costuri",
@@ -1376,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ă.",
@@ -1541,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",
@@ -2070,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",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
View File
@@ -604,6 +604,8 @@
"randomDesc": "Равномерный случайный выбор, затем возврат к оставшимся моделям",
"leastUsedDesc": "Выбирает модель с наименьшим количеством запросов, балансируя нагрузку с течением времени.",
"costOptimizedDesc": "Маршруты к самой дешевой модели в первую очередь на основе цены",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Модели",
"autoBalance": "Автобаланс",
"advancedSettings": "Расширенные настройки",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Затраты",
@@ -1376,6 +1380,8 @@
"email": "электронная почта",
"healthCheckMinutes": "Проверка здоровья (мин)",
"healthCheckHint": "Интервал обновления упреждающего токена. 0 = отключено.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Не удалось проверить соединение",
"failed": "Не удалось",
"leaveBlankKeepCurrentApiKey": "Оставьте пустым, чтобы сохранить текущий ключ API.",
@@ -1541,6 +1547,8 @@
"leastUsedDesc": "Выберите наименее использованную учетную запись",
"costOpt": "Опция стоимости",
"costOptDesc": "Предпочитаю самый дешевый доступный аккаунт",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Липкий лимит",
"stickyLimitDesc": "Звонки на аккаунт до переключения",
"modelAliases": "Псевдонимы моделей",
@@ -2070,6 +2078,9 @@
"rawPlanWithValue": "Необработанный план: {plan}",
"noPlanFromProvider": "Нет плана от провайдера",
"noQuotaData": "Нет данных о квотах",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Нет данных о квотах",
"noAccountsForTierFilter": "Аккаунты для фильтра уровня не найдены",
"tierAll": "Все",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "náklady",
@@ -1376,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.",
@@ -1541,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",
@@ -2070,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",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Kostnader",
@@ -1376,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.",
@@ -1541,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",
@@ -2070,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",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
View File
@@ -604,6 +604,8 @@
"randomDesc": "การเลือกแบบสุ่มแบบสม่ำเสมอ จากนั้นจึงย้อนกลับไปยังโมเดลที่เหลือ",
"leastUsedDesc": "เลือกโมเดลที่มีคำขอน้อยที่สุด โดยจะปรับสมดุลการโหลดเมื่อเวลาผ่านไป",
"costOptimizedDesc": "กำหนดเส้นทางไปยังรุ่นที่ถูกที่สุดก่อนตามราคา",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "โมเดล",
"autoBalance": "ปรับสมดุลอัตโนมัติ",
"advancedSettings": "การตั้งค่าขั้นสูง",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "ค่าใช้จ่าย",
@@ -1376,6 +1380,8 @@
"email": "อีเมล",
"healthCheckMinutes": "ตรวจสุขภาพ (ขั้นต่ำ)",
"healthCheckHint": "ช่วงเวลาการรีเฟรชโทเค็นเชิงรุก 0 = ปิดการใช้งาน",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "ทดสอบการเชื่อมต่อไม่สำเร็จ",
"failed": "ล้มเหลว",
"leaveBlankKeepCurrentApiKey": "เว้นว่างไว้เพื่อเก็บคีย์ API ปัจจุบันไว้",
@@ -1541,6 +1547,8 @@
"leastUsedDesc": "เลือกบัญชีที่ใช้ล่าสุดน้อยที่สุด",
"costOpt": "การเลือกใช้ต้นทุน",
"costOptDesc": "ต้องการบัญชีที่ถูกที่สุด",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "ขีด จำกัด เหนียว",
"stickyLimitDesc": "โทรต่อบัญชีก่อนที่จะเปลี่ยน",
"modelAliases": "นามแฝงของโมเดล",
@@ -2070,6 +2078,9 @@
"rawPlanWithValue": "แผนดิบ: {plan}",
"noPlanFromProvider": "ไม่มีแผนจากผู้ให้บริการ",
"noQuotaData": "ไม่มีข้อมูลโควต้า",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "ไม่มีข้อมูลโควต้า",
"noAccountsForTierFilter": "ไม่พบบัญชีสำหรับตัวกรองระดับ",
"tierAll": "ทั้งหมด",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
View File
@@ -604,6 +604,8 @@
"randomDesc": "Рівномірний випадковий вибір, а потім повернення до інших моделей",
"leastUsedDesc": "Вибирає модель із найменшою кількістю запитів, балансуючи навантаження за часом",
"costOptimizedDesc": "Маршрути до найдешевшої моделі на основі ціни",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "Моделі",
"autoBalance": "Автобаланс",
"advancedSettings": "Розширені налаштування",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Витрати",
@@ -1376,6 +1380,8 @@
"email": "Електронна пошта",
"healthCheckMinutes": "Перевірка стану (хв.)",
"healthCheckHint": "Проактивний інтервал оновлення маркера. 0 = вимкнено.",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "Не вдалося перевірити з’єднання",
"failed": "Не вдалося",
"leaveBlankKeepCurrentApiKey": "Залиште поле порожнім, щоб зберегти поточний ключ API.",
@@ -1541,6 +1547,8 @@
"leastUsedDesc": "Виберіть нещодавно використовуваний обліковий запис",
"costOpt": "Вартість Opt",
"costOptDesc": "Віддайте перевагу найдешевшому доступному обліковому запису",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "Sticky Limit",
"stickyLimitDesc": "Дзвінки на обліковий запис перед переходом",
"modelAliases": "Псевдоніми моделі",
@@ -2070,6 +2078,9 @@
"rawPlanWithValue": "Необроблений план: {plan}",
"noPlanFromProvider": "Без плану від провайдера",
"noQuotaData": "Немає даних про квоти",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "Немає даних про квоти",
"noAccountsForTierFilter": "Не знайдено облікових записів для фільтра рівня",
"tierAll": "всі",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
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",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "Chi phí",
@@ -1376,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.",
@@ -1541,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",
@@ -2070,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ả",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+35 -1
View File
@@ -604,6 +604,8 @@
"randomDesc": "统一随机选择,然后回退到剩余模型",
"leastUsedDesc": "选择请求最少的模型,随着时间的推移平衡负载",
"costOptimizedDesc": "首先根据定价路由至最便宜的型号",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
"models": "型号",
"autoBalance": "自动平衡",
"advancedSettings": "高级设置",
@@ -746,7 +748,9 @@
"tip2": "Keep a quality fallback for hard prompts.",
"tip3": "Use for batch/background jobs where cost is the main KPI."
}
}
},
"templateFreeStack": "Free Stack ($0)",
"templateFreeStackDesc": "Round-robin across all free providers: Kiro (Claude), iFlow (5 models), Qwen (4 models), Gemini CLI. Zero cost, never stops coding."
},
"costs": {
"title": "成本",
@@ -1376,6 +1380,8 @@
"email": "电子邮件",
"healthCheckMinutes": "健康检查(分钟)",
"healthCheckHint": "主动令牌刷新间隔。 0 = 禁用。",
"groupLabel": "Environment",
"groupPlaceholder": "e.g. eKaizen, Personal",
"failedTestConnection": "测试连接失败",
"failed": "失败",
"leaveBlankKeepCurrentApiKey": "留空以保留当前的 API 密钥。",
@@ -1541,6 +1547,8 @@
"leastUsedDesc": "选择最近最少使用的帐户",
"costOpt": "成本选择",
"costOptDesc": "更喜欢最便宜的可用帐户",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"stickyLimit": "粘性限制",
"stickyLimitDesc": "切换前每个账户的通话次数",
"modelAliases": "模型别名",
@@ -2070,6 +2078,9 @@
"rawPlanWithValue": "原始计划:{plan}",
"noPlanFromProvider": "提供商没有计划",
"noQuotaData": "无配额数据",
"ungrouped": "Ungrouped",
"viewFlat": "Flat",
"viewByEnvironment": "By Environment",
"noQuotaDataAvailable": "无可用配额数据",
"noAccountsForTierFilter": "未找到适用于层过滤器的帐户",
"tierAll": "全部",
@@ -2486,5 +2497,28 @@
"versionCommand": "Version Command",
"spawnArgs": "Spawn Args",
"addAgent": "Add Agent"
},
"autoCombo": {
"title": "Auto-Combo Engine",
"statusNormal": "Normal",
"statusIncident": "Incident Mode",
"modePack": "Mode Pack",
"providerScores": "Provider Scores",
"noAutoCombo": "No auto-combo configured.",
"excludedProviders": "Excluded Providers",
"noExclusions": "No providers currently excluded.",
"factorQuota": "Quota",
"factorHealth": "Health",
"factorCost": "Cost",
"factorLatency": "Latency",
"factorTaskFit": "Task Fit",
"factorStability": "Stability",
"factorTierPriority": "Tier Priority",
"factorTierPriorityDesc": "Prefers accounts with higher quota tiers (Ultra/Pro over Free)",
"scoreFactorBreakdown": "Scoring Factors",
"modePackShipFast": "Ship Fast",
"modePackCostSaver": "Cost Saver",
"modePackQualityFirst": "Quality First",
"modePackOfflineFriendly": "Offline Friendly"
}
}
+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);

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