Compare commits

...

13 Commits

Author SHA1 Message Date
diegosouzapw
5337111990 chore(release): bump version to 3.0.0-rc.10 2026-03-23 08:35:43 -03:00
diegosouzapw
bb06f8eb0c fix(deps): downgrade Next.js to 16.0.10 to fix turbopack hashing regression
Closes #509, #508

Docs: added rc.8 and rc.9 sprint summary to CHANGELOG.md
2026-03-23 08:20:54 -03:00
diegosouzapw
e47740e02e feat: sub2api T05/T08/T09/T13/T14 + bump to 3.0.0-rc.7 2026-03-22 23:17:52 -03:00
diegosouzapw
d9ff0035f5 chore: bump version to 3.0.0-rc.6 (sub2api gap tasks T01-T15) 2026-03-22 21:01:33 -03:00
diegosouzapw
7a7f3be0d2 feat(sub2api): implement T01-T15 gap analysis tasks (3.0.0-rc.6)
T01 (P1): requested_model column in call_logs
- Migration 009_requested_model.sql: ALTER TABLE call_logs ADD COLUMN requested_model
- callLogs.ts: INSERT + SELECT updated to include requestedModel field

T02 (P1): Strip empty text blocks from nested tool_result.content
- New stripEmptyTextBlocks() recursive helper in openai-to-claude.ts
- Applied on tool_result content before forwarding to Anthropic
- Prevents 400 'text content blocks must be non-empty' errors

T03 (P1): Parse x-codex-5h-*/x-codex-7d-* headers for precise quota reset
- parseCodexQuotaHeaders() in codex.ts extracts usage/limit/resetAt
- getCodexResetTime() returns furthest-out reset timestamp for safe unblocking

T04 (P1): X-Session-Id header for external sticky routing
- extractExternalSessionId() in sessionManager.ts reads x-session-id,
  x-omniroute-session, session-id headers with 'ext:' prefix to avoid collisions

T06 (P2): account_deactivated permanent expired status on 401
- ACCOUNT_DEACTIVATED_SIGNALS constant + isAccountDeactivated() in accountFallback.ts
- Returns 1-year cooldown (effectively permanent) to prevent retrying dead accounts

T07 (P2): X-Forwarded-For IP validation
- New src/lib/ipUtils.ts with extractClientIp() and getClientIpFromRequest()
- Skips 'unknown'/non-IP entries in X-Forwarded-For chain

T10 (P2): credits_exhausted distinct account status
- CREDITS_EXHAUSTED_SIGNALS + isCreditsExhausted() in accountFallback.ts
- Returns 1h cooldown with creditsExhausted flag, distinct from rate_limit 429

T11 (P1): max reasoning_effort -> budget_tokens: 131072
- EFFORT_BUDGETS and THINKING_LEVEL_MAP updated with max: 131072, xhigh: 131072
- Reverse mapping now returns 'max' for full-budget responses
- Unit test updated to expect 'max' (was 'high')

T12 (P3): Model pricing updates
- MiniMax M2.7 / MiniMax-M2.7 / minimax-m2.7-highspeed pricing added

T15 (P1): Array content normalization for system/tool messages
- normalizeContentToString() helper exported from openai-to-claude.ts
- System messages with array content now correctly collapsed to string
2026-03-22 20:55:35 -03:00
diegosouzapw
91e45fbe95 chore: remover new-features-sub21 do tracking do git
Remover as exceções !docs/new-features-sub21/ do .gitignore para que
a pasta de tasks internas não seja mais rastreada pelo git.
2026-03-22 20:32:17 -03:00
diegosouzapw
7d7e9da28c feat(providers): adicionar provedor Puter AI com 500+ modelos
Registrar o provedor Puter como gateway OpenAI-compatible que expõe
modelos de múltiplos fornecedores (GPT, Claude, Gemini, Grok, DeepSeek,
Qwen, Mistral, Llama) através de um único endpoint REST.

- Criar PuterExecutor com autenticação Bearer token
- Adicionar entrada no providerRegistry com 40+ modelos curados
- Habilitar passthroughModels para acesso aos 500+ modelos do catálogo
- Registrar alias "pu" para acesso rápido
- Adicionar metadados do provedor em shared/constants/providers.ts
2026-03-22 20:29:06 -03:00
diegosouzapw
24a9739604 docs: add sub2api gap analysis + 15 implementation tasks
Add competitive analysis of sub2api (v0.1.104, 87 contributors)
comparing features, open PRs, and model pricing against OmniRoute.

Files:
- docs/new-features-sub21/gap-analysis.md — full analysis (commits + 38 open PRs)
- docs/new-features-sub21/implementation-plan.md — phased plan for all 15 gaps
- docs/new-features-sub21/tasks/T01-T15 — detailed task files with:
  - Problem description + sub2api PR references
  - Step-by-step implementation with code snippets
  - Affected files list
  - Acceptance criteria

Priority breakdown:
  P1 (4): requested_model logs, empty tool_result blocks, x-codex-* headers, X-Session-Id
  P2 (6): rate-limit persistence, account_deactivated, XFF validation, session limits, Codex/Spark scopes, credits_exhausted
  P3 (5): max reasoning effort, model pricing, stale quota display, proxy fast-fail, array content

