Compare commits

...

74 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
diegosouzapw f6c0744d67 feat(release): v2.3.13 — tiered quota scoring, model fallback, auth fixes, pnpm fix
Build Electron Desktop App / Validate version (push) Failing after 35s
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
- Tiered quota scoring (Ultra>Pro>Free) as 7th Auto-Combo factor
- Intra-family model fallback on 404/400/403 errors
- Configurable API bridge timeout (API_BRIDGE_PROXY_TIMEOUT_MS)
- INITIAL_PASSWORD accepted on first login with timingSafeEqual
- README </details> truncation fix (affects all GitHub renders)
- pnpm @swc/helpers override conflict removed
- CLI path injection hardening (isSafePath validator)
- 429 retry, Gemini CLI headers, Claude response_format injection
- deepseek-3.1/3.2, qwen3-coder-next pricing added
- starchart.cc star widget in all 30 READMEs
2026-03-12 18:18:53 -03:00
diegosouzapw 639b49fc5b fix(ci): regenerate package-lock.json after removing @swc/helpers override
The @swc/helpers override removal changed dependency resolution.
npm ci was failing with 'Missing: @swc/helpers@0.5.15 from lock file'.
Updated lock file with npm install --package-lock-only.
2026-03-12 18:17:45 -03:00
diegosouzapw c0252f7b13 docs: replace star-history.com widget with starchart.cc in all READMEs
star-history.com embeds are often cached and slow to update. The new
starchart.cc widget (variant=adaptive) renders better on both light and
dark themes and updates in real-time.

Updated: README.md + 29 i18n locale READMEs
2026-03-12 18:15:38 -03:00
diegosouzapw a87d64372f feat: Phase 1 & 2 implementation plan — T1-T10, T12
T1 (openai-to-claude.ts): response_format injection for json_schema/json_object
T2 (base.ts): intra-URL retry for 429 errors (2x, 2s delay)
T3 (gemini-cli.ts): CLI fingerprint headers (User-Agent, X-Goog-Api-Client)
T5 (modelFamilyFallback.ts + chatCore.ts): intra-family model fallback on 400/404
T9 (pricing.ts): deepseek-3.1, deepseek-3.2, qwen3-coder-next pricing
T10 (scoring.ts + modePacks.ts): tierPriority as 7th scoring factor (Ultra>Pro>Free)
T12 (cliRuntime.ts): isSafePath() guard for CLI_*_BIN env var paths
2026-03-12 18:06:53 -03:00
diegosouzapw 02b19e63e8 fix(pnpm): remove @swc/helpers override conflict, add pnpm build-scripts config (#328)
The @swc/helpers override in package.json duplicated the direct dependency
at the exact same version (0.5.19), causing 'EOVERRIDE' errors when pnpm
users tried to rebuild native modules like better-sqlite3.

Fixes:
- Remove redundant 'overrides' block (direct dep already pins 0.5.19)
- Add pnpm.onlyBuiltDependencies for @parcel/watcher, @swc/core,
  better-sqlite3, esbuild, omniroute, sharp (replaces pnpm approve-builds)
- Add pnpm usage note to README Quick Start

Closes #328
2026-03-12 18:06:27 -03:00
diegosouzapw dba16363b7 fix(api-bridge): make proxy timeout configurable via env (#332)
Add API_BRIDGE_PROXY_TIMEOUT_MS env var to configure the api-bridge
proxy timeout. Default remains 30000ms for backward compatibility.
Handles invalid values with a warning log.

Co-authored-by: hijak <54431520+hijak@users.noreply.github.com>
2026-03-12 18:04:44 -03:00
diegosouzapw d20a2b3e44 fix(auth): accept INITIAL_PASSWORD when changing first password (#333)
- Use timingSafeEqual for constant-time password comparison
- Require non-empty currentPassword when INITIAL_PASSWORD env is set
- Legacy fallback: allow empty or '123456' when no INITIAL_PASSWORD

Co-authored-by: hijak <54431520+hijak@users.noreply.github.com>
2026-03-12 18:04:20 -03:00
diegosouzapw 677f5f8713 fix(docs): add missing </details> closing tag in Troubleshooting section
The outer <details> block at line 1459 was never closed, causing GitHub
to stop rendering everything below Troubleshooting (Tech Stack, Docs,
Roadmap, Contributors, etc.).

Fixes: README truncation on GitHub
2026-03-12 18:03:43 -03:00
diegosouzapw 7da23a90d4 feat: Make providerId nullable in providersBatchTestSchema and update validation to treat null as an absent value. 2026-03-12 17:08:26 -03:00
diegosouzapw 8dad2d32b6 fix(cli-tools): add opencode to cliRuntime, increase timeouts for slow-start CLIs
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
- opencode: add to CLI_TOOLS registry with 15s healthcheck timeout
- openclaw/cursor: increase from 12s → 15s (cold-start on VPS)
- continue: add healthcheckTimeoutMs 15s
- VPS: activated CLI_EXTRA_PATHS=/root/.local/bin for kiro-cli visibility
- VPS: installed droid and openclaw npm packages
2026-03-12 16:42:43 -03:00
diegosouzapw d07a5f0df7 fix(cli-tools): increase kilocode healthcheck timeout from 4s to 15s
Build Electron Desktop App / Validate version (push) Failing after 35s
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
kilocode renders ASCII logo banner on startup causing false healthcheck_failed
timeouts on cold-start or low-resource environments (VPS, CI, dashboard)
2026-03-12 16:34:39 -03:00
jack 55a9e31932 fix(auth): use timing-safe compare for INITIAL_PASSWORD check 2026-03-12 17:28:04 +00:00
jack e62be7e6b3 fix(auth): require explicit INITIAL_PASSWORD match on first password change 2026-03-12 17:04:26 +00:00
jack 7f9ec724ae fix(api-bridge): validate configured proxy timeout value 2026-03-12 17:02:30 +00:00
jack daaa3a8782 fix(auth): allow INITIAL_PASSWORD when updating first password 2026-03-12 17:00:01 +00:00
jack d1c62420bf fix(api-bridge): make proxy timeout configurable via env 2026-03-12 16:59:10 +00:00
168 changed files with 8156 additions and 883 deletions
+25 -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=
@@ -166,6 +188,8 @@ GEMINI_CLI_USER_AGENT=google-api-nodejs-client/9.15.1
# Timeout settings
# FETCH_TIMEOUT_MS=120000
# STREAM_IDLE_TIMEOUT_MS=60000
# API bridge timeout for /v1 proxy requests (default: 30000)
# API_BRIDGE_PROXY_TIMEOUT_MS=120000
# CORS configuration (default: * allows all origins)
# CORS_ORIGINS=*
+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
+217
View File
@@ -1,5 +1,222 @@
# Changelog
## [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
- **Tiered Quota Scoring (Auto-Combo)**: Added `tierPriority` as a 7th scoring factor — accounts with Ultra/Pro tiers are now preferred over Free tiers when other factors are equal. New optional fields `accountTier` and `quotaResetIntervalSecs` on `ProviderCandidate`. All 4 mode packs updated (`ship-fast`, `cost-saver`, `quality-first`, `offline-friendly`).
- **Intra-Family Model Fallback (T5)**: When a model is unavailable (404/400/403), OmniRoute now automatically falls back to sibling models from the same family before returning an error (`modelFamilyFallback.ts`).
- **Configurable API Bridge Timeout**: `API_BRIDGE_PROXY_TIMEOUT_MS` env var lets operators tune the proxy timeout (default 30s). Fixes 504 errors on slow upstream responses. (#332)
- **Star History**: Replaced star-history.com widget with starchart.cc (`?variant=adaptive`) in all 30 READMEs — adapts to light/dark theme, real-time updates.
### 🐛 Bug Fixes
- **Auth — First-time password**: `INITIAL_PASSWORD` env var is now accepted when setting the first dashboard password. Uses `timingSafeEqual` for constant-time comparison, preventing timing attacks. (#333)
- **README Truncation**: Fixed a missing `</details>` closing tag in the Troubleshooting section that caused GitHub to stop rendering everything below it (Tech Stack, Docs, Roadmap, Contributors).
- **pnpm install**: Removed redundant `@swc/helpers` override from `package.json` that conflicted with the direct dependency, causing `EOVERRIDE` errors on pnpm. Added `pnpm.onlyBuiltDependencies` config.
- **CLI Path Injection (T12)**: Added `isSafePath()` validator in `cliRuntime.ts` to block path traversal and shell metacharacters in `CLI_*_BIN` env vars.
- **CI**: Regenerated `package-lock.json` after override removal to fix `npm ci` failures on GitHub Actions.
### 🔧 Improvements
- **Response Format (T1)**: `response_format` (json_schema/json_object) now injected as a system prompt for Claude, enabling structured output compatibility.
- **429 Retry (T2)**: Intra-URL retry for 429 responses (2× attempts with 2s delay) before falling back to next URL.
- **Gemini CLI Headers (T3)**: Added `User-Agent` and `X-Goog-Api-Client` fingerprint headers for Gemini CLI compatibility.
- **Pricing Catalog (T9)**: Added `deepseek-3.1`, `deepseek-3.2`, and `qwen3-coder-next` pricing entries.
### 📁 New Files
| File | Purpose |
| ------------------------------------------ | -------------------------------------------------------- |
| `open-sse/services/modelFamilyFallback.ts` | Model family definitions and intra-family fallback logic |
### Fixed
- **KiloCode**: kilocode healthcheck timeout already fixed in v2.3.11
- **OpenCode**: Add opencode to cliRuntime registry with 15s healthcheck timeout
- **OpenClaw / Cursor**: Increase healthcheck timeout to 15s for slow-start variants
- **VPS**: Install droid and openclaw npm packages; activate CLI_EXTRA_PATHS for kiro-cli
- **cliRuntime**: Add opencode tool registration and increase timeout for continue
## [2.3.11] - 2026-03-12
### Fixed
- **KiloCode healthcheck**: Increase `healthcheckTimeoutMs` from 4000ms to 15000ms — kilocode renders an ASCII logo banner on startup causing false `healthcheck_failed` on slow/cold-start environments
## [2.3.10] - 2026-03-12
### Fixed
+189 -51
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
@@ -711,6 +727,14 @@ npm install -g omniroute
omniroute
```
> **pnpm users:** Run `pnpm approve-builds -g` after install to enable native build scripts required by `better-sqlite3` and `@swc/core`:
>
> ```bash
> pnpm install -g omniroute
> pnpm approve-builds -g # Select all packages → approve
> omniroute
> ```
Dashboard opens at `http://localhost:20128` and API base URL is `http://localhost:20128/v1`.
| Command | Description |
@@ -874,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.
@@ -931,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
@@ -1165,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
@@ -1197,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)
@@ -1276,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
@@ -1291,7 +1435,7 @@ Models:
if/deepseek-r1
```
### Qwen (3 FREE models)
### Qwen (4 FREE models via Device Code)
```bash
Dashboard → Connect Qwen
@@ -1694,6 +1838,8 @@ Se não quiser criar credenciais próprias agora, ainda é possível usar o flux
---
</details>
## 🛠️ Tech Stack
<details>
@@ -1788,17 +1934,9 @@ gh release create v2.0.0 --title "v2.0.0" --generate-notes
## 📊 Star History
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
> 📈 **[View live star history on star-history.com](https://star-history.com/#diegosouzapw/OmniRoute&Date)** — The embedded chart may be cached. Click the link for real-time data.
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Acknowledgments
+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.
+2 -8
View File
@@ -1651,15 +1651,9 @@ gh release create v2.0.0 --title "v2.0.0" --generate-notes
## 📊 تاريخ النجوم
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 شكر وتقدير
+2 -8
View File
@@ -1659,15 +1659,9 @@ gh release create v2.0.0 --title "v2.0.0" --generate-notes
## 📊 Звездна история
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Благодарности
+2 -8
View File
@@ -1660,15 +1660,9 @@ gh release create v2.0.0 --title "v2.0.0" --generate-notes
## 📊 Stjernehistorie
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Tak
+2 -8
View File
@@ -1664,15 +1664,9 @@ gh release create v2.0.0 --title "v2.0.0" --generate-notes
## 📊 Sterngeschichte
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Danksagungen
+2 -8
View File
@@ -1405,15 +1405,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 Historial de Stars
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Agradecimientos
+2 -8
View File
@@ -1555,15 +1555,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 Tähtihistoria
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Kiitokset
+2 -8
View File
@@ -1404,15 +1404,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 Historique des Stars
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Remerciements
+2 -8
View File
@@ -1555,15 +1555,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 היסטוריית כוכבים
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 תודות
+2 -8
View File
@@ -1555,15 +1555,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 Csillagtörténet
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Köszönetnyilvánítás
+19 -8
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
@@ -1555,15 +1572,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 Sejarah Bintang
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Ucapan Terima Kasih
+2 -8
View File
@@ -1198,15 +1198,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 सितारा इतिहास
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 आभार
+2 -8
View File
@@ -1403,15 +1403,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 Cronologia Stelle
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Ringraziamenti
+2 -8
View File
@@ -1556,15 +1556,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 スターの歴史
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 謝辞
+2 -8
View File
@@ -1555,15 +1555,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 스타 히스토리
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 감사의 말씀
+2 -8
View File
@@ -1556,15 +1556,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 Sejarah Bintang
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Ucapan terima kasih
+2 -8
View File
@@ -1555,15 +1555,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 Sterrengeschiedenis
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Dankbetuigingen
+2 -8
View File
@@ -1555,15 +1555,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 Stjernehistorie
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Anerkjennelser
+2 -8
View File
@@ -1556,15 +1556,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 Kasaysayan ng Bituin
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Pasasalamat
+2 -8
View File
@@ -1555,15 +1555,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 Historia gwiazd
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Podziękowania
+2 -8
View File
@@ -1468,15 +1468,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 Histórico de Stars
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Agradecimentos
+2 -8
View File
@@ -1555,15 +1555,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 História das Estrelas
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Agradecimentos
+2 -8
View File
@@ -1557,15 +1557,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 Istoria stelelor
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Mulțumiri
+2 -8
View File
@@ -1402,15 +1402,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 История звёзд
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Благодарности
+2 -8
View File
@@ -1559,15 +1559,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 História hviezd
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Poďakovanie
+2 -8
View File
@@ -1556,15 +1556,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 Stjärnhistorik
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Tack
+2 -8
View File
@@ -1546,15 +1546,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 ประวัติดารา
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏ขอบพระคุณ
+2 -8
View File
@@ -1561,15 +1561,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 Зоряна історія
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Подяка
+2 -8
View File
@@ -1555,15 +1555,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 Lịch sử ngôi sao
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 Lời cảm ơn
+2 -8
View File
@@ -1401,15 +1401,9 @@ gh release create v1.0.6 --title "v1.0.6" --generate-notes
## 📊 Star 历史
<a href="https://star-history.com/#diegosouzapw/OmniRoute&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=diegosouzapw/OmniRoute&type=Date" />
</picture>
</a>
## Stargazers over time
---
## [![Stargazers over time](https://starchart.cc/diegosouzapw/OmniRoute.svg?variant=adaptive)](https://starchart.cc/diegosouzapw/OmniRoute)
## 🙏 致谢
+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 ───────────────────────────────────────────────────
+31 -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) {
@@ -158,6 +167,9 @@ export class BaseExecutor {
return status === HTTP_STATUS.RATE_LIMITED && urlIndex + 1 < this.getFallbackCount();
}
// Intra-URL retry config: retry same URL before falling back to next node
static readonly RETRY_CONFIG = { maxAttempts: 2, delayMs: 2000 };
// Override in subclass for provider-specific refresh
async refreshCredentials(credentials: ProviderCredentials, log: ExecutorLog | null) {
void credentials;
@@ -179,6 +191,8 @@ export class BaseExecutor {
const fallbackCount = this.getFallbackCount();
let lastError: unknown = null;
let lastStatus = 0;
// Track per-URL intra-retry attempts to avoid infinite loops
const retryAttemptsByUrl: Record<number, number> = {};
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, stream, urlIndex, credentials);
@@ -236,6 +250,22 @@ export class BaseExecutor {
const response = await fetch(url, fetchOptions);
// Intra-URL retry: if 429 and we haven't exhausted per-URL retries, wait and retry the same URL
if (
response.status === HTTP_STATUS.RATE_LIMITED &&
(retryAttemptsByUrl[urlIndex] ?? 0) < BaseExecutor.RETRY_CONFIG.maxAttempts
) {
retryAttemptsByUrl[urlIndex] = (retryAttemptsByUrl[urlIndex] ?? 0) + 1;
const attempt = retryAttemptsByUrl[urlIndex];
log?.debug?.(
"RETRY",
`429 intra-retry ${attempt}/${BaseExecutor.RETRY_CONFIG.maxAttempts} on ${url} — waiting ${BaseExecutor.RETRY_CONFIG.delayMs}ms`
);
await new Promise((resolve) => setTimeout(resolve, BaseExecutor.RETRY_CONFIG.delayMs));
urlIndex--; // re-run this urlIndex on the next loop iteration
continue;
}
if (this.shouldRetry(response.status, urlIndex)) {
log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`);
lastStatus = response.status;
+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"];
+8
View File
@@ -2,6 +2,8 @@ import { BaseExecutor } from "./base.ts";
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
export class GeminiCLIExecutor extends BaseExecutor {
private _currentModel: string = "";
constructor() {
super("gemini-cli", PROVIDERS["gemini-cli"]);
}
@@ -15,11 +17,17 @@ export class GeminiCLIExecutor extends BaseExecutor {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${credentials.accessToken}`,
// Fingerprint headers matching native GeminiCLI client (prevents upstream rejection)
"User-Agent": `GeminiCLI/0.31.0/${this._currentModel || "unknown"} (linux; x64)`,
"X-Goog-Api-Client": "google-genai-sdk/1.41.0 gl-node/v22.19.0",
...(stream && { Accept: "text/event-stream" }),
};
}
transformRequest(model, body, stream, credentials) {
// Capture model so buildHeaders (called after transformRequest) can include it in User-Agent
this._currentModel = model || "";
const allowBodyProjectOverride = process.env.OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE === "1";
// Default: prefer OAuth-stored projectId. Incoming body.project can be stale
+68 -1
View File
@@ -40,6 +40,7 @@ import {
} from "@/lib/semanticCache";
import { getIdempotencyKey, checkIdempotency, saveIdempotency } from "@/lib/idempotencyLayer";
import { createProgressTransform, wantsProgress } from "../utils/progressTracker.ts";
import { isModelUnavailableError, getNextFamilyFallback } from "../services/modelFamilyFallback.ts";
/**
* Core chat handler - shared between SSE and Worker
@@ -93,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");
@@ -185,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,
@@ -248,6 +265,10 @@ export async function handleChatCore({
// Track pending request
trackPendingRequest(model, provider, connectionId, true);
// T5: track which models we've tried for intra-family fallback
const triedModels = new Set<string>([model]);
let currentModel = model;
// Log start
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => {});
@@ -421,7 +442,53 @@ export async function handleChatCore({
// Update rate limiter from error response headers
updateFromHeaders(provider, connectionId, providerResponse.headers, statusCode, model);
return createErrorResult(statusCode, errMsg, retryAfterMs);
// ── T5: Intra-family model fallback ──────────────────────────────────────
// Before returning a model-unavailable error upstream, try sibling models
// from the same family. This keeps the request alive on the same account
// instead of failing the entire combo.
if (isModelUnavailableError(statusCode, message)) {
const nextModel = getNextFamilyFallback(currentModel, triedModels);
if (nextModel) {
triedModels.add(nextModel);
currentModel = nextModel;
translatedBody.model = nextModel;
log?.info?.("MODEL_FALLBACK", `${model} unavailable (${statusCode}) → trying ${nextModel}`);
// Re-execute with the fallback model
try {
const fallbackResult = await withRateLimit(provider, connectionId, nextModel, () =>
executor.execute({
model: nextModel,
body: translatedBody,
stream,
credentials,
signal: streamController.signal,
log,
extendedContext,
})
);
if (fallbackResult.response.ok) {
providerResponse = fallbackResult.response;
providerUrl = fallbackResult.url;
providerHeaders = fallbackResult.headers;
finalBody = fallbackResult.transformedBody;
// Continue processing with the fallback response — skip error return
log?.info?.("MODEL_FALLBACK", `Serving ${nextModel} as fallback for ${model}`);
// Jump to streaming/non-streaming handling below
// We fall through by NOT returning here
} else {
// Fallback also failed — return original error
return createErrorResult(statusCode, errMsg, retryAfterMs);
}
} catch {
return createErrorResult(statusCode, errMsg, retryAfterMs);
}
} else {
return createErrorResult(statusCode, errMsg, retryAfterMs);
}
} else {
return createErrorResult(statusCode, errMsg, retryAfterMs);
}
// ── End T5 ───────────────────────────────────────────────────────────────
}
// Non-streaming response
@@ -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;
}
+1
View File
@@ -3,6 +3,7 @@
*/
export {
calculateScore,
calculateTierScore,
scorePool,
validateWeights,
DEFAULT_WEIGHTS,
+12 -4
View File
@@ -11,37 +11,45 @@
import type { ScoringWeights } from "./scoring";
export const MODE_PACKS: Record<string, ScoringWeights> = {
// Prioritize latency → health. tierPriority replaces 0.05 from stability.
"ship-fast": {
quota: 0.15,
health: 0.3,
costInv: 0.05,
latencyInv: 0.35,
taskFit: 0.1,
stability: 0.05,
stability: 0.0,
tierPriority: 0.05,
},
// Prioritize cost. tierPriority replaces 0.05 from stability.
"cost-saver": {
quota: 0.15,
health: 0.2,
costInv: 0.4,
latencyInv: 0.05,
taskFit: 0.1,
stability: 0.1,
stability: 0.05,
tierPriority: 0.05,
},
// Prioritize task fitness. tierPriority replaces 0.05 from latencyInv.
"quality-first": {
quota: 0.1,
health: 0.2,
costInv: 0.05,
latencyInv: 0.1,
latencyInv: 0.05,
taskFit: 0.4,
stability: 0.15,
tierPriority: 0.05,
},
// Prioritize quota availability. tierPriority replaces 0.05 from taskFit.
"offline-friendly": {
quota: 0.4,
health: 0.3,
costInv: 0.1,
latencyInv: 0.05,
taskFit: 0.05,
taskFit: 0.0,
stability: 0.1,
tierPriority: 0.05,
},
};
+42 -1
View File
@@ -17,6 +17,7 @@ export interface ScoringFactors {
latencyInv: number;
taskFit: number;
stability: number;
tierPriority: number; // T10: Ultra > Pro > Free account tier boost
}
export interface ScoringWeights {
@@ -26,15 +27,18 @@ export interface ScoringWeights {
latencyInv: number;
taskFit: number;
stability: number;
tierPriority: number; // T10
}
// T10: Rebalanced — stability 0.10→0.05, tierPriority 0.05 added. Sum = 1.0.
export const DEFAULT_WEIGHTS: ScoringWeights = {
quota: 0.2,
health: 0.25,
costInv: 0.2,
latencyInv: 0.15,
taskFit: 0.1,
stability: 0.1,
stability: 0.05,
tierPriority: 0.05,
};
export interface ProviderCandidate {
@@ -47,6 +51,10 @@ export interface ProviderCandidate {
p95LatencyMs: number;
latencyStdDev: number;
errorRate: number;
/** T10: Optional account tier for priority boosting (Ultra > Pro > Free) */
accountTier?: "ultra" | "pro" | "standard" | "free";
/** T10: Optional quota reset interval in seconds (shorter = higher priority when same quota) */
quotaResetIntervalSecs?: number;
}
export interface ScoredProvider {
@@ -70,6 +78,38 @@ export function calculateScore(factors: ScoringFactors, weights: ScoringWeights)
);
}
/**
* T10: Convert account tier string to a normalized score [0..1].
* Ultra = 1.0 (most quota, fastest reset)
* Pro = 0.67
* Standard = 0.33
* Free = 0.0
* Accounts with faster reset cycles (shorter quotaResetIntervalSecs) also get
* a small adjustment: monthly accounts are penalized vs. daily accounts.
*/
export function calculateTierScore(
tier: string | undefined,
quotaResetIntervalSecs: number | undefined
): number {
const BASE_TIER_SCORES: Record<string, number> = {
ultra: 1.0,
pro: 0.67,
standard: 0.33,
free: 0.0,
};
const baseScore = BASE_TIER_SCORES[tier?.toLowerCase() ?? ""] ?? 0.33; // unknown defaults to standard
// Bonus for faster reset intervals (daily quota > weekly > monthly)
// maxInterval ~ 30 days (2_592_000s). Normalize: [0..1] where 0=monthly, 1=per-minute
const resetBonus =
quotaResetIntervalSecs != null && quotaResetIntervalSecs > 0
? Math.max(0, 1 - quotaResetIntervalSecs / 2_592_000)
: 0;
// Blend: 80% tier level, 20% reset frequency
return Math.min(1, baseScore * 0.8 + resetBonus * 0.2);
}
/**
* Calculate individual factors for a provider within its pool.
*/
@@ -96,6 +136,7 @@ export function calculateFactors(
latencyInv: 1 - candidate.p95LatencyMs / maxLatency,
taskFit: getTaskFitness(candidate.model, taskType),
stability: 1 - candidate.latencyStdDev / maxStdDev,
tierPriority: calculateTierScore(candidate.accountTier, candidate.quotaResetIntervalSecs),
};
}
+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,
+157
View File
@@ -0,0 +1,157 @@
/**
* Model Family Fallback Phase 2 Feature (T5)
*
* Implements two-phase model resolution:
* Phase 1 (static, pre-request): already done by model.ts alias resolution.
* Phase 2 (dynamic, post-error): when a provider returns a model-not-available
* error (400 with specific message or 404), we try sibling models within the
* same "family" before giving up.
*
* Inspired by Antigravity Manager's account-aware dynamic model remapping
* (commit 6cea566, Mar 8 2026).
*/
// ── Model Family Definitions ─────────────────────────────────────────────────
/**
* Ordered candidate lists per model family.
* First entry is the most preferred; fallback proceeds in order.
*/
const MODEL_FAMILIES: Record<string, string[]> = {
// Gemini 3 / 3.1 Pro family — ordered by preference
"gemini-3-pro": [
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3.1-pro-high",
"gemini-3-pro-high",
"gemini-3.1-pro-low",
"gemini-3-pro-low",
],
"gemini-3.1-pro": [
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3.1-pro-high",
"gemini-3-pro-high",
"gemini-3.1-pro-low",
"gemini-3-pro-low",
],
"gemini-3-pro-preview": [
"gemini-3.1-pro-preview",
"gemini-3-pro-high",
"gemini-3.1-pro-high",
"gemini-3-pro-low",
"gemini-3.1-pro-low",
],
"gemini-3.1-pro-preview": [
"gemini-3-pro-preview",
"gemini-3.1-pro-high",
"gemini-3-pro-high",
"gemini-3.1-pro-low",
"gemini-3-pro-low",
],
"gemini-3-pro-high": [
"gemini-3.1-pro-high",
"gemini-3-pro-preview",
"gemini-3.1-pro-preview",
"gemini-3-pro-low",
"gemini-3.1-pro-low",
],
"gemini-3.1-pro-high": [
"gemini-3-pro-high",
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3.1-pro-low",
"gemini-3-pro-low",
],
// Gemini 2.5 Pro family
"gemini-2.5-pro": ["gemini-2.5-pro-preview-06-05", "gemini-2.5-pro-exp-03-25"],
"gemini-2.5-pro-preview-06-05": ["gemini-2.5-pro", "gemini-2.5-pro-exp-03-25"],
// Claude Opus family
"claude-opus-4-6": ["claude-opus-4-6-thinking", "claude-opus-4-5-20251101", "claude-sonnet-4-6"],
"claude-opus-4-6-thinking": ["claude-opus-4-6", "claude-opus-4-5-20251101"],
// Claude Sonnet family
"claude-sonnet-4-6": ["claude-sonnet-4-5-20250929", "claude-sonnet-4-20250514"],
"claude-sonnet-4-5-20250929": ["claude-sonnet-4-6", "claude-sonnet-4-20250514"],
// GPT-5 family
"gpt-5": ["gpt-5-mini", "gpt-4o"],
"gpt-5.1": ["gpt-5.1-mini", "gpt-5", "gpt-4o"],
};
// ── Error Detection ──────────────────────────────────────────────────────────
/**
* Error message fragments that indicate the requested model is unavailable
* for the current account/provider, as opposed to a transient error.
*/
const MODEL_UNAVAILABLE_FRAGMENTS = [
"model not found",
"model_not_found",
"model not available",
"model is not available",
"no such model",
"unsupported model",
"unknown model",
"this model does not exist",
"invalid model",
"model not supported",
"does not support",
"not enabled for",
"access to model",
];
/**
* Returns true if the HTTP status + error message indicates the model
* itself is not available, not a transient server error.
*/
export function isModelUnavailableError(status: number, errorMessage: string): boolean {
if (status === 404) return true;
if (status !== 400 && status !== 403) return false;
const msg = errorMessage.toLowerCase();
return MODEL_UNAVAILABLE_FRAGMENTS.some((fragment) => msg.includes(fragment));
}
// ── Fallback Resolution ──────────────────────────────────────────────────────
/**
* Get the next fallback model from the same family.
*
* @param currentModel The model that just failed
* @param triedModels Set of model IDs already tried (to avoid cycles)
* @returns Next model to try, or null if family exhausted
*/
export function getNextFamilyFallback(
currentModel: string,
triedModels: Set<string>
): string | null {
const family = MODEL_FAMILIES[currentModel];
if (!family) return null;
for (const candidate of family) {
if (!triedModels.has(candidate)) {
return candidate;
}
}
return null; // family exhausted
}
/**
* Check if a model belongs to any registered family.
*/
export function isInModelFamily(model: string): boolean {
return model in MODEL_FAMILIES;
}
/**
* Get all members of a model's family (including itself).
*/
export function getModelFamily(model: string): string[] {
const family = MODEL_FAMILIES[model];
if (!family) return [model];
return [model, ...family];
}
+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;
@@ -24,6 +24,7 @@ type ClaudeTool = {
description: string;
input_schema: Record<string, unknown>;
cache_control?: { type: string; ttl?: string };
defer_loading?: boolean;
};
// Convert OpenAI request to Claude format
@@ -193,6 +194,23 @@ export function openaiToClaudeRequest(model, body, stream) {
result.tool_choice = convertOpenAIToolChoice(body.tool_choice);
}
// response_format: inject JSON structured output instruction into system prompt.
// Claude doesn't natively support response_format, so we insert a system-level instruction.
// NOTE: systemParts are consumed later (after this block) — they're accumulated here.
if (body.response_format) {
const fmt = body.response_format;
if (fmt.type === "json_schema" && fmt.json_schema?.schema) {
const schemaJson = JSON.stringify(fmt.json_schema.schema, null, 2);
systemParts.push(
`You must respond with valid JSON that strictly follows this JSON schema:\n\`\`\`json\n${schemaJson}\n\`\`\`\nRespond ONLY with the JSON object, no other text.`
);
} else if (fmt.type === "json_object") {
systemParts.push(
"You must respond with valid JSON. Respond ONLY with a JSON object, no other text."
);
}
}
// Thinking configuration
if (body.thinking) {
result.thinking = {
@@ -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,
+17 -11
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "2.3.3",
"version": "2.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "2.3.3",
"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"
}
@@ -8979,6 +8976,15 @@
}
}
},
"node_modules/next/node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -11518,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"
+10 -3
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "2.3.10",
"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": {
@@ -143,7 +143,14 @@
"prettier --write"
]
},
"overrides": {
"@swc/helpers": "0.5.19"
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"@swc/core",
"better-sqlite3",
"esbuild",
"omniroute",
"sharp"
]
}
}
+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 });
}
}
+27 -4
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import { clearHealthCheckLogCache } from "@/lib/tokenHealthCheck";
import bcrypt from "bcryptjs";
import { timingSafeEqual } from "node:crypto";
import { getRuntimePorts } from "@/lib/runtime/ports";
import { updateSettingsSchema } from "@/shared/validation/settingsSchemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
@@ -60,10 +61,32 @@ export async function PATCH(request) {
return NextResponse.json({ error: "Invalid current password" }, { status: 401 });
}
} else {
// First time setting password, no current password needed
// Allow empty currentPassword or default "123456"
if (body.currentPassword && body.currentPassword !== "123456") {
return NextResponse.json({ error: "Invalid current password" }, { status: 401 });
// First-time password set (no DB hash yet).
const LEGACY_DEFAULT_PASSWORD = "123456";
const initialPassword = process.env.INITIAL_PASSWORD;
const currentPassword = body.currentPassword || "";
if (initialPassword) {
// If deploy is configured with INITIAL_PASSWORD, require explicit match.
if (!currentPassword) {
return NextResponse.json({ error: "Current password required" }, { status: 400 });
}
const providedBuffer = Buffer.from(currentPassword, "utf8");
const expectedBuffer = Buffer.from(initialPassword, "utf8");
const isValidInitialPassword =
providedBuffer.length === expectedBuffer.length &&
timingSafeEqual(providedBuffer, expectedBuffer);
if (!isValidInitialPassword) {
return NextResponse.json({ error: "Invalid current password" }, { status: 401 });
}
} else {
// Legacy compatibility: instances without INITIAL_PASSWORD may still use old default.
const allowedWithoutHash = ["", LEGACY_DEFAULT_PASSWORD];
if (!allowedWithoutHash.includes(currentPassword)) {
return NextResponse.json({ error: "Invalid current password" }, { status: 401 });
}
}
}
@@ -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" },

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