Source: https://github.com/Wei-Shaw/sub2api
2026-03-22 18:12:50 -03:00
diegosouzapw
4fb9687782 docs(3.0.0-rc.5): comprehensive CHANGELOG and README vs v2.9.5
- CHANGELOG: [3.0.0-rc.5] section now serves as full 'What's New vs v2.9.5':
  * 2 new providers (OpenCode Zen/Go via PR #530)
  * 3 new features: Registered Keys API (#464), provider icons (#529), model auto-sync (#488)
  * 10 bug fixes (#521, #522, #524, #527, #532, #535, #536, #537, #489, #510, #492)
  * 16 issues resolved total, DB migration 008
- README: added 'What's New in v3.0.0' table section after badges
2026-03-22 15:51:54 -03:00
diegosouzapw
95ffc21b60 feat(3.0.0-rc.5): Registered Keys Provisioning API (#464)
Complete implementation of auto-provisioning API:
- DB migration 008: registered_keys, provider_key_limits, account_key_limits
- src/lib/db/registeredKeys.ts: full quota enforcement, idempotency, sha256
  hashing, budget tracking, window auto-reset
- POST /api/v1/registered-keys — issue with quota check
- GET /api/v1/registered-keys — list (masked)
- GET|DELETE /api/v1/registered-keys/[id] — get/revoke
- POST /api/v1/registered-keys/[id]/revoke — explicit revoke
- GET /api/v1/quotas/check — pre-validate without issuing
- GET|PUT /api/v1/providers/[id]/limits — provider limits CRUD
- GET|PUT /api/v1/accounts/[id]/limits — account limits CRUD
- POST /api/v1/issues/report — optional GitHub issue reporting
  (requires GITHUB_ISSUES_REPO + GITHUB_ISSUES_TOKEN env vars)
- Exported all from localDb.ts
2026-03-22 15:33:45 -03:00
diegosouzapw
f3c5e55b26 feat(3.0.0-rc.4): merge PR #530 — OpenCode Zen and Go providers
Includes all commits from @kang-heewon's PR #530:
- OpencodeExecutor with multi-format routing
- opencode-zen + opencode-go registered in provider registry
- UI metadata added to providers.ts
- Unit tests for OpencodeExecutor (improved to avoid state coupling)

Cherry-picked from add-opencode-providers into 3.0.0-rc.
Conflicts resolved: executors/index.ts (merged pollinations+cloudflare-ai),
providerRegistry.ts (kept testKeyBaseUrl from rc.2 + PR's authType/models).
2026-03-22 15:23:00 -03:00
kang-heewon
40183c6a5c test(providers): improve OpencodeExecutor tests to avoid internal state coupling 2026-03-22 15:22:38 -03:00
kang-heewon
457c59e38a test(providers): add unit tests for OpencodeExecutor 2026-03-22 15:22:38 -03:00
33 changed files with 2181 additions and 66 deletions

1
.gitignore vendored
View File

@@ -89,6 +89,7 @@ docs/*
!docs/MCP-SERVER.md
!docs/CLI-TOOLS.md
# open-sse tests
open-sse/test/*

View File

@@ -2,6 +2,215 @@
## [Unreleased]
> **Coming next** — see [3.0.0-rc branch](https://github.com/diegosouzapw/OmniRoute/tree/3.0.0-rc).
---
## [3.0.0-rc.10] — 2026-03-23
### 🔧 Bug Fixes
- **#509 / #508** — Electron build regression: downgraded Next.js from `16.1.x` to `16.0.10` to eliminate Turbopack module-hashing instability that caused blank screens in the Electron desktop bundle.
- **Unit test fixes** — Corrected two stale test assertions (`nanobanana-image-handler` aspect ratio/resolution, `thinking-budget` Gemini `thinkingConfig` field mapping) that had drifted after recent implementation changes.
- **#541** — Responded to user feedback about installation complexity; no code changes required.
---
## [3.0.0-rc.9] — 2026-03-23
### ✨ New Features
- **T29** — Vertex AI SA JSON Executor: implemented using the `jose` library to handle JWT/Service Account auth, along with configurable regions in the UI and automatic partner model URL building.
- **T42** — Image generation aspect ratio mapping: created `sizeMapper` logic for generic OpenAI formats (`size`), added native `imagen3` handling, and updated NanoBanana endpoints to utilize mapped aspect ratios automatically.
- **T38** — Centralized model specifications: `modelSpecs.ts` created for limits and parameters per model.
### 🔧 Improvements
- **T40** — OpenCode CLI tools integration: native `opencode-zen` and `opencode-go` integration completed in earlier PR.
---
## [3.0.0-rc.8] — 2026-03-23
### 🔧 Bug Fixes & Improvements (Fallback, Quota & Budget)
- **T24** — `503` cooldown await fix + `406` mapping: mapped `406 Not Acceptable` to `503 Service Unavailable` with proper cooldown intervals.
- **T25** — Provider validation fallback: graceful fallback to standard validation models when a specific `validationModelId` is not present.
- **T36** — `403` vs `429` provider handling refinement: extracted into `errorClassifier.ts` to properly segregate hard permissions failures (`403`) from rate limits (`429`).
- **T39** — Endpoint Fallback for `fetchAvailableModels`: implemented a tri-tier mechanism (`/models` -> `/v1/models` -> local generic catalog) + `list_models_catalog` MCP tool updates to reflect `source` and `warning`.
- **T33** — Thinking level to budget conversion: translates qualitative thinking levels into precise budget allocations.
- **T41** — Background task auto redirect: routes heavy background evaluation tasks to flash/efficient models automatically.
- **T23** — Intelligent quota reset fallback: accurately extracts `x-ratelimit-reset` / `retry-after` header values or maps static cooldowns.
---
## [3.0.0-rc.7] — 2026-03-23 _(What's New vs v2.9.5 — will be released as v3.0.0)_
> **Upgrade from v2.9.5:** 16 issues resolved · 2 community PRs merged · 2 new providers · 7 new API endpoints · 3 new features · DB migration 008+009 · 832 tests passing · 15 sub2api gap improvements (T01T15 complete).
### 🆕 New Providers
| Provider | Alias | Tier | Notes |
| ---------------- | -------------- | ---- | -------------------------------------------------------------- |
| **OpenCode Zen** | `opencode-zen` | Free | 3 models via `opencode.ai/zen/v1` (PR #530 by @kang-heewon) |
| **OpenCode Go** | `opencode-go` | Paid | 4 models via `opencode.ai/zen/go/v1` (PR #530 by @kang-heewon) |
Both providers use the new `OpencodeExecutor` with multi-format routing (`/chat/completions`, `/messages`, `/responses`, `/models/{model}:generateContent`).
---
### ✨ New Features
#### 🔑 Registered Keys Provisioning API (#464)
Auto-generate and issue OmniRoute API keys programmatically with per-provider and per-account quota enforcement.
| Endpoint | Method | Description |
| ------------------------------------- | --------- | ------------------------------------------------ |
| `/api/v1/registered-keys` | `POST` | Issue a new key — raw key returned **once only** |
| `/api/v1/registered-keys` | `GET` | List registered keys (masked) |
| `/api/v1/registered-keys/{id}` | `GET` | Get key metadata |
| `/api/v1/registered-keys/{id}` | `DELETE` | Revoke a key |
| `/api/v1/registered-keys/{id}/revoke` | `POST` | Revoke (for clients without DELETE support) |
| `/api/v1/quotas/check` | `GET` | Pre-validate quota before issuing |
| `/api/v1/providers/{id}/limits` | `GET/PUT` | Configure per-provider issuance limits |
| `/api/v1/accounts/{id}/limits` | `GET/PUT` | Configure per-account issuance limits |
| `/api/v1/issues/report` | `POST` | Report quota events to GitHub Issues |
**DB — Migration 008:** Three new tables: `registered_keys`, `provider_key_limits`, `account_key_limits`.
**Security:** Keys stored as SHA-256 hashes. Raw key shown once on creation, never retrievable again.
**Quota types:** `maxActiveKeys`, `dailyIssueLimit`, `hourlyIssueLimit` per provider and per account.
**Idempotency:** `idempotency_key` field prevents duplicate issuance. Returns `409 IDEMPOTENCY_CONFLICT` if key was already used.
**Budget per key:** `dailyBudget` / `hourlyBudget` — limits how many requests a key can route per window.
**GitHub reporting:** Optional. Set `GITHUB_ISSUES_REPO` + `GITHUB_ISSUES_TOKEN` to auto-create GitHub issues on quota exceeded or issuance failures.
#### 🎨 Provider Icons — @lobehub/icons (#529)
All provider icons in the dashboard now use `@lobehub/icons` React components (130+ providers with SVG).
Fallback chain: **Lobehub SVG → existing `/providers/{id}.png` → generic icon**. Uses a proper React `ErrorBoundary` pattern.
#### 🔄 Model Auto-Sync Scheduler (#488)
OmniRoute now automatically refreshes model lists for connected providers every **24 hours**.
- Runs on server startup via the existing `/api/sync/initialize` hook
- Configurable via `MODEL_SYNC_INTERVAL_HOURS` environment variable
- Covers 16 major providers
- Records last sync time in the settings database
---
### 🔧 Bug Fixes
#### OAuth & Auth
- **#537 — Gemini CLI OAuth:** Clear actionable error when `GEMINI_OAUTH_CLIENT_SECRET` is missing in Docker/self-hosted deployments. Previously showed cryptic `client_secret is missing` from Google. Now provides specific `docker-compose.yml` and `~/.omniroute/.env` instructions.
#### Providers & Routing
- **#536 — LongCat AI:** Fixed `baseUrl` (`api.longcat.chat/openai`) and `authHeader` (`Authorization: Bearer`).
- **#535 — Pinned model override:** `body.model` is now correctly set to `pinnedModel` when context-cache protection is active.
- **#532 — OpenCode Go key validation:** Now uses the `zen/v1` test endpoint (`testKeyBaseUrl`) — same key works for both tiers.
#### CLI & Tools
- **#527 — Claude Code + Codex loop:** `tool_result` blocks are now converted to text instead of dropped, stopping infinite tool-result loops.
- **#524 — OpenCode config save:** Added `saveOpenCodeConfig()` handler (XDG_CONFIG_HOME aware, writes TOML).
- **#521 — Login stuck:** Login no longer freezes after skipping password setup — redirects correctly to onboarding.
- **#522 — API Manager:** Removed misleading "Copy masked key" button (replaced with a lock icon tooltip).
- **#532 — OpenCode Go config:** Guide settings handler now handles `opencode` toolId.
#### Developer Experience
- **#489 — Antigravity:** Missing `googleProjectId` returns a structured 422 error with reconnect guidance instead of a cryptic crash.
- **#510 — Windows paths:** MSYS2/Git-Bash paths (`/c/Program Files/...`) are now normalized to `C:\\Program Files\\...` automatically.
- **#492 — CLI startup:** `omniroute` CLI now detects `mise`/`nvm`-managed Node when `app/server.js` is missing and shows targeted fix instructions.
---
### 📖 Documentation Updates
- **#513** — Docker password reset: `INITIAL_PASSWORD` env var workaround documented
- **#520** — pnpm: `pnpm approve-builds better-sqlite3` step documented
---
### ✅ Issues Resolved in v3.0.0
`#464` `#488` `#489` `#492` `#510` `#513` `#520` `#521` `#522` `#524` `#527` `#529` `#532` `#535` `#536` `#537`
---
### 🔀 Community PRs Merged
| PR | Author | Summary |
| -------- | ------------ | ---------------------------------------------------------------------- |
| **#530** | @kang-heewon | OpenCode Zen + Go providers with `OpencodeExecutor` and improved tests |
---
## [3.0.0-rc.7] - 2026-03-23
### 🔧 Improvements (sub2api Gap Analysis — T05, T08, T09, T13, T14)
- **T05** — Rate-limit DB persistence: `setConnectionRateLimitUntil()`, `isConnectionRateLimited()`, `getRateLimitedConnections()` in `providers.ts`. The existing `rate_limited_until` column is now exposed as a dedicated API — OAuth token refresh must NOT touch this field to prevent rate-limit loops.
- **T08** — Per-API-key session limit: `max_sessions INTEGER DEFAULT 0` added to `api_keys` via auto-migration. `sessionManager.ts` gains `registerKeySession()`, `unregisterKeySession()`, `checkSessionLimit()`, and `getActiveSessionCountForKey()`. Callers in `chatCore.js` can enforce the limit and decrement on `req.close`.
- **T09** — Codex vs Spark rate-limit scopes: `getCodexModelScope()` and `getCodexRateLimitKey()` in `codex.ts`. Standard models (`gpt-5.x-codex`, `codex-mini`) get scope `"codex"`; spark models (`codex-spark*`) get scope `"spark"`. Rate-limit keys should be `${accountId}:${scope}` so exhausting one pool doesn't block the other.
- **T13** — Stale quota display fix: `getEffectiveQuotaUsage(used, resetAt)` returns `0` when the reset window has passed; `formatResetCountdown(resetAt)` returns a human-readable countdown string (e.g. `"2h 35m"`). Both exported from `providers.ts` + `localDb.ts` for dashboard consumption.
- **T14** — Proxy fast-fail: new `src/lib/proxyHealth.ts` with `isProxyReachable(proxyUrl, timeoutMs=2000)` (TCP check, ≤2s instead of 30s timeout), `getCachedProxyHealth()`, `invalidateProxyHealth()`, and `getAllProxyHealthStatuses()`. Results cached 30s by default; configurable via `PROXY_FAST_FAIL_TIMEOUT_MS` / `PROXY_HEALTH_CACHE_TTL_MS`.
### 🧪 Tests
- Test suite: **832 tests, 0 failures**
---
## [3.0.0-rc.6] - 2026-03-23
### 🔧 Bug Fixes & Improvements (sub2api Gap Analysis — T01T15)
- **T01** — `requested_model` column in `call_logs` (migration 009): track which model the client originally requested vs the actual routed model. Enables fallback rate analytics.
- **T02** — Strip empty text blocks from nested `tool_result.content`: prevents Anthropic 400 errors (`text content blocks must be non-empty`) when Claude Code chains tool results.
- **T03** — Parse `x-codex-5h-*` / `x-codex-7d-*` headers: `parseCodexQuotaHeaders()` + `getCodexResetTime()` extract Codex quota windows for precise cooldown scheduling instead of generic 5-min fallback.
- **T04** — `X-Session-Id` header for external sticky routing: `extractExternalSessionId()` in `sessionManager.ts` reads `x-session-id` / `x-omniroute-session` headers with `ext:` prefix to avoid collision with internal SHA-256 session IDs. Nginx-compatible (hyphenated header).
- **T06** — Account deactivated → permanent block: `isAccountDeactivated()` in `accountFallback.ts` detects 401 deactivation signals and applies a 1-year cooldown to prevent retrying permanently dead accounts.
- **T07** — X-Forwarded-For IP validation: new `src/lib/ipUtils.ts` with `extractClientIp()` and `getClientIpFromRequest()` — skips `unknown`/non-IP entries in `X-Forwarded-For` chains (Nginx/proxy-forwarded requests).
- **T10** — Credits exhausted → distinct fallback: `isCreditsExhausted()` in `accountFallback.ts` returns 1h cooldown with `creditsExhausted` flag, distinct from generic 429 rate limiting.
- **T11** — `max` reasoning effort → 131072 budget tokens: `EFFORT_BUDGETS` and `THINKING_LEVEL_MAP` updated; reverse mapping now returns `"max"` for full-budget responses. Unit test updated.
- **T12** — MiniMax M2.7 pricing entries added: `minimax-m2.7`, `MiniMax-M2.7`, `minimax-m2.7-highspeed` added to pricing table (sub2api PR #1120). M2.5/GLM-4.7/GLM-5/Kimi pricing already existed.
- **T15** — Array content normalization: `normalizeContentToString()` helper in `openai-to-claude.ts` correctly collapses array-formatted system/tool messages to string before sending to Anthropic.
### 🧪 Tests
- Test suite: **832 tests, 0 failures** (unchanged from rc.5)
---
## [3.0.0-rc.5] - 2026-03-22
### ✨ New Features
- **#464** — Registered Keys Provisioning API: auto-issue API keys with per-provider & per-account quota enforcement
- `POST /api/v1/registered-keys` — issue keys with idempotency support
- `GET /api/v1/registered-keys` — list (masked) registered keys
- `GET /api/v1/registered-keys/{id}` — get key metadata
- `DELETE /api/v1/registered-keys/{id}` / `POST ../{id}/revoke` — revoke keys
- `GET /api/v1/quotas/check` — pre-validate before issuing
- `PUT /api/v1/providers/{id}/limits` — set provider issuance limits
- `PUT /api/v1/accounts/{id}/limits` — set account issuance limits
- `POST /api/v1/issues/report` — optional GitHub issue reporting
- DB migration 008: `registered_keys`, `provider_key_limits`, `account_key_limits` tables
---
## [3.0.0-rc.4] - 2026-03-22
### ✨ New Features
- **#530 (PR)** — OpenCode Zen and OpenCode Go providers added (by @kang-heewon)
- New `OpencodeExecutor` with multi-format routing (`/chat/completions`, `/messages`, `/responses`)
- 7 models across both tiers
---
## [3.0.0-rc.3] - 2026-03-22

View File

@@ -26,6 +26,25 @@ _Your universal API proxy — one endpoint, 44+ providers, zero downtime. Now wi
---
## 🆕 What's New in v3.0.0
> **Upgrading from v2.9.5?** — See the [full CHANGELOG](CHANGELOG.md#300--2026-03-22-release-candidate--not-yet-merged-to-main) for all changes.
| Area | Change |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🔑 **Registered Keys API** | Auto-provision API keys via `POST /api/v1/registered-keys` with per-provider/account quota enforcement, idempotency, SHA-256 storage, and optional GitHub issue reporting |
| 🎨 **Provider Icons** | 130+ provider logos via `@lobehub/icons` (SVG) with PNG → generic fallback chain |
| 🔄 **Model Auto-Sync** | 24h scheduler refreshes model lists for 16 providers on startup — configurable via `MODEL_SYNC_INTERVAL_HOURS` |
| 🌐 **OpenCode Zen/Go** | Two new providers from @kang-heewon via PR #530: free tier + subscription tier via `OpencodeExecutor` |
| 🐛 **Gemini CLI OAuth** | Actionable error when `GEMINI_OAUTH_CLIENT_SECRET` is missing in Docker (was cryptic Google error) |
| 🐛 **OpenCode config** | `saveOpenCodeConfig()` now correctly writes TOML to `XDG_CONFIG_HOME` |
| 🐛 **Pinned model override** | `body.model` correctly set to `pinnedModel` on context-cache protection |
| 🐛 **Codex/Claude loop** | `tool_result` blocks now converted to text to stop infinite loops |
| 🐛 **Login redirect** | Login no longer freezes after skipping password setup |
| 🐛 **Windows paths** | MSYS2/Git-Bash paths (`/c/...`) normalized to `C:\...` automatically |
---
## 🖼️ Main Dashboard
<div align="center">

View File

@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.0.0-rc.3
version: 3.0.0-rc.10
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,

View File

@@ -1241,6 +1241,68 @@ export const REGISTRY: Record<string, RegistryEntry> = {
],
},
puter: {
id: "puter",
alias: "pu",
format: "openai",
executor: "puter",
// OpenAI-compatible gateway with 500+ models (GPT, Claude, Gemini, Grok, DeepSeek, Qwen…)
// Auth: Bearer <puter_auth_token> from puter.com/dashboard → Copy Auth Token
// Model IDs use provider/model-name format for non-OpenAI models.
// Only chat completions (incl. streaming) are available via REST.
// Image gen, TTS, STT, video are puter.js SDK-only (browser).
baseUrl: "https://api.puter.com/puterai/openai/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [
// OpenAI — use bare IDs
{ id: "gpt-4o-mini", name: "GPT-4o Mini (🆓 Puter)" },
{ id: "gpt-4o", name: "GPT-4o (Puter)" },
{ id: "gpt-4.1", name: "GPT-4.1 (Puter)" },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini (Puter)" },
{ id: "gpt-5-nano", name: "GPT-5 Nano (Puter)" },
{ id: "gpt-5-mini", name: "GPT-5 Mini (Puter)" },
{ id: "gpt-5", name: "GPT-5 (Puter)" },
{ id: "o3-mini", name: "OpenAI o3-mini (Puter)" },
{ id: "o3", name: "OpenAI o3 (Puter)" },
{ id: "o4-mini", name: "OpenAI o4-mini (Puter)" },
// Anthropic Claude — use bare IDs (confirmed working)
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (Puter)" },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (Puter)" },
{ id: "claude-opus-4-5", name: "Claude Opus 4.5 (Puter)" },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4 (Puter)" },
{ id: "claude-opus-4", name: "Claude Opus 4 (Puter)" },
// Google Gemini — use google/ prefix (confirmed working)
{ id: "google/gemini-2.0-flash", name: "Gemini 2.0 Flash (Puter)" },
{ id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash (Puter)" },
{ id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro (Puter)" },
{ id: "google/gemini-3-flash", name: "Gemini 3 Flash (Puter)" },
{ id: "google/gemini-3-pro", name: "Gemini 3 Pro (Puter)" },
// DeepSeek — use deepseek/ prefix (confirmed working)
{ id: "deepseek/deepseek-chat", name: "DeepSeek Chat (Puter)" },
{ id: "deepseek/deepseek-r1", name: "DeepSeek R1 (Puter)" },
{ id: "deepseek/deepseek-v3.2", name: "DeepSeek V3.2 (Puter)" },
// xAI Grok — use x-ai/ prefix
{ id: "x-ai/grok-3", name: "Grok 3 (Puter)" },
{ id: "x-ai/grok-3-mini", name: "Grok 3 Mini (Puter)" },
{ id: "x-ai/grok-4", name: "Grok 4 (Puter)" },
{ id: "x-ai/grok-4-fast", name: "Grok 4 Fast (Puter)" },
// Meta Llama — bare IDs (confirmed ✅)
{ id: "llama-4-scout", name: "Llama 4 Scout (Puter)" },
{ id: "llama-4-maverick", name: "Llama 4 Maverick (Puter)" },
{ id: "llama-3.3-70b-instruct", name: "Llama 3.3 70B (Puter)" },
// Mistral — bare IDs (confirmed ✅)
{ id: "mistral-small-latest", name: "Mistral Small (Puter)" },
{ id: "mistral-medium-latest", name: "Mistral Medium (Puter)" },
{ id: "open-mistral-nemo", name: "Mistral Nemo (Puter)" },
// Qwen — use qwen/ prefix (confirmed ✅)
{ id: "qwen/qwen3-235b-a22b", name: "Qwen3 235B (Puter)" },
{ id: "qwen/qwen3-32b", name: "Qwen3 32B (Puter)" },
{ id: "qwen/qwen3-coder", name: "Qwen3 Coder 480B (Puter)" },
],
passthroughModels: true, // 500+ models available — users can type any Puter model ID
},
"cloudflare-ai": {
id: "cloudflare-ai",
alias: "cf",

View File

@@ -3,6 +3,112 @@ import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.ts";
import { PROVIDERS } from "../config/constants.ts";
import { refreshCodexToken } from "../services/tokenRefresh.ts";
// ─── T09: Codex vs Spark Scope-Aware Rate Limiting ────────────────────────
// Codex has two independent quota pools: "codex" (standard) and "spark" (premium).
// Exhausting one should NOT block requests to the other.
// Ref: sub2api PR #1129 (feat(openai): split codex spark rate limiting from codex)
/**
* Maps model name substrings to their rate-limit scope.
* Checked in order — first match wins.
*/
const CODEX_SCOPE_PATTERNS: Array<{ pattern: string; scope: "codex" | "spark" }> = [
{ pattern: "codex-spark", scope: "spark" },
{ pattern: "spark", scope: "spark" },
{ pattern: "codex", scope: "codex" },
{ pattern: "gpt-5", scope: "codex" }, // gpt-5.2-codex, gpt-5.3-codex, etc.
];
/**
* T09: Determine the rate-limit scope for a Codex model.
* Use this key as the suffix for per-scope rate limit state:
* `${accountId}:${getModelScope(model)}`
*
* @param model - The Codex model ID (e.g. "gpt-5.3-codex", "codex-spark-mini")
* @returns "codex" | "spark"
*/
export function getCodexModelScope(model: string): "codex" | "spark" {
const lower = model.toLowerCase();
for (const { pattern, scope } of CODEX_SCOPE_PATTERNS) {
if (lower.includes(pattern)) return scope;
}
return "codex"; // default scope
}
/**
* T09: Get the scope-keyed rate limit identifier for an account+model combination.
* Use this as the key for rateLimitState maps to ensure scope isolation.
*/
export function getCodexRateLimitKey(accountId: string, model: string): string {
return `${accountId}:${getCodexModelScope(model)}`;
}
/**
* T03: Parsed quota snapshot from Codex response headers.
* Codex includes per-account usage windows that allow precise reset scheduling.
* Ref: sub2api PR #357 (feat(oauth): persist usage snapshots and window cooldown)
*/
export interface CodexQuotaSnapshot {
usage5h: number; // tokens used in 5h window
limit5h: number; // token limit for 5h window
resetAt5h: string | null; // ISO timestamp when 5h window resets
usage7d: number; // tokens used in 7d window
limit7d: number; // token limit for 7d window
resetAt7d: string | null; // ISO timestamp when 7d window resets
}
/**
* T03: Parse Codex-specific quota headers from a provider response.
* Returns null if none of the relevant headers are present.
*
* Extracts:
* x-codex-5h-usage / x-codex-5h-limit / x-codex-5h-reset-at
* x-codex-7d-usage / x-codex-7d-limit / x-codex-7d-reset-at
*/
export function parseCodexQuotaHeaders(headers: Headers): CodexQuotaSnapshot | null {
const usage5h = headers.get("x-codex-5h-usage");
const limit5h = headers.get("x-codex-5h-limit");
const resetAt5h = headers.get("x-codex-5h-reset-at");
const usage7d = headers.get("x-codex-7d-usage");
const limit7d = headers.get("x-codex-7d-limit");
const resetAt7d = headers.get("x-codex-7d-reset-at");
// Return null if none of the quota headers are present (not a quota-aware response)
if (!usage5h && !limit5h && !resetAt5h && !usage7d && !limit7d && !resetAt7d) {
return null;
}
return {
usage5h: usage5h ? parseFloat(usage5h) : 0,
limit5h: limit5h ? parseFloat(limit5h) : Infinity,
resetAt5h: resetAt5h ?? null,
usage7d: usage7d ? parseFloat(usage7d) : 0,
limit7d: limit7d ? parseFloat(limit7d) : Infinity,
resetAt7d: resetAt7d ?? null,
};
}
/**
* T03: Get the soonest quota reset time from a CodexQuotaSnapshot.
* 7d window takes priority (wider window, harder limit) but we use whichever
* is further in the future to avoid releasing the block too early.
*
* @returns Unix timestamp (ms) of the soonest effective reset, or null
*/
export function getCodexResetTime(quota: CodexQuotaSnapshot): number | null {
const times: number[] = [];
if (quota.resetAt7d) {
const t = new Date(quota.resetAt7d).getTime();
if (!isNaN(t) && t > Date.now()) times.push(t);
}
if (quota.resetAt5h) {
const t = new Date(quota.resetAt5h).getTime();
if (!isNaN(t) && t > Date.now()) times.push(t);
}
if (times.length === 0) return null;
return Math.max(...times); // Use furthest-out reset to avoid premature unblock
}
// 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];

View File

@@ -9,6 +9,7 @@ import { DefaultExecutor } from "./default.ts";
import { PollinationsExecutor } from "./pollinations.ts";
import { CloudflareAIExecutor } from "./cloudflare-ai.ts";
import { OpencodeExecutor } from "./opencode.ts";
import { PuterExecutor } from "./puter.ts";
const executors = {
antigravity: new AntigravityExecutor(),
@@ -25,6 +26,8 @@ const executors = {
cf: new CloudflareAIExecutor(), // Alias
"opencode-zen": new OpencodeExecutor("opencode-zen"),
"opencode-go": new OpencodeExecutor("opencode-go"),
puter: new PuterExecutor(),
pu: new PuterExecutor(), // Alias
};
const defaultCache = new Map();
@@ -51,3 +54,4 @@ export { DefaultExecutor } from "./default.ts";
export { PollinationsExecutor } from "./pollinations.ts";
export { CloudflareAIExecutor } from "./cloudflare-ai.ts";
export { OpencodeExecutor } from "./opencode.ts";
export { PuterExecutor } from "./puter.ts";

View File

@@ -0,0 +1,59 @@
import { BaseExecutor } from "./base.ts";
import { PROVIDERS } from "../config/constants.ts";
/**
* PuterExecutor — OpenAI-compatible proxy for Puter AI.
*
* Puter exposes 500+ models (GPT, Claude, Gemini, Grok, DeepSeek, Qwen, Mistral...)
* through a single OpenAI-compatible REST endpoint.
*
* Endpoint: https://api.puter.com/puterai/openai/v1/chat/completions
* Auth: Bearer <puter_auth_token> (from puter.com/dashboard → Copy Auth Token)
* Docs: https://docs.puter.com/AI/
*
* Model ID examples:
* OpenAI: "gpt-4o-mini", "gpt-4o", "gpt-4.1"
* Claude: "claude-sonnet-4-5", "claude-opus-4", "claude-haiku-4-5"
* Gemini: "google/gemini-2.0-flash", "google/gemini-2.5-pro"
* DeepSeek: "deepseek/deepseek-chat", "deepseek/deepseek-r1"
* Grok: "x-ai/grok-3", "x-ai/grok-4"
* Mistral: "mistralai/mistral-small-3.2"
* Meta: "meta-llama/llama-3.3-70b-instruct"
*
* Note: Image generation, TTS, STT, and video are puter.js SDK-only features.
* Only text chat completions (with streaming SSE) are available via REST.
*/
export class PuterExecutor extends BaseExecutor {
constructor() {
super("puter", PROVIDERS["puter"] || { format: "openai" });
}
buildUrl(_model: string, _stream: boolean, _urlIndex = 0, _credentials = null): string {
return "https://api.puter.com/puterai/openai/v1/chat/completions";
}
buildHeaders(credentials: any, stream = true): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
const key = credentials?.apiKey || credentials?.accessToken;
if (key) {
headers["Authorization"] = `Bearer ${key}`;
}
if (stream) {
headers["Accept"] = "text/event-stream";
}
return headers;
}
transformRequest(model: string, body: any, _stream: boolean, _credentials: any): any {
// Puter accepts model IDs directly from its catalog.
// No transformation required — model string is passed as-is.
return body;
}
}
export default PuterExecutor;

View File

@@ -8,6 +8,46 @@ import {
} from "../config/constants.ts";
import { getProviderCategory } from "../config/providerRegistry.ts";
// T06 (sub2api PR #1037): Signals that indicate permanent account deactivation.
// When a 401 body contains these strings, the account is permanently dead
// and should NOT be retried after token refresh.
export const ACCOUNT_DEACTIVATED_SIGNALS = [
"account_deactivated",
"account has been deactivated",
"account has been disabled",
"your account has been suspended",
"this account is deactivated",
];
// T10 (sub2api PR #1169): Signals that indicate billing credits are exhausted.
// Distinct from rate-limit 429 — the account won't recover until credits are added.
export const CREDITS_EXHAUSTED_SIGNALS = [
"insufficient_quota",
"billing_hard_limit_reached",
"exceeded your current quota",
"credit_balance_too_low",
"your credit balance is too low",
"credits exhausted",
"out of credits",
"payment required",
];
/**
* T06: Returns true if response body indicates the account is permanently deactivated.
*/
export function isAccountDeactivated(errorText: string): boolean {
const lower = String(errorText || "").toLowerCase();
return ACCOUNT_DEACTIVATED_SIGNALS.some((sig) => lower.includes(sig));
}
/**
* T10: Returns true if response body indicates credits/quota are permanently exhausted.
*/
export function isCreditsExhausted(errorText: string): boolean {
const lower = String(errorText || "").toLowerCase();
return CREDITS_EXHAUSTED_SIGNALS.some((sig) => lower.includes(sig));
}
// ─── Provider Profile Helper ────────────────────────────────────────────────
/**
@@ -201,6 +241,14 @@ export function classifyErrorText(errorText) {
) {
return RateLimitReason.QUOTA_EXHAUSTED;
}
// T10: credits_exhausted signals
if (isCreditsExhausted(errorText)) {
return RateLimitReason.QUOTA_EXHAUSTED;
}
// T06: account_deactivated signals
if (isAccountDeactivated(errorText)) {
return RateLimitReason.AUTH_ERROR;
}
if (
lower.includes("rate limit") ||
lower.includes("too many requests") ||
@@ -301,6 +349,26 @@ export function checkFallbackError(
const errorStr = typeof errorText === "string" ? errorText : JSON.stringify(errorText);
const lowerError = errorStr.toLowerCase();
// T06 (sub2api #1037): Permanent account deactivation — do NOT retry, mark as permanent failure
if (isAccountDeactivated(errorStr)) {
return {
shouldFallback: true,
cooldownMs: 365 * 24 * 60 * 60 * 1000, // 1 year = effectively permanent
reason: RateLimitReason.AUTH_ERROR,
permanent: true,
};
}
// T10 (sub2api #1169): Credits/quota exhausted — long cooldown, distinct from rate limit
if (isCreditsExhausted(errorStr)) {
return {
shouldFallback: true,
cooldownMs: COOLDOWN_MS.paymentRequired ?? 3600 * 1000, // 1h cooldown
reason: RateLimitReason.QUOTA_EXHAUSTED,
creditsExhausted: true,
};
}
if (lowerError.includes("no credentials")) {
return {
shouldFallback: true,

View File

@@ -173,6 +173,95 @@ export function getActiveSessions(): Array<SessionEntry & { sessionId: string; a
*/
export function clearSessions(): void {
sessions.clear();
activeSessionsByKey.clear();
}
// ─── T08: Per-API-Key Session Limit ─────────────────────────────────────────
// Tracks concurrent sticky sessions per API key and enforces max_sessions limits.
// Ref: sub2api PR #634 (fix: stabilize session hash + add user-level session limit)
// Map: apiKeyId → Set<sessionId>
const activeSessionsByKey = new Map<string, Set<string>>();
/**
* T08: Get the number of currently active sessions for an API key.
* @param apiKeyId - The API key's UUID from the database
*/
export function getActiveSessionCountForKey(apiKeyId: string): number {
return activeSessionsByKey.get(apiKeyId)?.size ?? 0;
}
/**
* T08: Register a session as belonging to an API key.
* Call this after session creation is allowed (i.e., limit check passed).
*/
export function registerKeySession(apiKeyId: string, sessionId: string): void {
if (!activeSessionsByKey.has(apiKeyId)) {
activeSessionsByKey.set(apiKeyId, new Set());
}
activeSessionsByKey.get(apiKeyId)!.add(sessionId);
}
/**
* T08: Unregister a session from an API key's active set.
* Call this when the request closes or the session TTL expires.
*/
export function unregisterKeySession(apiKeyId: string, sessionId: string): void {
activeSessionsByKey.get(apiKeyId)?.delete(sessionId);
// Clean up empty sets to avoid memory leaks
if (activeSessionsByKey.get(apiKeyId)?.size === 0) {
activeSessionsByKey.delete(apiKeyId);
}
}
/**
* T08: Check whether adding a new session would exceed the key's max_sessions limit.
* Returns null if allowed, or an error object to return as a 429 response.
*
* @param apiKeyId - The API key's UUID
* @param maxSessions - The limit from the DB (0 = unlimited)
*/
export function checkSessionLimit(
apiKeyId: string,
maxSessions: number
): { code: "SESSION_LIMIT_EXCEEDED"; message: string; limit: number; current: number } | null {
if (!maxSessions || maxSessions <= 0) return null; // unlimited
const current = getActiveSessionCountForKey(apiKeyId);
if (current < maxSessions) return null;
return {
code: "SESSION_LIMIT_EXCEEDED",
message:
`You have reached the maximum number of active sessions (${maxSessions}). ` +
`Please close unused sessions or wait for them to expire.`,
limit: maxSessions,
current,
};
}
/**
* T04: Extract an external session ID from request headers.
* Accepts both hyphenated and underscore forms for Nginx compatibility.
* Nginx drops headers with underscores by default — use `underscores_in_headers on`
* in nginx.conf, or use X-Session-Id (hyphenated) which passes cleanly.
*
* Ref: sub2api README + PR #634
*
* @param headers - Request headers (Headers object or plain object with .get())
* @returns External session ID with "ext:" prefix, or null
*/
export function extractExternalSessionId(
headers: Headers | { get?: (n: string) => string | null } | null | undefined
): string | null {
if (!headers || typeof (headers as Headers).get !== "function") return null;
const h = headers as Headers;
const raw =
h.get("x-session-id") ?? // Preferred: hyphenated (passes through Nginx)
h.get("x-omniroute-session") ?? // OmniRoute-specific form
h.get("session-id") ?? // Bare session-id
null;
if (!raw || !raw.trim()) return null;
// Prefix "ext:" to ensure no collision with internal SHA-256 hash IDs
return `ext:${raw.trim().slice(0, 64)}`; // max 64 chars to avoid abuse
}
// ─── Internal Helpers ───────────────────────────────────────────────────────

View File

@@ -19,6 +19,8 @@ export const EFFORT_BUDGETS = {
low: 1024,
medium: 10240,
high: 131072,
max: 131072, // T11: Claude "max" / "xhigh" — full budget
xhigh: 131072, // T11: explicit alias used internally
};
// thinkingLevel string → budget token mapping
@@ -28,6 +30,8 @@ export const THINKING_LEVEL_MAP = {
low: 1024,
medium: 10240,
high: 131072,
max: 131072, // T11: max = full Claude budget (sub2api: xhigh)
xhigh: 131072, // T11: explicit xhigh alias
};
// Default config (passthrough = backward compatible)
@@ -198,7 +202,7 @@ function setCustomBudget(body, budget) {
};
}
// OpenAI reasoning_effort mapping
// OpenAI reasoning_effort mapping (T11: add 'max' tier for full budget)
if (result.reasoning_effort !== undefined || result.reasoning !== undefined) {
if (budget <= 0) {
delete result.reasoning_effort;
@@ -207,8 +211,10 @@ function setCustomBudget(body, budget) {
result.reasoning_effort = "low";
} else if (budget <= 10240) {
result.reasoning_effort = "medium";
} else {
} else if (budget < 131072) {
result.reasoning_effort = "high";
} else {
result.reasoning_effort = "max"; // T11: full budget → "max"
}
}

View File

@@ -27,6 +27,60 @@ type ClaudeTool = {
defer_loading?: boolean;
};
/**
* T02: Recursively strips empty text blocks from content arrays.
* Anthropic returns 400 "text content blocks must be non-empty" if any
* text block has text: "". Must also recurse into nested tool_result.content.
* Ref: sub2api PR #1212
*/
export function stripEmptyTextBlocks(content: unknown[] | undefined): unknown[] {
if (!Array.isArray(content)) return content ?? [];
return content
.filter((block: unknown) => {
if (
block &&
typeof block === "object" &&
(block as Record<string, unknown>).type === "text"
) {
const text = (block as Record<string, unknown>).text;
if (text === "" || text == null) return false;
}
return true;
})
.map((block: unknown) => {
if (
block &&
typeof block === "object" &&
(block as Record<string, unknown>).type === "tool_result" &&
Array.isArray((block as Record<string, unknown>).content)
) {
// Recurse into nested tool_result.content
return {
...(block as Record<string, unknown>),
content: stripEmptyTextBlocks((block as Record<string, unknown>).content as unknown[]),
};
}
return block;
});
}
/**
* T15: Normalize content to string form.
* Handles both string and array-of-blocks forms (Cursor, Codex 2.x, etc.).
* Ref: sub2api PR #1197
*/
export function normalizeContentToString(content: string | unknown[] | null | undefined): string {
if (!content) return "";
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return (content as Array<Record<string, unknown>>)
.filter((b) => b.type === "text")
.map((b) => String(b.text ?? ""))
.join("\n");
}
return "";
}
// Convert OpenAI request to Claude format
export function openaiToClaudeRequest(model, body, stream) {
// Check if tool prefix should be disabled (configured per-provider or global)
@@ -61,11 +115,11 @@ export function openaiToClaudeRequest(model, body, stream) {
const systemParts = [];
if (body.messages && Array.isArray(body.messages)) {
// Extract system messages
// Extract system messages (T15: handle both string and array content)
for (const msg of body.messages) {
if (msg.role === "system") {
systemParts.push(
typeof msg.content === "string" ? msg.content : extractTextContent(msg.content)
typeof msg.content === "string" ? msg.content : normalizeContentToString(msg.content)
);
}
}
@@ -270,10 +324,14 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr
const blocks = [];
if (msg.role === "tool") {
// T02: Strip empty text blocks from nested tool_result content to avoid Anthropic 400
const toolContent = Array.isArray(msg.content)
? stripEmptyTextBlocks(msg.content)
: msg.content;
blocks.push({
type: "tool_result",
tool_use_id: msg.tool_call_id,
content: msg.content,
content: toolContent,
});
} else if (msg.role === "user") {
if (typeof msg.content === "string") {
@@ -287,10 +345,14 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr
} else if (part.type === "tool_result") {
// Skip tool_result with no tool_use_id (would be useless and may cause errors)
if (!part.tool_use_id) continue;
// T02: strip empty text blocks from nested content before passing to Anthropic
const resultContent = Array.isArray(part.content)
? stripEmptyTextBlocks(part.content)
: part.content;
blocks.push({
type: "tool_result",
tool_use_id: part.tool_use_id,
content: part.content,
content: resultContent,
...(part.is_error && { is_error: part.is_error }),
});
} else if (part.type === "image_url") {

114
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.10",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -28,7 +28,7 @@
"jose": "^6.1.3",
"lowdb": "^7.0.1",
"monaco-editor": "^0.55.1",
"next": "^16.1.6",
"next": "^16.0.10",
"next-intl": "^4.8.3",
"node-machine-id": "^1.1.12",
"open": "^11.0.0",
@@ -61,7 +61,7 @@
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.6",
"eslint-config-next": "^16.0.10",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "^3.8.1",
@@ -2567,15 +2567,15 @@
}
},
"node_modules/@next/env": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz",
"integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
"integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz",
"integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.10.tgz",
"integrity": "sha512-b2NlWN70bbPLmfyoLvvidPKWENBYYIe017ZGUpElvQjDytCWgxPJx7L9juxHt0xHvNVA08ZHJdOyhGzon/KJuw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2583,9 +2583,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz",
"integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.10.tgz",
"integrity": "sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==",
"cpu": [
"arm64"
],
@@ -2599,9 +2599,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz",
"integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.10.tgz",
"integrity": "sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==",
"cpu": [
"x64"
],
@@ -2615,12 +2615,15 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz",
"integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.10.tgz",
"integrity": "sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2631,12 +2634,15 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz",
"integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.10.tgz",
"integrity": "sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2647,12 +2653,15 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz",
"integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.10.tgz",
"integrity": "sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2663,12 +2672,15 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz",
"integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.10.tgz",
"integrity": "sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2679,9 +2691,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz",
"integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.10.tgz",
"integrity": "sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==",
"cpu": [
"arm64"
],
@@ -2695,9 +2707,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz",
"integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.10.tgz",
"integrity": "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==",
"cpu": [
"x64"
],
@@ -7522,6 +7534,7 @@
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
"integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
@@ -9705,13 +9718,13 @@
}
},
"node_modules/eslint-config-next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz",
"integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.10.tgz",
"integrity": "sha512-BxouZUm0I45K4yjOOIzj24nTi0H2cGo0y7xUmk+Po/PYtJXFBYVDS1BguE7t28efXjKdcN0tmiLivxQy//SsZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/eslint-plugin-next": "16.1.6",
"@next/eslint-plugin-next": "16.0.10",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@@ -14658,14 +14671,13 @@
}
},
"node_modules/next": {
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
"integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/next/-/next-16.0.10.tgz",
"integrity": "sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==",
"license": "MIT",
"dependencies": {
"@next/env": "16.1.7",
"@next/env": "16.0.10",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -14677,14 +14689,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.1.7",
"@next/swc-darwin-x64": "16.1.7",
"@next/swc-linux-arm64-gnu": "16.1.7",
"@next/swc-linux-arm64-musl": "16.1.7",
"@next/swc-linux-x64-gnu": "16.1.7",
"@next/swc-linux-x64-musl": "16.1.7",
"@next/swc-win32-arm64-msvc": "16.1.7",
"@next/swc-win32-x64-msvc": "16.1.7",
"@next/swc-darwin-arm64": "16.0.10",
"@next/swc-darwin-x64": "16.0.10",
"@next/swc-linux-arm64-gnu": "16.0.10",
"@next/swc-linux-arm64-musl": "16.0.10",
"@next/swc-linux-x64-gnu": "16.0.10",
"@next/swc-linux-x64-musl": "16.0.10",
"@next/swc-win32-arm64-msvc": "16.0.10",
"@next/swc-win32-x64-msvc": "16.0.10",
"sharp": "^0.34.4"
},
"peerDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.0.0-rc.3",
"version": "3.0.0-rc.10",
"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": {
@@ -96,7 +96,7 @@
"jose": "^6.1.3",
"lowdb": "^7.0.1",
"monaco-editor": "^0.55.1",
"next": "^16.1.6",
"next": "^16.0.10",
"next-intl": "^4.8.3",
"node-machine-id": "^1.1.12",
"open": "^11.0.0",
@@ -125,7 +125,7 @@
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.6",
"eslint-config-next": "^16.0.10",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "^3.8.1",

View File

@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { setAccountKeyLimit, getAccountKeyLimit } from "@/lib/db/registeredKeys";
const limitsSchema = z.object({
maxActiveKeys: z.number().int().positive().nullable().optional(),
dailyIssueLimit: z.number().int().positive().nullable().optional(),
hourlyIssueLimit: z.number().int().positive().nullable().optional(),
});
/**
* GET /api/v1/accounts/[id]/limits
* Get the current issuance limits for an account.
*/
export async function GET(request: Request, { params }: { params: { id: string } }) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
}
const limits = getAccountKeyLimit(params.id);
return NextResponse.json({ accountId: params.id, limits: limits ?? null });
}
/**
* PUT /api/v1/accounts/[id]/limits
* Configure issuance limits for an account.
*/
export async function PUT(request: Request, { params }: { params: { id: string } }) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsed = limitsSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
setAccountKeyLimit(params.id, parsed.data);
const updated = getAccountKeyLimit(params.id);
return NextResponse.json({ accountId: params.id, limits: updated });
}

View File

@@ -0,0 +1,123 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { isAuthenticated } from "@/shared/utils/apiAuth";
const reportSchema = z.object({
title: z.string().min(1).max(300),
provider: z.string().max(80).optional(),
accountId: z.string().max(120).optional(),
requestId: z.string().max(200).optional(),
errorCode: z.string().max(100).optional(),
details: z.record(z.unknown()).optional(),
labels: z.array(z.string().max(50)).optional(),
});
/**
* POST /api/v1/issues/report
*
* Optionally report a quota-exceeded or key-issuance failure event to GitHub.
*
* Requires GITHUB_ISSUES_REPO (format: owner/repo) and GITHUB_ISSUES_TOKEN
* environment variables to be set. If not configured, returns 202 (accepted but
* logged only).
*/
export async function POST(request: Request) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsed = reportSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { title, provider, accountId, requestId, errorCode, details, labels = [] } = parsed.data;
const repo = process.env.GITHUB_ISSUES_REPO;
const token = process.env.GITHUB_ISSUES_TOKEN;
// ── Structured body for the GitHub issue ──
const issueBody = [
`## ${errorCode ?? "Key Issuance Event"}`,
"",
"| Field | Value |",
"|-------|-------|",
provider ? `| Provider | \`${provider}\` |` : null,
accountId ? `| Account ID | \`${accountId}\` |` : null,
requestId ? `| Request ID | \`${requestId}\` |` : null,
errorCode ? `| Error Code | \`${errorCode}\` |` : null,
`| Reported At | ${new Date().toISOString()} |`,
"",
details ? "### Details\n```json\n" + JSON.stringify(details, null, 2) + "\n```" : null,
"",
"_Auto-reported by OmniRoute Registered Key Issuer_",
]
.filter(Boolean)
.join("\n");
// ── Log locally regardless ──
console.log(
`[issues/report] title="${title}" errorCode=${errorCode ?? "—"} provider=${provider ?? "—"} accountId=${accountId ?? "—"}`
);
if (!repo || !token) {
// No GitHub config — log only
return NextResponse.json(
{
logged: true,
githubIssueCreated: false,
reason: !repo ? "GITHUB_ISSUES_REPO not configured" : "GITHUB_ISSUES_TOKEN not configured",
},
{ status: 202 }
);
}
// ── Create GitHub issue ──
try {
const [owner, repoName] = repo.split("/");
const ghRes = await fetch(`https://api.github.com/repos/${owner}/${repoName}/issues`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
title: `[Key Issuer] ${title}`,
body: issueBody,
labels: ["key-issuer", "automated", ...labels],
}),
});
if (!ghRes.ok) {
const errText = await ghRes.text();
console.error(`[issues/report] GitHub API error ${ghRes.status}: ${errText}`);
return NextResponse.json(
{ logged: true, githubIssueCreated: false, githubError: ghRes.status },
{ status: 207 }
);
}
const ghData = await ghRes.json();
return NextResponse.json({
logged: true,
githubIssueCreated: true,
githubIssueUrl: ghData.html_url,
githubIssueNumber: ghData.number,
});
} catch (err) {
console.error("[issues/report] GitHub fetch failed:", err);
return NextResponse.json(
{ logged: true, githubIssueCreated: false, error: "GitHub request failed" },
{ status: 207 }
);
}
}

View File

@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { setProviderKeyLimit, getProviderKeyLimit } from "@/lib/db/registeredKeys";
const limitsSchema = z.object({
maxActiveKeys: z.number().int().positive().nullable().optional(),
dailyIssueLimit: z.number().int().positive().nullable().optional(),
hourlyIssueLimit: z.number().int().positive().nullable().optional(),
});
/**
* GET /api/v1/providers/[id]/limits
* Get the current issuance limits for a provider.
*/
export async function GET(request: Request, { params }: { params: { id: string } }) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
}
const limits = getProviderKeyLimit(params.id);
return NextResponse.json({ provider: params.id, limits: limits ?? null });
}
/**
* PUT /api/v1/providers/[id]/limits
* Configure issuance limits for a provider.
*/
export async function PUT(request: Request, { params }: { params: { id: string } }) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsed = limitsSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
setProviderKeyLimit(params.id, parsed.data);
const updated = getProviderKeyLimit(params.id);
return NextResponse.json({ provider: params.id, limits: updated });
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { checkQuota } from "@/lib/db/registeredKeys";
/**
* GET /api/v1/quotas/check?provider=&accountId=
*
* Check if a new registered key can be issued for the given provider/account
* without actually issuing one. Use this to pre-validate before POST /registered-keys.
*/
export async function GET(request: Request) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const provider = searchParams.get("provider") ?? "";
const accountId = searchParams.get("accountId") ?? "";
try {
const result = checkQuota(provider, accountId);
return NextResponse.json({
allowed: result.allowed,
...(result.errorCode ? { errorCode: result.errorCode, reason: result.errorMessage } : {}),
provider: provider || null,
accountId: accountId || null,
checkedAt: new Date().toISOString(),
});
} catch (err) {
console.error("[quotas/check] error:", err);
return NextResponse.json({ error: "Quota check failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { revokeRegisteredKey } from "@/lib/db/registeredKeys";
/**
* POST /api/v1/registered-keys/[id]/revoke
*
* Explicit revoke endpoint (supports clients that cannot issue DELETE requests).
*/
export async function POST(request: Request, { params }: { params: { id: string } }) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
}
const revoked = revokeRegisteredKey(params.id);
if (!revoked) {
return NextResponse.json({ error: "Key not found or already revoked" }, { status: 404 });
}
return NextResponse.json({ success: true, id: params.id, revokedAt: new Date().toISOString() });
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { getRegisteredKey, revokeRegisteredKey } from "@/lib/db/registeredKeys";
// ─── GET /api/v1/registered-keys/[id] ────────────────────────────────────────
export async function GET(request: Request, { params }: { params: { id: string } }) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
}
const key = getRegisteredKey(params.id);
if (!key) {
return NextResponse.json({ error: "Key not found" }, { status: 404 });
}
return NextResponse.json({ key });
}
// ─── DELETE /api/v1/registered-keys/[id] ─────────────────────────────────────
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
}
const revoked = revokeRegisteredKey(params.id);
if (!revoked) {
return NextResponse.json({ error: "Key not found or already revoked" }, { status: 404 });
}
return NextResponse.json({ success: true, id: params.id, revokedAt: new Date().toISOString() });
}

View File

@@ -0,0 +1,118 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { issueRegisteredKey, checkQuota, listRegisteredKeys } from "@/lib/db/registeredKeys";
// ─── Validation ───────────────────────────────────────────────────────────────
const issueKeySchema = z.object({
name: z.string().min(1).max(120),
provider: z.string().max(80).optional().default(""),
accountId: z.string().max(120).optional().default(""),
idempotencyKey: z.string().max(256).optional(),
expiresAt: z.string().datetime({ offset: true }).optional(),
dailyBudget: z.number().int().positive().optional(),
hourlyBudget: z.number().int().positive().optional(),
});
// ─── GET /api/v1/registered-keys ─────────────────────────────────────────────
/**
* List registered keys (masked — no raw key material returned after creation).
* Optional query params: ?provider=&accountId=
*/
export async function GET(request: Request) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const provider = searchParams.get("provider") ?? undefined;
const accountId = searchParams.get("accountId") ?? undefined;
try {
const keys = listRegisteredKeys({ provider, accountId });
return NextResponse.json({ keys, total: keys.length });
} catch (err) {
console.error("[registered-keys] GET failed:", err);
return NextResponse.json({ error: "Failed to list registered keys" }, { status: 500 });
}
}
// ─── POST /api/v1/registered-keys ────────────────────────────────────────────
/**
* Issue a new registered key.
*
* Checks provider + account quotas before issuing.
* Returns the raw key ONCE — it is never stored in plain text.
* Subsequent fetches will only return the masked prefix.
*/
export async function POST(request: Request) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: { message: "Authentication required" } }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const parsed = issueKeySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { provider, accountId } = parsed.data;
// ── Quota check ──
try {
const quota = checkQuota(provider, accountId);
if (!quota.allowed) {
return NextResponse.json(
{ error: quota.errorMessage, errorCode: quota.errorCode },
{ status: 429 }
);
}
} catch (err) {
console.error("[registered-keys] quota check failed:", err);
return NextResponse.json({ error: "Quota check failed" }, { status: 500 });
}
// ── Issue ──
try {
const result = issueRegisteredKey(parsed.data);
if ("idempotencyConflict" in result) {
return NextResponse.json(
{
error: "Idempotency key already used",
errorCode: "IDEMPOTENCY_CONFLICT",
existing: result.existing,
},
{ status: 409 }
);
}
const { rawKey, ...keyMeta } = result;
return NextResponse.json(
{
key: rawKey, // ← shown ONCE only
keyId: keyMeta.id,
keyPrefix: keyMeta.keyPrefix,
name: keyMeta.name,
provider: keyMeta.provider,
accountId: keyMeta.accountId,
expiresAt: keyMeta.expiresAt,
createdAt: keyMeta.createdAt,
warning: "Store this key securely — it will not be shown again.",
},
{ status: 201 }
);
} catch (err) {
console.error("[registered-keys] issue failed:", err);
return NextResponse.json({ error: "Failed to issue key" }, { status: 500 });
}
}

View File

@@ -40,6 +40,8 @@ interface ApiKeyMetadata {
accessSchedule: AccessSchedule | null;
maxRequestsPerDay: number | null;
maxRequestsPerMinute: number | null;
// T08: Per-key max concurrent sticky sessions (0 = unlimited)
maxSessions: number;
}
interface ApiKeyRow extends JsonRecord {
@@ -197,6 +199,11 @@ function ensureApiKeysColumns(db: ApiKeysDbLike) {
db.exec("ALTER TABLE api_keys ADD COLUMN max_requests_per_minute INTEGER");
console.log("[DB] Added api_keys.max_requests_per_minute column");
}
// T08: max concurrent sticky sessions per key (0 = unlimited)
if (!columnNames.has("max_sessions")) {
db.exec("ALTER TABLE api_keys ADD COLUMN max_sessions INTEGER NOT NULL DEFAULT 0");
console.log("[DB] Added api_keys.max_sessions column");
}
_schemaChecked = true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -222,7 +229,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
_stmtGetKeyById = db.prepare<ApiKeyRow>("SELECT * FROM api_keys WHERE id = ?");
_stmtValidateKey = db.prepare<JsonRecord>("SELECT 1 FROM api_keys WHERE key = ?");
_stmtGetKeyMetadata = db.prepare<ApiKeyRow>(
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute FROM api_keys WHERE key = ?"
"SELECT id, name, machine_id, allowed_models, allowed_connections, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, max_sessions FROM api_keys WHERE key = ?"
);
_stmtInsertKey = db.prepare(
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
@@ -418,6 +425,8 @@ export async function updateApiKeyPermissions(
accessSchedule?: AccessSchedule | null;
maxRequestsPerDay?: number | null;
maxRequestsPerMinute?: number | null;
// T08: max concurrent sessions for this key (0 = unlimited)
maxSessions?: number | null;
}
) {
const db = getDbInstance() as ApiKeysDbLike;
@@ -436,6 +445,7 @@ export async function updateApiKeyPermissions(
accessSchedule: update.accessSchedule,
maxRequestsPerDay: update.maxRequestsPerDay,
maxRequestsPerMinute: update.maxRequestsPerMinute,
maxSessions: (update as { maxSessions?: number | null }).maxSessions,
};
if (
@@ -447,7 +457,8 @@ export async function updateApiKeyPermissions(
normalized.isActive === undefined &&
normalized.accessSchedule === undefined &&
normalized.maxRequestsPerDay === undefined &&
normalized.maxRequestsPerMinute === undefined
normalized.maxRequestsPerMinute === undefined &&
(normalized as Record<string, unknown>).maxSessions === undefined
) {
return false;
}
@@ -464,6 +475,7 @@ export async function updateApiKeyPermissions(
accessSchedule?: string | null;
maxRequestsPerDay?: number | null;
maxRequestsPerMinute?: number | null;
maxSessions?: number;
} = { id };
if (normalized.name !== undefined) {
@@ -514,6 +526,12 @@ export async function updateApiKeyPermissions(
params.maxRequestsPerMinute = normalized.maxRequestsPerMinute;
}
const maxSessionsUpdate = (normalized as Record<string, unknown>).maxSessions;
if (maxSessionsUpdate !== undefined) {
updates.push("max_sessions = @maxSessions");
params.maxSessions = typeof maxSessionsUpdate === "number" ? Math.max(0, maxSessionsUpdate) : 0;
}
const result = db.prepare(`UPDATE api_keys SET ${updates.join(", ")} WHERE id = @id`).run(params);
if (result.changes === 0) return false;
@@ -605,6 +623,8 @@ export async function getApiKeyMetadata(
const rawMaxRPD = record.max_requests_per_day ?? record.maxRequestsPerDay;
const rawMaxRPM = record.max_requests_per_minute ?? record.maxRequestsPerMinute;
const rawMaxSessions = record.max_sessions ?? record.maxSessions;
const metadata: ApiKeyMetadata = {
id: metadataId,
name: metadataName,
@@ -619,6 +639,8 @@ export async function getApiKeyMetadata(
accessSchedule: parseAccessSchedule(record.access_schedule ?? record.accessSchedule),
maxRequestsPerDay: typeof rawMaxRPD === "number" && rawMaxRPD > 0 ? rawMaxRPD : null,
maxRequestsPerMinute: typeof rawMaxRPM === "number" && rawMaxRPM > 0 ? rawMaxRPM : null,
// T08: max concurrent sessions; 0 = unlimited (default & backward-compatible)
maxSessions: typeof rawMaxSessions === "number" && rawMaxSessions > 0 ? rawMaxSessions : 0,
};
if (!metadata.id) {

View File

@@ -0,0 +1,65 @@
-- Migration 008: Registered Keys Provisioning API (#464)
--
-- Adds three tables:
-- registered_keys — auto-provisioned API keys with quota metadata
-- provider_key_limits — per-provider issuance limits
-- account_key_limits — per-account issuance limits
-- --------------------------------------------------------------------------
-- Table: registered_keys
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS registered_keys (
id TEXT PRIMARY KEY, -- UUID
key TEXT NOT NULL UNIQUE, -- hashed key material (sha256)
key_prefix TEXT NOT NULL, -- first 8 chars for display (e.g. "ork_abc1")
name TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT '', -- associated provider (optional)
account_id TEXT NOT NULL DEFAULT '', -- account/tenant identifier
is_active INTEGER NOT NULL DEFAULT 1,
revoked_at TEXT, -- ISO timestamp, null if active
expires_at TEXT, -- ISO timestamp, null = no expiry
idempotency_key TEXT UNIQUE, -- prevents duplicate issue requests
daily_budget INTEGER, -- max requests per day (null = unlimited)
hourly_budget INTEGER, -- max requests per hour (null = unlimited)
daily_used INTEGER NOT NULL DEFAULT 0,
hourly_used INTEGER NOT NULL DEFAULT 0,
last_reset_day TEXT NOT NULL DEFAULT '', -- YYYY-MM-DD for daily reset tracking
last_reset_hour TEXT NOT NULL DEFAULT '', -- YYYY-MM-DDTHH for hourly reset tracking
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_registered_keys_provider ON registered_keys(provider);
CREATE INDEX IF NOT EXISTS idx_registered_keys_account ON registered_keys(account_id);
CREATE INDEX IF NOT EXISTS idx_registered_keys_active ON registered_keys(is_active);
CREATE INDEX IF NOT EXISTS idx_registered_keys_idempotency ON registered_keys(idempotency_key);
-- --------------------------------------------------------------------------
-- Table: provider_key_limits (per-provider issuance limits)
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS provider_key_limits (
provider TEXT PRIMARY KEY,
max_active_keys INTEGER, -- null = unlimited
daily_issue_limit INTEGER, -- max keys per day
hourly_issue_limit INTEGER, -- max keys per hour
daily_issued INTEGER NOT NULL DEFAULT 0,
hourly_issued INTEGER NOT NULL DEFAULT 0,
last_reset_day TEXT NOT NULL DEFAULT '',
last_reset_hour TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- --------------------------------------------------------------------------
-- Table: account_key_limits (per-account issuance limits)
-- --------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS account_key_limits (
account_id TEXT PRIMARY KEY,
max_active_keys INTEGER,
daily_issue_limit INTEGER,
hourly_issue_limit INTEGER,
daily_issued INTEGER NOT NULL DEFAULT 0,
hourly_issued INTEGER NOT NULL DEFAULT 0,
last_reset_day TEXT NOT NULL DEFAULT '',
last_reset_hour TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View File

@@ -0,0 +1,9 @@
-- Migration 009: Add requested_model to call_logs for billing transparency
-- Tracks the model the client *asked* for vs the model that was *actually routed*.
-- Needed when a combo falls back: requested_model ≠ model in call_logs.
-- Ref: sub2api commits 0b845c25 + 4edcfe1f (T01 sub2api gap analysis)
ALTER TABLE call_logs ADD COLUMN requested_model TEXT DEFAULT NULL;
-- Index for filtering/aggregating by requested_model in Analytics
CREATE INDEX IF NOT EXISTS idx_call_logs_requested_model
ON call_logs(requested_model);

View File

@@ -513,3 +513,98 @@ export async function deleteProviderNode(id: string) {
backupDbFile("pre-write");
return rowToCamel(existing);
}
// ──────────────── T05: Rate-Limit DB Persistence ──────────────────────────
// Allows rate-limit state to survive token refresh without being accidentally
// cleared. DB column rate_limited_until already exists in schema.
// Ref: sub2api PR #1218 (fix(openai): prevent rescheduling rate-limited accounts)
/**
* T05: Persist when a connection is rate-limited, directly in DB.
* This survives token refresh — OAuth flows must NOT override this field.
*
* @param connectionId - The provider_connections.id
* @param until - Epoch ms when the rate limit expires (null to clear)
*/
export function setConnectionRateLimitUntil(connectionId: string, until: number | null): void {
const db = getDbInstance() as unknown as DbLike;
db.prepare(
"UPDATE provider_connections SET rate_limited_until = ?, updated_at = ? WHERE id = ?"
).run(until, new Date().toISOString(), connectionId);
invalidateDbCache("connections");
}
/**
* T05: Check if a connection is currently rate-limited (DB-backed).
* Use this before account selection to skip transiently rate-limited accounts.
*
* @returns true if rate_limited_until is set and in the future
*/
export function isConnectionRateLimited(connectionId: string): boolean {
const db = getDbInstance() as unknown as DbLike;
const row = db
.prepare("SELECT rate_limited_until FROM provider_connections WHERE id = ?")
.get(connectionId) as { rate_limited_until?: number | null } | undefined;
if (!row?.rate_limited_until) return false;
return Date.now() < row.rate_limited_until;
}
/**
* T05: Get all connections for a provider that are currently rate-limited.
* Returns an array of { id, rateLimitedUntil } for dashboard display.
*/
export function getRateLimitedConnections(
provider: string
): Array<{ id: string; rateLimitedUntil: number }> {
const db = getDbInstance() as unknown as DbLike;
const now = Date.now();
const rows = db
.prepare(
"SELECT id, rate_limited_until FROM provider_connections WHERE provider = ? AND rate_limited_until > ?"
)
.all(provider, now) as Array<{ id: string; rate_limited_until: number }>;
return rows.map((r) => ({ id: r.id, rateLimitedUntil: r.rate_limited_until }));
}
// ──────────────── T13: Stale Quota Display Fix ─────────────────────────────
// Codex/Claude quotas display stale cumulative usage after the window resets.
// By comparing resetAt timestamp to now(), we can show 0 when window has passed.
// Ref: sub2api PR #1171 (fix: quota display shows stale cumulative usage after reset)
/**
* T13: Get effective quota usage, zeroing it out if the window has already reset.
*
* @param used - Stored usage value (tokens used in the window)
* @param resetAt - ISO-8601 string or epoch ms when the window resets, or null
* @returns Effective usage: 0 if window expired, original value otherwise
*/
export function getEffectiveQuotaUsage(
used: number,
resetAt: string | number | null | undefined
): number {
if (!resetAt) return used;
const resetTime = typeof resetAt === "number" ? resetAt : new Date(resetAt).getTime();
if (isNaN(resetTime)) return used;
// Window has passed — display should show 0 (pending next snapshot)
if (Date.now() >= resetTime) return 0;
return used;
}
/**
* T13: Format a reset countdown as a human-readable string: "2h 35m" or "4m 30s".
* Returns null if resetAt is in the past or not set.
*/
export function formatResetCountdown(resetAt: string | number | null | undefined): string | null {
if (!resetAt) return null;
const resetTime = typeof resetAt === "number" ? resetAt : new Date(resetAt).getTime();
if (isNaN(resetTime)) return null;
const diffMs = resetTime - Date.now();
if (diffMs <= 0) return null;
const totalSeconds = Math.floor(diffMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
return `${seconds}s`;
}

View File

@@ -0,0 +1,531 @@
/**
* db/registeredKeys.ts — Registered Keys Provisioning (#464)
*
* Handles:
* - Issuing registered keys with idempotency
* - Per-provider and per-account quota enforcement
* - Key revocation
* - Quota status queries for rate-limiting decisions
*/
import { createHash, randomBytes } from "crypto";
import { v4 as uuidv4 } from "uuid";
import { getDbInstance, rowToCamel } from "./core";
// ─── Types ───────────────────────────────────────────────────────────────────
export interface RegisteredKey {
id: string;
keyPrefix: string;
name: string;
provider: string;
accountId: string;
isActive: boolean;
revokedAt: string | null;
expiresAt: string | null;
idempotencyKey: string | null;
dailyBudget: number | null;
hourlyBudget: number | null;
dailyUsed: number;
hourlyUsed: number;
createdAt: string;
updatedAt: string;
}
export interface RegisteredKeyWithSecret extends RegisteredKey {
/** Raw key material — only returned once on creation */
rawKey: string;
}
export interface ProviderKeyLimit {
provider: string;
maxActiveKeys: number | null;
dailyIssueLimit: number | null;
hourlyIssueLimit: number | null;
dailyIssued: number;
hourlyIssued: number;
updatedAt: string;
}
export interface AccountKeyLimit {
accountId: string;
maxActiveKeys: number | null;
dailyIssueLimit: number | null;
hourlyIssueLimit: number | null;
dailyIssued: number;
hourlyIssued: number;
updatedAt: string;
}
export interface QuotaCheckResult {
allowed: boolean;
errorCode?: string;
errorMessage?: string;
provider?: string;
accountId?: string;
providerActiveKeys?: number;
accountActiveKeys?: number;
}
export interface IssueKeyParams {
name: string;
provider?: string;
accountId?: string;
idempotencyKey?: string;
expiresAt?: string;
dailyBudget?: number;
hourlyBudget?: number;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function nowDay(): string {
return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
}
function nowHour(): string {
return new Date().toISOString().slice(0, 13); // YYYY-MM-DDTHH
}
function hashKey(raw: string): string {
return createHash("sha256").update(raw).digest("hex");
}
function generateRawKey(): string {
// ork_ prefix so users can easily identify these keys
return "ork_" + randomBytes(24).toString("base64url");
}
/** Reset window counters if the tracking period has changed. */
function maybeResetWindow(
db: ReturnType<typeof getDbInstance>,
table: string,
idField: string,
idValue: string
): void {
const today = nowDay();
const hour = nowHour();
db.prepare(
`
UPDATE ${table}
SET daily_issued = CASE WHEN last_reset_day <> ? THEN 0 ELSE daily_issued END,
hourly_issued = CASE WHEN last_reset_hour <> ? THEN 0 ELSE hourly_issued END,
last_reset_day = ?,
last_reset_hour = ?
WHERE ${idField} = ?
`
).run(today, hour, today, hour, idValue);
}
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Check if a new registered key can be issued for the given provider/account.
* Returns { allowed: true } or { allowed: false, errorCode, errorMessage }.
*/
export function checkQuota(provider = "", accountId = ""): QuotaCheckResult {
const db = getDbInstance();
const today = nowDay();
const hour = nowHour();
// ── provider-level check ──
if (provider) {
maybeResetWindow(db, "provider_key_limits", "provider", provider);
const limits = db
.prepare("SELECT * FROM provider_key_limits WHERE provider = ?")
.get(provider) as ProviderKeyLimitRow | undefined;
if (limits) {
if (limits.hourly_issue_limit !== null && limits.hourly_issued >= limits.hourly_issue_limit) {
return {
allowed: false,
errorCode: "PROVIDER_QUOTA_EXCEEDED",
errorMessage: `Hourly issue limit (${limits.hourly_issue_limit}) reached for provider '${provider}'`,
provider,
};
}
if (limits.daily_issue_limit !== null && limits.daily_issued >= limits.daily_issue_limit) {
return {
allowed: false,
errorCode: "PROVIDER_QUOTA_EXCEEDED",
errorMessage: `Daily issue limit (${limits.daily_issue_limit}) reached for provider '${provider}'`,
provider,
};
}
if (limits.max_active_keys !== null) {
const { activeCount } = db
.prepare(
"SELECT COUNT(*) as activeCount FROM registered_keys WHERE provider = ? AND is_active = 1"
)
.get(provider) as { activeCount: number };
if (activeCount >= limits.max_active_keys) {
return {
allowed: false,
errorCode: "MAX_ACTIVE_KEYS_EXCEEDED",
errorMessage: `Max active keys (${limits.max_active_keys}) reached for provider '${provider}'`,
provider,
providerActiveKeys: activeCount,
};
}
}
}
}
// ── account-level check ──
if (accountId) {
maybeResetWindow(db, "account_key_limits", "account_id", accountId);
const limits = db
.prepare("SELECT * FROM account_key_limits WHERE account_id = ?")
.get(accountId) as AccountKeyLimitRow | undefined;
if (limits) {
if (limits.hourly_issue_limit !== null && limits.hourly_issued >= limits.hourly_issue_limit) {
return {
allowed: false,
errorCode: "ACCOUNT_QUOTA_EXCEEDED",
errorMessage: `Hourly issue limit (${limits.hourly_issue_limit}) reached for account '${accountId}'`,
accountId,
};
}
if (limits.daily_issue_limit !== null && limits.daily_issued >= limits.daily_issue_limit) {
return {
allowed: false,
errorCode: "ACCOUNT_QUOTA_EXCEEDED",
errorMessage: `Daily issue limit (${limits.daily_issue_limit}) reached for account '${accountId}'`,
accountId,
};
}
if (limits.max_active_keys !== null) {
const { activeCount } = db
.prepare(
"SELECT COUNT(*) as activeCount FROM registered_keys WHERE account_id = ? AND is_active = 1"
)
.get(accountId) as { activeCount: number };
if (activeCount >= limits.max_active_keys) {
return {
allowed: false,
errorCode: "MAX_ACTIVE_KEYS_EXCEEDED",
errorMessage: `Max active keys (${limits.max_active_keys}) reached for account '${accountId}'`,
accountId,
accountActiveKeys: activeCount,
};
}
}
}
}
return { allowed: true };
}
/**
* Issue a new registered key.
* Returns the key with rawKey (only on creation) or null if idempotency_key already exists.
*/
export function issueRegisteredKey(
params: IssueKeyParams
): RegisteredKeyWithSecret | { idempotencyConflict: true; existing: RegisteredKey } {
const db = getDbInstance();
const {
name,
provider = "",
accountId = "",
idempotencyKey,
expiresAt,
dailyBudget,
hourlyBudget,
} = params;
// ── idempotency check ──
if (idempotencyKey) {
const existing = db
.prepare("SELECT * FROM registered_keys WHERE idempotency_key = ?")
.get(idempotencyKey) as RegisteredKeyRow | undefined;
if (existing) {
return {
idempotencyConflict: true,
existing: rowToCamel(existing) as unknown as RegisteredKey,
};
}
}
const rawKey = generateRawKey();
const id = uuidv4();
const keyHash = hashKey(rawKey);
const keyPrefix = rawKey.slice(0, 12); // "ork_" + 8 chars
db.prepare(
`
INSERT INTO registered_keys
(id, key, key_prefix, name, provider, account_id, idempotency_key, expires_at, daily_budget, hourly_budget, last_reset_day, last_reset_hour)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
).run(
id,
keyHash,
keyPrefix,
name,
provider,
accountId,
idempotencyKey ?? null,
expiresAt ?? null,
dailyBudget ?? null,
hourlyBudget ?? null,
nowDay(),
nowHour()
);
// Increment provider/account issuance counters
if (provider) {
maybeResetWindow(db, "provider_key_limits", "provider", provider);
db.prepare(
`
INSERT INTO provider_key_limits (provider, daily_issued, hourly_issued, last_reset_day, last_reset_hour)
VALUES (?, 1, 1, ?, ?)
ON CONFLICT(provider) DO UPDATE SET
daily_issued = daily_issued + 1,
hourly_issued = hourly_issued + 1,
updated_at = datetime('now')
`
).run(provider, nowDay(), nowHour());
}
if (accountId) {
maybeResetWindow(db, "account_key_limits", "account_id", accountId);
db.prepare(
`
INSERT INTO account_key_limits (account_id, daily_issued, hourly_issued, last_reset_day, last_reset_hour)
VALUES (?, 1, 1, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
daily_issued = daily_issued + 1,
hourly_issued = hourly_issued + 1,
updated_at = datetime('now')
`
).run(accountId, nowDay(), nowHour());
}
const created = db
.prepare("SELECT * FROM registered_keys WHERE id = ?")
.get(id) as RegisteredKeyRow;
return { ...(rowToCamel(created) as unknown as RegisteredKey), rawKey };
}
/**
* Get a registered key by ID (without the raw key — only prefix is returned).
*/
export function getRegisteredKey(id: string): RegisteredKey | null {
const db = getDbInstance();
const row = db.prepare("SELECT * FROM registered_keys WHERE id = ?").get(id) as
| RegisteredKeyRow
| undefined;
return row ? (rowToCamel(row) as unknown as RegisteredKey) : null;
}
/**
* List all registered keys (optionally filtered by provider/accountId).
*/
export function listRegisteredKeys(
opts: { provider?: string; accountId?: string } = {}
): RegisteredKey[] {
const db = getDbInstance();
let sql = "SELECT * FROM registered_keys WHERE 1=1";
const args: string[] = [];
if (opts.provider) {
sql += " AND provider = ?";
args.push(opts.provider);
}
if (opts.accountId) {
sql += " AND account_id = ?";
args.push(opts.accountId);
}
sql += " ORDER BY created_at DESC LIMIT 500";
const rows = db.prepare(sql).all(...args) as RegisteredKeyRow[];
return rows.map((r) => rowToCamel(r) as unknown as RegisteredKey);
}
/**
* Revoke a registered key by ID.
*/
export function revokeRegisteredKey(id: string): boolean {
const db = getDbInstance();
const result = db
.prepare(
`
UPDATE registered_keys
SET is_active = 0, revoked_at = datetime('now'), updated_at = datetime('now')
WHERE id = ? AND is_active = 1
`
)
.run(id);
return result.changes > 0;
}
/**
* Validate a raw registered key against stored hashes.
* Returns the key metadata if valid, null otherwise.
*/
export function validateRegisteredKey(rawKey: string): RegisteredKey | null {
const db = getDbInstance();
const hash = hashKey(rawKey);
const row = db
.prepare(
`
SELECT * FROM registered_keys
WHERE key = ? AND is_active = 1
AND (expires_at IS NULL OR expires_at > datetime('now'))
`
)
.get(hash) as RegisteredKeyRow | undefined;
if (!row) return null;
// Auto-reset budget windows if needed
const today = nowDay();
const hour = nowHour();
if (row.last_reset_day !== today || row.last_reset_hour !== hour) {
db.prepare(
`
UPDATE registered_keys
SET daily_used = CASE WHEN last_reset_day <> ? THEN 0 ELSE daily_used END,
hourly_used = CASE WHEN last_reset_hour <> ? THEN 0 ELSE hourly_used END,
last_reset_day = ?, last_reset_hour = ?
WHERE id = ?
`
).run(today, hour, today, hour, row.id);
}
// Budget check
if (row.daily_budget !== null && row.daily_used >= row.daily_budget) return null;
if (row.hourly_budget !== null && row.hourly_used >= row.hourly_budget) return null;
return rowToCamel(row) as unknown as RegisteredKey;
}
/**
* Increment usage counters for a registered key (called by request pipeline).
*/
export function incrementRegisteredKeyUsage(id: string): void {
const db = getDbInstance();
db.prepare(
`
UPDATE registered_keys
SET daily_used = daily_used + 1, hourly_used = hourly_used + 1, updated_at = datetime('now')
WHERE id = ?
`
).run(id);
}
// ─── Provider / Account Limit Management ──────────────────────────────────────
export function setProviderKeyLimit(
provider: string,
limits: Partial<Omit<ProviderKeyLimit, "provider" | "dailyIssued" | "hourlyIssued" | "updatedAt">>
): void {
const db = getDbInstance();
db.prepare(
`
INSERT INTO provider_key_limits (provider, max_active_keys, daily_issue_limit, hourly_issue_limit, last_reset_day, last_reset_hour)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(provider) DO UPDATE SET
max_active_keys = excluded.max_active_keys,
daily_issue_limit = excluded.daily_issue_limit,
hourly_issue_limit = excluded.hourly_issue_limit,
updated_at = datetime('now')
`
).run(
provider,
limits.maxActiveKeys ?? null,
limits.dailyIssueLimit ?? null,
limits.hourlyIssueLimit ?? null,
nowDay(),
nowHour()
);
}
export function setAccountKeyLimit(
accountId: string,
limits: Partial<Omit<AccountKeyLimit, "accountId" | "dailyIssued" | "hourlyIssued" | "updatedAt">>
): void {
const db = getDbInstance();
db.prepare(
`
INSERT INTO account_key_limits (account_id, max_active_keys, daily_issue_limit, hourly_issue_limit, last_reset_day, last_reset_hour)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
max_active_keys = excluded.max_active_keys,
daily_issue_limit = excluded.daily_issue_limit,
hourly_issue_limit = excluded.hourly_issue_limit,
updated_at = datetime('now')
`
).run(
accountId,
limits.maxActiveKeys ?? null,
limits.dailyIssueLimit ?? null,
limits.hourlyIssueLimit ?? null,
nowDay(),
nowHour()
);
}
export function getProviderKeyLimit(provider: string): ProviderKeyLimit | null {
const db = getDbInstance();
const row = db.prepare("SELECT * FROM provider_key_limits WHERE provider = ?").get(provider) as
| ProviderKeyLimitRow
| undefined;
return row ? (rowToCamel(row) as unknown as ProviderKeyLimit) : null;
}
export function getAccountKeyLimit(accountId: string): AccountKeyLimit | null {
const db = getDbInstance();
const row = db.prepare("SELECT * FROM account_key_limits WHERE account_id = ?").get(accountId) as
| AccountKeyLimitRow
| undefined;
return row ? (rowToCamel(row) as unknown as AccountKeyLimit) : null;
}
// ─── Internal types (raw DB rows) ─────────────────────────────────────────────
interface RegisteredKeyRow {
id: string;
key: string;
key_prefix: string;
name: string;
provider: string;
account_id: string;
is_active: number;
revoked_at: string | null;
expires_at: string | null;
idempotency_key: string | null;
daily_budget: number | null;
hourly_budget: number | null;
daily_used: number;
hourly_used: number;
last_reset_day: string;
last_reset_hour: string;
created_at: string;
updated_at: string;
}
interface ProviderKeyLimitRow {
provider: string;
max_active_keys: number | null;
daily_issue_limit: number | null;
hourly_issue_limit: number | null;
daily_issued: number;
hourly_issued: number;
last_reset_day: string;
last_reset_hour: string;
updated_at: string;
}
interface AccountKeyLimitRow {
account_id: string;
max_active_keys: number | null;
daily_issue_limit: number | null;
hourly_issue_limit: number | null;
daily_issued: number;
hourly_issued: number;
last_reset_day: string;
last_reset_hour: string;
updated_at: string;
}

56
src/lib/ipUtils.ts Normal file
View File

@@ -0,0 +1,56 @@
import { isIP } from "node:net";
/**
* T07: Extract the real client IP from X-Forwarded-For header.
* Skips invalid entries like "unknown" or empty strings.
* Falls back to remoteAddress if no valid IP found.
* Ref: sub2api PR #1135
*
* @param xForwardedFor - Value of the X-Forwarded-For header (may be CSV)
* @param remoteAddress - Fallback from the raw socket (req.socket.remoteAddress)
* @returns The first valid IP address found, or "unknown"
*/
export function extractClientIp(
xForwardedFor: string | null | undefined,
remoteAddress: string | undefined
): string {
if (xForwardedFor) {
const entries = xForwardedFor.split(",");
for (const entry of entries) {
const trimmed = entry.trim();
if (trimmed && isIP(trimmed) !== 0) {
return trimmed; // First valid IP wins
}
}
}
return remoteAddress?.trim() ?? "unknown";
}
/**
* Extract client IP from a Request or NextRequest object.
* Checks X-Forwarded-For, X-Real-IP, CF-Connecting-IP, then socket.
*/
export function getClientIpFromRequest(req: {
headers?: Headers | { get?: (n: string) => string | null };
socket?: { remoteAddress?: string };
ip?: string;
}): string {
// Helper to get header value from either Headers object or plain object
const getHeader = (name: string): string | null => {
if (!req.headers) return null;
if (typeof (req.headers as Headers).get === "function") {
return (req.headers as Headers).get(name);
}
return null;
};
// Priority: CF-Connecting-IP (Cloudflare) > X-Forwarded-For > X-Real-IP > socket
const cfIp = getHeader("cf-connecting-ip");
if (cfIp && isIP(cfIp.trim()) !== 0) return cfIp.trim();
const xff = getHeader("x-forwarded-for");
const realIp = getHeader("x-real-ip");
const remoteAddress = req.ip ?? req.socket?.remoteAddress;
return extractClientIp(xff ?? realIp, remoteAddress);
}

View File

@@ -23,6 +23,15 @@ export {
createProviderNode,
updateProviderNode,
deleteProviderNode,
// T05: Rate-limit DB persistence (survives token refresh)
setConnectionRateLimitUntil,
isConnectionRateLimited,
getRateLimitedConnections,
// T13: Stale quota display fix (zero out usage after window resets)
getEffectiveQuotaUsage,
formatResetCountdown,
} from "./db/providers";
export {
@@ -138,3 +147,27 @@ export {
getCachedProviderConnections,
invalidateDbCache,
} from "./db/readCache";
export {
// Registered Keys Provisioning (#464)
issueRegisteredKey,
getRegisteredKey,
listRegisteredKeys,
revokeRegisteredKey,
validateRegisteredKey,
incrementRegisteredKeyUsage,
checkQuota,
setProviderKeyLimit,
setAccountKeyLimit,
getProviderKeyLimit,
getAccountKeyLimit,
} from "./db/registeredKeys";
export type {
RegisteredKey,
RegisteredKeyWithSecret,
ProviderKeyLimit,
AccountKeyLimit,
QuotaCheckResult,
IssueKeyParams,
} from "./db/registeredKeys";

140
src/lib/proxyHealth.ts Normal file
View File

@@ -0,0 +1,140 @@
/**
* T14: Proxy Fast-Fail — TCP health check with in-memory cache.
*
* When a configured HTTP/SOCKS5 proxy is unreachable, every request
* through OmniRoute used to wait for the full PROXY_TIMEOUT_MS (30s)
* before failing. This module detects dead proxies in <2s via a quick
* TCP connection check, caching the result to avoid overhead per request.
*
* Ref: sub2api PR #1167 (fix: proxy-fast-fail)
*/
import { createConnection } from "node:net";
// Configurable via env vars
const FAST_FAIL_TIMEOUT_MS = parseInt(process.env.PROXY_FAST_FAIL_TIMEOUT_MS ?? "2000", 10);
const HEALTH_CACHE_TTL_MS = parseInt(process.env.PROXY_HEALTH_CACHE_TTL_MS ?? "30000", 10);
interface ProxyHealthEntry {
healthy: boolean;
checkedAt: number;
ttlMs: number;
}
// In-memory cache: proxyUrl → health entry
const proxyHealthCache = new Map<string, ProxyHealthEntry>();
/**
* T14: Perform a fast TCP check to see if a proxy host:port is reachable.
* Results are cached for `cacheTtlMs` (default 30s) to avoid checking every request.
*
* @param proxyUrl - Full proxy URL, e.g. http://user:pass@1.2.3.4:8080
* @param timeoutMs - TCP connection timeout (default 2000ms)
* @param cacheTtlMs - How long to cache the health result (default 30000ms)
* @returns true if proxy TCP port is open, false otherwise
*/
export async function isProxyReachable(
proxyUrl: string,
timeoutMs = FAST_FAIL_TIMEOUT_MS,
cacheTtlMs = HEALTH_CACHE_TTL_MS
): Promise<boolean> {
const cached = proxyHealthCache.get(proxyUrl);
if (cached && Date.now() - cached.checkedAt < cached.ttlMs) {
return cached.healthy;
}
let url: URL;
try {
url = new URL(proxyUrl);
} catch {
// Malformed URL — treat as unreachable
proxyHealthCache.set(proxyUrl, {
healthy: false,
checkedAt: Date.now(),
ttlMs: cacheTtlMs,
});
return false;
}
const host = url.hostname;
const port = parseInt(url.port || defaultPortForScheme(url.protocol), 10);
if (!host || isNaN(port)) {
proxyHealthCache.set(proxyUrl, {
healthy: false,
checkedAt: Date.now(),
ttlMs: cacheTtlMs,
});
return false;
}
const healthy = await tcpCheck(host, port, timeoutMs);
proxyHealthCache.set(proxyUrl, { healthy, checkedAt: Date.now(), ttlMs: cacheTtlMs });
return healthy;
}
/**
* Get the cached health status of a proxy without re-checking.
* Returns null if there is no cached entry.
*/
export function getCachedProxyHealth(proxyUrl: string): boolean | null {
const cached = proxyHealthCache.get(proxyUrl);
if (!cached) return null;
if (Date.now() - cached.checkedAt >= cached.ttlMs) return null; // stale
return cached.healthy;
}
/**
* Invalidate the cached health for a proxy URL (force re-check on next call).
*/
export function invalidateProxyHealth(proxyUrl: string): void {
proxyHealthCache.delete(proxyUrl);
}
/**
* Get all currently cached proxy health entries (for dashboard display).
*/
export function getAllProxyHealthStatuses(): Array<{
proxyUrl: string;
healthy: boolean;
checkedAt: number;
stale: boolean;
}> {
const now = Date.now();
return [...proxyHealthCache.entries()].map(([proxyUrl, entry]) => ({
proxyUrl,
healthy: entry.healthy,
checkedAt: entry.checkedAt,
stale: now - entry.checkedAt >= entry.ttlMs,
}));
}
// ─── Internals ────────────────────────────────────────────────────────────────
function defaultPortForScheme(protocol: string): string {
switch (protocol.replace(":", "").toLowerCase()) {
case "https":
return "443";
case "socks5":
case "socks5h":
return "1080";
case "http":
default:
return "8080";
}
}
function tcpCheck(host: string, port: number, timeoutMs: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const socket = createConnection({ host, port }, () => {
socket.destroy();
resolve(true);
});
socket.setTimeout(timeoutMs);
socket.on("error", () => resolve(false));
socket.on("timeout", () => {
socket.destroy();
resolve(false);
});
});
}

View File

@@ -180,6 +180,7 @@ export async function saveCallLog(entry: any) {
path: entry.path || "/v1/chat/completions",
status: entry.status || 0,
model: entry.model || "-",
requestedModel: entry.requestedModel || null, // T01: model the client asked for
provider: entry.provider || "-",
account,
connectionId: entry.connectionId || null,
@@ -205,10 +206,10 @@ export async function saveCallLog(entry: any) {
const db = getDbInstance();
db.prepare(
`
INSERT INTO call_logs (id, timestamp, method, path, status, model, provider,
INSERT INTO call_logs (id, timestamp, method, path, status, model, requested_model, provider,
account, connection_id, duration, tokens_in, tokens_out, request_type, source_format, target_format,
api_key_id, api_key_name, combo_name, request_body, response_body, error)
VALUES (@id, @timestamp, @method, @path, @status, @model, @provider,
VALUES (@id, @timestamp, @method, @path, @status, @model, @requestedModel, @provider,
@account, @connectionId, @duration, @tokensIn, @tokensOut, @requestType, @sourceFormat, @targetFormat,
@apiKeyId, @apiKeyName, @comboName, @requestBody, @responseBody, @error)
`
@@ -374,6 +375,7 @@ export async function getCallLogs(filter: any = {}) {
path: toStringOrNull(l.path),
status: toNumber(l.status),
model: toStringOrNull(l.model),
requestedModel: toStringOrNull(l.requested_model), // T01: original model from client
provider: toStringOrNull(l.provider),
account: toStringOrNull(l.account),
duration: toNumber(l.duration),

View File

@@ -802,6 +802,30 @@ export const DEFAULT_PRICING = {
reasoning: 1.8,
cache_creation: 0.3,
},
// T12: MiniMax M2.7 — new default model (sub2api PR #1120)
// Upgraded from M2.5, same API endpoint api.minimax.io
// Pricing estimated, check https://platform.minimaxi.com/document/Price
"minimax-m2.7": {
input: 0.4,
output: 1.6,
cached: 0.2,
reasoning: 2.4,
cache_creation: 0.4,
},
"MiniMax-M2.7": {
input: 0.4,
output: 1.6,
cached: 0.2,
reasoning: 2.4,
cache_creation: 0.4,
},
"minimax-m2.7-highspeed": {
input: 0.4,
output: 1.6,
cached: 0.2,
reasoning: 2.4,
cache_creation: 0.4,
},
},
// ─── Free-tier API Key Providers (nominal $0 pricing) ───

View File

@@ -546,6 +546,20 @@ export const APIKEY_PROVIDERS = {
freeNote:
"No API key needed — access GPT-5, Claude, Gemini, DeepSeek V3, Llama 4 free (1 req/15s)",
},
puter: {
id: "puter",
alias: "pu",
name: "Puter AI",
icon: "cloud_circle",
color: "#6366F1",
textIcon: "PU",
website: "https://puter.com",
hasFree: true,
freeNote:
"500+ models (GPT-5, Claude Opus 4, Gemini 3 Pro, Grok 4, DeepSeek V3...) — Users pay via free Puter account",
passthroughModels: true,
authHint: "Get token at puter.com/dashboard → Copy Auth Token",
},
"cloudflare-ai": {
id: "cloudflare-ai",
alias: "cf",

View File

@@ -101,7 +101,8 @@ test("CUSTOM: sets OpenAI reasoning_effort from budget", () => {
reasoning_effort: "low",
};
const result = applyThinkingBudget(body);
assert.equal(result.reasoning_effort, "high");
// T11 (sub2api gap): full budget (131072) now maps to "max" instead of "high"
assert.equal(result.reasoning_effort, "max");
setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG);
});