mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Compare commits
20 Commits
v3.0.0-rc.
...
v3.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ad5d42982 | ||
|
|
3912734498 | ||
|
|
0fa3f9a057 | ||
|
|
0fbabdcf25 | ||
|
|
67b7ae98a6 | ||
|
|
0f703c95dd | ||
|
|
c34b3f41bd | ||
|
|
e003b17280 | ||
|
|
e003d58c60 | ||
|
|
0546d06c0a | ||
|
|
5337111990 | ||
|
|
bb06f8eb0c | ||
|
|
e47740e02e | ||
|
|
d9ff0035f5 | ||
|
|
7a7f3be0d2 | ||
|
|
91e45fbe95 | ||
|
|
7d7e9da28c | ||
|
|
24a9739604 | ||
|
|
4fb9687782 | ||
|
|
95ffc21b60 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -89,6 +89,7 @@ docs/*
|
||||
!docs/MCP-SERVER.md
|
||||
!docs/CLI-TOOLS.md
|
||||
|
||||
|
||||
# open-sse tests
|
||||
open-sse/test/*
|
||||
|
||||
@@ -130,3 +131,6 @@ vscode-extension/
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
*.sqlite-journal
|
||||
|
||||
# Compiled npm-package build artifact (not source, should not be in git)
|
||||
/app
|
||||
|
||||
220
CHANGELOG.md
220
CHANGELOG.md
@@ -2,6 +2,226 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
> **Coming next** — see [3.0.0-rc branch](https://github.com/diegosouzapw/OmniRoute/tree/3.0.0-rc).
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.12] — 2026-03-23
|
||||
|
||||
### 🔀 Community PRs Merged
|
||||
|
||||
| PR | Author | Summary |
|
||||
| -------- | -------- | --------------------------------------------------------------------------------- |
|
||||
| **#546** | @k0valik | fix(cli): `--version` returning `unknown` on Windows — use `JSON.parse(readFileSync)` instead of ESM import |
|
||||
| **#555** | @k0valik | fix(sse): centralized `resolveDataDir()` for path resolution in credentials, autoCombo, responses logger, and request logger |
|
||||
| **#544** | @k0valik | fix(cli): secure CLI tool detection via known installation paths (8 tools) with symlink validation, file-type checks, size bounds, minimal env in healthcheck |
|
||||
| **#542** | @rdself | fix(ui): improve light mode contrast — add missing CSS theme variables (`bg-primary`, `bg-subtle`, `text-primary`) and fix dark-only colors in log detail |
|
||||
|
||||
### 🔧 Bug Fixes
|
||||
|
||||
- **TDZ fix in `cliRuntime.ts`** — `validateEnvPath` was used before initialization at module startup by `getExpectedParentPaths()`. Reordered declarations to fix `ReferenceError`.
|
||||
- **Build fixes** — Added `pino` and `pino-pretty` to `serverExternalPackages` to prevent Turbopack from breaking Pino's internal worker loading.
|
||||
|
||||
### 🧪 Tests
|
||||
|
||||
- Test suite: **905 tests, 0 failures**
|
||||
|
||||
---
|
||||
## [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 (T01–T15 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 — T01–T15)
|
||||
|
||||
- **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
|
||||
|
||||
19
README.md
19
README.md
@@ -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">
|
||||
|
||||
@@ -116,10 +116,8 @@ if (args.includes("--help") || args.includes("-h")) {
|
||||
|
||||
if (args.includes("--version") || args.includes("-v")) {
|
||||
try {
|
||||
const pkg = await import(join(ROOT, "package.json"), {
|
||||
with: { type: "json" },
|
||||
});
|
||||
console.log(pkg.default.version);
|
||||
const { version } = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
|
||||
console.log(version);
|
||||
} catch {
|
||||
console.log("unknown");
|
||||
}
|
||||
|
||||
@@ -38,15 +38,20 @@ Content-Type: application/json
|
||||
|
||||
### Custom Headers
|
||||
|
||||
| Header | Direction | Description |
|
||||
| ------------------------ | --------- | --------------------------------- |
|
||||
| `X-OmniRoute-No-Cache` | Request | Set to `true` to bypass cache |
|
||||
| `X-OmniRoute-Progress` | Request | Set to `true` for progress events |
|
||||
| `Idempotency-Key` | Request | Dedup key (5s window) |
|
||||
| `X-Request-Id` | Request | Alternative dedup key |
|
||||
| `X-OmniRoute-Cache` | Response | `HIT` or `MISS` (non-streaming) |
|
||||
| `X-OmniRoute-Idempotent` | Response | `true` if deduplicated |
|
||||
| `X-OmniRoute-Progress` | Response | `enabled` if progress tracking on |
|
||||
| Header | Direction | Description |
|
||||
| ------------------------ | --------- | ------------------------------------------------ |
|
||||
| `X-OmniRoute-No-Cache` | Request | Set to `true` to bypass cache |
|
||||
| `X-OmniRoute-Progress` | Request | Set to `true` for progress events |
|
||||
| `X-Session-Id` | Request | Sticky session key for external session affinity |
|
||||
| `x_session_id` | Request | Underscore variant also accepted (direct HTTP) |
|
||||
| `Idempotency-Key` | Request | Dedup key (5s window) |
|
||||
| `X-Request-Id` | Request | Alternative dedup key |
|
||||
| `X-OmniRoute-Cache` | Response | `HIT` or `MISS` (non-streaming) |
|
||||
| `X-OmniRoute-Idempotent` | Response | `true` if deduplicated |
|
||||
| `X-OmniRoute-Progress` | Response | `enabled` if progress tracking on |
|
||||
| `X-OmniRoute-Session-Id` | Response | Effective session ID used by OmniRoute |
|
||||
|
||||
> Nginx note: if you rely on underscore headers (for example `x_session_id`), enable `underscores_in_headers on;`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -578,6 +578,22 @@ Configure via **Dashboard → Settings → Routing**.
|
||||
| **Least Used** | Routes to the account with the oldest `lastUsedAt` timestamp, distributing traffic evenly |
|
||||
| **Cost Optimized** | Routes to the account with the lowest priority value, optimizing for lowest-cost providers |
|
||||
|
||||
#### External Sticky Session Header
|
||||
|
||||
For external session affinity (for example, Claude Code/Codex agents behind reverse proxies), send:
|
||||
|
||||
```http
|
||||
X-Session-Id: your-session-key
|
||||
```
|
||||
|
||||
OmniRoute also accepts `x_session_id` and returns the effective session key in `X-OmniRoute-Session-Id`.
|
||||
|
||||
If you use Nginx and send underscore-form headers, enable:
|
||||
|
||||
```nginx
|
||||
underscores_in_headers on;
|
||||
```
|
||||
|
||||
#### Wildcard Model Aliases
|
||||
|
||||
Create wildcard patterns to remap model names:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 3.0.0-rc.4
|
||||
version: 3.0.0-rc.12
|
||||
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,
|
||||
|
||||
@@ -13,6 +13,9 @@ const nextConfig = {
|
||||
},
|
||||
output: "standalone",
|
||||
serverExternalPackages: [
|
||||
"pino",
|
||||
"pino-pretty",
|
||||
"thread-stream",
|
||||
"better-sqlite3",
|
||||
"zod",
|
||||
"child_process",
|
||||
@@ -37,8 +40,16 @@ const nextConfig = {
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
webpack: (config, { isServer }) => {
|
||||
webpack: (config, { isServer, webpack }) => {
|
||||
if (isServer) {
|
||||
// Webpack IgnorePlugin: skip thread-stream test files that contain
|
||||
// intentionally broken syntax/imports (they cause Turbopack build errors)
|
||||
config.plugins.push(
|
||||
new webpack.IgnorePlugin({
|
||||
resourceRegExp: /\/test\//,
|
||||
contextRegExp: /thread-stream/,
|
||||
})
|
||||
);
|
||||
// ── Turbopack / Next.js 16 module-hash patch (#394, #396, #398) ────────
|
||||
//
|
||||
// Next.js 16 (with or without Turbopack) compiles the instrumentation hook
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { resolveDataDir } from "../../src/lib/dataPaths";
|
||||
|
||||
// Fields that can be overridden per provider
|
||||
const CREDENTIAL_FIELDS = ["clientId", "clientSecret", "tokenUrl", "authUrl", "refreshUrl"];
|
||||
@@ -30,8 +31,7 @@ let cachedProviders = null;
|
||||
* Priority: DATA_DIR env → ./data (project root)
|
||||
*/
|
||||
function resolveCredentialsPath() {
|
||||
const dataDir = process.env.DATA_DIR || join(process.cwd(), "data");
|
||||
return join(dataDir, "provider-credentials.json");
|
||||
return join(resolveDataDir(), "provider-credentials.json");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,7 +93,11 @@ export function loadProviderCredentials(providers) {
|
||||
`[CREDENTIALS] ${isReload ? "Reloaded" : "Loaded"} external credentials: ${overrideCount} field(s) from ${credPath}`
|
||||
);
|
||||
} catch (err) {
|
||||
console.log(`[CREDENTIALS] Error reading credentials file: ${err.message}. Using defaults.`);
|
||||
const reason =
|
||||
err instanceof SyntaxError
|
||||
? "Invalid JSON format"
|
||||
: (err as NodeJS.ErrnoException).code || "read error";
|
||||
console.log(`[CREDENTIALS] Error reading credentials file (${reason}). Using defaults.`);
|
||||
}
|
||||
|
||||
cachedProviders = providers;
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* is auto-generated from this registry.
|
||||
*/
|
||||
|
||||
import { platform, arch } from "os";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RegistryModel {
|
||||
@@ -96,6 +98,32 @@ const KIMI_CODING_SHARED = {
|
||||
] as RegistryModel[],
|
||||
} as const;
|
||||
|
||||
function mapStainlessOs() {
|
||||
switch (platform()) {
|
||||
case "darwin":
|
||||
return "MacOS";
|
||||
case "win32":
|
||||
return "Windows";
|
||||
case "linux":
|
||||
return "Linux";
|
||||
default:
|
||||
return `Other::${platform()}`;
|
||||
}
|
||||
}
|
||||
|
||||
function mapStainlessArch() {
|
||||
switch (arch()) {
|
||||
case "x64":
|
||||
return "x64";
|
||||
case "arm64":
|
||||
return "arm64";
|
||||
case "ia32":
|
||||
return "x86";
|
||||
default:
|
||||
return `other::${arch()}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Registry ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
@@ -112,19 +140,19 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
headers: {
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
"Anthropic-Beta":
|
||||
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27",
|
||||
"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05",
|
||||
"Anthropic-Dangerous-Direct-Browser-Access": "true",
|
||||
"User-Agent": "claude-cli/1.0.83 (external, cli)",
|
||||
"User-Agent": "claude-cli/2.1.63 (external, cli)",
|
||||
"X-App": "cli",
|
||||
"X-Stainless-Helper-Method": "stream",
|
||||
"X-Stainless-Retry-Count": "0",
|
||||
"X-Stainless-Runtime-Version": "v24.3.0",
|
||||
"X-Stainless-Package-Version": "0.55.1",
|
||||
"X-Stainless-Package-Version": "0.74.0",
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Arch": "arm64",
|
||||
"X-Stainless-Os": "MacOS",
|
||||
"X-Stainless-Timeout": "60",
|
||||
"X-Stainless-Arch": mapStainlessArch(),
|
||||
"X-Stainless-Os": mapStainlessOs(),
|
||||
"X-Stainless-Timeout": "600",
|
||||
},
|
||||
oauth: {
|
||||
clientIdEnv: "CLAUDE_OAUTH_CLIENT_ID",
|
||||
@@ -159,9 +187,13 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
clientSecretDefault: "",
|
||||
},
|
||||
models: [
|
||||
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" },
|
||||
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" },
|
||||
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
|
||||
{ id: "gemini-3-1-pro", name: "Gemini 3.1 Pro (Alt ID)" },
|
||||
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
|
||||
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
|
||||
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
|
||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
|
||||
@@ -191,9 +223,13 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
clientSecretDefault: "",
|
||||
},
|
||||
models: [
|
||||
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" },
|
||||
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" },
|
||||
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
|
||||
{ id: "gemini-3-1-pro", name: "Gemini 3.1 Pro (Alt ID)" },
|
||||
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
|
||||
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
|
||||
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
|
||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
|
||||
@@ -322,7 +358,11 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
alias: "ag",
|
||||
format: "antigravity",
|
||||
executor: "antigravity",
|
||||
baseUrls: ["https://daily-cloudcode-pa.googleapis.com", "https://cloudcode-pa.googleapis.com"],
|
||||
baseUrls: [
|
||||
"https://daily-cloudcode-pa.googleapis.com",
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
"https://cloudcode-pa.googleapis.com",
|
||||
],
|
||||
urlBuilder: (base, model, stream) => {
|
||||
const path = stream
|
||||
? "/v1internal:streamGenerateContent?alt=sse"
|
||||
@@ -332,7 +372,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
authType: "oauth",
|
||||
authHeader: "bearer",
|
||||
headers: {
|
||||
"User-Agent": "antigravity/1.104.0 darwin/arm64",
|
||||
"User-Agent": `antigravity/1.107.0 ${platform()}/${arch()}`,
|
||||
},
|
||||
oauth: {
|
||||
clientIdEnv: "ANTIGRAVITY_OAUTH_CLIENT_ID",
|
||||
@@ -361,9 +401,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
authHeader: "bearer",
|
||||
headers: {
|
||||
"copilot-integration-id": "vscode-chat",
|
||||
"editor-version": "vscode/1.107.1",
|
||||
"editor-plugin-version": "copilot-chat/0.26.7",
|
||||
"user-agent": "GitHubCopilotChat/0.26.7",
|
||||
"editor-version": "vscode/1.110.0",
|
||||
"editor-plugin-version": "copilot-chat/0.38.0",
|
||||
"user-agent": "GitHubCopilotChat/0.38.0",
|
||||
"openai-intent": "conversation-panel",
|
||||
"x-github-api-version": "2025-04-01",
|
||||
"x-vscode-user-agent-library-version": "electron-fetch",
|
||||
@@ -746,6 +786,10 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
|
||||
},
|
||||
models: [
|
||||
// T12/T28: MiniMax default upgraded from M2.5 to M2.7
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7" },
|
||||
{ id: "MiniMax-M2.7", name: "MiniMax M2.7 (Legacy Alias)" },
|
||||
{ id: "minimax-m2.7-highspeed", name: "MiniMax M2.7 Highspeed" },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5 (Legacy Alias)" },
|
||||
{ id: "MiniMax-M2.1", name: "MiniMax M2.1" },
|
||||
@@ -767,6 +811,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
},
|
||||
models: [
|
||||
// Keep parity with minimax to ensure model discovery works for minimax-cn connections.
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7" },
|
||||
{ id: "MiniMax-M2.7", name: "MiniMax M2.7 (Legacy Alias)" },
|
||||
{ id: "minimax-m2.7-highspeed", name: "MiniMax M2.7 Highspeed" },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
|
||||
{ id: "MiniMax-M2.5", name: "MiniMax M2.5 (Legacy Alias)" },
|
||||
{ id: "MiniMax-M2.1", name: "MiniMax M2.1" },
|
||||
@@ -1146,7 +1193,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
alias: "vertex",
|
||||
// Vertex AI uses Google's generateContent format (same as Gemini)
|
||||
format: "gemini",
|
||||
executor: "default",
|
||||
executor: "vertex",
|
||||
// URL uses {project_id} and {region} from providerSpecificData — handled by custom executor or fallback
|
||||
// Default to us-central1 / generic endpoint; users configure project via providerSpecificData
|
||||
baseUrl: "https://us-central1-aiplatform.googleapis.com/v1/projects",
|
||||
@@ -1160,10 +1207,16 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview (Vertex)" },
|
||||
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview (Vertex)" },
|
||||
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview (Vertex)" },
|
||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (Vertex)" },
|
||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash (Vertex)" },
|
||||
{ id: "gemini-2.0-flash-thinking-exp", name: "Gemini 2.0 Flash Thinking Exp (Vertex)" },
|
||||
{ id: "gemma-2-27b-it", name: "Gemma 2 27B (Vertex)" },
|
||||
{ id: "deepseek-v3.2", name: "DeepSeek V3.2 (Vertex Partner)" },
|
||||
{ id: "qwen3-next-80b", name: "Qwen3 Next 80B (Vertex Partner)" },
|
||||
{ id: "glm-5", name: "GLM-5 (Vertex Partner)" },
|
||||
{ id: "claude-opus-4-5@20251101", name: "Claude Opus 4.5 (Vertex)" },
|
||||
{ id: "claude-sonnet-4-5@20251101", name: "Claude Sonnet 4.5 (Vertex)" },
|
||||
],
|
||||
@@ -1241,6 +1294,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",
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseExecutor } from "./base.ts";
|
||||
import { BaseExecutor, ExecuteInput } from "./base.ts";
|
||||
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
|
||||
import { getModelTargetFormat } from "../config/providerModels.ts";
|
||||
|
||||
@@ -19,15 +19,82 @@ export class GithubExecutor extends BaseExecutor {
|
||||
return this.config.baseUrl;
|
||||
}
|
||||
|
||||
injectResponseFormat(messages: any[], responseFormat: any) {
|
||||
if (!responseFormat) return messages;
|
||||
|
||||
let formatInstruction = "";
|
||||
if (responseFormat.type === "json_object") {
|
||||
formatInstruction =
|
||||
"Respond only with valid JSON. Do not include any text before or after the JSON object.";
|
||||
} else if (responseFormat.type === "json_schema" && responseFormat.json_schema) {
|
||||
formatInstruction = `Respond only with valid JSON matching this schema:\n${JSON.stringify(
|
||||
responseFormat.json_schema.schema,
|
||||
null,
|
||||
2
|
||||
)}\nDo not include any text before or after the JSON.`;
|
||||
}
|
||||
|
||||
if (!formatInstruction) return messages;
|
||||
|
||||
const systemIdx = messages.findIndex((m: any) => m.role === "system");
|
||||
if (systemIdx >= 0) {
|
||||
return messages.map((m: any, i: number) =>
|
||||
i === systemIdx ? { ...m, content: `${m.content}\n\n${formatInstruction}` } : m
|
||||
);
|
||||
}
|
||||
|
||||
return [{ role: "system", content: formatInstruction }, ...messages];
|
||||
}
|
||||
|
||||
transformRequest(model: string, body: any, stream: boolean, credentials: any): any {
|
||||
const modifiedBody = JSON.parse(JSON.stringify(body));
|
||||
if (modifiedBody.response_format && model.toLowerCase().includes("claude")) {
|
||||
modifiedBody.messages = this.injectResponseFormat(
|
||||
modifiedBody.messages,
|
||||
modifiedBody.response_format
|
||||
);
|
||||
delete modifiedBody.response_format;
|
||||
}
|
||||
return modifiedBody;
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
const result = await super.execute(input);
|
||||
if (!result || !result.response?.body) return result;
|
||||
|
||||
const isStreaming = input.stream === true;
|
||||
if (isStreaming) {
|
||||
const decoder = new TextDecoder();
|
||||
const transformStream = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
const text = decoder.decode(chunk, { stream: true });
|
||||
if (text.includes("data: [DONE]")) {
|
||||
return;
|
||||
}
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
const newResponse = new Response(result.response.body.pipeThrough(transformStream), {
|
||||
status: result.response.status,
|
||||
statusText: result.response.statusText,
|
||||
headers: result.response.headers, // Headers class carries over correctly
|
||||
});
|
||||
result.response = newResponse;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
buildHeaders(credentials, stream = true) {
|
||||
const token = credentials.copilotToken || credentials.accessToken;
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"copilot-integration-id": "vscode-chat",
|
||||
"editor-version": "vscode/1.107.1",
|
||||
"editor-plugin-version": "copilot-chat/0.26.7",
|
||||
"user-agent": "GitHubCopilotChat/0.26.7",
|
||||
"editor-version": "vscode/1.110.0",
|
||||
"editor-plugin-version": "copilot-chat/0.38.0",
|
||||
"user-agent": "GitHubCopilotChat/0.38.0",
|
||||
"openai-intent": "conversation-panel",
|
||||
"x-github-api-version": "2025-04-01",
|
||||
"x-request-id":
|
||||
@@ -44,7 +111,7 @@ export class GithubExecutor extends BaseExecutor {
|
||||
headers: {
|
||||
Authorization: `token ${githubAccessToken}`,
|
||||
"User-Agent": "GithubCopilot/1.0",
|
||||
"Editor-Version": "vscode/1.100.0",
|
||||
"Editor-Version": "vscode/1.110.0",
|
||||
"Editor-Plugin-Version": "copilot/1.300.0",
|
||||
Accept: "application/json",
|
||||
},
|
||||
|
||||
@@ -9,6 +9,8 @@ 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";
|
||||
import { VertexExecutor } from "./vertex.ts";
|
||||
|
||||
const executors = {
|
||||
antigravity: new AntigravityExecutor(),
|
||||
@@ -25,6 +27,9 @@ 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
|
||||
vertex: new VertexExecutor(),
|
||||
};
|
||||
|
||||
const defaultCache = new Map();
|
||||
@@ -51,3 +56,5 @@ 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";
|
||||
export { VertexExecutor } from "./vertex.ts";
|
||||
|
||||
59
open-sse/executors/puter.ts
Normal file
59
open-sse/executors/puter.ts
Normal 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;
|
||||
150
open-sse/executors/vertex.ts
Normal file
150
open-sse/executors/vertex.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { SignJWT, importPKCS8 } from "jose";
|
||||
import { BaseExecutor, ExecuteInput } from "./base.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
|
||||
interface ServiceAccount {
|
||||
type: string;
|
||||
project_id: string;
|
||||
private_key_id: string;
|
||||
private_key: string;
|
||||
client_email: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const TOKEN_CACHE = new Map<string, { token: string; expiresAt: number }>();
|
||||
|
||||
function parseSAFromApiKey(apiKey: string): ServiceAccount {
|
||||
try {
|
||||
return JSON.parse(apiKey);
|
||||
} catch {
|
||||
throw new Error("Vertex AI requires a valid Service Account JSON as the API key");
|
||||
}
|
||||
}
|
||||
|
||||
async function getAccessToken(sa: ServiceAccount): Promise<string> {
|
||||
if (!sa.client_email || !sa.private_key) {
|
||||
throw new Error(
|
||||
"Service Account JSON is missing required fields (client_email or private_key)"
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = sa.client_email;
|
||||
const cached = TOKEN_CACHE.get(cacheKey);
|
||||
|
||||
// Buffer of 60 seconds
|
||||
if (cached && Date.now() < cached.expiresAt - 60_000) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
const privateKey = await importPKCS8(sa.private_key, "RS256");
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const jwt = await new SignJWT({
|
||||
iss: sa.client_email,
|
||||
sub: sa.client_email,
|
||||
aud: "https://oauth2.googleapis.com/token",
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
scope: "https://www.googleapis.com/auth/cloud-platform",
|
||||
})
|
||||
.setProtectedHeader({ alg: "RS256", kid: sa.private_key_id })
|
||||
.sign(privateKey);
|
||||
|
||||
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion: jwt,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
const errorText = await tokenRes.text();
|
||||
throw new Error(
|
||||
`Failed to exchange JWT for Vertex access token: ${tokenRes.status} ${errorText}`
|
||||
);
|
||||
}
|
||||
|
||||
const tokenData = await tokenRes.json();
|
||||
const accessToken = tokenData.access_token;
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error("Vertex AI token exchange succeeded but no access_token found");
|
||||
}
|
||||
|
||||
TOKEN_CACHE.set(cacheKey, {
|
||||
token: accessToken,
|
||||
expiresAt: (now + 3600) * 1000,
|
||||
});
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
const PARTNER_MODELS = new Set([
|
||||
"claude-3-5-sonnet",
|
||||
"claude-3-opus",
|
||||
"claude-3-haiku",
|
||||
"deepseek-v3",
|
||||
"deepseek-v3.2",
|
||||
"deepseek-deepseek-r1",
|
||||
"qwen3-next-80b",
|
||||
"llama-3.1",
|
||||
"mistral-",
|
||||
"glm-5",
|
||||
"meta/llama",
|
||||
]);
|
||||
|
||||
function isPartnerModel(model: string) {
|
||||
return [...PARTNER_MODELS].some((prefix) => model.startsWith(prefix));
|
||||
}
|
||||
|
||||
export class VertexExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("vertex", PROVIDERS.vertex);
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
const { credentials, log } = input;
|
||||
if (credentials.apiKey && !credentials.accessToken) {
|
||||
try {
|
||||
const sa = parseSAFromApiKey(credentials.apiKey);
|
||||
credentials.accessToken = await getAccessToken(sa);
|
||||
} catch (err: any) {
|
||||
log?.error?.("VERTEX", `Failed to generate JWT token: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return super.execute(input);
|
||||
}
|
||||
|
||||
buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) {
|
||||
const region = credentials?.providerSpecificData?.region || "us-central1";
|
||||
let project = "unknown-project";
|
||||
|
||||
if (credentials?.apiKey) {
|
||||
try {
|
||||
const sa = parseSAFromApiKey(credentials.apiKey);
|
||||
if (sa.project_id) project = sa.project_id;
|
||||
} catch {
|
||||
// Ignored, handled in execute
|
||||
}
|
||||
}
|
||||
|
||||
if (isPartnerModel(model)) {
|
||||
return `https://aiplatform.googleapis.com/v1/projects/${project}/locations/global/endpoints/openapi/chat/completions`;
|
||||
}
|
||||
return `https://aiplatform.googleapis.com/v1/projects/${project}/locations/${region}/publishers/google/models/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
|
||||
}
|
||||
|
||||
buildHeaders(credentials: any, stream = true) {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (credentials.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
}
|
||||
if (stream) {
|
||||
headers["Accept"] = "text/event-stream";
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,9 @@ import { resolveModelAlias } from "../services/modelDeprecation.ts";
|
||||
import { getUnsupportedParams } from "../config/providerRegistry.ts";
|
||||
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.ts";
|
||||
import { HTTP_STATUS } from "../config/constants.ts";
|
||||
import { classifyProviderError, PROVIDER_ERROR_TYPES } from "../services/errorClassifier.ts";
|
||||
import { updateProviderConnection } from "@/lib/db/providers";
|
||||
import { logAuditEvent } from "@/lib/compliance";
|
||||
import { handleBypassRequest } from "../utils/bypassHandler.ts";
|
||||
import {
|
||||
saveRequestUsage,
|
||||
@@ -25,6 +28,11 @@ import {
|
||||
} from "@/lib/usageDb";
|
||||
import { getModelNormalizeToolCallId, getModelPreserveOpenAIDeveloperRole } from "@/lib/localDb";
|
||||
import { getExecutor } from "../executors/index.ts";
|
||||
import {
|
||||
parseCodexQuotaHeaders,
|
||||
getCodexResetTime,
|
||||
getCodexModelScope,
|
||||
} from "../executors/codex.ts";
|
||||
import { translateNonStreamingResponse } from "./responseTranslator.ts";
|
||||
import { extractUsageFromResponse } from "./usageExtractor.ts";
|
||||
import { parseSSEToOpenAIResponse, parseSSEToResponsesOutput } from "./sseParser.ts";
|
||||
@@ -44,11 +52,17 @@ import { getIdempotencyKey, checkIdempotency, saveIdempotency } from "@/lib/idem
|
||||
import { createProgressTransform, wantsProgress } from "../utils/progressTracker.ts";
|
||||
import { isModelUnavailableError, getNextFamilyFallback } from "../services/modelFamilyFallback.ts";
|
||||
import { computeRequestHash, deduplicate, shouldDeduplicate } from "../services/requestDedup.ts";
|
||||
import {
|
||||
getBackgroundTaskReason,
|
||||
getDegradedModel,
|
||||
getBackgroundDegradationConfig,
|
||||
} from "../services/backgroundTaskDetector.ts";
|
||||
import {
|
||||
shouldUseFallback,
|
||||
isFallbackDecision,
|
||||
EMERGENCY_FALLBACK_CONFIG,
|
||||
} from "../services/emergencyFallback.ts";
|
||||
import { resolveStreamFlag, stripMarkdownCodeFence } from "../utils/aiSdkCompat.ts";
|
||||
|
||||
export function shouldUseNativeCodexPassthrough({
|
||||
provider,
|
||||
@@ -93,7 +107,9 @@ export async function handleChatCore({
|
||||
userAgent,
|
||||
comboName,
|
||||
}) {
|
||||
const { provider, model, extendedContext } = modelInfo;
|
||||
let { provider, model, extendedContext } = modelInfo;
|
||||
const requestedModel =
|
||||
typeof body?.model === "string" && body.model.trim().length > 0 ? body.model : model;
|
||||
const startTime = Date.now();
|
||||
const persistFailureUsage = (statusCode: number, errorCode?: string | null) => {
|
||||
saveRequestUsage({
|
||||
@@ -112,6 +128,67 @@ export async function handleChatCore({
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const persistCodexQuotaState = async (
|
||||
headers: Headers | Record<string, string> | null,
|
||||
status = 0
|
||||
) => {
|
||||
if (provider !== "codex" || !connectionId || !headers) return;
|
||||
|
||||
try {
|
||||
const quota = parseCodexQuotaHeaders(headers as Headers);
|
||||
if (!quota) return;
|
||||
|
||||
const existingProviderData =
|
||||
credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object"
|
||||
? credentials.providerSpecificData
|
||||
: {};
|
||||
const scope = getCodexModelScope(model || requestedModel || "");
|
||||
const quotaState = {
|
||||
usage5h: quota.usage5h,
|
||||
limit5h: quota.limit5h,
|
||||
resetAt5h: quota.resetAt5h,
|
||||
usage7d: quota.usage7d,
|
||||
limit7d: quota.limit7d,
|
||||
resetAt7d: quota.resetAt7d,
|
||||
scope,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const nextProviderData: Record<string, unknown> = {
|
||||
...existingProviderData,
|
||||
codexQuotaState: quotaState,
|
||||
};
|
||||
|
||||
// T03/T09: on 429, persist exact reset time per scope to avoid global over-blocking.
|
||||
if (status === 429) {
|
||||
const resetTimeMs = getCodexResetTime(quota);
|
||||
if (resetTimeMs && resetTimeMs > Date.now()) {
|
||||
const scopeUntil = new Date(resetTimeMs).toISOString();
|
||||
const scopeMapRaw =
|
||||
existingProviderData &&
|
||||
typeof existingProviderData === "object" &&
|
||||
existingProviderData.codexScopeRateLimitedUntil &&
|
||||
typeof existingProviderData.codexScopeRateLimitedUntil === "object"
|
||||
? existingProviderData.codexScopeRateLimitedUntil
|
||||
: {};
|
||||
|
||||
nextProviderData.codexScopeRateLimitedUntil = {
|
||||
...(scopeMapRaw as Record<string, unknown>),
|
||||
[scope]: scopeUntil,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await updateProviderConnection(connectionId, {
|
||||
providerSpecificData: nextProviderData,
|
||||
});
|
||||
|
||||
credentials.providerSpecificData = nextProviderData;
|
||||
} catch (err) {
|
||||
log?.debug?.("CODEX", `Failed to persist codex quota state: ${err?.message || err}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Phase 9.2: Idempotency check ──
|
||||
const idempotencyKey = getIdempotencyKey(clientRawRequest?.headers);
|
||||
const cachedIdemp = checkIdempotency(idempotencyKey);
|
||||
@@ -157,6 +234,37 @@ export async function handleChatCore({
|
||||
// Detect source format and get target format
|
||||
// Model-specific targetFormat takes priority over provider default
|
||||
|
||||
// ── Background Task Redirection (T41) ──
|
||||
const bgConfig = getBackgroundDegradationConfig();
|
||||
const backgroundReason = bgConfig.enabled
|
||||
? getBackgroundTaskReason(body, clientRawRequest?.headers)
|
||||
: null;
|
||||
if (backgroundReason) {
|
||||
const degradedModel = getDegradedModel(model);
|
||||
if (degradedModel !== model) {
|
||||
const originalModel = model;
|
||||
log?.info?.(
|
||||
"BACKGROUND",
|
||||
`Background task redirect (${backgroundReason}): ${originalModel} → ${degradedModel}`
|
||||
);
|
||||
model = degradedModel;
|
||||
if (body && typeof body === "object") {
|
||||
body.model = model;
|
||||
}
|
||||
|
||||
logAuditEvent({
|
||||
action: "routing.background_task_redirect",
|
||||
actor: apiKeyInfo?.name || "system",
|
||||
target: connectionId || provider || "chat",
|
||||
details: {
|
||||
original_model: originalModel,
|
||||
redirected_to: degradedModel,
|
||||
reason: backgroundReason,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply custom model aliases (Settings → Model Aliases → Pattern→Target) before routing (#315, #472)
|
||||
// Custom aliases take priority over built-in and must be resolved here so the
|
||||
// downstream getModelTargetFormat() lookup AND the actual provider request use
|
||||
@@ -173,7 +281,12 @@ export async function handleChatCore({
|
||||
const targetFormat = modelTargetFormat || getTargetFormat(provider);
|
||||
|
||||
// Default to false unless client explicitly sets stream: true (OpenAI spec compliant)
|
||||
const stream = body.stream === true;
|
||||
const acceptHeader =
|
||||
clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function"
|
||||
? clientRawRequest.headers.get("accept") || clientRawRequest.headers.get("Accept")
|
||||
: (clientRawRequest?.headers || {})["accept"] || (clientRawRequest?.headers || {})["Accept"];
|
||||
|
||||
const stream = resolveStreamFlag(body?.stream, acceptHeader);
|
||||
|
||||
// ── Phase 9.1: Semantic cache check (non-streaming, temp=0 only) ──
|
||||
if (isCacheable(body, clientRawRequest?.headers)) {
|
||||
@@ -457,7 +570,7 @@ export async function handleChatCore({
|
||||
// Non-stream responses need cloning for shared dedup consumers.
|
||||
const status = rawResult.response.status;
|
||||
const statusText = rawResult.response.statusText;
|
||||
const headers = Array.from(rawResult.response.headers.entries());
|
||||
const headers = Array.from(rawResult.response.headers.entries()) as [string, string][];
|
||||
const payload = await rawResult.response.text();
|
||||
|
||||
return {
|
||||
@@ -532,6 +645,7 @@ export async function handleChatCore({
|
||||
path: clientRawRequest?.endpoint || "/v1/chat/completions",
|
||||
status: error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY,
|
||||
model,
|
||||
requestedModel,
|
||||
provider,
|
||||
connectionId,
|
||||
duration: Date.now() - startTime,
|
||||
@@ -603,6 +717,8 @@ export async function handleChatCore({
|
||||
}
|
||||
}
|
||||
|
||||
await persistCodexQuotaState(providerResponse.headers, providerResponse.status);
|
||||
|
||||
// Check provider response - return error info for fallback handling
|
||||
if (!providerResponse.ok) {
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
@@ -610,6 +726,54 @@ export async function handleChatCore({
|
||||
providerResponse,
|
||||
provider
|
||||
);
|
||||
|
||||
// T06/T10/T36: classify provider errors and persist terminal account states.
|
||||
const errorType = classifyProviderError(statusCode, message);
|
||||
if (connectionId && errorType) {
|
||||
try {
|
||||
if (errorType === PROVIDER_ERROR_TYPES.FORBIDDEN) {
|
||||
await updateProviderConnection(connectionId, {
|
||||
isActive: false,
|
||||
testStatus: "banned",
|
||||
lastErrorType: errorType,
|
||||
lastError: message,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
console.warn(
|
||||
`[provider] Node ${connectionId} banned (${statusCode}) — disabling permanently`
|
||||
);
|
||||
} else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) {
|
||||
await updateProviderConnection(connectionId, {
|
||||
testStatus: "credits_exhausted",
|
||||
lastErrorType: errorType,
|
||||
lastError: message,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
console.warn(`[provider] Node ${connectionId} exhausted quota (${statusCode})`);
|
||||
} else if (errorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED) {
|
||||
await updateProviderConnection(connectionId, {
|
||||
isActive: false,
|
||||
testStatus: "expired",
|
||||
lastErrorType: errorType,
|
||||
lastError: message,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
console.warn(
|
||||
`[provider] Node ${connectionId} account deactivated (${statusCode}) — marked expired`
|
||||
);
|
||||
} else if (errorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED) {
|
||||
// Normal 401 (token/session auth issue): keep account active for refresh/re-auth.
|
||||
await updateProviderConnection(connectionId, {
|
||||
lastErrorType: errorType,
|
||||
lastError: message,
|
||||
errorCode: statusCode,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Best-effort state update; request flow should continue with fallback handling.
|
||||
}
|
||||
}
|
||||
|
||||
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(
|
||||
() => {}
|
||||
);
|
||||
@@ -618,6 +782,7 @@ export async function handleChatCore({
|
||||
path: clientRawRequest?.endpoint || "/v1/chat/completions",
|
||||
status: statusCode,
|
||||
model,
|
||||
requestedModel,
|
||||
provider,
|
||||
connectionId,
|
||||
duration: Date.now() - startTime,
|
||||
@@ -808,6 +973,7 @@ export async function handleChatCore({
|
||||
path: clientRawRequest?.endpoint || "/v1/chat/completions",
|
||||
status: 200,
|
||||
model,
|
||||
requestedModel,
|
||||
provider,
|
||||
connectionId,
|
||||
duration: Date.now() - startTime,
|
||||
@@ -848,6 +1014,28 @@ export async function handleChatCore({
|
||||
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
|
||||
: responseBody;
|
||||
|
||||
// T26: Strip markdown code blocks if provider format is Claude
|
||||
if (sourceFormat === "claude" && !stream) {
|
||||
if (typeof translatedResponse?.choices?.[0]?.message?.content === "string") {
|
||||
translatedResponse.choices[0].message.content = stripMarkdownCodeFence(
|
||||
translatedResponse.choices[0].message.content
|
||||
) as string;
|
||||
}
|
||||
}
|
||||
|
||||
// T18: Normalize finish_reason to 'tool_calls' if tool calls are present
|
||||
if (translatedResponse?.choices) {
|
||||
for (const choice of translatedResponse.choices) {
|
||||
if (
|
||||
choice.message?.tool_calls &&
|
||||
choice.message.tool_calls.length > 0 &&
|
||||
choice.finish_reason !== "tool_calls"
|
||||
) {
|
||||
choice.finish_reason = "tool_calls";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize response for OpenAI SDK compatibility
|
||||
// Strips non-standard fields (x_groq, usage_breakdown, service_tier, etc.)
|
||||
// Extracts <think> tags into reasoning_content
|
||||
@@ -921,6 +1109,7 @@ export async function handleChatCore({
|
||||
path: clientRawRequest?.endpoint || "/v1/chat/completions",
|
||||
status: streamStatus || 200,
|
||||
model,
|
||||
requestedModel,
|
||||
provider,
|
||||
connectionId,
|
||||
duration: Date.now() - startTime,
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
|
||||
import { getImageProvider, parseImageModel } from "../config/imageRegistry.ts";
|
||||
import { mapImageSize } from "../translator/image/sizeMapper.ts";
|
||||
import { saveCallLog } from "@/lib/usageDb";
|
||||
import {
|
||||
submitComfyWorkflow,
|
||||
@@ -95,11 +96,21 @@ export async function handleImageGeneration({ body, credentials, log, resolvedPr
|
||||
});
|
||||
}
|
||||
|
||||
// Route to format-specific handler
|
||||
if (providerConfig.format === "gemini-image") {
|
||||
return handleGeminiImageGeneration({ model, providerConfig, body, credentials, log });
|
||||
}
|
||||
|
||||
if (providerConfig.format === "imagen3") {
|
||||
return handleImagen3ImageGeneration({
|
||||
model,
|
||||
provider,
|
||||
providerConfig,
|
||||
body,
|
||||
credentials,
|
||||
log,
|
||||
});
|
||||
}
|
||||
|
||||
if (providerConfig.format === "hyperbolic") {
|
||||
return handleHyperbolicImageGeneration({
|
||||
model,
|
||||
@@ -539,7 +550,7 @@ async function handleNanoBananaImageGeneration({
|
||||
? body.aspectRatio
|
||||
: typeof body.aspect_ratio === "string"
|
||||
? body.aspect_ratio
|
||||
: inferAspectRatioFromSize(body.size) || "1:1";
|
||||
: mapImageSize(body.size);
|
||||
|
||||
let resolution =
|
||||
typeof body.resolution === "string"
|
||||
@@ -856,18 +867,6 @@ async function normalizeNanoBananaTaskResult(taskData, body, log) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function inferAspectRatioFromSize(size) {
|
||||
if (typeof size !== "string") return null;
|
||||
const [wRaw, hRaw] = size.split("x");
|
||||
const width = Number(wRaw);
|
||||
const height = Number(hRaw);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
|
||||
|
||||
const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b));
|
||||
const div = gcd(Math.round(width), Math.round(height));
|
||||
return `${Math.round(width / div)}:${Math.round(height / div)}`;
|
||||
}
|
||||
|
||||
function inferResolutionFromSize(size) {
|
||||
if (typeof size !== "string") return null;
|
||||
const [wRaw, hRaw] = size.split("x");
|
||||
@@ -1081,3 +1080,113 @@ async function handleComfyUIImageGeneration({ model, provider, providerConfig, b
|
||||
return { success: false, status: 502, error: `Image provider error: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Imagen 3 image generation
|
||||
*/
|
||||
async function handleImagen3ImageGeneration({
|
||||
model,
|
||||
provider,
|
||||
providerConfig,
|
||||
body,
|
||||
credentials,
|
||||
log,
|
||||
}: any) {
|
||||
const startTime = Date.now();
|
||||
const token = credentials.apiKey || credentials.accessToken;
|
||||
const aspectRatio = mapImageSize(body.size);
|
||||
|
||||
const upstreamBody = {
|
||||
prompt: body.prompt,
|
||||
aspect_ratio: aspectRatio,
|
||||
number_of_images: body.n ?? 1,
|
||||
};
|
||||
|
||||
if (log) {
|
||||
const promptPreview = String(body.prompt ?? "").slice(0, 60);
|
||||
log.info(
|
||||
"IMAGE",
|
||||
`${provider}/${model} (imagen3) | prompt: "${promptPreview}..." | aspect_ratio: ${aspectRatio}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(providerConfig.baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(upstreamBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (log)
|
||||
log.error("IMAGE", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`);
|
||||
|
||||
saveCallLog({
|
||||
method: "POST",
|
||||
path: "/v1/images/generations",
|
||||
status: response.status,
|
||||
model: `${provider}/${model}`,
|
||||
provider,
|
||||
duration: Date.now() - startTime,
|
||||
error: errorText.slice(0, 500),
|
||||
requestBody: upstreamBody,
|
||||
}).catch(() => {});
|
||||
|
||||
return { success: false, status: response.status, error: errorText };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Normalize response to OpenAI format
|
||||
const images: any[] = [];
|
||||
if (Array.isArray(data.images)) {
|
||||
images.push(
|
||||
...data.images.map((img: any) => ({
|
||||
b64_json: img.image || img.b64_json || img.url || img,
|
||||
revised_prompt: body.prompt,
|
||||
}))
|
||||
);
|
||||
} else if (Array.isArray(data.data)) {
|
||||
images.push(...data.data);
|
||||
} else if (data.url || data.b64_json || data.image) {
|
||||
images.push({
|
||||
b64_json: data.image || data.b64_json || data.url,
|
||||
url: data.url,
|
||||
revised_prompt: body.prompt,
|
||||
});
|
||||
}
|
||||
|
||||
saveCallLog({
|
||||
method: "POST",
|
||||
path: "/v1/images/generations",
|
||||
status: 200,
|
||||
model: `${provider}/${model}`,
|
||||
provider,
|
||||
duration: Date.now() - startTime,
|
||||
responseBody: { images_count: images.length },
|
||||
}).catch(() => {});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { created: data.created || Math.floor(Date.now() / 1000), data: images },
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (log) log.error("IMAGE", `${provider} fetch error: ${err.message}`);
|
||||
|
||||
saveCallLog({
|
||||
method: "POST",
|
||||
path: "/v1/images/generations",
|
||||
status: 502,
|
||||
model: `${provider}/${model}`,
|
||||
provider,
|
||||
duration: Date.now() - startTime,
|
||||
error: err.message,
|
||||
}).catch(() => {});
|
||||
|
||||
return { success: false, status: 502, error: `Image provider error: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,51 @@ function toNumber(value: unknown, fallback = 0): number {
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function extractMessageOutputText(item: JsonRecord): string {
|
||||
if (!Array.isArray(item.content)) return "";
|
||||
let text = "";
|
||||
for (const part of item.content) {
|
||||
if (!part || typeof part !== "object") continue;
|
||||
const partObj = toRecord(part);
|
||||
if (partObj.type === "output_text" && typeof partObj.text === "string") {
|
||||
text += partObj.text;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* T19: Pick the last non-empty message output text from Responses API output.
|
||||
* Falls back to the last message item even when all message texts are empty.
|
||||
*/
|
||||
function findBestMessageText(output: unknown[]): {
|
||||
text: string;
|
||||
selectedMessageIndex: number;
|
||||
messageItems: JsonRecord[];
|
||||
} {
|
||||
const messageItems = output
|
||||
.map((item) => toRecord(item))
|
||||
.filter((item) => item.type === "message" && Array.isArray(item.content));
|
||||
|
||||
for (let i = messageItems.length - 1; i >= 0; i -= 1) {
|
||||
const text = extractMessageOutputText(messageItems[i]);
|
||||
if (text.trim().length > 0) {
|
||||
return { text, selectedMessageIndex: i, messageItems };
|
||||
}
|
||||
}
|
||||
|
||||
if (messageItems.length > 0) {
|
||||
const lastIndex = messageItems.length - 1;
|
||||
return {
|
||||
text: extractMessageOutputText(messageItems[lastIndex]),
|
||||
selectedMessageIndex: lastIndex,
|
||||
messageItems,
|
||||
};
|
||||
}
|
||||
|
||||
return { text: "", selectedMessageIndex: -1, messageItems: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate non-streaming response to OpenAI format
|
||||
* Handles different provider response formats (Gemini, Claude, etc.)
|
||||
@@ -44,7 +89,8 @@ export function translateNonStreamingResponse(
|
||||
const output = Array.isArray(response.output) ? response.output : [];
|
||||
const usage = toRecord(response.usage ?? responseRoot.usage);
|
||||
|
||||
let textContent = "";
|
||||
const messageSelection = findBestMessageText(output);
|
||||
let textContent = messageSelection.text;
|
||||
let reasoningContent = "";
|
||||
const toolCalls: JsonRecord[] = [];
|
||||
|
||||
@@ -56,9 +102,7 @@ export function translateNonStreamingResponse(
|
||||
for (const part of itemObj.content) {
|
||||
if (!part || typeof part !== "object") continue;
|
||||
const partObj = toRecord(part);
|
||||
if (partObj.type === "output_text" && typeof partObj.text === "string") {
|
||||
textContent += partObj.text;
|
||||
} else if (partObj.type === "summary_text" && typeof partObj.text === "string") {
|
||||
if (partObj.type === "summary_text" && typeof partObj.text === "string") {
|
||||
reasoningContent += partObj.text;
|
||||
}
|
||||
}
|
||||
@@ -103,6 +147,18 @@ export function translateNonStreamingResponse(
|
||||
message.content = "";
|
||||
}
|
||||
|
||||
if (process.env.DEBUG_RESPONSES_SSE_TO_JSON === "true") {
|
||||
console.log(
|
||||
`[ResponsesSSE] ${output.length} output items, ${messageSelection.messageItems.length} message items`
|
||||
);
|
||||
messageSelection.messageItems.forEach((item, idx) => {
|
||||
const textLen = extractMessageOutputText(item).length;
|
||||
console.log(` [${idx}] text length: ${textLen}`);
|
||||
});
|
||||
console.log(` → Selected message index: ${messageSelection.selectedMessageIndex}`);
|
||||
console.log(` → Final text content length: ${textContent.length}`);
|
||||
}
|
||||
|
||||
const createdAt = toNumber(response.created_at, Math.floor(Date.now() / 1000));
|
||||
const model = toString(response.model || responseRoot.model, "openai-responses");
|
||||
const finishReason = toolCalls.length > 0 ? "tool_calls" : "stop";
|
||||
|
||||
@@ -23,9 +23,18 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
|
||||
const first = chunks[0];
|
||||
const contentParts = [];
|
||||
const reasoningParts = [];
|
||||
const accumulatedToolCalls = new Map<string, any>();
|
||||
let unknownToolCallSeq = 0;
|
||||
let finishReason = "stop";
|
||||
let usage = null;
|
||||
|
||||
const getToolCallKey = (toolCall: any) => {
|
||||
if (toolCall?.id) return `id:${toolCall.id}`;
|
||||
if (Number.isInteger(toolCall?.index)) return `idx:${toolCall.index}`;
|
||||
unknownToolCallSeq += 1;
|
||||
return `seq:${unknownToolCallSeq}`;
|
||||
};
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const choice = chunk?.choices?.[0];
|
||||
const delta = choice?.delta || {};
|
||||
@@ -36,6 +45,40 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
|
||||
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
|
||||
reasoningParts.push(delta.reasoning_content);
|
||||
}
|
||||
|
||||
// T18: Accumulate tool calls correctly across streamed chunks
|
||||
if (delta.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
const key = getToolCallKey(tc);
|
||||
const existing = accumulatedToolCalls.get(key);
|
||||
const deltaArgs = typeof tc?.function?.arguments === "string" ? tc.function.arguments : "";
|
||||
|
||||
if (!existing) {
|
||||
accumulatedToolCalls.set(key, {
|
||||
id: tc?.id ?? null,
|
||||
index: Number.isInteger(tc?.index) ? tc.index : accumulatedToolCalls.size,
|
||||
type: tc?.type || "function",
|
||||
function: {
|
||||
name: tc?.function?.name || "unknown",
|
||||
arguments: deltaArgs,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
existing.id = existing.id || tc?.id || null;
|
||||
if (!Number.isInteger(existing.index) && Number.isInteger(tc?.index)) {
|
||||
existing.index = tc.index;
|
||||
}
|
||||
if (tc?.function?.name && !existing.function?.name) {
|
||||
existing.function = existing.function || {};
|
||||
existing.function.name = tc.function.name;
|
||||
}
|
||||
existing.function = existing.function || {};
|
||||
existing.function.arguments = `${existing.function.arguments || ""}${deltaArgs}`;
|
||||
accumulatedToolCalls.set(key, existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (choice?.finish_reason) {
|
||||
finishReason = choice.finish_reason;
|
||||
}
|
||||
@@ -46,12 +89,22 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
|
||||
|
||||
const message: Record<string, unknown> = {
|
||||
role: "assistant",
|
||||
content: contentParts.join(""),
|
||||
content: contentParts.length > 0 ? contentParts.join("") : null,
|
||||
};
|
||||
if (reasoningParts.length > 0) {
|
||||
message.reasoning_content = reasoningParts.join("");
|
||||
}
|
||||
|
||||
const finalToolCalls = [...accumulatedToolCalls.values()].filter(Boolean).sort((a, b) => {
|
||||
const ai = Number.isInteger(a?.index) ? a.index : 0;
|
||||
const bi = Number.isInteger(b?.index) ? b.index : 0;
|
||||
return ai - bi;
|
||||
});
|
||||
if (finalToolCalls.length > 0) {
|
||||
finishReason = "tool_calls"; // T18 normalization
|
||||
message.tool_calls = finalToolCalls;
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
id: first.id || `chatcmpl-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
|
||||
@@ -433,23 +433,48 @@ async function handleListModelsCatalog(args: { provider?: string; capability?: s
|
||||
const start = Date.now();
|
||||
try {
|
||||
let path = "/v1/models";
|
||||
const params = new URLSearchParams();
|
||||
if (args.provider) params.set("provider", args.provider);
|
||||
if (args.capability) params.set("capability", args.capability);
|
||||
if (params.toString()) path += `?${params.toString()}`;
|
||||
let isProviderSpecific = false;
|
||||
let source = "local_catalog";
|
||||
let warning = undefined;
|
||||
|
||||
if (args.provider && !args.capability) {
|
||||
// Use direct provider fetch to get real-time API status
|
||||
path = `/api/providers/${encodeURIComponent(args.provider)}/models`;
|
||||
isProviderSpecific = true;
|
||||
} else {
|
||||
const params = new URLSearchParams();
|
||||
if (args.provider) params.set("provider", args.provider);
|
||||
if (args.capability) params.set("capability", args.capability);
|
||||
if (params.toString()) path += `?${params.toString()}`;
|
||||
}
|
||||
|
||||
const raw = toRecord(await omniRouteFetch(path));
|
||||
|
||||
// If we used the direct provider endpoint
|
||||
let rawModels = [];
|
||||
if (isProviderSpecific) {
|
||||
rawModels = Array.isArray(raw.models) ? raw.models : [];
|
||||
source = typeof raw.source === "string" ? raw.source : "api";
|
||||
if (raw.warning) warning = String(raw.warning);
|
||||
} else {
|
||||
rawModels = Array.isArray(raw.data) ? raw.data : [];
|
||||
source = "local_catalog";
|
||||
// OmniRoute's global /v1/models is always a cached/local catalog
|
||||
}
|
||||
|
||||
const result = {
|
||||
models: toArray(raw.data).map((rawModel) => {
|
||||
models: rawModels.map((rawModel) => {
|
||||
const model = toRecord(rawModel);
|
||||
return {
|
||||
id: toString(model.id, ""),
|
||||
provider: toString(model.owned_by, toString(model.provider, "unknown")),
|
||||
provider: toString(model.owned_by, toString(model.provider, args.provider || "unknown")),
|
||||
capabilities: toStringArray(model.capabilities, ["chat"]),
|
||||
status: toString(model.status, "available"),
|
||||
pricing: model.pricing,
|
||||
};
|
||||
}),
|
||||
source,
|
||||
...(warning ? { warning } : {}),
|
||||
};
|
||||
|
||||
await logToolCall(
|
||||
|
||||
@@ -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") ||
|
||||
@@ -294,13 +342,67 @@ export function checkFallbackError(
|
||||
errorText,
|
||||
backoffLevel = 0,
|
||||
model = null,
|
||||
provider = null
|
||||
provider = null,
|
||||
headers = null
|
||||
) {
|
||||
const errorStr = (errorText || "").toString();
|
||||
|
||||
function parseResetFromHeaders(headers, errorStr = "") {
|
||||
if (!headers) return null;
|
||||
|
||||
// Retry-After header
|
||||
const retryAfter =
|
||||
typeof headers.get === "function"
|
||||
? headers.get("retry-after")
|
||||
: headers["retry-after"] || headers["Retry-After"];
|
||||
|
||||
if (retryAfter) {
|
||||
const seconds = parseInt(retryAfter, 10);
|
||||
if (!isNaN(seconds) && String(seconds) === String(retryAfter).trim()) {
|
||||
return Date.now() + seconds * 1000;
|
||||
}
|
||||
const date = new Date(retryAfter);
|
||||
if (!isNaN(date.getTime())) return date.getTime();
|
||||
}
|
||||
|
||||
// X-RateLimit-Reset
|
||||
const rlReset =
|
||||
typeof headers.get === "function"
|
||||
? headers.get("x-ratelimit-reset")
|
||||
: headers["x-ratelimit-reset"] || headers["X-RateLimit-Reset"];
|
||||
|
||||
if (rlReset) {
|
||||
const ts = parseInt(rlReset, 10);
|
||||
if (!isNaN(ts)) {
|
||||
return ts > 10000000000 ? ts : ts * 1000;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Check error message FIRST - specific patterns take priority over status codes
|
||||
if (errorText) {
|
||||
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,
|
||||
@@ -325,6 +427,18 @@ export function checkFallbackError(
|
||||
lowerError.includes("capacity") ||
|
||||
lowerError.includes("overloaded")
|
||||
) {
|
||||
const resetTime = parseResetFromHeaders(headers);
|
||||
if (resetTime) {
|
||||
const waitMs = resetTime - Date.now();
|
||||
if (waitMs > 60_000) {
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: waitMs,
|
||||
newBackoffLevel: 0,
|
||||
reason: RateLimitReason.RATE_LIMIT_EXCEEDED,
|
||||
};
|
||||
}
|
||||
}
|
||||
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
|
||||
const reason = classifyErrorText(errorStr);
|
||||
return {
|
||||
@@ -362,6 +476,19 @@ export function checkFallbackError(
|
||||
|
||||
// 429 - Rate limit with exponential backoff
|
||||
if (status === HTTP_STATUS.RATE_LIMITED) {
|
||||
const resetTime = parseResetFromHeaders(headers);
|
||||
if (resetTime) {
|
||||
const waitMs = resetTime - Date.now();
|
||||
if (waitMs > 60_000) {
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: waitMs,
|
||||
newBackoffLevel: 0,
|
||||
reason: RateLimitReason.RATE_LIMIT_EXCEEDED,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
|
||||
return {
|
||||
shouldFallback: true,
|
||||
@@ -381,6 +508,19 @@ export function checkFallbackError(
|
||||
HTTP_STATUS.GATEWAY_TIMEOUT,
|
||||
];
|
||||
if (transientStatuses.includes(status)) {
|
||||
const resetTime = parseResetFromHeaders(headers, errorStr);
|
||||
if (resetTime) {
|
||||
const waitMs = resetTime - Date.now();
|
||||
if (waitMs > 60_000) {
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: waitMs,
|
||||
newBackoffLevel: 0,
|
||||
reason: RateLimitReason.SERVER_ERROR,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const profile = provider ? getProviderProfile(provider) : null;
|
||||
const baseCooldown = profile?.transientCooldown ?? COOLDOWN_MS.transientInitial;
|
||||
const maxLevel = profile?.maxBackoffLevel ?? BACKOFF_CONFIG.maxLevel;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { resolveDataDir } from "../../../src/lib/dataPaths";
|
||||
|
||||
export interface AdaptationState {
|
||||
comboId: string;
|
||||
@@ -23,7 +24,7 @@ export interface AdaptationState {
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
const PERSISTENCE_DIR = path.join(process.cwd(), "data");
|
||||
const PERSISTENCE_DIR = resolveDataDir();
|
||||
const STATE_FILE = path.join(PERSISTENCE_DIR, "auto_combo_state.json");
|
||||
|
||||
let stateCache = new Map<string, AdaptationState>();
|
||||
|
||||
@@ -47,16 +47,16 @@ const DEFAULT_DETECTION_PATTERNS = [
|
||||
|
||||
const DEFAULT_DEGRADATION_MAP: Record<string, string> = {
|
||||
// Premium → Cheap alternatives
|
||||
"claude-opus-4-6": "gemini-2.5-flash",
|
||||
"claude-opus-4-6-thinking": "gemini-2.5-flash",
|
||||
"claude-opus-4-5-20251101": "gemini-2.5-flash",
|
||||
"claude-sonnet-4-5-20250929": "gemini-2.5-flash",
|
||||
"claude-sonnet-4-20250514": "gemini-2.5-flash",
|
||||
"claude-sonnet-4": "gemini-2.5-flash",
|
||||
"gemini-3.1-pro": "gemini-3.1-flash",
|
||||
"gemini-3.1-pro-high": "gemini-3.1-flash",
|
||||
"claude-opus-4-6": "gemini-3-flash",
|
||||
"claude-opus-4-6-thinking": "gemini-3-flash",
|
||||
"claude-opus-4-5-20251101": "gemini-3-flash",
|
||||
"claude-sonnet-4-5-20250929": "gemini-3-flash",
|
||||
"claude-sonnet-4-20250514": "gemini-3-flash",
|
||||
"claude-sonnet-4": "gemini-3-flash",
|
||||
"gemini-3.1-pro": "gemini-3-flash",
|
||||
"gemini-3.1-pro-high": "gemini-3-flash",
|
||||
"gemini-3-pro-preview": "gemini-3-flash-preview",
|
||||
"gemini-2.5-pro": "gemini-2.5-flash",
|
||||
"gemini-2.5-pro": "gemini-3-flash",
|
||||
"gpt-4o": "gpt-4o-mini",
|
||||
"gpt-5": "gpt-5-mini",
|
||||
"gpt-5.1": "gpt-5-mini",
|
||||
@@ -114,12 +114,93 @@ interface BackgroundMessage {
|
||||
interface BackgroundTaskBody {
|
||||
messages?: BackgroundMessage[];
|
||||
input?: BackgroundMessage[];
|
||||
max_tokens?: unknown;
|
||||
max_completion_tokens?: unknown;
|
||||
max_output_tokens?: unknown;
|
||||
}
|
||||
|
||||
function toMessageArray(value: unknown): BackgroundMessage[] {
|
||||
return Array.isArray(value) ? (value as BackgroundMessage[]) : [];
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function headerValue(headers: Record<string, string> | null, key: string): string {
|
||||
if (!headers) return "";
|
||||
const value = headers[key] ?? headers[key.toLowerCase()] ?? headers[key.toUpperCase()];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reason label when request is a background/utility task.
|
||||
*
|
||||
* @param {object} body - Request body
|
||||
* @param {object} [headers] - Request headers (optional)
|
||||
* @returns {string | null} Reason label or null when not detected
|
||||
*/
|
||||
export function getBackgroundTaskReason(
|
||||
body: BackgroundTaskBody | unknown,
|
||||
headers: Record<string, string> | null = null
|
||||
): string | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const typedBody = body as BackgroundTaskBody;
|
||||
|
||||
// 1. Check explicit header
|
||||
if (headers) {
|
||||
const taskType = headerValue(headers, "x-task-type");
|
||||
const priority = headerValue(headers, "x-request-priority");
|
||||
const initiator = headerValue(headers, "x-initiator");
|
||||
const explicitValue = [taskType, priority, initiator].find(Boolean);
|
||||
if (explicitValue && explicitValue.toLowerCase() === "background") {
|
||||
return "header_background";
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Very low max tokens usually indicates utility/background tasks
|
||||
const maxTokens = toFiniteNumber(
|
||||
typedBody.max_tokens ?? typedBody.max_completion_tokens ?? typedBody.max_output_tokens
|
||||
);
|
||||
if (maxTokens !== null && maxTokens > 0 && maxTokens < 50) {
|
||||
return "low_max_tokens";
|
||||
}
|
||||
|
||||
// 3. Check system prompt for background task patterns
|
||||
const messages = toMessageArray(typedBody.messages ?? typedBody.input ?? []);
|
||||
if (!Array.isArray(messages) || messages.length === 0) return null;
|
||||
|
||||
// Find system message
|
||||
const systemMsg = messages.find(
|
||||
(message: BackgroundMessage) => message.role === "system" || message.role === "developer"
|
||||
);
|
||||
if (!systemMsg) return null;
|
||||
|
||||
const systemContent =
|
||||
typeof systemMsg.content === "string" ? systemMsg.content.toLowerCase() : "";
|
||||
|
||||
if (!systemContent) return null;
|
||||
|
||||
// Check against detection patterns
|
||||
const matched = _config.detectionPatterns.some((pattern) =>
|
||||
systemContent.includes(pattern.toLowerCase())
|
||||
);
|
||||
|
||||
if (!matched) return null;
|
||||
|
||||
// 4. Additional heuristic: background tasks typically have very few messages
|
||||
// (system + 1-2 user messages)
|
||||
const userMessages = messages.filter((message: BackgroundMessage) => message.role === "user");
|
||||
if (userMessages.length > 3) return null; // Too many turns for a background task
|
||||
|
||||
return "system_prompt_pattern";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is a background/utility task.
|
||||
*
|
||||
@@ -131,44 +212,7 @@ export function isBackgroundTask(
|
||||
body: BackgroundTaskBody | unknown,
|
||||
headers: Record<string, string> | null = null
|
||||
): boolean {
|
||||
if (!body || typeof body !== "object") return false;
|
||||
const typedBody = body as BackgroundTaskBody;
|
||||
|
||||
// 1. Check explicit header
|
||||
if (headers) {
|
||||
const priority =
|
||||
headers["x-request-priority"] || headers["X-Request-Priority"] || headers["x-initiator"];
|
||||
if (priority === "background" || priority === "Background") return true;
|
||||
}
|
||||
|
||||
// 2. Check system prompt for background task patterns
|
||||
const messages = toMessageArray(typedBody.messages ?? typedBody.input ?? []);
|
||||
if (!Array.isArray(messages) || messages.length === 0) return false;
|
||||
|
||||
// Find system message
|
||||
const systemMsg = messages.find(
|
||||
(message: BackgroundMessage) => message.role === "system" || message.role === "developer"
|
||||
);
|
||||
if (!systemMsg) return false;
|
||||
|
||||
const systemContent =
|
||||
typeof systemMsg.content === "string" ? systemMsg.content.toLowerCase() : "";
|
||||
|
||||
if (!systemContent) return false;
|
||||
|
||||
// Check against detection patterns
|
||||
const matched = _config.detectionPatterns.some((pattern) =>
|
||||
systemContent.includes(pattern.toLowerCase())
|
||||
);
|
||||
|
||||
if (!matched) return false;
|
||||
|
||||
// 3. Additional heuristic: background tasks typically have very few messages
|
||||
// (system + 1-2 user messages)
|
||||
const userMessages = messages.filter((message: BackgroundMessage) => message.role === "user");
|
||||
if (userMessages.length > 3) return false; // Too many turns for a background task
|
||||
|
||||
return true;
|
||||
return getBackgroundTaskReason(body, headers) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -841,7 +841,8 @@ export async function handleComboChat({
|
||||
errorText,
|
||||
0,
|
||||
null,
|
||||
provider
|
||||
provider,
|
||||
result.headers
|
||||
);
|
||||
|
||||
// Record failure in circuit breaker for transient errors
|
||||
@@ -865,6 +866,12 @@ export async function handleComboChat({
|
||||
if (!lastStatus) lastStatus = result.status;
|
||||
if (i > 0) fallbackCount++;
|
||||
log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status });
|
||||
|
||||
if ([502, 503, 504].includes(result.status) && cooldownMs > 0 && cooldownMs <= 5000) {
|
||||
log.info("COMBO", `Waiting ${cooldownMs}ms before fallback to next model`);
|
||||
await new Promise((r) => setTimeout(r, cooldownMs));
|
||||
}
|
||||
|
||||
break; // Move to next model
|
||||
}
|
||||
}
|
||||
@@ -886,7 +893,20 @@ export async function handleComboChat({
|
||||
);
|
||||
}
|
||||
|
||||
const status = lastStatus || 406;
|
||||
if (!lastStatus) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: "Service temporarily unavailable: all upstream accounts are inactive",
|
||||
type: "service_unavailable",
|
||||
code: "ALL_ACCOUNTS_INACTIVE",
|
||||
},
|
||||
}),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const status = lastStatus;
|
||||
const msg = lastError || "All combo models unavailable";
|
||||
|
||||
if (earliestRetryAfter) {
|
||||
@@ -941,7 +961,7 @@ async function handleRoundRobinCombo({
|
||||
|
||||
const modelCount = orderedModels.length;
|
||||
if (modelCount === 0) {
|
||||
return unavailableResponse(406, "Round-robin combo has no models");
|
||||
return unavailableResponse(503, "Round-robin combo has no models");
|
||||
}
|
||||
|
||||
// Get and increment atomic counter
|
||||
@@ -1077,7 +1097,8 @@ async function handleRoundRobinCombo({
|
||||
errorText,
|
||||
0,
|
||||
null,
|
||||
provider
|
||||
provider,
|
||||
result.headers
|
||||
);
|
||||
|
||||
// Transient errors → mark in semaphore AND record circuit breaker failure
|
||||
@@ -1106,6 +1127,12 @@ async function handleRoundRobinCombo({
|
||||
if (!lastStatus) lastStatus = result.status;
|
||||
if (offset > 0) fallbackCount++;
|
||||
log.warn("COMBO-RR", `${modelStr} failed, trying next model`, { status: result.status });
|
||||
|
||||
if ([502, 503, 504].includes(result.status) && cooldownMs > 0 && cooldownMs <= 5000) {
|
||||
log.info("COMBO-RR", `Waiting ${cooldownMs}ms before fallback to next model`);
|
||||
await new Promise((r) => setTimeout(r, cooldownMs));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
@@ -1136,7 +1163,20 @@ async function handleRoundRobinCombo({
|
||||
);
|
||||
}
|
||||
|
||||
const status = lastStatus || 406;
|
||||
if (!lastStatus) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: "Service temporarily unavailable: all upstream accounts are inactive",
|
||||
type: "service_unavailable",
|
||||
code: "ALL_ACCOUNTS_INACTIVE",
|
||||
},
|
||||
}),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const status = lastStatus;
|
||||
const msg = lastError || "All round-robin combo models unavailable";
|
||||
|
||||
if (earliestRetryAfter) {
|
||||
|
||||
53
open-sse/services/errorClassifier.ts
Normal file
53
open-sse/services/errorClassifier.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { isAccountDeactivated, isCreditsExhausted } from "./accountFallback.ts";
|
||||
|
||||
export const PROVIDER_ERROR_TYPES = {
|
||||
RATE_LIMITED: "rate_limited", // 429 — transient, retry with backoff
|
||||
UNAUTHORIZED: "unauthorized", // 401 — token expired, refresh
|
||||
ACCOUNT_DEACTIVATED: "account_deactivated", // 401 + deactivation signal
|
||||
FORBIDDEN: "forbidden", // 403 — account banned/revoked, disable node
|
||||
SERVER_ERROR: "server_error", // 500/502/503 — retry limited
|
||||
QUOTA_EXHAUSTED: "quota_exhausted", // 402/429/400 + billing signals
|
||||
};
|
||||
|
||||
function responseBodyToString(responseBody: unknown): string {
|
||||
if (typeof responseBody === "string") return responseBody;
|
||||
if (responseBody !== null && typeof responseBody === "object") {
|
||||
try {
|
||||
return JSON.stringify(responseBody);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function classifyProviderError(statusCode: number, responseBody: unknown): string | null {
|
||||
const bodyStr = responseBodyToString(responseBody);
|
||||
const creditsExhausted = isCreditsExhausted(bodyStr);
|
||||
const accountDeactivated = isAccountDeactivated(bodyStr);
|
||||
|
||||
// T10: credits exhausted is terminal and can appear as 400/402/429 depending on provider.
|
||||
if (
|
||||
creditsExhausted &&
|
||||
(statusCode === 400 || statusCode === 402 || statusCode === 429 || statusCode === 403)
|
||||
) {
|
||||
return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED;
|
||||
}
|
||||
|
||||
if (statusCode === 429) {
|
||||
return PROVIDER_ERROR_TYPES.RATE_LIMITED;
|
||||
}
|
||||
|
||||
// T06: only deactivation-like 401s should be treated as permanent account expiry.
|
||||
if (statusCode === 401) {
|
||||
return accountDeactivated
|
||||
? PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED
|
||||
: PROVIDER_ERROR_TYPES.UNAUTHORIZED;
|
||||
}
|
||||
|
||||
if (statusCode === 402) return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED;
|
||||
if (statusCode === 403) return PROVIDER_ERROR_TYPES.FORBIDDEN;
|
||||
if (statusCode >= 500) return PROVIDER_ERROR_TYPES.SERVER_ERROR;
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
* IP-based access control with blacklist, whitelist, priority modes, and temporary bans.
|
||||
*/
|
||||
|
||||
import { isIP } from "node:net";
|
||||
|
||||
// In-memory IP lists
|
||||
let _config = {
|
||||
enabled: false,
|
||||
@@ -161,10 +163,10 @@ export function createIPFilterMiddleware() {
|
||||
*/
|
||||
export function checkRequestIP(request) {
|
||||
const ip =
|
||||
request.headers?.get?.("x-forwarded-for")?.split(",")[0].trim() ||
|
||||
request.headers?.get?.("x-real-ip") ||
|
||||
request.headers?.get?.("cf-connecting-ip") ||
|
||||
request.ip ||
|
||||
pickFirstValidIp(request.headers?.get?.("cf-connecting-ip")) ||
|
||||
pickFirstValidIp(request.headers?.get?.("x-forwarded-for")) ||
|
||||
pickFirstValidIp(request.headers?.get?.("x-real-ip")) ||
|
||||
normalizeIP(request.ip || "") ||
|
||||
"unknown";
|
||||
return checkIP(ip);
|
||||
}
|
||||
@@ -177,6 +179,18 @@ function normalizeIP(ip) {
|
||||
return ip.replace(/^::ffff:/, "").trim();
|
||||
}
|
||||
|
||||
function pickFirstValidIp(rawValue) {
|
||||
if (typeof rawValue !== "string" || rawValue.trim().length === 0) return null;
|
||||
const candidates = rawValue.split(",");
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeIP(candidate);
|
||||
if (normalized && isIP(normalized) !== 0) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchesAny(ip, ipSet) {
|
||||
// Direct match
|
||||
if (ipSet.has(ip)) return true;
|
||||
@@ -225,12 +239,13 @@ function matchesWildcard(ip, pattern) {
|
||||
}
|
||||
|
||||
function extractClientIP(req) {
|
||||
const headers = req.headers || {};
|
||||
return (
|
||||
req.headers?.["x-forwarded-for"]?.split(",")[0].trim() ||
|
||||
req.headers?.["x-real-ip"] ||
|
||||
req.headers?.["cf-connecting-ip"] ||
|
||||
req.socket?.remoteAddress ||
|
||||
req.ip ||
|
||||
pickFirstValidIp(headers["cf-connecting-ip"]) ||
|
||||
pickFirstValidIp(headers["x-forwarded-for"]) ||
|
||||
pickFirstValidIp(headers["x-real-ip"]) ||
|
||||
pickFirstValidIp(req.socket?.remoteAddress) ||
|
||||
pickFirstValidIp(req.ip) ||
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ const BUILT_IN_ALIASES: Record<string, string> = {
|
||||
"gemini-1.5-flash": "gemini-2.5-flash",
|
||||
"gemini-1.0-pro": "gemini-2.5-pro",
|
||||
"gemini-2.0-flash": "gemini-2.5-flash",
|
||||
"gemini-3-pro-high": "gemini-3.1-pro-high",
|
||||
"gemini-3-pro-low": "gemini-3.1-pro-low",
|
||||
|
||||
// Claude legacy → current
|
||||
"claude-3-opus-20240229": "claude-opus-4-20250514",
|
||||
|
||||
@@ -101,6 +101,7 @@ const MODEL_UNAVAILABLE_FRAGMENTS = [
|
||||
"does not support",
|
||||
"not enabled for",
|
||||
"access to model",
|
||||
"improperly formed request", // Kiro 400 (model unavailable)
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import Bottleneck from "bottleneck";
|
||||
import { parseRetryAfterFromBody, lockModel } from "./accountFallback.ts";
|
||||
import { getProviderCategory } from "../config/providerRegistry.ts";
|
||||
import { DEFAULT_API_LIMITS } from "../config/constants.ts";
|
||||
import { getCodexRateLimitKey } from "../executors/codex.ts";
|
||||
|
||||
interface LearnedLimitEntry {
|
||||
provider: string;
|
||||
@@ -195,8 +196,15 @@ export function isRateLimitEnabled(connectionId) {
|
||||
/**
|
||||
* Get or create a limiter for a given provider+connection combination
|
||||
*/
|
||||
function getLimiterKey(provider, connectionId, model = null) {
|
||||
if (provider === "codex" && model) {
|
||||
return `${provider}:${getCodexRateLimitKey(connectionId, model)}`;
|
||||
}
|
||||
return `${provider}:${connectionId}`;
|
||||
}
|
||||
|
||||
function getLimiter(provider, connectionId, model = null) {
|
||||
const key = model ? `${provider}:${connectionId}:${model}` : `${provider}:${connectionId}`;
|
||||
const key = getLimiterKey(provider, connectionId, model);
|
||||
|
||||
if (!limiters.has(key)) {
|
||||
const limiter = new Bottleneck({
|
||||
@@ -235,7 +243,7 @@ export async function withRateLimit(provider, connectionId, model, fn) {
|
||||
return fn();
|
||||
}
|
||||
|
||||
const limiter = getLimiter(provider, connectionId, null);
|
||||
const limiter = getLimiter(provider, connectionId, model);
|
||||
return limiter.schedule(fn);
|
||||
}
|
||||
|
||||
@@ -320,7 +328,7 @@ export function updateFromHeaders(provider, connectionId, headers, status, model
|
||||
if (!enabledConnections.has(connectionId)) return;
|
||||
if (!headers) return;
|
||||
|
||||
const limiter = getLimiter(provider, connectionId, null);
|
||||
const limiter = getLimiter(provider, connectionId, model);
|
||||
const headerMap =
|
||||
provider === "claude" || provider === "anthropic" ? ANTHROPIC_HEADERS : STANDARD_HEADERS;
|
||||
|
||||
@@ -340,7 +348,7 @@ export function updateFromHeaders(provider, connectionId, headers, status, model
|
||||
if (status === 429) {
|
||||
const retryAfterMs = parseResetTime(retryAfterStr) || 60000; // Default 60s
|
||||
const counts = limiter.counts();
|
||||
const limiterKey = `${provider}:${connectionId}`;
|
||||
const limiterKey = getLimiterKey(provider, connectionId, model);
|
||||
console.log(
|
||||
`🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — 429 received, pausing for ${Math.ceil(retryAfterMs / 1000)}s, dropping ${counts.QUEUED} queued request(s)`
|
||||
);
|
||||
@@ -397,7 +405,12 @@ export function updateFromHeaders(provider, connectionId, headers, status, model
|
||||
limiter.updateSettings(updates);
|
||||
|
||||
// Persist learned limits (debounced)
|
||||
recordLearnedLimit(provider, connectionId, { limit, remaining, minTime: updates.minTime });
|
||||
recordLearnedLimit(
|
||||
provider,
|
||||
connectionId,
|
||||
{ limit, remaining, minTime: updates.minTime },
|
||||
model
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,9 +472,10 @@ export function getLearnedLimits() {
|
||||
function recordLearnedLimit(
|
||||
provider: string,
|
||||
connectionId: string,
|
||||
limits: Partial<Omit<LearnedLimitEntry, "provider" | "connectionId" | "lastUpdated">>
|
||||
limits: Partial<Omit<LearnedLimitEntry, "provider" | "connectionId" | "lastUpdated">>,
|
||||
model: string | null = null
|
||||
) {
|
||||
const key = `${provider}:${connectionId}`;
|
||||
const key = getLimiterKey(provider, connectionId, model);
|
||||
learnedLimits[key] = {
|
||||
...limits,
|
||||
provider,
|
||||
|
||||
@@ -41,7 +41,13 @@ const SESSION_TTL_MS = 30 * 60 * 1000;
|
||||
const _cleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of sessions) {
|
||||
if (now - entry.lastActive > SESSION_TTL_MS) sessions.delete(key);
|
||||
if (now - entry.lastActive > SESSION_TTL_MS) {
|
||||
sessions.delete(key);
|
||||
for (const [apiKeyId, sessionSet] of activeSessionsByKey) {
|
||||
sessionSet.delete(key);
|
||||
if (sessionSet.size === 0) activeSessionsByKey.delete(apiKeyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
_cleanupTimer.unref();
|
||||
@@ -173,6 +179,114 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of active session counts per API key.
|
||||
*/
|
||||
export function getAllActiveSessionCountsByKey(): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const [apiKeyId, sessionIds] of activeSessionsByKey) {
|
||||
out[apiKeyId] = sessionIds.size;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a given session is already registered for an API key.
|
||||
*/
|
||||
export function isSessionRegisteredForKey(apiKeyId: string, sessionId: string): boolean {
|
||||
return activeSessionsByKey.get(apiKeyId)?.has(sessionId) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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_session_id") ?? // Underscore variant (direct HTTP / custom clients)
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -13,21 +13,27 @@ export const ThinkingMode = {
|
||||
ADAPTIVE: "adaptive", // Scale based on request complexity
|
||||
};
|
||||
|
||||
import { capThinkingBudget, getDefaultThinkingBudget } from "@/shared/constants/modelSpecs";
|
||||
|
||||
// Effort → budget token mapping
|
||||
export const EFFORT_BUDGETS = {
|
||||
none: 0,
|
||||
low: 1024,
|
||||
medium: 10240,
|
||||
high: 131072,
|
||||
high: 131072, // Handled globally by capThinkingBudget later
|
||||
max: 131072, // T11: Claude "max" / "xhigh" — full budget
|
||||
xhigh: 131072, // T11: explicit alias used internally
|
||||
};
|
||||
|
||||
// thinkingLevel string → budget token mapping
|
||||
// Used when clients send string-based thinking levels (e.g., VS Code Copilot)
|
||||
export const THINKING_LEVEL_MAP = {
|
||||
none: 0,
|
||||
low: 1024,
|
||||
medium: 10240,
|
||||
high: 131072,
|
||||
low: 4096,
|
||||
medium: 8192,
|
||||
high: 24576,
|
||||
max: 131072, // T11: max = full Claude budget (sub2api: xhigh)
|
||||
xhigh: 131072, // T11: explicit xhigh alias
|
||||
};
|
||||
|
||||
// Default config (passthrough = backward compatible)
|
||||
@@ -68,8 +74,9 @@ export function normalizeThinkingLevel(body) {
|
||||
|
||||
// Handle top-level thinkingLevel or thinking_level string fields
|
||||
const levelStr = result.thinkingLevel || result.thinking_level;
|
||||
if (typeof levelStr === "string" && THINKING_LEVEL_MAP[levelStr] !== undefined) {
|
||||
const budget = THINKING_LEVEL_MAP[levelStr];
|
||||
if (typeof levelStr === "string" && THINKING_LEVEL_MAP[levelStr.toLowerCase()] !== undefined) {
|
||||
const rawBudget = THINKING_LEVEL_MAP[levelStr.toLowerCase()];
|
||||
const budget = capThinkingBudget(result.model || "", rawBudget);
|
||||
// Convert to Claude thinking format as canonical representation
|
||||
result.thinking = {
|
||||
type: budget > 0 ? "enabled" : "disabled",
|
||||
@@ -83,15 +90,22 @@ export function normalizeThinkingLevel(body) {
|
||||
const geminiLevel =
|
||||
result.generationConfig?.thinkingConfig?.thinkingLevel ||
|
||||
result.generationConfig?.thinking_config?.thinkingLevel;
|
||||
if (typeof geminiLevel === "string" && THINKING_LEVEL_MAP[geminiLevel] !== undefined) {
|
||||
const budget = THINKING_LEVEL_MAP[geminiLevel];
|
||||
if (
|
||||
typeof geminiLevel === "string" &&
|
||||
THINKING_LEVEL_MAP[geminiLevel.toLowerCase()] !== undefined
|
||||
) {
|
||||
const rawBudget = THINKING_LEVEL_MAP[geminiLevel.toLowerCase()];
|
||||
const budget = capThinkingBudget(result.model || "", rawBudget);
|
||||
result.generationConfig = {
|
||||
...result.generationConfig,
|
||||
thinking_config: { thinking_budget: budget },
|
||||
thinkingConfig: { ...result.generationConfig.thinkingConfig, thinkingBudget: budget },
|
||||
};
|
||||
// Clean up camelCase variant if it was the source
|
||||
// Clean up string variants
|
||||
if (result.generationConfig.thinkingConfig) {
|
||||
delete result.generationConfig.thinkingConfig;
|
||||
delete result.generationConfig.thinkingConfig.thinkingLevel;
|
||||
}
|
||||
if (result.generationConfig.thinking_config) {
|
||||
delete result.generationConfig.thinking_config;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +132,7 @@ export function ensureThinkingConfig(body) {
|
||||
const result = { ...body };
|
||||
result.thinking = {
|
||||
type: "enabled",
|
||||
budget_tokens: EFFORT_BUDGETS.medium, // 10240 default
|
||||
budget_tokens: getDefaultThinkingBudget(model) || EFFORT_BUDGETS.medium,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
@@ -198,7 +212,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 +221,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,8 +267,11 @@ function applyAdaptiveBudget(body, cfg) {
|
||||
if (toolCount > 3) multiplier += 0.5;
|
||||
if (lastMsgLength > 2000) multiplier += 0.3;
|
||||
|
||||
const baseBudget = EFFORT_BUDGETS[cfg.effortLevel] || EFFORT_BUDGETS.medium;
|
||||
const budget = Math.min(Math.ceil(baseBudget * multiplier), 131072);
|
||||
const baseBudget =
|
||||
EFFORT_BUDGETS[cfg.effortLevel] ||
|
||||
getDefaultThinkingBudget(body.model || "") ||
|
||||
EFFORT_BUDGETS.medium;
|
||||
const budget = capThinkingBudget(body.model || "", Math.ceil(baseBudget * multiplier));
|
||||
|
||||
return setCustomBudget(body, budget);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { resolveDataDir } from "../../src/lib/dataPaths";
|
||||
/**
|
||||
* Responses API Transformer
|
||||
* Converts OpenAI Chat Completions SSE to Codex Responses API SSE format
|
||||
@@ -39,7 +40,7 @@ export function createResponsesLogger(model, logsDir = null) {
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15);
|
||||
const uniqueId = Math.random().toString(36).slice(2, 8);
|
||||
const baseDir = logsDir || (typeof process !== "undefined" ? process.cwd() : ".");
|
||||
const baseDir = logsDir || resolveDataDir();
|
||||
const logDir = path.join(baseDir, "logs", `responses_${model}_${timestamp}_${uniqueId}`);
|
||||
|
||||
try {
|
||||
@@ -402,6 +403,16 @@ export function createResponsesApiTransformStream(logger = null) {
|
||||
const newCallId = tc.id;
|
||||
const funcName = tc.function?.name;
|
||||
|
||||
// T37: Prevent merging if a new tool_call uses the same index
|
||||
if (state.funcCallIds[tcIdx] && newCallId && state.funcCallIds[tcIdx] !== newCallId) {
|
||||
closeToolCall(controller, tcIdx);
|
||||
delete state.funcCallIds[tcIdx];
|
||||
delete state.funcNames[tcIdx];
|
||||
delete state.funcArgsBuf[tcIdx];
|
||||
delete state.funcArgsDone[tcIdx];
|
||||
delete state.funcItemDone[tcIdx];
|
||||
}
|
||||
|
||||
if (funcName) state.funcNames[tcIdx] = funcName;
|
||||
|
||||
if (!state.funcCallIds[tcIdx] && newCallId) {
|
||||
|
||||
@@ -172,6 +172,9 @@ function convertEnumValuesToStrings(obj) {
|
||||
|
||||
if (obj.enum && Array.isArray(obj.enum)) {
|
||||
obj.enum = obj.enum.map((v) => String(v));
|
||||
if (!obj.type) {
|
||||
obj.type = "string";
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of Object.values(obj)) {
|
||||
|
||||
22
open-sse/translator/image/sizeMapper.ts
Normal file
22
open-sse/translator/image/sizeMapper.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
const OPENAI_SIZE_TO_ASPECT_RATIO: Record<string, string> = {
|
||||
"256x256": "1:1",
|
||||
"512x512": "1:1",
|
||||
"1024x1024": "1:1",
|
||||
"1792x1024": "16:9",
|
||||
"1024x1792": "9:16",
|
||||
"1536x1024": "3:2",
|
||||
"1024x1536": "2:3",
|
||||
};
|
||||
|
||||
// Supports direct aspect ratios (e.g. "16:9")
|
||||
const ASPECT_RATIO_PASSTHROUGH = /^\d+:\d+$/;
|
||||
|
||||
export function mapImageSize(sizeParam?: string | null): string {
|
||||
if (!sizeParam) return "1:1"; // default
|
||||
|
||||
// Native aspect ratio (e.g. "16:9") — pass-through
|
||||
if (ASPECT_RATIO_PASSTHROUGH.test(sizeParam)) return sizeParam;
|
||||
|
||||
// Map OpenAI sizes to aspect ratios
|
||||
return OPENAI_SIZE_TO_ASPECT_RATIO[sizeParam] ?? "1:1";
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { register } from "../registry.ts";
|
||||
import { FORMATS } from "../formats.ts";
|
||||
import { DEFAULT_SAFETY_SETTINGS, tryParseJSON } from "../helpers/geminiHelper.ts";
|
||||
import {
|
||||
DEFAULT_SAFETY_SETTINGS,
|
||||
tryParseJSON,
|
||||
cleanJSONSchemaForAntigravity,
|
||||
} from "../helpers/geminiHelper.ts";
|
||||
import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts";
|
||||
|
||||
/**
|
||||
@@ -154,7 +158,9 @@ export function claudeToGeminiRequest(model, body, stream) {
|
||||
functionDeclarations.push({
|
||||
name: tool.name,
|
||||
description: tool.description || "",
|
||||
parameters: tool.input_schema || { type: "object", properties: {} },
|
||||
parameters: cleanJSONSchemaForAntigravity(
|
||||
tool.input_schema || { type: "object", properties: {} }
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -3,6 +3,11 @@ import { FORMATS } from "../formats.ts";
|
||||
import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts";
|
||||
import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/constants.ts";
|
||||
import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.ts";
|
||||
import {
|
||||
capMaxOutputTokens,
|
||||
capThinkingBudget,
|
||||
getDefaultThinkingBudget,
|
||||
} from "../../../src/shared/constants/modelSpecs.ts";
|
||||
|
||||
function generateUUID() {
|
||||
return crypto.randomUUID();
|
||||
@@ -88,7 +93,9 @@ function openaiToGeminiBase(model, body, stream) {
|
||||
result.generationConfig.topK = body.top_k;
|
||||
}
|
||||
if (body.max_tokens !== undefined) {
|
||||
result.generationConfig.maxOutputTokens = body.max_tokens;
|
||||
result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, body.max_tokens);
|
||||
} else {
|
||||
result.generationConfig.maxOutputTokens = capMaxOutputTokens(model);
|
||||
}
|
||||
|
||||
// Build tool_call_id -> name map
|
||||
@@ -283,8 +290,12 @@ export function openaiToGeminiCLIRequest(model, body, stream) {
|
||||
|
||||
// Add thinking config for CLI
|
||||
if (body.reasoning_effort) {
|
||||
const budgetMap = { low: 1024, medium: 8192, high: 32768 };
|
||||
const budget = budgetMap[body.reasoning_effort] || 8192;
|
||||
const budgetMap = {
|
||||
low: 1024,
|
||||
medium: getDefaultThinkingBudget(model) || 8192,
|
||||
high: capThinkingBudget(model, 32768),
|
||||
};
|
||||
const budget = budgetMap[body.reasoning_effort] || getDefaultThinkingBudget(model) || 8192;
|
||||
gemini.generationConfig.thinkingConfig = {
|
||||
thinkingBudget: budget,
|
||||
include_thoughts: true,
|
||||
|
||||
@@ -257,6 +257,17 @@ function emitToolCall(state, emit, tc) {
|
||||
const newCallId = tc.id;
|
||||
const funcName = tc.function?.name;
|
||||
|
||||
// T37: If we already have a tool call at this index but the ID changed,
|
||||
// we must close the current one and start a new one to prevent merging.
|
||||
if (state.funcCallIds[tcIdx] && newCallId && state.funcCallIds[tcIdx] !== newCallId) {
|
||||
closeToolCall(state, emit, tcIdx);
|
||||
delete state.funcCallIds[tcIdx];
|
||||
delete state.funcNames[tcIdx];
|
||||
delete state.funcArgsBuf[tcIdx];
|
||||
delete state.funcArgsDone[tcIdx];
|
||||
delete state.funcItemDone[tcIdx];
|
||||
}
|
||||
|
||||
if (funcName) state.funcNames[tcIdx] = funcName;
|
||||
|
||||
if (!state.funcCallIds[tcIdx] && newCallId) {
|
||||
|
||||
31
open-sse/utils/aiSdkCompat.ts
Normal file
31
open-sse/utils/aiSdkCompat.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* AI SDK compatibility helpers (T26).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Detects when a client explicitly prefers JSON (non-SSE) responses.
|
||||
*/
|
||||
export function clientWantsJsonResponse(acceptHeader: unknown): boolean {
|
||||
if (typeof acceptHeader !== "string") return false;
|
||||
const normalized = acceptHeader.toLowerCase();
|
||||
return normalized.includes("application/json") && !normalized.includes("text/event-stream");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves stream behavior from request body + Accept header.
|
||||
* OpenAI-compatible behavior: stream only when `stream: true` and client did not force JSON.
|
||||
*/
|
||||
export function resolveStreamFlag(bodyStream: unknown, acceptHeader: unknown): boolean {
|
||||
return bodyStream === true && !clientWantsJsonResponse(acceptHeader);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes surrounding markdown code fences when Claude wraps JSON payloads.
|
||||
* Example: ```json\n{"ok":true}\n``` -> {"ok":true}
|
||||
*/
|
||||
export function stripMarkdownCodeFence(text: unknown): unknown {
|
||||
if (typeof text !== "string") return text;
|
||||
const codeBlockRegex = /^```(?:json|javascript|typescript|js|ts)?\s*\n?([\s\S]*?)\n?```\s*$/i;
|
||||
const match = text.trim().match(codeBlockRegex);
|
||||
return match ? match[1].trim() : text;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ type PendingToolCall = {
|
||||
export function transformToOllama(response, model) {
|
||||
let buffer = "";
|
||||
let pendingToolCalls: Record<number, PendingToolCall> = {};
|
||||
const completedToolCalls: PendingToolCall[] = [];
|
||||
|
||||
const transform = new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
@@ -41,6 +42,13 @@ export function transformToOllama(response, model) {
|
||||
if (toolCalls) {
|
||||
for (const tc of toolCalls) {
|
||||
const idx = tc.index;
|
||||
|
||||
// T37: Prevent merging tool_calls on same index if ID changes
|
||||
if (pendingToolCalls[idx] && tc.id && pendingToolCalls[idx].id !== tc.id) {
|
||||
completedToolCalls.push(pendingToolCalls[idx]);
|
||||
delete pendingToolCalls[idx];
|
||||
}
|
||||
|
||||
if (!pendingToolCalls[idx]) {
|
||||
pendingToolCalls[idx] = { id: tc.id, function: { name: "", arguments: "" } };
|
||||
}
|
||||
@@ -59,7 +67,7 @@ export function transformToOllama(response, model) {
|
||||
|
||||
const finishReason = parsed.choices?.[0]?.finish_reason;
|
||||
if (finishReason === "tool_calls" || finishReason === "stop") {
|
||||
const toolCallsArr = Object.values(pendingToolCalls);
|
||||
const toolCallsArr = [...completedToolCalls, ...Object.values(pendingToolCalls)];
|
||||
if (toolCallsArr.length > 0) {
|
||||
const formattedCalls = toolCallsArr.map((tc) => ({
|
||||
function: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
proxyUrlForLogs,
|
||||
} from "./proxyDispatcher.ts";
|
||||
import tlsClient from "./tlsClient.ts";
|
||||
import { isProxyReachable } from "@/lib/proxyHealth";
|
||||
|
||||
function isTlsFingerprintEnabled() {
|
||||
return process.env.ENABLE_TLS_FINGERPRINT === "true";
|
||||
@@ -134,6 +135,22 @@ export async function runWithProxyContext(proxyConfig, fn) {
|
||||
|
||||
const resolvedProxyUrl = proxyConfig ? proxyConfigToUrl(proxyConfig) : null;
|
||||
|
||||
// T14: Proxy Fast-Fail
|
||||
// Perform a short TCP reachability check before issuing upstream requests.
|
||||
if (resolvedProxyUrl) {
|
||||
const reachable = await isProxyReachable(resolvedProxyUrl);
|
||||
if (!reachable) {
|
||||
const proxyLabel = proxyUrlForLogs(resolvedProxyUrl);
|
||||
const err = new Error(`[Proxy Fast-Fail] Proxy unreachable: ${proxyLabel}`) as Error & {
|
||||
code?: string;
|
||||
statusCode?: number;
|
||||
};
|
||||
err.code = "PROXY_UNREACHABLE";
|
||||
err.statusCode = 503;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return proxyContext.run(proxyConfig || null, async () => {
|
||||
if (resolvedProxyUrl) {
|
||||
console.log(
|
||||
|
||||
@@ -16,10 +16,8 @@ async function ensureNodeModules() {
|
||||
try {
|
||||
fs = await import("fs");
|
||||
path = await import("path");
|
||||
LOGS_DIR = path.join(
|
||||
typeof process !== "undefined" && process.cwd ? process.cwd() : ".",
|
||||
"logs"
|
||||
);
|
||||
const { resolveDataDir } = await import("../../src/lib/dataPaths");
|
||||
LOGS_DIR = path.join(resolveDataDir(), "logs");
|
||||
} catch {
|
||||
// Running in non-Node environment (Worker, Browser, etc.)
|
||||
}
|
||||
|
||||
@@ -222,16 +222,17 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
const extracted = extractUsage(parsed);
|
||||
if (extracted) {
|
||||
// Non-destructive merge: never overwrite a positive value with 0
|
||||
// message_start carries input_tokens, message_delta carries output_tokens
|
||||
if (!usage) usage = {};
|
||||
if (extracted.prompt_tokens > 0) usage.prompt_tokens = extracted.prompt_tokens;
|
||||
if (extracted.completion_tokens > 0)
|
||||
usage.completion_tokens = extracted.completion_tokens;
|
||||
if (extracted.total_tokens > 0) usage.total_tokens = extracted.total_tokens;
|
||||
if (extracted.cache_read_input_tokens)
|
||||
usage.cache_read_input_tokens = extracted.cache_read_input_tokens;
|
||||
if (extracted.cache_creation_input_tokens)
|
||||
usage.cache_creation_input_tokens = extracted.cache_creation_input_tokens;
|
||||
// message_start carries input_tokens, message_delta carries output_tokens;
|
||||
if (!usage) usage = {} as any;
|
||||
const u = usage as Record<string, number>;
|
||||
const eu = extracted as Record<string, number>;
|
||||
if (eu.prompt_tokens > 0) u.prompt_tokens = eu.prompt_tokens;
|
||||
if (eu.completion_tokens > 0) u.completion_tokens = eu.completion_tokens;
|
||||
if (eu.total_tokens > 0) u.total_tokens = eu.total_tokens;
|
||||
if (eu.cache_read_input_tokens)
|
||||
u.cache_read_input_tokens = eu.cache_read_input_tokens;
|
||||
if (eu.cache_creation_input_tokens)
|
||||
u.cache_creation_input_tokens = eu.cache_creation_input_tokens;
|
||||
}
|
||||
// Track content length and accumulate from Claude format
|
||||
if (parsed.delta?.text) {
|
||||
@@ -263,6 +264,11 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// T18: Track if we saw tool calls
|
||||
if (delta?.tool_calls && delta.tool_calls.length > 0) {
|
||||
(state as any).passthroughHasToolCalls = true;
|
||||
}
|
||||
|
||||
const content = delta?.content || delta?.reasoning_content;
|
||||
if (content && typeof content === "string") {
|
||||
totalContentLength += content.length;
|
||||
@@ -278,6 +284,20 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
}
|
||||
|
||||
const isFinishChunk = parsed.choices?.[0]?.finish_reason;
|
||||
|
||||
// T18: Normalize finish_reason to 'tool_calls' if tool calls were used
|
||||
if (
|
||||
isFinishChunk &&
|
||||
(state as any).passthroughHasToolCalls &&
|
||||
parsed.choices[0].finish_reason !== "tool_calls"
|
||||
) {
|
||||
parsed.choices[0].finish_reason = "tool_calls";
|
||||
// If we modify it, we must output the modified object
|
||||
if (!injectedUsage && hasValidUsage(parsed.usage)) {
|
||||
output = `data: ${JSON.stringify(parsed)}\n`;
|
||||
injectedUsage = true;
|
||||
}
|
||||
}
|
||||
if (isFinishChunk && !hasValidUsage(parsed.usage)) {
|
||||
const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
|
||||
parsed.usage = filterUsageForFormat(estimated, FORMATS.OPENAI);
|
||||
@@ -529,19 +549,17 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
if (!state.usage) {
|
||||
state.usage = extracted;
|
||||
} else {
|
||||
if (extracted.prompt_tokens > 0)
|
||||
state.usage.prompt_tokens = extracted.prompt_tokens;
|
||||
if (extracted.completion_tokens > 0)
|
||||
state.usage.completion_tokens = extracted.completion_tokens;
|
||||
if (extracted.total_tokens > 0) state.usage.total_tokens = extracted.total_tokens;
|
||||
if (extracted.cache_read_input_tokens > 0)
|
||||
state.usage.cache_read_input_tokens = extracted.cache_read_input_tokens;
|
||||
if (extracted.cache_creation_input_tokens > 0)
|
||||
state.usage.cache_creation_input_tokens = extracted.cache_creation_input_tokens;
|
||||
if (extracted.cached_tokens > 0)
|
||||
state.usage.cached_tokens = extracted.cached_tokens;
|
||||
if (extracted.reasoning_tokens > 0)
|
||||
state.usage.reasoning_tokens = extracted.reasoning_tokens;
|
||||
const su = state.usage as Record<string, number>;
|
||||
const eu = extracted as Record<string, number>;
|
||||
if (eu.prompt_tokens > 0) su.prompt_tokens = eu.prompt_tokens;
|
||||
if (eu.completion_tokens > 0) su.completion_tokens = eu.completion_tokens;
|
||||
if (eu.total_tokens > 0) su.total_tokens = eu.total_tokens;
|
||||
if (eu.cache_read_input_tokens > 0)
|
||||
su.cache_read_input_tokens = eu.cache_read_input_tokens;
|
||||
if (eu.cache_creation_input_tokens > 0)
|
||||
su.cache_creation_input_tokens = eu.cache_creation_input_tokens;
|
||||
if (eu.cached_tokens > 0) su.cached_tokens = eu.cached_tokens;
|
||||
if (eu.reasoning_tokens > 0) su.reasoning_tokens = eu.reasoning_tokens;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,36 @@ export function createDisconnectAwareStream(transformStream, streamController) {
|
||||
controller.enqueue(value);
|
||||
} catch (error) {
|
||||
streamController.handleError(error);
|
||||
controller.error(error);
|
||||
|
||||
// T35: Encapsulate mid-stream errors as SSE events instead of abruptly aborting
|
||||
// This prevents TransferEncodingError on the client side
|
||||
const errorMsg = error instanceof Error ? error.message : "Upstream stream error";
|
||||
const statusCode =
|
||||
typeof error === "object" && error !== null && "statusCode" in error
|
||||
? (error as any).statusCode
|
||||
: 500;
|
||||
|
||||
const errorEvent = {
|
||||
object: "chat.completion.chunk",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: "error",
|
||||
},
|
||||
],
|
||||
error: {
|
||||
message: errorMsg,
|
||||
type: "upstream_error",
|
||||
code: statusCode,
|
||||
},
|
||||
};
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(errorEvent)}\n\n`));
|
||||
controller.enqueue(encoder.encode(`data: [DONE]\n\n`));
|
||||
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
114
package-lock.json
generated
114
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.0.0-rc.4",
|
||||
"version": "3.0.0-rc.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "3.0.0-rc.4",
|
||||
"version": "3.0.0-rc.12",
|
||||
"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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.0.0-rc.4",
|
||||
"version": "3.0.0-rc.12",
|
||||
"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",
|
||||
|
||||
@@ -69,6 +69,7 @@ interface ApiKey {
|
||||
noLog?: boolean;
|
||||
autoResolve?: boolean;
|
||||
isActive?: boolean;
|
||||
maxSessions?: number;
|
||||
accessSchedule?: AccessSchedule | null;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -109,6 +110,7 @@ export default function ApiManagerPageClient() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [usageStats, setUsageStats] = useState<Record<string, KeyUsageStats>>({});
|
||||
const [sessionCounts, setSessionCounts] = useState<Record<string, number>>({});
|
||||
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
@@ -150,6 +152,7 @@ export default function ApiManagerPageClient() {
|
||||
setKeys(data.keys || []);
|
||||
// Fetch usage stats after keys are loaded
|
||||
fetchUsageStats(data.keys || []);
|
||||
fetchSessionCounts(data.keys || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching keys:", error);
|
||||
@@ -187,6 +190,31 @@ export default function ApiManagerPageClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSessionCounts = async (apiKeys: ApiKey[]) => {
|
||||
if (apiKeys.length === 0) {
|
||||
setSessionCounts({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/sessions");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const byApiKeyRaw =
|
||||
data && typeof data.byApiKey === "object" && !Array.isArray(data.byApiKey)
|
||||
? data.byApiKey
|
||||
: {};
|
||||
const normalized: Record<string, number> = {};
|
||||
for (const key of apiKeys) {
|
||||
const value = byApiKeyRaw[key.id];
|
||||
normalized[key.id] =
|
||||
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
||||
}
|
||||
setSessionCounts(normalized);
|
||||
} catch (error) {
|
||||
console.log("Error fetching session counts:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
@@ -266,6 +294,7 @@ export default function ApiManagerPageClient() {
|
||||
allowedConnections: string[],
|
||||
autoResolve: boolean,
|
||||
isActive: boolean,
|
||||
maxSessions: number,
|
||||
accessSchedule: AccessSchedule | null
|
||||
) => {
|
||||
if (!editingKey || !editingKey.id) return;
|
||||
@@ -291,6 +320,10 @@ export default function ApiManagerPageClient() {
|
||||
const validConnections = allowedConnections.filter(
|
||||
(id) => typeof id === "string" && /^[0-9a-f-]{36}$/i.test(id)
|
||||
);
|
||||
const normalizedMaxSessions =
|
||||
typeof maxSessions === "number" && Number.isFinite(maxSessions)
|
||||
? Math.max(0, Math.floor(maxSessions))
|
||||
: 0;
|
||||
|
||||
setIsSubmitting(true);
|
||||
clearError();
|
||||
@@ -305,6 +338,7 @@ export default function ApiManagerPageClient() {
|
||||
noLog,
|
||||
autoResolve,
|
||||
isActive,
|
||||
maxSessions: normalizedMaxSessions,
|
||||
accessSchedule,
|
||||
}),
|
||||
});
|
||||
@@ -505,6 +539,9 @@ export default function ApiManagerPageClient() {
|
||||
Array.isArray(key.allowedConnections) && key.allowedConnections.length > 0;
|
||||
const noLogEnabled = key.noLog === true;
|
||||
const keyIsActive = key.isActive !== false; // default true
|
||||
const maxSessions = typeof key.maxSessions === "number" ? key.maxSessions : 0;
|
||||
const hasSessionLimit = maxSessions > 0;
|
||||
const activeSessions = sessionCounts[key.id] || 0;
|
||||
const hasSchedule = key.accessSchedule?.enabled === true;
|
||||
return (
|
||||
<div
|
||||
@@ -574,6 +611,12 @@ export default function ApiManagerPageClient() {
|
||||
Auto-Resolve
|
||||
</span>
|
||||
)}
|
||||
{hasSessionLimit && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">group</span>
|
||||
Sessions: {activeSessions}/{maxSessions}
|
||||
</span>
|
||||
)}
|
||||
{!keyIsActive && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-red-500/10 text-red-600 dark:text-red-400 text-[11px] font-medium">
|
||||
<span className="material-symbols-outlined text-[12px]">block</span>
|
||||
@@ -778,6 +821,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
connections: string[],
|
||||
autoResolve: boolean,
|
||||
isActive: boolean,
|
||||
maxSessions: number,
|
||||
accessSchedule: AccessSchedule | null
|
||||
) => void;
|
||||
}) {
|
||||
@@ -794,6 +838,9 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
const [noLogEnabled, setNoLogEnabled] = useState(apiKey?.noLog === true);
|
||||
const [autoResolveEnabled, setAutoResolveEnabled] = useState(apiKey?.autoResolve === true);
|
||||
const [keyIsActive, setKeyIsActive] = useState(apiKey?.isActive !== false);
|
||||
const [maxSessions, setMaxSessions] = useState(
|
||||
typeof apiKey?.maxSessions === "number" && apiKey.maxSessions > 0 ? apiKey.maxSessions : 0
|
||||
);
|
||||
const [scheduleEnabled, setScheduleEnabled] = useState(apiKey?.accessSchedule?.enabled === true);
|
||||
const [scheduleFrom, setScheduleFrom] = useState(apiKey?.accessSchedule?.from ?? "08:00");
|
||||
const [scheduleUntil, setScheduleUntil] = useState(apiKey?.accessSchedule?.until ?? "18:00");
|
||||
@@ -905,6 +952,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
allowAllConnections ? [] : selectedConnections,
|
||||
autoResolveEnabled,
|
||||
keyIsActive,
|
||||
maxSessions,
|
||||
schedule
|
||||
);
|
||||
}, [
|
||||
@@ -916,6 +964,7 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
selectedConnections,
|
||||
autoResolveEnabled,
|
||||
keyIsActive,
|
||||
maxSessions,
|
||||
scheduleEnabled,
|
||||
scheduleFrom,
|
||||
scheduleUntil,
|
||||
@@ -1007,6 +1056,28 @@ const PermissionsModal = memo(function PermissionsModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Max Sessions Limit (T08) */}
|
||||
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-text-main">Max Active Sessions</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
0 = unlimited. Return 429 when this key exceeds concurrent sticky sessions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={String(maxSessions)}
|
||||
onChange={(e) => {
|
||||
const parsed = Number.parseInt(e.target.value || "0", 10);
|
||||
setMaxSessions(Number.isFinite(parsed) && parsed > 0 ? parsed : 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access Schedule */}
|
||||
<div className="flex flex-col gap-2 p-3 rounded-lg border border-border bg-surface/40">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
|
||||
@@ -153,7 +153,7 @@ export default function DefaultToolCard({
|
||||
};
|
||||
|
||||
// Check if this tool supports direct config file write
|
||||
const supportsDirectSave = ["continue"].includes(toolId);
|
||||
const supportsDirectSave = ["continue", "opencode"].includes(toolId);
|
||||
|
||||
const renderApiKeySelector = () => {
|
||||
return (
|
||||
|
||||
@@ -3017,6 +3017,7 @@ CooldownTimer.propTypes = {
|
||||
const ERROR_TYPE_LABELS = {
|
||||
runtime_error: { labelKey: "errorTypeRuntime", variant: "warning" },
|
||||
upstream_auth_error: { labelKey: "errorTypeUpstreamAuth", variant: "error" },
|
||||
account_deactivated: { labelKey: "Account Deactivated", variant: "error" },
|
||||
auth_missing: { labelKey: "errorTypeMissingCredential", variant: "warning" },
|
||||
token_refresh_failed: { labelKey: "errorTypeRefreshFailed", variant: "warning" },
|
||||
token_expired: { labelKey: "errorTypeTokenExpired", variant: "warning" },
|
||||
@@ -3025,10 +3026,14 @@ const ERROR_TYPE_LABELS = {
|
||||
network_error: { labelKey: "errorTypeNetworkError", variant: "warning" },
|
||||
unsupported: { labelKey: "errorTypeTestUnsupported", variant: "default" },
|
||||
upstream_error: { labelKey: "errorTypeUpstreamError", variant: "error" },
|
||||
banned: { labelKey: "403 Banned", variant: "error" },
|
||||
credits_exhausted: { labelKey: "No Credits", variant: "warning" },
|
||||
};
|
||||
|
||||
function inferErrorType(connection, isCooldown) {
|
||||
if (isCooldown) return "upstream_rate_limited";
|
||||
if (connection.testStatus === "banned") return "banned";
|
||||
if (connection.testStatus === "credits_exhausted") return "credits_exhausted";
|
||||
if (connection.lastErrorType) return connection.lastErrorType;
|
||||
|
||||
const code = Number(connection.errorCode);
|
||||
@@ -3108,6 +3113,16 @@ function getStatusPresentation(connection, effectiveStatus, isCooldown, t) {
|
||||
};
|
||||
}
|
||||
|
||||
if (errorType === "account_deactivated") {
|
||||
return {
|
||||
statusVariant: "error",
|
||||
statusLabel: t("statusDeactivated", "Deactivated"),
|
||||
errorType,
|
||||
errorBadge,
|
||||
errorTextClass: "text-red-600 font-bold",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
errorType === "upstream_auth_error" ||
|
||||
errorType === "auth_missing" ||
|
||||
@@ -3153,6 +3168,26 @@ function getStatusPresentation(connection, effectiveStatus, isCooldown, t) {
|
||||
};
|
||||
}
|
||||
|
||||
if (errorType === "banned") {
|
||||
return {
|
||||
statusVariant: "error",
|
||||
statusLabel: t("statusBanned", "Banned (403)"),
|
||||
errorType,
|
||||
errorBadge,
|
||||
errorTextClass: "text-red-600 font-bold",
|
||||
};
|
||||
}
|
||||
|
||||
if (errorType === "credits_exhausted") {
|
||||
return {
|
||||
statusVariant: "warning",
|
||||
statusLabel: t("statusCreditsExhausted", "Out of Credits"),
|
||||
errorType,
|
||||
errorBadge,
|
||||
errorTextClass: "text-amber-500",
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackStatusMap = {
|
||||
unavailable: t("statusUnavailable"),
|
||||
failed: t("statusFailed"),
|
||||
@@ -3520,12 +3555,16 @@ function AddApiKeyModal({
|
||||
const t = useTranslations("providers");
|
||||
const isBailian = provider === "bailian-coding-plan";
|
||||
const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
|
||||
const isVertex = provider === "vertex";
|
||||
const defaultRegion = "us-central1";
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
apiKey: "",
|
||||
priority: 1,
|
||||
baseUrl: isBailian ? defaultBailianUrl : "",
|
||||
region: isVertex ? defaultRegion : "",
|
||||
validationModelId: "",
|
||||
});
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState(null);
|
||||
@@ -3539,7 +3578,11 @@ function AddApiKeyModal({
|
||||
const res = await fetch("/api/providers/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, apiKey: formData.apiKey }),
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
apiKey: formData.apiKey,
|
||||
validationModelId: formData.validationModelId || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
setValidationResult(data.valid ? "success" : "failed");
|
||||
@@ -3573,7 +3616,11 @@ function AddApiKeyModal({
|
||||
const res = await fetch("/api/providers/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, apiKey: formData.apiKey }),
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
apiKey: formData.apiKey,
|
||||
validationModelId: formData.validationModelId || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
isValid = !!data.valid;
|
||||
@@ -3602,6 +3649,10 @@ function AddApiKeyModal({
|
||||
payload.providerSpecificData = {
|
||||
baseUrl: validatedBailianBaseUrl,
|
||||
};
|
||||
} else if (isVertex) {
|
||||
payload.providerSpecificData = {
|
||||
region: formData.region,
|
||||
};
|
||||
}
|
||||
|
||||
const error = await onSave(payload);
|
||||
@@ -3635,6 +3686,7 @@ function AddApiKeyModal({
|
||||
value={formData.apiKey}
|
||||
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||
className="flex-1"
|
||||
placeholder={isVertex ? "Cole o Service Account JSON aqui" : undefined}
|
||||
/>
|
||||
<div className="pt-6">
|
||||
<Button
|
||||
@@ -3667,6 +3719,13 @@ function AddApiKeyModal({
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
label="Model ID (opcional)"
|
||||
placeholder="ex: grok-3 ou meta-llama/Llama-3.1-8B-Instruct"
|
||||
value={formData.validationModelId}
|
||||
onChange={(e) => setFormData({ ...formData, validationModelId: e.target.value })}
|
||||
hint="Usado como fallback se a listagem de models não estiver disponível"
|
||||
/>
|
||||
<Input
|
||||
label={t("priorityLabel")}
|
||||
type="number"
|
||||
@@ -3684,6 +3743,15 @@ function AddApiKeyModal({
|
||||
hint="Optional: Custom base URL for bailian-coding-plan provider"
|
||||
/>
|
||||
)}
|
||||
{isVertex && (
|
||||
<Input
|
||||
label="Região (Region)"
|
||||
value={formData.region}
|
||||
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
|
||||
placeholder={defaultRegion}
|
||||
hint="ex: us-central1 ou europe-west4. Partner models usam a região global automaticamente."
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
@@ -3732,6 +3800,8 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
apiKey: "",
|
||||
healthCheckInterval: 60,
|
||||
baseUrl: "",
|
||||
region: "",
|
||||
validationModelId: "",
|
||||
});
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
@@ -3744,17 +3814,23 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
|
||||
const isBailian = connection?.provider === "bailian-coding-plan";
|
||||
const defaultBailianUrl = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1";
|
||||
const isVertex = connection?.provider === "vertex";
|
||||
const defaultRegion = "us-central1";
|
||||
|
||||
useEffect(() => {
|
||||
if (connection) {
|
||||
const rawBaseUrl = connection.providerSpecificData?.baseUrl;
|
||||
const existingBaseUrl = typeof rawBaseUrl === "string" ? rawBaseUrl : "";
|
||||
const rawRegion = connection.providerSpecificData?.region;
|
||||
const existingRegion = typeof rawRegion === "string" ? rawRegion : "";
|
||||
setFormData({
|
||||
name: connection.name || "",
|
||||
priority: connection.priority || 1,
|
||||
apiKey: "",
|
||||
healthCheckInterval: connection.healthCheckInterval ?? 60,
|
||||
baseUrl: existingBaseUrl || (isBailian ? defaultBailianUrl : ""),
|
||||
region: existingRegion || (isVertex ? defaultRegion : ""),
|
||||
validationModelId: (connection.providerSpecificData?.validationModelId as string) || "",
|
||||
});
|
||||
// Load existing extra keys from providerSpecificData
|
||||
const existing = connection.providerSpecificData?.extraApiKeys;
|
||||
@@ -3771,7 +3847,13 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" });
|
||||
const res = await fetch(`/api/providers/${connection.id}/test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
validationModelId: formData.validationModelId || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
setTestResult({
|
||||
valid: !!data.valid,
|
||||
@@ -3797,7 +3879,11 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
const res = await fetch("/api/providers/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }),
|
||||
body: JSON.stringify({
|
||||
provider: connection.provider,
|
||||
apiKey: formData.apiKey,
|
||||
validationModelId: formData.validationModelId || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
setValidationResult(data.valid ? "success" : "failed");
|
||||
@@ -3838,7 +3924,11 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
const res = await fetch("/api/providers/validate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }),
|
||||
body: JSON.stringify({
|
||||
provider: connection.provider,
|
||||
apiKey: formData.apiKey,
|
||||
validationModelId: formData.validationModelId || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
isValid = !!data.valid;
|
||||
@@ -3865,9 +3955,14 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
...(connection.providerSpecificData || {}),
|
||||
extraApiKeys: extraApiKeys.filter((k) => k.trim().length > 0),
|
||||
};
|
||||
if (formData.validationModelId) {
|
||||
updates.providerSpecificData.validationModelId = formData.validationModelId;
|
||||
}
|
||||
// Update baseUrl for bailian-coding-plan
|
||||
if (isBailian) {
|
||||
updates.providerSpecificData.baseUrl = validatedBailianBaseUrl;
|
||||
} else if (isVertex) {
|
||||
updates.providerSpecificData.region = formData.region;
|
||||
}
|
||||
}
|
||||
const error = (await onSave(updates)) as void | unknown;
|
||||
@@ -3935,7 +4030,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
type="password"
|
||||
value={formData.apiKey}
|
||||
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||
placeholder={t("enterNewApiKey")}
|
||||
placeholder={isVertex ? "Cole o Service Account JSON aqui" : t("enterNewApiKey")}
|
||||
hint={t("leaveBlankKeepCurrentApiKey")}
|
||||
className="flex-1"
|
||||
/>
|
||||
@@ -3959,6 +4054,13 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label="Model ID (opcional)"
|
||||
placeholder="ex: grok-3 ou meta-llama/Llama-3.1-8B-Instruct"
|
||||
value={formData.validationModelId}
|
||||
onChange={(e) => setFormData({ ...formData, validationModelId: e.target.value })}
|
||||
hint="Usado como fallback se a listagem de models não estiver disponível"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3972,6 +4074,16 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
|
||||
/>
|
||||
)}
|
||||
|
||||
{isVertex && (
|
||||
<Input
|
||||
label="Região (Region)"
|
||||
value={formData.region}
|
||||
onChange={(e) => setFormData({ ...formData, region: e.target.value })}
|
||||
placeholder={defaultRegion}
|
||||
hint="ex: us-central1 ou europe-west4. Partner models usam a região global automaticamente."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* T07: Extra API Keys for round-robin rotation */}
|
||||
{!isOAuth && (
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -165,6 +165,7 @@ export default function ProviderLimitCard({
|
||||
percentage={percentage}
|
||||
unlimited={unlimited}
|
||||
resetTime={quota.resetAt}
|
||||
staleAfterReset={quota.staleAfterReset === true}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -71,6 +71,7 @@ export default function QuotaProgressBar({
|
||||
total = 0,
|
||||
unlimited = false,
|
||||
resetTime = null,
|
||||
staleAfterReset = false,
|
||||
}) {
|
||||
const colors = getColorClasses(percentage);
|
||||
const countdown = formatResetTime(resetTime);
|
||||
@@ -105,12 +106,17 @@ export default function QuotaProgressBar({
|
||||
<span>
|
||||
{used.toLocaleString()} / {total.toLocaleString()} requests
|
||||
</span>
|
||||
{countdown !== "-" && (
|
||||
{staleAfterReset ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>⟳</span>
|
||||
<span className="font-medium">Refreshing...</span>
|
||||
</div>
|
||||
) : countdown !== "-" ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>•</span>
|
||||
<span className="font-medium">Reset in {countdown}</span>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Reset time display */}
|
||||
|
||||
@@ -92,6 +92,7 @@ export default function QuotaTable({ quotas = [] }) {
|
||||
quota.remainingPercentage !== undefined
|
||||
? Math.round(quota.remainingPercentage)
|
||||
: calculatePercentage(quota.used, quota.total);
|
||||
const staleAfterReset = quota.staleAfterReset === true;
|
||||
|
||||
const colors = getColorClasses(remaining);
|
||||
const countdown = formatResetTime(quota.resetAt);
|
||||
@@ -140,7 +141,9 @@ export default function QuotaTable({ quotas = [] }) {
|
||||
|
||||
{/* Reset Time */}
|
||||
<td className="py-2 px-3">
|
||||
{countdown !== t("notAvailableSymbol") || resetDisplay ? (
|
||||
{staleAfterReset ? (
|
||||
<div className="text-xs text-text-muted">⟳ Refreshing...</div>
|
||||
) : countdown !== t("notAvailableSymbol") || resetDisplay ? (
|
||||
<div className="space-y-0.5">
|
||||
{countdown !== t("notAvailableSymbol") && (
|
||||
<div className="text-sm text-text-primary font-medium">
|
||||
|
||||
@@ -122,6 +122,7 @@ export default function ProviderLimits() {
|
||||
const intervalRef = useRef(null);
|
||||
const countdownRef = useRef(null);
|
||||
const lastFetchTimeRef = useRef({});
|
||||
const staleProbeRef = useRef({});
|
||||
|
||||
const fetchConnections = useCallback(async () => {
|
||||
try {
|
||||
@@ -137,11 +138,12 @@ export default function ProviderLimits() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchQuota = useCallback(async (connectionId, provider) => {
|
||||
const fetchQuota = useCallback(async (connectionId, provider, options = {}) => {
|
||||
const force = options?.force === true;
|
||||
// Debounce: skip if last fetch was < MIN_FETCH_INTERVAL_MS ago
|
||||
const now = Date.now();
|
||||
const lastFetch = lastFetchTimeRef.current[connectionId] || 0;
|
||||
if (now - lastFetch < MIN_FETCH_INTERVAL_MS) {
|
||||
if (!force && now - lastFetch < MIN_FETCH_INTERVAL_MS) {
|
||||
return; // Skip, data is still fresh
|
||||
}
|
||||
lastFetchTimeRef.current[connectionId] = now;
|
||||
@@ -165,6 +167,20 @@ export default function ProviderLimits() {
|
||||
}
|
||||
const data = await response.json();
|
||||
const parsedQuotas = parseQuotaData(provider, data);
|
||||
|
||||
// T13: If resetAt already passed but provider still returned stale cumulative usage,
|
||||
// display 0 immediately and trigger a background probe to refresh snapshot.
|
||||
const hasStaleAfterReset = parsedQuotas.some((q) => q?.staleAfterReset === true);
|
||||
if (hasStaleAfterReset) {
|
||||
const lastProbeAt = staleProbeRef.current[connectionId] || 0;
|
||||
if (Date.now() - lastProbeAt >= MIN_FETCH_INTERVAL_MS) {
|
||||
staleProbeRef.current[connectionId] = Date.now();
|
||||
setTimeout(() => {
|
||||
fetchQuota(connectionId, provider, { force: true }).catch(() => {});
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
setQuotaData((prev) => ({
|
||||
...prev,
|
||||
[connectionId]: {
|
||||
@@ -571,6 +587,7 @@ export default function ProviderLimits() {
|
||||
const colors = getBarColor(remaining);
|
||||
const cd = formatCountdown(q.resetAt);
|
||||
const shortName = getShortModelName(q.name);
|
||||
const staleAfterReset = q.staleAfterReset === true;
|
||||
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5 min-w-[200px] shrink-0">
|
||||
@@ -583,11 +600,15 @@ export default function ProviderLimits() {
|
||||
</span>
|
||||
|
||||
{/* Countdown */}
|
||||
{cd && (
|
||||
{staleAfterReset ? (
|
||||
<span className="text-[10px] text-text-muted whitespace-nowrap">
|
||||
⟳ Refreshing...
|
||||
</span>
|
||||
) : cd ? (
|
||||
<span className="text-[10px] text-text-muted whitespace-nowrap">
|
||||
⏱ {cd}
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex-1 h-1.5 rounded-sm bg-white/[0.06] min-w-[60px] overflow-hidden">
|
||||
|
||||
@@ -76,6 +76,40 @@ export function calculatePercentage(used, total) {
|
||||
return Math.round(((total - used) / total) * 100);
|
||||
}
|
||||
|
||||
function isPastResetWindow(resetAt) {
|
||||
if (!resetAt) return false;
|
||||
const resetTime =
|
||||
typeof resetAt === "number" ? resetAt : typeof resetAt === "string" ? Date.parse(resetAt) : NaN;
|
||||
if (!Number.isFinite(resetTime)) return false;
|
||||
return Date.now() >= resetTime;
|
||||
}
|
||||
|
||||
function normalizeQuotaEntry(name, quota = {}, extras = {}) {
|
||||
const usedRaw = Number(quota?.used || 0);
|
||||
const totalRaw = Number(quota?.total || 0);
|
||||
const resetAt = quota?.resetAt || null;
|
||||
const staleAfterReset = isPastResetWindow(resetAt);
|
||||
const used = staleAfterReset ? 0 : usedRaw;
|
||||
const total = Number.isFinite(totalRaw) ? totalRaw : 0;
|
||||
const remainingPercentageRaw = safePercentage(quota?.remainingPercentage);
|
||||
const remainingPercentage =
|
||||
staleAfterReset && total > 0
|
||||
? 100
|
||||
: remainingPercentageRaw !== undefined
|
||||
? remainingPercentageRaw
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
name,
|
||||
used: Number.isFinite(used) ? used : 0,
|
||||
total,
|
||||
resetAt,
|
||||
staleAfterReset,
|
||||
...(remainingPercentage !== undefined ? { remainingPercentage } : {}),
|
||||
...extras,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse provider-specific quota structures into normalized array
|
||||
* @param {string} provider - Provider name (github, antigravity, codex, kiro, claude)
|
||||
@@ -95,13 +129,7 @@ export function parseQuotaData(provider, data) {
|
||||
if (quota?.unlimited && (!quota?.total || quota.total <= 0)) {
|
||||
return;
|
||||
}
|
||||
normalizedQuotas.push({
|
||||
name,
|
||||
used: quota.used || 0,
|
||||
total: quota.total || 0,
|
||||
resetAt: quota.resetAt || null,
|
||||
remainingPercentage: safePercentage(quota.remainingPercentage),
|
||||
});
|
||||
normalizedQuotas.push(normalizeQuotaEntry(name, quota));
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -109,14 +137,11 @@ export function parseQuotaData(provider, data) {
|
||||
case "antigravity":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([modelKey, quota]: [string, any]) => {
|
||||
normalizedQuotas.push({
|
||||
name: quota.displayName || modelKey,
|
||||
modelKey: modelKey, // Keep modelKey for sorting
|
||||
used: quota.used || 0,
|
||||
total: quota.total || 0,
|
||||
resetAt: quota.resetAt || null,
|
||||
remainingPercentage: safePercentage(quota.remainingPercentage),
|
||||
});
|
||||
normalizedQuotas.push(
|
||||
normalizeQuotaEntry(quota.displayName || modelKey, quota, {
|
||||
modelKey: modelKey, // Keep modelKey for sorting
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -124,12 +149,7 @@ export function parseQuotaData(provider, data) {
|
||||
case "codex":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([quotaType, quota]: [string, any]) => {
|
||||
normalizedQuotas.push({
|
||||
name: quotaType,
|
||||
used: quota.used || 0,
|
||||
total: quota.total || 0,
|
||||
resetAt: quota.resetAt || null,
|
||||
});
|
||||
normalizedQuotas.push(normalizeQuotaEntry(quotaType, quota));
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -137,12 +157,7 @@ export function parseQuotaData(provider, data) {
|
||||
case "kiro":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([quotaType, quota]: [string, any]) => {
|
||||
normalizedQuotas.push({
|
||||
name: quotaType,
|
||||
used: quota.used || 0,
|
||||
total: quota.total || 0,
|
||||
resetAt: quota.resetAt || null,
|
||||
});
|
||||
normalizedQuotas.push(normalizeQuotaEntry(quotaType, quota));
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -159,13 +174,7 @@ export function parseQuotaData(provider, data) {
|
||||
});
|
||||
} else if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
|
||||
normalizedQuotas.push({
|
||||
name,
|
||||
used: quota.used || 0,
|
||||
total: quota.total || 0,
|
||||
resetAt: quota.resetAt || null,
|
||||
remainingPercentage: safePercentage(quota.remainingPercentage),
|
||||
});
|
||||
normalizedQuotas.push(normalizeQuotaEntry(name, quota));
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -174,12 +183,7 @@ export function parseQuotaData(provider, data) {
|
||||
// Generic fallback for unknown providers
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
|
||||
normalizedQuotas.push({
|
||||
name,
|
||||
used: quota.used || 0,
|
||||
total: quota.total || 0,
|
||||
resetAt: quota.resetAt || null,
|
||||
});
|
||||
normalizedQuotas.push(normalizeQuotaEntry(name, quota));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -218,11 +222,7 @@ export function normalizePlanTier(plan) {
|
||||
|
||||
const upper = raw.toUpperCase();
|
||||
|
||||
if (
|
||||
upper.includes("PRO+") ||
|
||||
upper.includes("PRO PLUS") ||
|
||||
upper.includes("PROPLUS")
|
||||
) {
|
||||
if (upper.includes("PRO+") || upper.includes("PRO PLUS") || upper.includes("PROPLUS")) {
|
||||
return { key: "plus", label: "Pro+", variant: "secondary", rank: 4, raw };
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
import { getOpenCodeConfigPath } from "@/shared/services/cliRuntime";
|
||||
import { mergeOpenCodeConfig } from "@/shared/services/opencodeConfig";
|
||||
import { guideSettingsSaveSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
@@ -10,7 +12,7 @@ import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
* POST /api/cli-tools/guide-settings/:toolId
|
||||
*
|
||||
* Save configuration for guide-based tools that have config files.
|
||||
* Currently supports: continue
|
||||
* Currently supports: continue, opencode
|
||||
*/
|
||||
export async function POST(request, { params }) {
|
||||
let rawBody;
|
||||
@@ -131,50 +133,39 @@ async function saveContinueConfig({ baseUrl, apiKey, model }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save OpenCode config to ~/.config/opencode/config.toml (XDG_CONFIG_HOME aware).
|
||||
* Save OpenCode config to:
|
||||
* - Linux/macOS: ~/.config/opencode/opencode.json (XDG_CONFIG_HOME aware)
|
||||
* - Windows: %APPDATA%/opencode/opencode.json
|
||||
*
|
||||
* (#524) OpenCode was silently failing because this handler was missing.
|
||||
*/
|
||||
async function saveOpenCodeConfig({ baseUrl, apiKey, model }) {
|
||||
const { apiPort } = getRuntimePorts();
|
||||
// Honour $XDG_CONFIG_HOME if set, otherwise use ~/.config per the XDG Base Directory spec
|
||||
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
||||
const configPath = path.join(xdgConfigHome, "opencode", "config.toml");
|
||||
const configPath = getOpenCodeConfigPath();
|
||||
const configDir = path.dirname(configPath);
|
||||
|
||||
// Ensure ~/.config/opencode/ exists
|
||||
// Ensure config directory exists
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
const normalizedBaseUrl = String(baseUrl || "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
|
||||
// Read existing TOML to preserve any user settings outside our block
|
||||
let existingContent = "";
|
||||
// Read existing JSON to preserve other provider entries
|
||||
let existingConfig: Record<string, any> = {};
|
||||
try {
|
||||
existingContent = await fs.readFile(configPath, "utf-8");
|
||||
const raw = await fs.readFile(configPath, "utf-8");
|
||||
existingConfig = JSON.parse(raw);
|
||||
} catch {
|
||||
// File doesn't exist yet — start fresh
|
||||
// File doesn't exist or invalid JSON — start fresh
|
||||
}
|
||||
|
||||
// Build the OmniRoute TOML block.
|
||||
// opencode config.toml uses the [provider.X] table format.
|
||||
void apiPort; // available for future port-based detection
|
||||
const omniBlock = `
|
||||
# OmniRoute managed — updated automatically by OmniRoute CLI Tools
|
||||
[provider.omniroute]
|
||||
api_key = "${apiKey || "sk_omniroute"}"
|
||||
base_url = "${normalizedBaseUrl}"
|
||||
model = "${model}"
|
||||
`;
|
||||
const nextConfig = mergeOpenCodeConfig(existingConfig, {
|
||||
baseUrl: normalizedBaseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
});
|
||||
|
||||
// Remove old OmniRoute-managed block (if any) then append fresh one
|
||||
const cleanedContent = existingContent
|
||||
.replace(/\n?# OmniRoute managed[\s\S]*?(?=\n\[|$)/, "")
|
||||
.trimEnd();
|
||||
|
||||
const newContent = (cleanedContent ? cleanedContent + "\n" : "") + omniBlock;
|
||||
|
||||
await fs.writeFile(configPath, newContent, "utf-8");
|
||||
await fs.writeFile(configPath, JSON.stringify(nextConfig, null, 2), "utf-8");
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -62,6 +62,7 @@ export async function PATCH(request, { params }) {
|
||||
noLog,
|
||||
autoResolve,
|
||||
isActive,
|
||||
maxSessions,
|
||||
accessSchedule,
|
||||
} = validation.data;
|
||||
|
||||
@@ -72,6 +73,7 @@ export async function PATCH(request, { params }) {
|
||||
if (noLog !== undefined) payload.noLog = noLog;
|
||||
if (autoResolve !== undefined) payload.autoResolve = autoResolve;
|
||||
if (isActive !== undefined) payload.isActive = isActive;
|
||||
if (maxSessions !== undefined) payload.maxSessions = maxSessions;
|
||||
if (accessSchedule !== undefined) payload.accessSchedule = accessSchedule;
|
||||
|
||||
const updated = await updateApiKeyPermissions(id, payload);
|
||||
@@ -90,6 +92,7 @@ export async function PATCH(request, { params }) {
|
||||
...(noLog !== undefined && { noLog }),
|
||||
...(autoResolve !== undefined && { autoResolve }),
|
||||
...(isActive !== undefined && { isActive }),
|
||||
...(maxSessions !== undefined && { maxSessions }),
|
||||
...(accessSchedule !== undefined && { accessSchedule }),
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isOpenAICompatibleProvider,
|
||||
isAnthropicCompatibleProvider,
|
||||
} from "@/shared/constants/providers";
|
||||
import { PROVIDER_MODELS } from "@/shared/constants/models";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -336,39 +337,85 @@ export async function GET(request, { params }) {
|
||||
);
|
||||
}
|
||||
|
||||
let modelsUrl = baseUrl.replace(/\/$/, "");
|
||||
if (modelsUrl.endsWith("/chat/completions")) {
|
||||
modelsUrl = modelsUrl.slice(0, -17) + "/models";
|
||||
} else if (modelsUrl.endsWith("/completions")) {
|
||||
modelsUrl = modelsUrl.slice(0, -12) + "/models";
|
||||
} else {
|
||||
modelsUrl = `${modelsUrl}/models`;
|
||||
let base = baseUrl.replace(/\/$/, "");
|
||||
if (base.endsWith("/chat/completions")) {
|
||||
base = base.slice(0, -17);
|
||||
} else if (base.endsWith("/completions")) {
|
||||
base = base.slice(0, -12);
|
||||
} else if (base.endsWith("/v1")) {
|
||||
base = base.slice(0, -3);
|
||||
}
|
||||
|
||||
const response = await fetch(modelsUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
// T39: Try multiple endpoint formats
|
||||
const endpoints = [
|
||||
`${base}/v1/models`,
|
||||
`${base}/models`,
|
||||
`${baseUrl.replace(/\/$/, "")}/models`, // Original fallback
|
||||
];
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.log(`Error fetching models from ${provider}:`, errorText);
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch models: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
// Remove duplicates
|
||||
const uniqueEndpoints = [...new Set(endpoints)];
|
||||
let models = null;
|
||||
let lastErrorStatus = null;
|
||||
|
||||
for (const modelsUrl of uniqueEndpoints) {
|
||||
try {
|
||||
const response = await fetch(modelsUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(5000), // Quick timeout for fallbacks
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
models = data.data || data.models || [];
|
||||
break; // Success!
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
lastErrorStatus = response.status;
|
||||
throw new Error("auth_failed");
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.message === "auth_failed") break; // Don't try other endpoints if auth failed
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const models = data.data || data.models || [];
|
||||
// If all endpoints failed (but not because of auth), fallback to local catalog
|
||||
if (!models) {
|
||||
if (lastErrorStatus === 401 || lastErrorStatus === 403) {
|
||||
return NextResponse.json(
|
||||
{ error: `Auth failed: ${lastErrorStatus}` },
|
||||
{ status: lastErrorStatus }
|
||||
);
|
||||
}
|
||||
|
||||
console.warn(`[models] All endpoints failed for ${provider}, using local catalog`);
|
||||
const localModels = PROVIDER_MODELS[provider] || [];
|
||||
models = localModels.map((m: any) => ({
|
||||
id: m.id,
|
||||
name: m.name || m.id,
|
||||
owned_by: provider,
|
||||
}));
|
||||
}
|
||||
|
||||
// Track source for MCP tool T39 requirement
|
||||
const source =
|
||||
models === null || (models && models.length > 0 && models[0].owned_by === provider)
|
||||
? "local_catalog"
|
||||
: "api";
|
||||
|
||||
return NextResponse.json({
|
||||
provider,
|
||||
connectionId,
|
||||
models,
|
||||
source,
|
||||
...(source === "local_catalog"
|
||||
? { warning: "API unavailable — using cached catalog" }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -516,6 +516,7 @@ async function testApiKeyConnection(connection: any) {
|
||||
return {
|
||||
valid: !!result.valid,
|
||||
error,
|
||||
warning: result.warning || null,
|
||||
diagnosis,
|
||||
};
|
||||
}
|
||||
@@ -523,9 +524,10 @@ async function testApiKeyConnection(connection: any) {
|
||||
/**
|
||||
* Core test logic — reusable by test-batch without HTTP self-calls.
|
||||
* @param {string} connectionId
|
||||
* @param {string} validationModelId Optional custom model ID to test connection with
|
||||
* @returns {Promise<object>} Test result (same shape as the JSON response)
|
||||
*/
|
||||
export async function testSingleConnection(connectionId: string) {
|
||||
export async function testSingleConnection(connectionId: string, validationModelId?: string) {
|
||||
const connection = await getProviderConnectionById(connectionId);
|
||||
|
||||
if (!connection) {
|
||||
@@ -567,8 +569,17 @@ export async function testSingleConnection(connectionId: string) {
|
||||
diagnosis: (runtime as any).diagnosis,
|
||||
};
|
||||
} else if (connection.authType === "apikey") {
|
||||
const enrichedConnection = validationModelId
|
||||
? {
|
||||
...connection,
|
||||
providerSpecificData: {
|
||||
...((connection.providerSpecificData as any) || {}),
|
||||
validationModelId,
|
||||
},
|
||||
}
|
||||
: connection;
|
||||
result = await runWithProxyContext(proxyInfo?.proxy || null, () =>
|
||||
testApiKeyConnection(connection)
|
||||
testApiKeyConnection(enrichedConnection)
|
||||
);
|
||||
} else {
|
||||
result = await runWithProxyContext(proxyInfo?.proxy || null, () =>
|
||||
@@ -657,6 +668,7 @@ export async function testSingleConnection(connectionId: string) {
|
||||
return {
|
||||
valid: result.valid,
|
||||
error: result.error,
|
||||
warning: result.warning || null,
|
||||
refreshed: result.refreshed || false,
|
||||
diagnosis,
|
||||
latencyMs,
|
||||
@@ -670,7 +682,17 @@ export async function testSingleConnection(connectionId: string) {
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const data = await testSingleConnection(id);
|
||||
|
||||
// Parse optional body for validationModelId
|
||||
let validationModelId;
|
||||
try {
|
||||
const body = await request.json();
|
||||
validationModelId = body?.validationModelId;
|
||||
} catch {
|
||||
// Body is optional
|
||||
}
|
||||
|
||||
const data = await testSingleConnection(id, validationModelId);
|
||||
|
||||
if (data.error === "Connection not found") {
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
|
||||
@@ -30,9 +30,9 @@ export async function POST(request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { provider, apiKey } = validation.data;
|
||||
const { provider, apiKey, validationModelId } = validation.data;
|
||||
|
||||
let providerSpecificData = {};
|
||||
let providerSpecificData: any = { validationModelId };
|
||||
|
||||
if (isOpenAICompatibleProvider(provider) || isAnthropicCompatibleProvider(provider)) {
|
||||
const node: any = await getProviderNodeById(provider);
|
||||
@@ -44,6 +44,7 @@ export async function POST(request) {
|
||||
);
|
||||
}
|
||||
providerSpecificData = {
|
||||
...providerSpecificData,
|
||||
baseUrl: node.baseUrl,
|
||||
apiType: node.apiType,
|
||||
};
|
||||
@@ -62,6 +63,8 @@ export async function POST(request) {
|
||||
return NextResponse.json({
|
||||
valid: !!result.valid,
|
||||
error: result.valid ? null : result.error || "Invalid API key",
|
||||
warning: result.warning || null,
|
||||
method: result.method || null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error validating API key:", error);
|
||||
|
||||
@@ -2,13 +2,15 @@ import { NextResponse } from "next/server";
|
||||
import {
|
||||
getActiveSessions,
|
||||
getActiveSessionCount,
|
||||
getAllActiveSessionCountsByKey,
|
||||
} from "@omniroute/open-sse/services/sessionManager.ts";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const sessions = getActiveSessions();
|
||||
const count = getActiveSessionCount();
|
||||
return NextResponse.json({ count, sessions });
|
||||
const byApiKey = getAllActiveSessionCountsByKey();
|
||||
return NextResponse.json({ count, sessions, byApiKey });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,36 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getUsageDb } from "@/lib/usageDb";
|
||||
import { computeAnalytics } from "@/lib/usageAnalytics";
|
||||
import { getDbInstance } from "@/lib/db/core";
|
||||
|
||||
function getRangeStartIso(range: string): string | null {
|
||||
const end = new Date();
|
||||
const start = new Date(end);
|
||||
|
||||
switch (range) {
|
||||
case "1d":
|
||||
start.setDate(start.getDate() - 1);
|
||||
break;
|
||||
case "7d":
|
||||
start.setDate(start.getDate() - 7);
|
||||
break;
|
||||
case "30d":
|
||||
start.setDate(start.getDate() - 30);
|
||||
break;
|
||||
case "90d":
|
||||
start.setDate(start.getDate() - 90);
|
||||
break;
|
||||
case "ytd":
|
||||
start.setMonth(0, 1);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
break;
|
||||
case "all":
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return start.toISOString();
|
||||
}
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
@@ -36,6 +66,47 @@ export async function GET(request) {
|
||||
|
||||
const analytics = await computeAnalytics(history, range, connectionMap);
|
||||
|
||||
// T01: fallback transparency metrics from call_logs (requested_model vs routed model).
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const sinceIso = getRangeStartIso(range);
|
||||
const whereClause = sinceIso ? "WHERE timestamp >= @since" : "";
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN requested_model IS NOT NULL AND requested_model != '' THEN 1 ELSE 0 END) as with_requested,
|
||||
SUM(CASE
|
||||
WHEN requested_model IS NOT NULL
|
||||
AND requested_model != ''
|
||||
AND model IS NOT NULL
|
||||
AND requested_model != model
|
||||
THEN 1 ELSE 0 END
|
||||
) as fallbacks
|
||||
FROM call_logs
|
||||
${whereClause}
|
||||
`
|
||||
)
|
||||
.get(sinceIso ? { since: sinceIso } : {}) as
|
||||
| { total?: number; with_requested?: number; fallbacks?: number }
|
||||
| undefined;
|
||||
|
||||
const total = Number(row?.total || 0);
|
||||
const withRequested = Number(row?.with_requested || 0);
|
||||
const fallbackCount = Number(row?.fallbacks || 0);
|
||||
|
||||
analytics.summary.fallbackCount = fallbackCount;
|
||||
analytics.summary.fallbackRatePct =
|
||||
withRequested > 0 ? Number(((fallbackCount / withRequested) * 100).toFixed(2)) : 0;
|
||||
analytics.summary.requestedModelCoveragePct =
|
||||
total > 0 ? Number(((withRequested / total) * 100).toFixed(2)) : 0;
|
||||
} catch {
|
||||
analytics.summary.fallbackCount = 0;
|
||||
analytics.summary.fallbackRatePct = 0;
|
||||
analytics.summary.requestedModelCoveragePct = 0;
|
||||
}
|
||||
|
||||
return NextResponse.json(analytics);
|
||||
} catch (error) {
|
||||
console.error("Error computing analytics:", error);
|
||||
|
||||
49
src/app/api/v1/accounts/[id]/limits/route.ts
Normal file
49
src/app/api/v1/accounts/[id]/limits/route.ts
Normal 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 });
|
||||
}
|
||||
123
src/app/api/v1/issues/report/route.ts
Normal file
123
src/app/api/v1/issues/report/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
49
src/app/api/v1/providers/[id]/limits/route.ts
Normal file
49
src/app/api/v1/providers/[id]/limits/route.ts
Normal 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 });
|
||||
}
|
||||
33
src/app/api/v1/quotas/check/route.ts
Normal file
33
src/app/api/v1/quotas/check/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
21
src/app/api/v1/registered-keys/[id]/revoke/route.ts
Normal file
21
src/app/api/v1/registered-keys/[id]/revoke/route.ts
Normal 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() });
|
||||
}
|
||||
33
src/app/api/v1/registered-keys/[id]/route.ts
Normal file
33
src/app/api/v1/registered-keys/[id]/route.ts
Normal 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() });
|
||||
}
|
||||
118
src/app/api/v1/registered-keys/route.ts
Normal file
118
src/app/api/v1/registered-keys/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,13 @@
|
||||
/* Light theme */
|
||||
--color-bg: #f9f9fb;
|
||||
--color-bg-alt: #f0f0f5;
|
||||
--color-bg-primary: #f9f9fb;
|
||||
--color-bg-subtle: #f0f0f5;
|
||||
--color-surface: #ffffff;
|
||||
--color-sidebar: rgba(245, 245, 250, 0.8);
|
||||
--color-border: rgba(0, 0, 0, 0.08);
|
||||
--color-text-main: #1a1a2e;
|
||||
--color-text-primary: #1a1a2e;
|
||||
--color-text-muted: #71717a;
|
||||
|
||||
/* Shadows */
|
||||
@@ -52,10 +55,13 @@
|
||||
/* Dark theme (ClawHub deep) */
|
||||
--color-bg: #0b0e14;
|
||||
--color-bg-alt: #111520;
|
||||
--color-bg-primary: #0b0e14;
|
||||
--color-bg-subtle: #111520;
|
||||
--color-surface: #161b22;
|
||||
--color-sidebar: rgba(16, 20, 30, 0.8);
|
||||
--color-border: rgba(255, 255, 255, 0.08);
|
||||
--color-text-main: #e6e6ef;
|
||||
--color-text-primary: #e6e6ef;
|
||||
--color-text-muted: #a1a1aa;
|
||||
|
||||
/* Dark shadows */
|
||||
@@ -81,10 +87,13 @@
|
||||
|
||||
/* Auto-switch colors (use CSS variables from :root/.dark) */
|
||||
--color-bg: var(--color-bg);
|
||||
--color-bg-primary: var(--color-bg-primary);
|
||||
--color-bg-subtle: var(--color-bg-subtle);
|
||||
--color-surface: var(--color-surface);
|
||||
--color-sidebar: var(--color-sidebar);
|
||||
--color-border: var(--color-border);
|
||||
--color-text-main: var(--color-text-main);
|
||||
--color-text-primary: var(--color-text-primary);
|
||||
--color-text-muted: var(--color-text-muted);
|
||||
|
||||
/* Static colors (for explicit light/dark usage) */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
65
src/lib/db/migrations/008_registered_keys.sql
Normal file
65
src/lib/db/migrations/008_registered_keys.sql
Normal 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'))
|
||||
);
|
||||
9
src/lib/db/migrations/009_requested_model.sql
Normal file
9
src/lib/db/migrations/009_requested_model.sql
Normal 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);
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
531
src/lib/db/registeredKeys.ts
Normal file
531
src/lib/db/registeredKeys.ts
Normal 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
56
src/lib/ipUtils.ts
Normal 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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -99,8 +99,10 @@ async function validateOpenAILikeProvider({
|
||||
return { valid: false, error: `Validation failed: ${modelsRes.status}` };
|
||||
}
|
||||
|
||||
const testModelId = (providerSpecificData as any)?.validationModelId || modelId;
|
||||
|
||||
const testBody = {
|
||||
model: modelId,
|
||||
model: testModelId,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
};
|
||||
@@ -131,7 +133,13 @@ async function validateOpenAILikeProvider({
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
|
||||
async function validateAnthropicLikeProvider({ apiKey, baseUrl, modelId, headers = {} }: any) {
|
||||
async function validateAnthropicLikeProvider({
|
||||
apiKey,
|
||||
baseUrl,
|
||||
modelId,
|
||||
headers = {},
|
||||
providerSpecificData = {},
|
||||
}: any) {
|
||||
if (!baseUrl) {
|
||||
return { valid: false, error: "Missing base URL" };
|
||||
}
|
||||
@@ -149,11 +157,14 @@ async function validateAnthropicLikeProvider({ apiKey, baseUrl, modelId, headers
|
||||
requestHeaders["anthropic-version"] = "2023-06-01";
|
||||
}
|
||||
|
||||
const testModelId =
|
||||
providerSpecificData?.validationModelId || modelId || "claude-3-5-sonnet-20241022";
|
||||
|
||||
const response = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body: JSON.stringify({
|
||||
model: modelId || "claude-3-5-sonnet-20241022",
|
||||
model: testModelId,
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
}),
|
||||
@@ -352,52 +363,104 @@ async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData =
|
||||
return { valid: false, error: "No base URL configured for OpenAI compatible provider" };
|
||||
}
|
||||
|
||||
const validationModelId =
|
||||
typeof providerSpecificData?.validationModelId === "string"
|
||||
? providerSpecificData.validationModelId.trim()
|
||||
: "";
|
||||
|
||||
// Step 1: Try GET /models
|
||||
let modelsReachable = false;
|
||||
try {
|
||||
const modelsRes = await fetch(`${baseUrl}/models`, {
|
||||
method: "GET",
|
||||
headers: buildBearerHeaders(apiKey),
|
||||
});
|
||||
|
||||
modelsReachable = true;
|
||||
|
||||
if (modelsRes.ok) {
|
||||
return { valid: true, error: null };
|
||||
return { valid: true, error: null, method: "models_endpoint" };
|
||||
}
|
||||
|
||||
if (modelsRes.status === 401 || modelsRes.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
// Endpoint responded and auth seems valid, but quota is exhausted/rate-limited.
|
||||
if (modelsRes.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "models_endpoint",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// /models fetch failed (network error, etc.) — fall through to chat test
|
||||
}
|
||||
|
||||
// T25: if /models cannot be used and no custom model was provided, return a
|
||||
// clear actionable message instead of a generic connection error.
|
||||
if (!validationModelId) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Endpoint /models unavailable. Provide a Model ID to validate via /chat/completions.",
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Fallback — try a minimal chat completion request
|
||||
// Many providers don't expose /models but accept chat completions fine
|
||||
const apiType = providerSpecificData.apiType || "chat";
|
||||
const chatSuffix = apiType === "responses" ? "/responses" : "/chat/completions";
|
||||
const chatUrl = `${baseUrl}${chatSuffix}`;
|
||||
const testModelId = validationModelId;
|
||||
|
||||
try {
|
||||
const chatRes = await fetch(chatUrl, {
|
||||
method: "POST",
|
||||
headers: buildBearerHeaders(apiKey),
|
||||
body: JSON.stringify({
|
||||
model: "gpt-4o-mini",
|
||||
model: testModelId,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (chatRes.ok) {
|
||||
return { valid: true, error: null };
|
||||
return { valid: true, error: null, method: "chat_completions" };
|
||||
}
|
||||
|
||||
if (chatRes.status === 401 || chatRes.status === 403) {
|
||||
return { valid: false, error: "Invalid API key" };
|
||||
}
|
||||
|
||||
if (chatRes.status === 429) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "chat_completions",
|
||||
warning: "Rate limited, but credentials are valid",
|
||||
};
|
||||
}
|
||||
|
||||
// If /models was reachable but returned non-auth error, and chat succeeds
|
||||
// auth-wise, this still confirms credentials are valid.
|
||||
if (chatRes.status === 400) {
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "inference_available",
|
||||
warning: "Model ID may be invalid, but credentials are valid",
|
||||
};
|
||||
}
|
||||
|
||||
// 4xx other than auth (e.g. 400 bad model, 422) usually means auth passed
|
||||
if (chatRes.status >= 400 && chatRes.status < 500) {
|
||||
return { valid: true, error: null };
|
||||
return {
|
||||
valid: true,
|
||||
error: null,
|
||||
method: "inference_available",
|
||||
};
|
||||
}
|
||||
|
||||
if (chatRes.status >= 500) {
|
||||
@@ -410,6 +473,10 @@ async function validateOpenAICompatibleProvider({ apiKey, providerSpecificData =
|
||||
// Step 3: Final fallback — simple connectivity check
|
||||
// For local providers (Ollama, LM Studio, etc.) that may not respond to
|
||||
// standard OpenAI endpoints but are still reachable
|
||||
if (!modelsReachable) {
|
||||
return { valid: false, error: "Connection failed while testing /chat/completions" };
|
||||
}
|
||||
|
||||
try {
|
||||
const pingRes = await fetch(baseUrl, {
|
||||
method: "GET",
|
||||
@@ -464,12 +531,13 @@ async function validateAnthropicCompatibleProvider({ apiKey, providerSpecificDat
|
||||
}
|
||||
|
||||
// Step 2: Fallback — try a minimal messages request
|
||||
const testModelId = providerSpecificData?.validationModelId || "claude-3-5-sonnet-20241022";
|
||||
try {
|
||||
const messagesRes = await fetch(`${baseUrl}/messages`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
model: testModelId,
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
}),
|
||||
@@ -646,6 +714,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
baseUrl: requestBaseUrl,
|
||||
modelId,
|
||||
headers: requestHeaders,
|
||||
providerSpecificData,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
140
src/lib/proxyHealth.ts
Normal file
140
src/lib/proxyHealth.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
`
|
||||
@@ -329,7 +330,7 @@ export async function getCallLogs(filter: any = {}) {
|
||||
}
|
||||
|
||||
if (filter.model) {
|
||||
conditions.push("model LIKE @modelQ");
|
||||
conditions.push("(model LIKE @modelQ OR requested_model LIKE @modelQ)");
|
||||
params.modelQ = `%${filter.model}%`;
|
||||
}
|
||||
if (filter.provider) {
|
||||
@@ -350,7 +351,8 @@ export async function getCallLogs(filter: any = {}) {
|
||||
if (filter.search) {
|
||||
conditions.push(`(
|
||||
model LIKE @searchQ OR path LIKE @searchQ OR account LIKE @searchQ OR
|
||||
provider LIKE @searchQ OR api_key_name LIKE @searchQ OR api_key_id LIKE @searchQ OR
|
||||
requested_model LIKE @searchQ OR provider LIKE @searchQ OR
|
||||
api_key_name LIKE @searchQ OR api_key_id LIKE @searchQ OR
|
||||
combo_name LIKE @searchQ OR CAST(status AS TEXT) LIKE @searchQ
|
||||
)`);
|
||||
params.searchQ = `%${filter.search}%`;
|
||||
@@ -374,6 +376,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),
|
||||
@@ -406,6 +409,7 @@ export async function getCallLogById(id: string) {
|
||||
path: toStringOrNull(entryRow.path),
|
||||
status: toNumber(entryRow.status),
|
||||
model: toStringOrNull(entryRow.model),
|
||||
requestedModel: toStringOrNull(entryRow.requested_model),
|
||||
provider: toStringOrNull(entryRow.provider),
|
||||
account: toStringOrNull(entryRow.account),
|
||||
connectionId: toStringOrNull(entryRow.connection_id),
|
||||
|
||||
@@ -36,7 +36,7 @@ function PayloadSection({ title, json, onCopy }) {
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-4 rounded-xl bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-primary max-h-[600px] overflow-y-auto leading-relaxed whitespace-pre-wrap break-words">
|
||||
<pre className="p-4 rounded-xl bg-black/5 dark:bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-main max-h-[600px] overflow-y-auto leading-relaxed whitespace-pre-wrap break-words">
|
||||
{json}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
|
||||
<span className="px-2 py-0.5 rounded bg-primary/20 text-primary text-xs font-bold">
|
||||
In: {(detail?.tokens?.in || log.tokens?.in || 0).toLocaleString()}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-400 text-xs font-bold">
|
||||
<span className="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 text-xs font-bold">
|
||||
Out: {(detail?.tokens?.out || log.tokens?.out || 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -147,6 +147,21 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Model</div>
|
||||
<div className="text-sm font-medium text-primary font-mono">{log.model}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Requested Model
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm font-medium font-mono ${
|
||||
(detail?.requestedModel || log.requestedModel) &&
|
||||
(detail?.requestedModel || log.requestedModel) !== log.model
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{detail?.requestedModel || log.requestedModel || "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
|
||||
Provider
|
||||
@@ -198,7 +213,7 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
|
||||
<div>
|
||||
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Combo</div>
|
||||
{detail?.comboName || log.comboName ? (
|
||||
<span className="inline-block px-2.5 py-1 rounded-full text-[10px] font-bold bg-violet-500/20 text-violet-300 border border-violet-500/30">
|
||||
<span className="inline-block px-2.5 py-1 rounded-full text-[10px] font-bold bg-violet-500/20 text-violet-700 dark:text-violet-300 border border-violet-500/30">
|
||||
{detail?.comboName || log.comboName}
|
||||
</span>
|
||||
) : (
|
||||
@@ -210,10 +225,12 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
|
||||
{/* Error Message */}
|
||||
{(detail?.error || log.error) && (
|
||||
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/30">
|
||||
<div className="text-[10px] text-red-400 uppercase tracking-wider mb-1 font-bold">
|
||||
<div className="text-[10px] text-red-600 dark:text-red-400 uppercase tracking-wider mb-1 font-bold">
|
||||
Error
|
||||
</div>
|
||||
<div className="text-sm text-red-300 font-mono">{detail?.error || log.error}</div>
|
||||
<div className="text-sm text-red-600 dark:text-red-300 font-mono">
|
||||
{detail?.error || log.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const STATUS_FILTERS = [
|
||||
const COLUMNS = [
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "model", label: "Model" },
|
||||
{ key: "requestedModel", label: "Requested" },
|
||||
{ key: "provider", label: "Provider" },
|
||||
{ key: "protocol", label: "Protocol" },
|
||||
{ key: "account", label: "Account" },
|
||||
@@ -234,7 +235,9 @@ export default function RequestLoggerV2() {
|
||||
// Unique accounts and providers for dropdowns
|
||||
|
||||
const uniqueAccounts = [...new Set(logs.map((l) => l.account).filter((a) => a && a !== "-"))];
|
||||
const uniqueModels = [...new Set(logs.map((l) => l.model).filter(Boolean))].sort();
|
||||
const uniqueModels = [
|
||||
...new Set(logs.flatMap((l) => [l.model, l.requestedModel]).filter((value) => Boolean(value))),
|
||||
].sort();
|
||||
const uniqueProviders = [
|
||||
...new Set(logs.map((l) => l.provider).filter((p) => p && p !== "-")),
|
||||
].sort();
|
||||
@@ -514,6 +517,11 @@ export default function RequestLoggerV2() {
|
||||
Model
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.requestedModel && (
|
||||
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
|
||||
Requested
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.provider && (
|
||||
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
|
||||
Provider
|
||||
@@ -596,6 +604,28 @@ export default function RequestLoggerV2() {
|
||||
{log.model}
|
||||
</td>
|
||||
)}
|
||||
{visibleColumns.requestedModel && (
|
||||
<td className="px-3 py-2 font-mono text-[11px]">
|
||||
{log.requestedModel ? (
|
||||
<span
|
||||
className={
|
||||
log.requestedModel !== log.model
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-text-muted"
|
||||
}
|
||||
title={
|
||||
log.requestedModel !== log.model
|
||||
? `Requested ${log.requestedModel}, routed as ${log.model}`
|
||||
: log.requestedModel
|
||||
}
|
||||
>
|
||||
{log.requestedModel}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-muted text-[10px]">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{visibleColumns.provider && (
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function UsageAnalytics() {
|
||||
</div>
|
||||
|
||||
{/* Summary Cards — Row 1: Core metrics */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-7 gap-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-8 gap-3">
|
||||
<StatCard
|
||||
icon="generating_tokens"
|
||||
label="Total Tokens"
|
||||
@@ -142,10 +142,17 @@ export default function UsageAnalytics() {
|
||||
<StatCard icon="group" label="Accounts" value={s.uniqueAccounts || 0} />
|
||||
<StatCard icon="vpn_key" label="API Keys" value={s.uniqueApiKeys || 0} />
|
||||
<StatCard icon="model_training" label="Models" value={s.uniqueModels || 0} />
|
||||
<StatCard
|
||||
icon="swap_horiz"
|
||||
label="Fallback Rate"
|
||||
value={`${Number(s.fallbackRatePct || 0).toFixed(1)}%`}
|
||||
subValue={`${fmtFull(s.fallbackCount || 0)} fallbacks`}
|
||||
color="text-amber-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards — Row 2: Derived insights */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-7 gap-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-8 gap-3">
|
||||
<StatCard
|
||||
icon="speed"
|
||||
label="Avg Tokens/Req"
|
||||
@@ -168,6 +175,12 @@ export default function UsageAnalytics() {
|
||||
<StatCard icon="cloud" label="Top Provider" value={topProvider} color="text-teal-500" />
|
||||
<StatCard icon="today" label="Busiest Day" value={busiestDay} color="text-rose-500" />
|
||||
<StatCard icon="dns" label="Providers" value={providerCount} color="text-indigo-500" />
|
||||
<StatCard
|
||||
icon="rule"
|
||||
label="Requested Coverage"
|
||||
value={`${Number(s.requestedModelCoveragePct || 0).toFixed(1)}%`}
|
||||
color="text-sky-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Activity Heatmap + Weekly Widgets */}
|
||||
|
||||
@@ -16,6 +16,7 @@ export const CLI_TOOLS = {
|
||||
},
|
||||
modelAliases: ["default", "sonnet", "opus", "haiku", "opusplan"],
|
||||
settingsFile: "~/.claude/settings.json",
|
||||
defaultCommand: "claude",
|
||||
defaultModels: [
|
||||
{
|
||||
id: "opus",
|
||||
@@ -47,6 +48,7 @@ export const CLI_TOOLS = {
|
||||
color: "#10A37F",
|
||||
description: "OpenAI Codex CLI",
|
||||
configType: "custom",
|
||||
defaultCommand: "codex",
|
||||
},
|
||||
droid: {
|
||||
id: "droid",
|
||||
@@ -55,6 +57,7 @@ export const CLI_TOOLS = {
|
||||
color: "#00D4FF",
|
||||
description: "Factory Droid AI Assistant",
|
||||
configType: "custom",
|
||||
defaultCommand: "droid",
|
||||
},
|
||||
openclaw: {
|
||||
id: "openclaw",
|
||||
@@ -63,6 +66,7 @@ export const CLI_TOOLS = {
|
||||
color: "#FF6B35",
|
||||
description: "Open Claw AI Assistant",
|
||||
configType: "custom",
|
||||
defaultCommand: "openclaw",
|
||||
},
|
||||
cursor: {
|
||||
id: "cursor",
|
||||
@@ -72,6 +76,7 @@ export const CLI_TOOLS = {
|
||||
description: "Cursor AI Code Editor",
|
||||
configType: "guide",
|
||||
requiresCloud: true,
|
||||
defaultCommands: ["agent", "cursor"],
|
||||
notes: [
|
||||
{ type: "warning", text: "Requires Cursor Pro account to use this feature." },
|
||||
{
|
||||
@@ -95,6 +100,7 @@ export const CLI_TOOLS = {
|
||||
color: "#00D1B2",
|
||||
description: "Cline AI Coding Assistant CLI",
|
||||
configType: "custom",
|
||||
defaultCommand: "cline",
|
||||
},
|
||||
kilo: {
|
||||
id: "kilo",
|
||||
@@ -103,6 +109,7 @@ export const CLI_TOOLS = {
|
||||
color: "#FF6B6B",
|
||||
description: "Kilo Code AI Assistant CLI",
|
||||
configType: "custom",
|
||||
defaultCommand: "kilocode",
|
||||
},
|
||||
continue: {
|
||||
id: "continue",
|
||||
@@ -180,12 +187,47 @@ export const CLI_TOOLS = {
|
||||
color: "#FF6B35",
|
||||
description: "OpenCode AI coding agent (Terminal)",
|
||||
configType: "guide",
|
||||
defaultCommand: "opencode",
|
||||
notes: [
|
||||
{
|
||||
type: "warning",
|
||||
text: "Config path: Linux/macOS ~/.config/opencode/opencode.json • Windows %APPDATA%\\\\opencode\\\\opencode.json",
|
||||
},
|
||||
{
|
||||
type: "warning",
|
||||
text: 'Thinking variant example: opencode run "implement this feature" --model omniroute/claude-sonnet-4-5-thinking --variant high',
|
||||
},
|
||||
],
|
||||
guideSteps: [
|
||||
{ step: 1, title: "Install OpenCode", desc: "Install via npm: npm install -g opencode-ai" },
|
||||
{ step: 2, title: "API Key", type: "apiKeySelector" },
|
||||
{ step: 3, title: "Set Base URL", desc: "opencode config set baseUrl {{baseUrl}}" },
|
||||
{ step: 4, title: "Select Model", type: "modelSelector" },
|
||||
{
|
||||
step: 5,
|
||||
title: "Use Thinking Variant",
|
||||
desc: "For thinking models, run with --variant high/low/max (example command below).",
|
||||
},
|
||||
],
|
||||
codeBlock: {
|
||||
language: "json",
|
||||
code: `{
|
||||
"providers": {
|
||||
"omniroute": {
|
||||
"name": "OmniRoute",
|
||||
"api": "openai",
|
||||
"baseURL": "{{baseUrl}}",
|
||||
"apiKey": "{{apiKey}}",
|
||||
"models": [
|
||||
"{{model}}",
|
||||
"claude-sonnet-4-5-thinking",
|
||||
"gemini-3.1-pro-high",
|
||||
"gemini-3-flash"
|
||||
]
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
},
|
||||
kiro: {
|
||||
id: "kiro",
|
||||
|
||||
111
src/shared/constants/modelSpecs.ts
Normal file
111
src/shared/constants/modelSpecs.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Centralized specifications for AI Models.
|
||||
* Contains maximum token caps and thinking budgets to prevent API errors
|
||||
* when clients request more than the model supports.
|
||||
*/
|
||||
|
||||
export interface ModelSpec {
|
||||
maxOutputTokens: number;
|
||||
contextWindow?: number;
|
||||
defaultThinkingBudget?: number;
|
||||
thinkingBudgetCap?: number;
|
||||
thinkingOverhead?: number; // buffer de tokens para thinking
|
||||
adaptiveMaxTokens?: number; // tokens disponíveis para output quando thinking ativo
|
||||
aliases?: string[]; // IDs alternativos para este modelo
|
||||
supportsThinking?: boolean;
|
||||
supportsTools?: boolean;
|
||||
supportsVision?: boolean;
|
||||
}
|
||||
|
||||
export const MODEL_SPECS: Record<string, ModelSpec> = {
|
||||
// ── Gemini 3 Flash series ───────────────────────────────────────
|
||||
"gemini-3-flash": {
|
||||
maxOutputTokens: 65536,
|
||||
contextWindow: 1048576,
|
||||
defaultThinkingBudget: 0,
|
||||
thinkingBudgetCap: 0,
|
||||
supportsThinking: false,
|
||||
supportsTools: true,
|
||||
supportsVision: true,
|
||||
aliases: ["gemini-3-flash-preview", "gemini-3.1-flash-lite-preview"],
|
||||
},
|
||||
|
||||
// ── Gemini 3.1 Pro High ─────────────────────────────────────────
|
||||
"gemini-3.1-pro-high": {
|
||||
maxOutputTokens: 131072,
|
||||
contextWindow: 1048576,
|
||||
defaultThinkingBudget: 24576,
|
||||
thinkingBudgetCap: 32768,
|
||||
thinkingOverhead: 1000,
|
||||
supportsThinking: true,
|
||||
supportsTools: true,
|
||||
supportsVision: true,
|
||||
aliases: ["gemini-3-pro-high"],
|
||||
},
|
||||
|
||||
// ── Gemini 3.1 Pro Low ──────────────────────────────────────────
|
||||
"gemini-3.1-pro-low": {
|
||||
maxOutputTokens: 131072,
|
||||
contextWindow: 1048576,
|
||||
defaultThinkingBudget: 8192,
|
||||
thinkingBudgetCap: 16000,
|
||||
supportsThinking: true,
|
||||
supportsTools: true,
|
||||
supportsVision: true,
|
||||
aliases: ["gemini-3-pro-low"],
|
||||
},
|
||||
|
||||
// ── Claude Opus 4.5 ─────────────────────────────────────────────
|
||||
"claude-opus-4-5": {
|
||||
maxOutputTokens: 32768,
|
||||
contextWindow: 200000,
|
||||
defaultThinkingBudget: 10000,
|
||||
thinkingBudgetCap: 32000,
|
||||
supportsThinking: true,
|
||||
supportsTools: true,
|
||||
supportsVision: true,
|
||||
},
|
||||
|
||||
// Defaults
|
||||
__default__: {
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
};
|
||||
|
||||
export function getModelSpec(modelId: string): ModelSpec | undefined {
|
||||
if (MODEL_SPECS[modelId]) return MODEL_SPECS[modelId];
|
||||
|
||||
// Buscas por alias
|
||||
for (const [canonical, spec] of Object.entries(MODEL_SPECS)) {
|
||||
if (spec.aliases?.includes(modelId)) return spec;
|
||||
}
|
||||
|
||||
// Prefix matching
|
||||
for (const [key, spec] of Object.entries(MODEL_SPECS)) {
|
||||
if (key !== "__default__" && modelId.startsWith(key)) return spec;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function capMaxOutputTokens(modelId: string, requested?: number): number {
|
||||
const spec = getModelSpec(modelId);
|
||||
const cap = spec?.maxOutputTokens ?? MODEL_SPECS.__default__.maxOutputTokens;
|
||||
return requested ? Math.min(requested, cap) : cap;
|
||||
}
|
||||
|
||||
export function getDefaultThinkingBudget(modelId: string): number {
|
||||
return getModelSpec(modelId)?.defaultThinkingBudget ?? 0;
|
||||
}
|
||||
|
||||
export function capThinkingBudget(modelId: string, budget: number): number {
|
||||
const cap = getModelSpec(modelId)?.thinkingBudgetCap ?? budget;
|
||||
return Math.min(budget, cap);
|
||||
}
|
||||
|
||||
export function resolveModelAlias(modelId: string): string {
|
||||
for (const [canonical, spec] of Object.entries(MODEL_SPECS)) {
|
||||
if (spec.aliases?.includes(modelId)) return canonical;
|
||||
}
|
||||
return modelId;
|
||||
}
|
||||
@@ -102,6 +102,21 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 30.0,
|
||||
cache_creation: 5.0,
|
||||
},
|
||||
// T12: fallback pricing for gpt-5.4 mini variants
|
||||
"gpt-5.4-mini": {
|
||||
input: 1.5,
|
||||
output: 6.0,
|
||||
cached: 0.75,
|
||||
reasoning: 9.0,
|
||||
cache_creation: 1.5,
|
||||
},
|
||||
"gpt5.4-mini": {
|
||||
input: 1.5,
|
||||
output: 6.0,
|
||||
cached: 0.75,
|
||||
reasoning: 9.0,
|
||||
cache_creation: 1.5,
|
||||
},
|
||||
// GPT 5.3 Codex family (all same pricing tier)
|
||||
"gpt-5.3-codex": GPT_5_3_CODEX_PRICING,
|
||||
"gpt-5.3-codex-xhigh": GPT_5_3_CODEX_PRICING,
|
||||
@@ -183,6 +198,13 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 4.5,
|
||||
cache_creation: 0.5,
|
||||
},
|
||||
"gemini-3.1-flash-lite-preview": {
|
||||
input: 0.5,
|
||||
output: 3.0,
|
||||
cached: 0.03,
|
||||
reasoning: 4.5,
|
||||
cache_creation: 0.5,
|
||||
},
|
||||
"gemini-3-pro-preview": {
|
||||
input: 2.0,
|
||||
output: 12.0,
|
||||
@@ -197,6 +219,20 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 18.0,
|
||||
cache_creation: 2.0,
|
||||
},
|
||||
"gemini-3-flash-preview": {
|
||||
input: 0.5,
|
||||
output: 3.0,
|
||||
cached: 0.03,
|
||||
reasoning: 4.5,
|
||||
cache_creation: 0.5,
|
||||
},
|
||||
"gemini-3.1-flash-lite-preview": {
|
||||
input: 0.5,
|
||||
output: 3.0,
|
||||
cached: 0.03,
|
||||
reasoning: 4.5,
|
||||
cache_creation: 0.5,
|
||||
},
|
||||
"gemini-2.5-pro": {
|
||||
input: 2.0,
|
||||
output: 12.0,
|
||||
@@ -707,11 +743,11 @@ export const DEFAULT_PRICING = {
|
||||
// GLM
|
||||
glm: {
|
||||
"glm-5": {
|
||||
input: 1.0,
|
||||
output: 3.2,
|
||||
cached: 0.5,
|
||||
reasoning: 4.8,
|
||||
cache_creation: 1.0,
|
||||
input: 0.38,
|
||||
output: 1.98,
|
||||
cached: 0.19,
|
||||
reasoning: 2.97,
|
||||
cache_creation: 0.38,
|
||||
},
|
||||
"glm-5-turbo": {
|
||||
input: 1.2,
|
||||
@@ -721,11 +757,11 @@ export const DEFAULT_PRICING = {
|
||||
cache_creation: 1.2,
|
||||
},
|
||||
"glm-4.7": {
|
||||
input: 0.75,
|
||||
output: 3.0,
|
||||
cached: 0.375,
|
||||
reasoning: 4.5,
|
||||
cache_creation: 0.75,
|
||||
input: 0.38,
|
||||
output: 1.98,
|
||||
cached: 0.19,
|
||||
reasoning: 2.97,
|
||||
cache_creation: 0.38,
|
||||
},
|
||||
"glm-4.6": {
|
||||
input: 0.5,
|
||||
@@ -761,6 +797,20 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 4.5,
|
||||
cache_creation: 0.6,
|
||||
},
|
||||
"kimi-k2.5-thinking": {
|
||||
input: 0.6,
|
||||
output: 3.0,
|
||||
cached: 0.3,
|
||||
reasoning: 4.5,
|
||||
cache_creation: 0.6,
|
||||
},
|
||||
"kimi-for-coding": {
|
||||
input: 0.6,
|
||||
output: 3.0,
|
||||
cached: 0.3,
|
||||
reasoning: 4.5,
|
||||
cache_creation: 0.6,
|
||||
},
|
||||
"moonshot-kimi-k2.5": {
|
||||
input: 0.6,
|
||||
output: 3.0,
|
||||
@@ -770,6 +820,30 @@ export const DEFAULT_PRICING = {
|
||||
},
|
||||
},
|
||||
|
||||
// Kimi Coding aliases (OAuth/API key)
|
||||
kmc: {
|
||||
"kimi-k2.5": { input: 0.6, output: 3.0, cached: 0.3, reasoning: 4.5, cache_creation: 0.6 },
|
||||
"kimi-k2.5-thinking": {
|
||||
input: 0.6,
|
||||
output: 3.0,
|
||||
cached: 0.3,
|
||||
reasoning: 4.5,
|
||||
cache_creation: 0.6,
|
||||
},
|
||||
"kimi-latest": { input: 1.0, output: 4.0, cached: 0.5, reasoning: 6.0, cache_creation: 1.0 },
|
||||
},
|
||||
kmca: {
|
||||
"kimi-k2.5": { input: 0.6, output: 3.0, cached: 0.3, reasoning: 4.5, cache_creation: 0.6 },
|
||||
"kimi-k2.5-thinking": {
|
||||
input: 0.6,
|
||||
output: 3.0,
|
||||
cached: 0.3,
|
||||
reasoning: 4.5,
|
||||
cache_creation: 0.6,
|
||||
},
|
||||
"kimi-latest": { input: 1.0, output: 4.0, cached: 0.5, reasoning: 6.0, cache_creation: 1.0 },
|
||||
},
|
||||
|
||||
// MiniMax
|
||||
minimax: {
|
||||
"minimax-m2.1": {
|
||||
@@ -789,18 +863,42 @@ export const DEFAULT_PRICING = {
|
||||
// MiniMax M2.5 — mais barato que M2.1, reasoning + tools
|
||||
// Context: 204.800 tokens | Max Output: 16.384 tokens
|
||||
"minimax-m2.5": {
|
||||
input: 0.3,
|
||||
output: 1.2,
|
||||
cached: 0.15,
|
||||
reasoning: 1.8,
|
||||
cache_creation: 0.3,
|
||||
input: 0.27,
|
||||
output: 0.95,
|
||||
cached: 0.135,
|
||||
reasoning: 1.425,
|
||||
cache_creation: 0.27,
|
||||
},
|
||||
"MiniMax-M2.5": {
|
||||
input: 0.3,
|
||||
output: 1.2,
|
||||
cached: 0.15,
|
||||
reasoning: 1.8,
|
||||
cache_creation: 0.3,
|
||||
input: 0.27,
|
||||
output: 0.95,
|
||||
cached: 0.135,
|
||||
reasoning: 1.425,
|
||||
cache_creation: 0.27,
|
||||
},
|
||||
// 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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1083,11 +1181,11 @@ export const DEFAULT_PRICING = {
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
zai: {
|
||||
"glm-5": {
|
||||
input: 1.0,
|
||||
output: 3.2,
|
||||
cached: 0.5,
|
||||
reasoning: 4.8,
|
||||
cache_creation: 1.0,
|
||||
input: 0.38,
|
||||
output: 1.98,
|
||||
cached: 0.19,
|
||||
reasoning: 2.97,
|
||||
cache_creation: 0.38,
|
||||
},
|
||||
"glm-5-turbo": {
|
||||
input: 1.2,
|
||||
@@ -1096,6 +1194,13 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 6.0,
|
||||
cache_creation: 1.2,
|
||||
},
|
||||
"glm-4.7": {
|
||||
input: 0.38,
|
||||
output: 1.98,
|
||||
cached: 0.19,
|
||||
reasoning: 2.97,
|
||||
cache_creation: 0.38,
|
||||
},
|
||||
},
|
||||
|
||||
kiro: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -98,7 +98,7 @@ const CLI_TOOLS: Record<string, any> = {
|
||||
// opencode takes several seconds on cold start environments
|
||||
healthcheckTimeoutMs: 15000,
|
||||
paths: {
|
||||
config: ".config/opencode/config.toml",
|
||||
config: ".config/opencode/opencode.json",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -197,15 +197,220 @@ const getRuntimeMode = () => {
|
||||
return VALID_RUNTIME_MODES.has(mode) ? mode : "auto";
|
||||
};
|
||||
|
||||
/**
|
||||
* T12: Validate a CLI executable path to prevent shell injection.
|
||||
* Enforces: absolute path, no dangerous shell metacharacters, must exist and be a file.
|
||||
* Inspired by Antigravity Manager commit 96732c2 (Mar 11, 2026).
|
||||
*/
|
||||
const DANGEROUS_PATH_CHARS = ["&", "|", ";", "<", ">", "(", ")", "`", "$", "^", "%", "!"];
|
||||
|
||||
/**
|
||||
* Check if a path is within a parent directory (case-insensitive, handles mixed separators).
|
||||
* Normalizes both paths to forward slashes before comparison to handle
|
||||
* inconsistent separator styles on Windows.
|
||||
*/
|
||||
const isPathWithin = (childPath: string, parentPath: string): boolean => {
|
||||
// Normalize to forward slashes for consistent comparison
|
||||
const normalize = (p: string) => path.normalize(p).toLowerCase().replace(/\\/g, "/");
|
||||
const normalizedChild = normalize(childPath);
|
||||
const normalizedParent = normalize(parentPath);
|
||||
|
||||
if (normalizedChild === normalizedParent) return true;
|
||||
|
||||
// Ensure parent ends with / for proper prefix matching
|
||||
const parentWithSep = normalizedParent.endsWith("/") ? normalizedParent : normalizedParent + "/";
|
||||
|
||||
return normalizedChild.startsWith(parentWithSep);
|
||||
};
|
||||
|
||||
const isSafePath = (execPath: string): boolean => {
|
||||
if (!execPath || !path.isAbsolute(execPath)) return false;
|
||||
if (DANGEROUS_PATH_CHARS.some((c) => execPath.includes(c))) return false;
|
||||
// Allow path.sep and path.delimiter — no further character filtering needed
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate that an environment variable value is a safe, absolute path
|
||||
* within acceptable directory trees. Rejects traversal, special chars,
|
||||
* and paths outside expected locations.
|
||||
*/
|
||||
const validateEnvPath = (value: string | undefined, allowedParents: string[]): string => {
|
||||
if (!value) return "";
|
||||
const trimmed = value.trim();
|
||||
|
||||
// Reject if not absolute
|
||||
if (!path.isAbsolute(trimmed)) return "";
|
||||
|
||||
// Reject dangerous characters (same as isSafePath but applied to env vars)
|
||||
if (DANGEROUS_PATH_CHARS.some((c) => trimmed.includes(c))) return "";
|
||||
|
||||
// Reject if contains path traversal segments
|
||||
const normalized = path.normalize(trimmed);
|
||||
if (normalized.includes("..")) return "";
|
||||
|
||||
// Reject if outside allowed parent directories
|
||||
if (allowedParents.length > 0) {
|
||||
const withinAllowed = allowedParents.some((parent) => isPathWithin(normalized, parent));
|
||||
if (!withinAllowed) return "";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pre-compute expected parent directories at module startup for performance.
|
||||
* These are the allowed directories for CLI binary installation locations.
|
||||
*/
|
||||
const getExpectedParentPaths = (): string[] => {
|
||||
const home = os.homedir();
|
||||
const userProfile = process.env.USERPROFILE || home;
|
||||
|
||||
const validatedAppData = validateEnvPath(process.env.APPDATA, [home, userProfile]);
|
||||
const validatedLocalAppData = validateEnvPath(process.env.LOCALAPPDATA, [
|
||||
path.join(home, "AppData", "Local"),
|
||||
path.join(userProfile, "AppData", "Local"),
|
||||
userProfile,
|
||||
]);
|
||||
const validatedProgramFiles = validateEnvPath(process.env.ProgramFiles, [
|
||||
"C:\\Program Files",
|
||||
"C:\\Program Files (x86)",
|
||||
]);
|
||||
const validatedProgramFilesX86 = validateEnvPath(process.env["ProgramFiles(x86)"], [
|
||||
"C:\\Program Files",
|
||||
"C:\\Program Files (x86)",
|
||||
]);
|
||||
|
||||
return [
|
||||
home,
|
||||
userProfile,
|
||||
validatedAppData,
|
||||
validatedLocalAppData,
|
||||
validatedProgramFiles,
|
||||
validatedProgramFilesX86,
|
||||
].filter(Boolean);
|
||||
};
|
||||
|
||||
// Cache expected parent paths at module startup (avoid recalculation on every checkKnownPath call)
|
||||
const EXPECTED_PARENT_PATHS = getExpectedParentPaths();
|
||||
|
||||
const getExtraPaths = () =>
|
||||
String(process.env.CLI_EXTRA_PATHS || "")
|
||||
.split(path.delimiter)
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
.filter(Boolean)
|
||||
.filter((p) => {
|
||||
// Must be absolute
|
||||
if (!path.isAbsolute(p)) return false;
|
||||
// No dangerous characters
|
||||
if (DANGEROUS_PATH_CHARS.some((c) => p.includes(c))) return false;
|
||||
// No path traversal
|
||||
if (path.normalize(p).includes("..")) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
/**
|
||||
* Get known installation paths for a specific CLI tool on Windows.
|
||||
* Returns ONLY verified, tool-specific paths - NOT generic user bin directories.
|
||||
* This is more secure than searching PATH as it checks known locations only.
|
||||
*/
|
||||
const getKnownToolPaths = (toolId: string): string[] => {
|
||||
if (!isWindows()) return [];
|
||||
|
||||
const home = os.homedir();
|
||||
const userProfile = process.env.USERPROFILE || home;
|
||||
|
||||
// Validate environment paths against allowed parent directories
|
||||
const appData = validateEnvPath(process.env.APPDATA, [home, userProfile]);
|
||||
const localAppData = validateEnvPath(process.env.LOCALAPPDATA, [
|
||||
path.join(home, "AppData", "Local"),
|
||||
path.join(userProfile, "AppData", "Local"),
|
||||
userProfile,
|
||||
]);
|
||||
|
||||
// Cache nvm node path to avoid duplicate detection calls
|
||||
const nvmNodePath = getNvmNodePath();
|
||||
|
||||
// Tool-specific known installation paths (verified locations only)
|
||||
const knownPaths: Record<string, string[]> = {
|
||||
claude: [
|
||||
// Official Claude Code standalone installer locations
|
||||
path.join(home, ".local", "bin", "claude.exe"),
|
||||
...(localAppData ? [path.join(localAppData, "Programs", "Claude", "claude.exe")] : []),
|
||||
...(localAppData ? [path.join(localAppData, "claude-code", "claude.exe")] : []),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "claude-code.cmd")] : []),
|
||||
],
|
||||
codex: [
|
||||
path.join(home, ".local", "bin", "codex"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "codex.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "codex.cmd")] : []),
|
||||
],
|
||||
droid: [
|
||||
path.join(home, ".local", "bin", "droid"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "droid.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "droid.cmd")] : []),
|
||||
],
|
||||
openclaw: [
|
||||
path.join(home, ".local", "bin", "openclaw"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "openclaw.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "openclaw.cmd")] : []),
|
||||
],
|
||||
cursor: [
|
||||
path.join(home, ".local", "bin", "agent"),
|
||||
path.join(home, ".local", "bin", "cursor"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "agent.cmd")] : []),
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "cursor.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "agent.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "cursor.cmd")] : []),
|
||||
],
|
||||
cline: [
|
||||
path.join(home, ".local", "bin", "cline"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "cline.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "cline.cmd")] : []),
|
||||
],
|
||||
kilo: [
|
||||
path.join(home, ".local", "bin", "kilocode"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "kilocode.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "kilocode.cmd")] : []),
|
||||
],
|
||||
opencode: [
|
||||
path.join(home, ".local", "bin", "opencode"),
|
||||
// npm global (only if nvm-windows is detected)
|
||||
...(nvmNodePath ? [path.join(nvmNodePath, "opencode.cmd")] : []),
|
||||
...(appData ? [path.join(appData, "npm", "opencode.cmd")] : []),
|
||||
],
|
||||
// Add other tools as needed with their specific known paths
|
||||
};
|
||||
|
||||
return knownPaths[toolId] || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect nvm-windows installation path dynamically from current Node.js executable.
|
||||
* Returns the directory containing node.exe if nvm is detected, null otherwise.
|
||||
*/
|
||||
const getNvmNodePath = (): string | null => {
|
||||
// Simple heuristic: if process.execPath includes "nvm", use its directory
|
||||
if (process.execPath.toLowerCase().includes("nvm")) {
|
||||
return path.dirname(process.execPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getLookupEnv = () => {
|
||||
const env = { ...process.env };
|
||||
const extraPaths = getExtraPaths();
|
||||
|
||||
// Only add user-specified extra paths, NOT generic user directories
|
||||
// This is more secure - user explicitly opts in via CLI_EXTRA_PATHS
|
||||
if (extraPaths.length > 0) {
|
||||
env.PATH = [...extraPaths, env.PATH || ""].filter(Boolean).join(path.delimiter);
|
||||
}
|
||||
@@ -223,20 +428,6 @@ const resolveToolCommands = (toolId: string): string[] => {
|
||||
return tool.defaultCommand ? [tool.defaultCommand] : [];
|
||||
};
|
||||
|
||||
/**
|
||||
* T12: Validate a CLI executable path to prevent shell injection.
|
||||
* Enforces: absolute path, no dangerous shell metacharacters, must exist and be a file.
|
||||
* Inspired by Antigravity Manager commit 96732c2 (Mar 11, 2026).
|
||||
*/
|
||||
const DANGEROUS_PATH_CHARS = ["&", "|", ";", "<", ">", "(", ")", "`", "$", "^", "%", "!"];
|
||||
|
||||
const isSafePath = (execPath: string): boolean => {
|
||||
if (!execPath || !path.isAbsolute(execPath)) return false;
|
||||
if (DANGEROUS_PATH_CHARS.some((c) => execPath.includes(c))) return false;
|
||||
// Allow path.sep and path.delimiter — no further character filtering needed
|
||||
return true;
|
||||
};
|
||||
|
||||
const checkExplicitPath = async (commandPath: string) => {
|
||||
// Reject paths that look like injection attempts
|
||||
if (!isSafePath(commandPath)) {
|
||||
@@ -294,14 +485,93 @@ const locateCommand = async (command: string, env: Record<string, string | undef
|
||||
return { installed: !!first, commandPath: first, reason: first ? null : "not_found" };
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a command exists at a specific absolute path.
|
||||
* Used for known installation locations.
|
||||
*
|
||||
* Security hardening:
|
||||
* - Resolves symlinks and verifies target stays within expected directories
|
||||
* - Verifies file is a regular file (not directory, pipe, or device)
|
||||
* - Checks file size bounds (1KB - 100MB) to detect suspicious binaries
|
||||
*/
|
||||
const checkKnownPath = async (commandPath: string) => {
|
||||
if (!path.isAbsolute(commandPath)) {
|
||||
return { installed: false, commandPath: null, reason: "not_absolute" };
|
||||
}
|
||||
|
||||
if (!isSafePath(commandPath)) {
|
||||
return { installed: false, commandPath: null, reason: "unsafe_path" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve symlinks to get the real path and detect symlink escapes
|
||||
const realPath = await fs.realpath(commandPath);
|
||||
|
||||
// Verify the resolved path is still within expected directories
|
||||
// Use pre-computed expected parent paths (cached at module startup for performance)
|
||||
const isWithinExpected = EXPECTED_PARENT_PATHS.some((parent) => isPathWithin(realPath, parent));
|
||||
|
||||
if (!isWithinExpected) {
|
||||
return { installed: false, commandPath: null, reason: "symlink_escape" };
|
||||
}
|
||||
|
||||
// Verify it's a regular file with reasonable size
|
||||
const stat = await fs.stat(realPath);
|
||||
if (!stat.isFile()) {
|
||||
return { installed: false, commandPath: null, reason: "not_file" };
|
||||
}
|
||||
|
||||
// CLI binaries should be > 1KB and < 100MB
|
||||
// This catches suspicious files while allowing for wrapper scripts
|
||||
if (stat.size < 1024 || stat.size > 100 * 1024 * 1024) {
|
||||
return { installed: false, commandPath: null, reason: "suspicious_size" };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorCode = (error as NodeJS.ErrnoException).code;
|
||||
if (errorCode === "ENOENT") {
|
||||
return { installed: false, commandPath: null, reason: "not_found" };
|
||||
}
|
||||
if (errorCode === "EINVAL") {
|
||||
return { installed: false, commandPath: null, reason: "invalid_path" };
|
||||
}
|
||||
return { installed: false, commandPath: null, reason: "access_error" };
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(commandPath, fs.constants.X_OK);
|
||||
return { installed: true, commandPath, reason: null };
|
||||
} catch {
|
||||
return { installed: true, commandPath, reason: "not_executable" };
|
||||
}
|
||||
};
|
||||
|
||||
const locateCommandCandidate = async (
|
||||
commands: string[],
|
||||
env: Record<string, string | undefined>
|
||||
env: Record<string, string | undefined>,
|
||||
toolId?: string
|
||||
) => {
|
||||
if (!Array.isArray(commands) || commands.length === 0) {
|
||||
return { command: null, installed: false, commandPath: null, reason: "missing_command" };
|
||||
}
|
||||
|
||||
// SECURITY: First check known installation paths for this specific tool
|
||||
// This avoids searching PATH and reduces attack surface
|
||||
if (toolId && isWindows()) {
|
||||
const knownPaths = getKnownToolPaths(toolId);
|
||||
for (const knownPath of knownPaths) {
|
||||
const result = await checkKnownPath(knownPath);
|
||||
if (result.installed && result.reason === null) {
|
||||
return {
|
||||
command: commands[0],
|
||||
installed: true,
|
||||
commandPath: result.commandPath,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: search PATH (user can set CLI_EXTRA_PATHS if needed)
|
||||
for (const command of commands) {
|
||||
const located = await locateCommand(command, env);
|
||||
if (located.installed || located.reason !== "not_found") {
|
||||
@@ -317,10 +587,18 @@ const checkRunnable = async (
|
||||
env: Record<string, string | undefined>,
|
||||
timeoutMs = 4000
|
||||
) => {
|
||||
// Minimal environment to prevent credential leakage to potentially malicious binaries
|
||||
const minimalEnv: Record<string, string | undefined> = {
|
||||
PATH: env.PATH,
|
||||
HOME: env.HOME || env.USERPROFILE,
|
||||
SystemRoot: env.SystemRoot, // Windows needs this
|
||||
};
|
||||
|
||||
for (const args of [["--version"], ["-v"]]) {
|
||||
const result = await runProcess(commandPath, args, { env, timeoutMs });
|
||||
if (result.ok) {
|
||||
return { runnable: true, reason: null };
|
||||
const result = await runProcess(commandPath, args, { env: minimalEnv, timeoutMs });
|
||||
// Validate output: must be non-empty and reasonable length (< 4KB)
|
||||
if (result.ok && result.stdout.length > 0 && result.stdout.length < 4096) {
|
||||
return { runnable: true, reason: null, version: result.stdout.trim() };
|
||||
}
|
||||
}
|
||||
return { runnable: false, reason: "healthcheck_failed" };
|
||||
@@ -334,12 +612,62 @@ export const ensureCliConfigWriteAllowed = () => {
|
||||
return "CLI config writes are disabled (CLI_ALLOW_CONFIG_WRITES=false)";
|
||||
};
|
||||
|
||||
export const getCliConfigHome = () =>
|
||||
String(process.env.CLI_CONFIG_HOME || "").trim() || os.homedir();
|
||||
export const getCliConfigHome = () => {
|
||||
const override = String(process.env.CLI_CONFIG_HOME || "").trim();
|
||||
if (!override) return os.homedir();
|
||||
|
||||
// Must be absolute
|
||||
if (!path.isAbsolute(override)) return os.homedir();
|
||||
|
||||
// Must not contain dangerous characters
|
||||
if (DANGEROUS_PATH_CHARS.some((c) => override.includes(c))) return os.homedir();
|
||||
|
||||
// Must not contain path traversal
|
||||
if (path.normalize(override).includes("..")) return os.homedir();
|
||||
|
||||
// Must be within user's home directory (prevent reading from system dirs)
|
||||
const home = os.homedir();
|
||||
const normalized = path.normalize(override);
|
||||
if (!isPathWithin(normalized, home)) {
|
||||
return home; // Silently fall back to home
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const resolveOpencodeConfigDir = (
|
||||
platform = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homeDir = os.homedir()
|
||||
) => {
|
||||
const isWin = platform === "win32";
|
||||
if (isWin) {
|
||||
const appData = String(env.APPDATA || "").trim();
|
||||
return appData || path.join(homeDir, "AppData", "Roaming");
|
||||
}
|
||||
|
||||
const xdgConfigHome = String(env.XDG_CONFIG_HOME || "").trim();
|
||||
return xdgConfigHome || path.join(homeDir, ".config");
|
||||
};
|
||||
|
||||
export const resolveOpencodeConfigPath = (
|
||||
platform = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
homeDir = os.homedir()
|
||||
) => path.join(resolveOpencodeConfigDir(platform, env, homeDir), "opencode", "opencode.json");
|
||||
|
||||
export const getOpenCodeConfigPath = () => resolveOpencodeConfigPath();
|
||||
|
||||
export const getCliConfigPaths = (toolId: string) => {
|
||||
const tool = CLI_TOOLS[toolId];
|
||||
if (!tool) return null;
|
||||
|
||||
if (toolId === "opencode") {
|
||||
return {
|
||||
config: getOpenCodeConfigPath(),
|
||||
};
|
||||
}
|
||||
|
||||
const home = getCliConfigHome();
|
||||
return Object.fromEntries(
|
||||
Object.entries(tool.paths).map(([key, relativePath]) => [
|
||||
@@ -387,7 +715,7 @@ export const getCliRuntimeStatus = async (toolId: string) => {
|
||||
};
|
||||
}
|
||||
|
||||
const located = await locateCommandCandidate(commands, env);
|
||||
const located = await locateCommandCandidate(commands, env, toolId);
|
||||
const command = located.command;
|
||||
|
||||
if (!located.installed) {
|
||||
|
||||
64
src/shared/services/opencodeConfig.ts
Normal file
64
src/shared/services/opencodeConfig.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
type OpenCodeConfigInput = {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type OpenCodeProviderConfig = {
|
||||
name: string;
|
||||
api: "openai";
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
models: string[];
|
||||
};
|
||||
|
||||
const OPENCODE_DEFAULT_MODELS = [
|
||||
"claude-opus-4-5-thinking",
|
||||
"claude-sonnet-4-5-thinking",
|
||||
"gemini-3.1-pro-high",
|
||||
"gemini-3-flash",
|
||||
] as const;
|
||||
|
||||
const normalizeValue = (value: unknown) =>
|
||||
String(value || "")
|
||||
.trim()
|
||||
.replace(/^\/+/, "");
|
||||
|
||||
export const buildOpenCodeProviderConfig = ({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
}: OpenCodeConfigInput): OpenCodeProviderConfig => {
|
||||
const normalizedBaseUrl = String(baseUrl || "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
const normalizedModel = normalizeValue(model);
|
||||
|
||||
const uniqueModels = [...new Set([normalizedModel, ...OPENCODE_DEFAULT_MODELS].filter(Boolean))];
|
||||
|
||||
return {
|
||||
name: "OmniRoute",
|
||||
api: "openai",
|
||||
baseURL: normalizedBaseUrl,
|
||||
apiKey: apiKey || "sk_omniroute",
|
||||
models: uniqueModels,
|
||||
};
|
||||
};
|
||||
|
||||
export const mergeOpenCodeConfig = (
|
||||
existingConfig: Record<string, any> | null | undefined,
|
||||
input: OpenCodeConfigInput
|
||||
) => {
|
||||
const safeConfig =
|
||||
existingConfig && typeof existingConfig === "object" && !Array.isArray(existingConfig)
|
||||
? existingConfig
|
||||
: {};
|
||||
|
||||
return {
|
||||
...safeConfig,
|
||||
providers: {
|
||||
...((safeConfig as any).providers || {}),
|
||||
omniroute: buildOpenCodeProviderConfig(input),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -37,6 +37,7 @@ export interface ApiKeyMetadata {
|
||||
accessSchedule?: AccessSchedule | null;
|
||||
maxRequestsPerDay?: number | null;
|
||||
maxRequestsPerMinute?: number | null;
|
||||
maxSessions?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -905,6 +905,7 @@ export const updateKeyPermissionsSchema = z
|
||||
noLog: z.boolean().optional(),
|
||||
autoResolve: z.boolean().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
maxSessions: z.number().int().min(0).max(10000).optional(),
|
||||
accessSchedule: z.union([accessScheduleSchema, z.null()]).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
@@ -915,6 +916,7 @@ export const updateKeyPermissionsSchema = z
|
||||
value.noLog === undefined &&
|
||||
value.autoResolve === undefined &&
|
||||
value.isActive === undefined &&
|
||||
value.maxSessions === undefined &&
|
||||
value.accessSchedule === undefined
|
||||
) {
|
||||
ctx.addIssue({
|
||||
@@ -1028,6 +1030,7 @@ export const providersBatchTestSchema = z
|
||||
export const validateProviderApiKeySchema = z.object({
|
||||
provider: z.string().trim().min(1, "Provider and API key required"),
|
||||
apiKey: z.string().trim().min(1, "Provider and API key required"),
|
||||
validationModelId: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
const geminiPartSchema = z
|
||||
|
||||
@@ -46,6 +46,14 @@ import {
|
||||
applyTaskAwareRouting,
|
||||
getTaskRoutingConfig,
|
||||
} from "@omniroute/open-sse/services/taskAwareRouter.ts";
|
||||
import {
|
||||
generateSessionId as generateStableSessionId,
|
||||
touchSession,
|
||||
extractExternalSessionId,
|
||||
checkSessionLimit,
|
||||
registerKeySession,
|
||||
isSessionRegisteredForKey,
|
||||
} from "@omniroute/open-sse/services/sessionManager.ts";
|
||||
import {
|
||||
isFallbackDecision,
|
||||
shouldUseFallback,
|
||||
@@ -161,6 +169,13 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model");
|
||||
}
|
||||
|
||||
// T04: client-provided external session header has priority over generated fingerprint.
|
||||
const externalSessionId = extractExternalSessionId(request.headers);
|
||||
const sessionId = externalSessionId || generateStableSessionId(body);
|
||||
if (sessionId) {
|
||||
touchSession(sessionId);
|
||||
}
|
||||
|
||||
// Pipeline: API key policy enforcement (model restrictions + budget limits)
|
||||
telemetry.startPhase("policy");
|
||||
const policy = await enforceApiKeyPolicy(request, modelStr);
|
||||
@@ -174,6 +189,25 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
const apiKeyInfo = policy.apiKeyInfo;
|
||||
telemetry.endPhase();
|
||||
|
||||
// T08: per-key active session limit (0 = unlimited).
|
||||
if (apiKeyInfo?.id && sessionId) {
|
||||
const maxSessions =
|
||||
typeof apiKeyInfo.maxSessions === "number" && apiKeyInfo.maxSessions > 0
|
||||
? apiKeyInfo.maxSessions
|
||||
: 0;
|
||||
|
||||
if (maxSessions > 0 && !isSessionRegisteredForKey(apiKeyInfo.id, sessionId)) {
|
||||
const sessionViolation = checkSessionLimit(apiKeyInfo.id, maxSessions);
|
||||
if (sessionViolation) {
|
||||
return withSessionHeader(
|
||||
errorResponse(HTTP_STATUS.RATE_LIMITED, sessionViolation.message),
|
||||
sessionId
|
||||
);
|
||||
}
|
||||
registerKeySession(apiKeyInfo.id, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// T05 — Task-Aware Smart Routing
|
||||
// Detect the semantic task type and optionally route to the optimal model
|
||||
let resolvedModelStr = modelStr;
|
||||
@@ -221,7 +255,8 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
const creds = await getProviderCredentials(
|
||||
provider,
|
||||
null,
|
||||
apiKeyInfo?.allowedConnections ?? null
|
||||
apiKeyInfo?.allowedConnections ?? null,
|
||||
modelInfo.model || modelString
|
||||
);
|
||||
if (!creds || creds.allRateLimited) return false;
|
||||
return true;
|
||||
@@ -238,7 +273,9 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
body,
|
||||
combo,
|
||||
handleSingleModel: (b: any, m: string) =>
|
||||
handleSingleModelChat(b, m, clientRawRequest, request, combo.name, apiKeyInfo, telemetry),
|
||||
handleSingleModelChat(b, m, clientRawRequest, request, combo.name, apiKeyInfo, telemetry, {
|
||||
sessionId,
|
||||
}),
|
||||
isModelAvailable: checkModelAvailable,
|
||||
log,
|
||||
settings,
|
||||
@@ -247,7 +284,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
|
||||
// Record telemetry
|
||||
recordTelemetry(telemetry);
|
||||
return response;
|
||||
return withSessionHeader(response, sessionId);
|
||||
}
|
||||
telemetry.endPhase();
|
||||
|
||||
@@ -259,10 +296,11 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
request,
|
||||
null,
|
||||
apiKeyInfo,
|
||||
telemetry
|
||||
telemetry,
|
||||
{ sessionId }
|
||||
);
|
||||
recordTelemetry(telemetry);
|
||||
return response;
|
||||
return withSessionHeader(response, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -280,7 +318,7 @@ async function handleSingleModelChat(
|
||||
comboName: string | null = null,
|
||||
apiKeyInfo: any = null,
|
||||
telemetry: any = null,
|
||||
runtimeOptions: { emergencyFallbackTried?: boolean } = {}
|
||||
runtimeOptions: { emergencyFallbackTried?: boolean; sessionId?: string | null } = {}
|
||||
) {
|
||||
// 1. Resolve model → provider/model
|
||||
const resolved = await resolveModelOrError(modelStr, body);
|
||||
@@ -310,7 +348,8 @@ async function handleSingleModelChat(
|
||||
const credentials = await getProviderCredentials(
|
||||
provider,
|
||||
excludeConnectionId,
|
||||
apiKeyInfo?.allowedConnections ?? null
|
||||
apiKeyInfo?.allowedConnections ?? null,
|
||||
model
|
||||
);
|
||||
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
@@ -333,6 +372,9 @@ async function handleSingleModelChat(
|
||||
|
||||
const accountId = credentials.connectionId.slice(0, 8);
|
||||
log.info("AUTH", `Using ${provider} account: ${accountId}...`);
|
||||
if (runtimeOptions.sessionId) {
|
||||
touchSession(runtimeOptions.sessionId, credentials.connectionId);
|
||||
}
|
||||
|
||||
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
|
||||
const proxyInfo = await safeResolveProxy(credentials.connectionId);
|
||||
@@ -604,6 +646,23 @@ async function executeChatWithBreaker({
|
||||
tlsFingerprintUsed: false,
|
||||
};
|
||||
}
|
||||
|
||||
// T14: Proxy Fast-Fail should be converted into an upstream-unavailable result
|
||||
// so account fallback logic can continue with another connection.
|
||||
if (cbErr?.code === "PROXY_UNREACHABLE" || /proxy unreachable/i.test(cbErr?.message || "")) {
|
||||
const detail = cbErr?.message || "Proxy unreachable";
|
||||
log.warn("PROXY", detail);
|
||||
return {
|
||||
result: {
|
||||
success: false,
|
||||
response: (unavailableResponse as any)(HTTP_STATUS.SERVICE_UNAVAILABLE, detail, 2),
|
||||
status: HTTP_STATUS.SERVICE_UNAVAILABLE,
|
||||
error: detail,
|
||||
},
|
||||
tlsFingerprintUsed: false,
|
||||
};
|
||||
}
|
||||
|
||||
throw cbErr;
|
||||
}
|
||||
}
|
||||
@@ -710,3 +769,20 @@ function safeLogEvents({
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function withSessionHeader(response: Response, sessionId: string | null): Response {
|
||||
if (!response || !sessionId) return response;
|
||||
|
||||
try {
|
||||
response.headers.set("X-OmniRoute-Session-Id", sessionId);
|
||||
return response;
|
||||
} catch {
|
||||
const cloned = new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
cloned.headers.set("X-OmniRoute-Session-Id", sessionId);
|
||||
return cloned;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@omniroute/open-sse/services/accountFallback.ts";
|
||||
import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
import { COOLDOWN_MS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { getCodexModelScope } from "@omniroute/open-sse/executors/codex.ts";
|
||||
import * as log from "../utils/logger";
|
||||
import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck";
|
||||
|
||||
@@ -166,6 +167,56 @@ function applyCodexWindowPolicy(rawWindows: string[], providerSpecificData: Json
|
||||
return uniqueWindows(windows);
|
||||
}
|
||||
|
||||
function getCodexScopeRateLimitedUntil(
|
||||
providerSpecificData: JsonRecord,
|
||||
model: string | null
|
||||
): string | null {
|
||||
if (!model) return null;
|
||||
const scope = getCodexModelScope(model);
|
||||
const scopeMap = asRecord(providerSpecificData.codexScopeRateLimitedUntil);
|
||||
const value = scopeMap[scope];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function isCodexScopeUnavailable(
|
||||
connection: ProviderConnectionView,
|
||||
model: string | null
|
||||
): boolean {
|
||||
const until = getCodexScopeRateLimitedUntil(connection.providerSpecificData, model);
|
||||
if (!until) return false;
|
||||
return new Date(until).getTime() > Date.now();
|
||||
}
|
||||
|
||||
function getEarliestCodexScopeRateLimitedUntil(
|
||||
connections: ProviderConnectionView[],
|
||||
model: string | null
|
||||
): string | null {
|
||||
let earliest: string | null = null;
|
||||
let earliestMs = Infinity;
|
||||
|
||||
for (const conn of connections) {
|
||||
const until = getCodexScopeRateLimitedUntil(conn.providerSpecificData, model);
|
||||
if (!until) continue;
|
||||
const ms = new Date(until).getTime();
|
||||
if (!Number.isFinite(ms) || ms <= Date.now()) continue;
|
||||
if (ms < earliestMs) {
|
||||
earliest = until;
|
||||
earliestMs = ms;
|
||||
}
|
||||
}
|
||||
|
||||
return earliest;
|
||||
}
|
||||
|
||||
function normalizeStatus(value: string | null): string {
|
||||
return (value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isTerminalConnectionStatus(connection: ProviderConnectionView): boolean {
|
||||
const status = normalizeStatus(connection.testStatus);
|
||||
return status === "credits_exhausted" || status === "banned" || status === "expired";
|
||||
}
|
||||
|
||||
export function resolveQuotaLimitPolicy(
|
||||
provider: string,
|
||||
providerSpecificData: JsonRecord
|
||||
@@ -259,7 +310,8 @@ export { fisherYatesShuffle, getNextFromDeckSync as getNextFromDeck };
|
||||
export async function getProviderCredentials(
|
||||
provider: string,
|
||||
excludeConnectionId: string | null = null,
|
||||
allowedConnections: string[] | null = null
|
||||
allowedConnections: string[] | null = null,
|
||||
requestedModel: string | null = null
|
||||
) {
|
||||
// Acquire mutex to prevent race conditions
|
||||
const currentMutex = selectionMutex;
|
||||
@@ -320,6 +372,8 @@ export async function getProviderCredentials(
|
||||
const availableConnections = connections.filter((c) => {
|
||||
if (excludeConnectionId && c.id === excludeConnectionId) return false;
|
||||
if (isAccountUnavailable(c.rateLimitedUntil)) return false;
|
||||
if (isTerminalConnectionStatus(c)) return false;
|
||||
if (provider === "codex" && isCodexScopeUnavailable(c, requestedModel)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -330,16 +384,27 @@ export async function getProviderCredentials(
|
||||
connections.forEach((c) => {
|
||||
const excluded = excludeConnectionId && c.id === excludeConnectionId;
|
||||
const rateLimited = isAccountUnavailable(c.rateLimitedUntil);
|
||||
const terminalStatus = isTerminalConnectionStatus(c);
|
||||
const codexScopeLimited = provider === "codex" && isCodexScopeUnavailable(c, requestedModel);
|
||||
if (excluded || rateLimited) {
|
||||
log.debug(
|
||||
"AUTH",
|
||||
` → ${c.id?.slice(0, 8)} | ${excluded ? "excluded" : ""} ${rateLimited ? `rateLimited until ${c.rateLimitedUntil}` : ""}`
|
||||
);
|
||||
} else if (terminalStatus) {
|
||||
log.debug("AUTH", ` → ${c.id?.slice(0, 8)} | skipped terminal status=${c.testStatus}`);
|
||||
} else if (codexScopeLimited) {
|
||||
const scopeUntil = getCodexScopeRateLimitedUntil(c.providerSpecificData, requestedModel);
|
||||
log.debug("AUTH", ` → ${c.id?.slice(0, 8)} | codex scope-limited until ${scopeUntil}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (availableConnections.length === 0) {
|
||||
const earliest = getEarliestRateLimitedUntil(connections);
|
||||
const earliest =
|
||||
getEarliestRateLimitedUntil(connections) ||
|
||||
(provider === "codex"
|
||||
? getEarliestCodexScopeRateLimitedUntil(connections, requestedModel)
|
||||
: null);
|
||||
if (earliest) {
|
||||
// Find the connection with the earliest rateLimitedUntil to get its error info
|
||||
const rateLimitedConns = connections.filter(
|
||||
@@ -618,6 +683,15 @@ export async function markAccountUnavailable(
|
||||
const conn = connections.find((connection) => connection.id === connectionId);
|
||||
const backoffLevel = conn?.backoffLevel || 0;
|
||||
|
||||
// T06/T10/T36: terminal statuses should not be overwritten by transient cooldown state.
|
||||
if (conn && isTerminalConnectionStatus(conn)) {
|
||||
log.info(
|
||||
"AUTH",
|
||||
`${connectionId.slice(0, 8)} terminal status=${conn.testStatus}, skipping cooldown overwrite`
|
||||
);
|
||||
return { shouldFallback: true, cooldownMs: 0 };
|
||||
}
|
||||
|
||||
// ─── Anti-Thundering Herd Guard ─────────────────────────────────
|
||||
// If this connection was ALREADY marked unavailable by a prior concurrent
|
||||
// request (within the mutex window), skip re-marking to avoid resetting
|
||||
@@ -633,6 +707,24 @@ export async function markAccountUnavailable(
|
||||
};
|
||||
}
|
||||
|
||||
// T09: Codex scope-aware lockout guard (codex vs spark independent pools).
|
||||
if (provider === "codex" && model) {
|
||||
const scopeRateLimitedUntil = getCodexScopeRateLimitedUntil(
|
||||
conn?.providerSpecificData || {},
|
||||
model
|
||||
);
|
||||
if (scopeRateLimitedUntil && new Date(scopeRateLimitedUntil).getTime() > Date.now()) {
|
||||
log.info(
|
||||
"AUTH",
|
||||
`${connectionId.slice(0, 8)} already scope-limited for ${getCodexModelScope(model)} (until ${scopeRateLimitedUntil}), skipping duplicate mark`
|
||||
);
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: new Date(scopeRateLimitedUntil).getTime() - Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const { shouldFallback, cooldownMs, newBackoffLevel, reason } = checkFallbackError(
|
||||
status,
|
||||
errorText,
|
||||
@@ -662,6 +754,40 @@ export async function markAccountUnavailable(
|
||||
const rateLimitedUntil = getUnavailableUntil(cooldownMs);
|
||||
const errorMsg = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
|
||||
|
||||
// T09: Codex per-scope lockout (do not block the whole account globally).
|
||||
if (provider === "codex" && status === 429 && model && conn) {
|
||||
const scope = getCodexModelScope(model);
|
||||
const existingScopeMap = asRecord(conn.providerSpecificData.codexScopeRateLimitedUntil);
|
||||
const persistedScopeUntil = getCodexScopeRateLimitedUntil(conn.providerSpecificData, model);
|
||||
const scopeRateLimitedUntil = persistedScopeUntil || rateLimitedUntil;
|
||||
const scopeCooldownMs = Math.max(new Date(scopeRateLimitedUntil).getTime() - Date.now(), 0);
|
||||
|
||||
await updateProviderConnection(connectionId, {
|
||||
testStatus: "unavailable",
|
||||
lastError: errorMsg,
|
||||
errorCode: status,
|
||||
lastErrorAt: new Date().toISOString(),
|
||||
backoffLevel: newBackoffLevel ?? backoffLevel,
|
||||
providerSpecificData: {
|
||||
...conn.providerSpecificData,
|
||||
codexScopeRateLimitedUntil: {
|
||||
...existingScopeMap,
|
||||
[scope]: scopeRateLimitedUntil,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (scopeCooldownMs > 0) {
|
||||
lockModel(provider, connectionId, model, reason || "unknown", scopeCooldownMs);
|
||||
}
|
||||
|
||||
if (status && errorMsg) {
|
||||
console.error(`❌ ${provider} [${status}] (${scope}): ${errorMsg}`);
|
||||
}
|
||||
|
||||
return { shouldFallback: true, cooldownMs: scopeCooldownMs };
|
||||
}
|
||||
|
||||
await updateProviderConnection(connectionId, {
|
||||
rateLimitedUntil,
|
||||
testStatus: "unavailable",
|
||||
|
||||
90
tests/unit/auth-terminal-status.test.mjs
Normal file
90
tests/unit/auth-terminal-status.test.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-auth-terminal-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const auth = await import("../../src/sse/services/auth.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("getProviderCredentials skips credits_exhausted connections", async () => {
|
||||
await resetStorage();
|
||||
|
||||
const exhausted = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
apiKey: "sk-exhausted",
|
||||
isActive: true,
|
||||
testStatus: "credits_exhausted",
|
||||
});
|
||||
|
||||
const healthy = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
apiKey: "sk-healthy",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
|
||||
const selected = await auth.getProviderCredentials("openai");
|
||||
assert.ok(selected);
|
||||
assert.equal(selected.connectionId, healthy.id);
|
||||
assert.notEqual(selected.connectionId, exhausted.id);
|
||||
});
|
||||
|
||||
test("getProviderCredentials returns null when all active connections are terminal", async () => {
|
||||
await resetStorage();
|
||||
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
apiKey: "sk-only-exhausted",
|
||||
isActive: true,
|
||||
testStatus: "credits_exhausted",
|
||||
});
|
||||
|
||||
const selected = await auth.getProviderCredentials("openai");
|
||||
assert.equal(selected, null);
|
||||
});
|
||||
|
||||
test("markAccountUnavailable does not overwrite terminal status", async () => {
|
||||
await resetStorage();
|
||||
|
||||
const conn = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
apiKey: "sk-terminal",
|
||||
isActive: true,
|
||||
testStatus: "credits_exhausted",
|
||||
lastError: "insufficient_quota",
|
||||
});
|
||||
|
||||
const result = await auth.markAccountUnavailable(
|
||||
conn.id,
|
||||
503,
|
||||
"temporary upstream error",
|
||||
"openai",
|
||||
"gpt-4.1"
|
||||
);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.cooldownMs, 0);
|
||||
|
||||
const after = await providersDb.getProviderConnectionById(conn.id);
|
||||
assert.equal(after.testStatus, "credits_exhausted");
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
isBackgroundTask,
|
||||
getBackgroundTaskReason,
|
||||
getDegradedModel,
|
||||
setBackgroundDegradationConfig,
|
||||
getBackgroundDegradationConfig,
|
||||
@@ -68,6 +69,26 @@ test("isBackgroundTask: detects X-Request-Priority header", () => {
|
||||
assert.equal(isBackgroundTask(body, headers), true);
|
||||
});
|
||||
|
||||
test("isBackgroundTask: detects X-Task-Type header", () => {
|
||||
const body = {
|
||||
model: "claude-sonnet-4",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
};
|
||||
const headers = { "x-task-type": "background" };
|
||||
assert.equal(isBackgroundTask(body, headers), true);
|
||||
assert.equal(getBackgroundTaskReason(body, headers), "header_background");
|
||||
});
|
||||
|
||||
test("isBackgroundTask: detects low max_tokens requests", () => {
|
||||
const body = {
|
||||
model: "claude-sonnet-4",
|
||||
max_tokens: 32,
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
};
|
||||
assert.equal(isBackgroundTask(body), true);
|
||||
assert.equal(getBackgroundTaskReason(body), "low_max_tokens");
|
||||
});
|
||||
|
||||
test("isBackgroundTask: returns false for null/undefined body", () => {
|
||||
assert.equal(isBackgroundTask(null), false);
|
||||
assert.equal(isBackgroundTask(undefined), false);
|
||||
@@ -81,8 +102,8 @@ test("isBackgroundTask: returns false for empty messages", () => {
|
||||
|
||||
test("getDegradedModel: returns cheaper model from map", () => {
|
||||
resetStats();
|
||||
assert.equal(getDegradedModel("claude-opus-4-6"), "gemini-2.5-flash");
|
||||
assert.equal(getDegradedModel("gemini-2.5-pro"), "gemini-2.5-flash");
|
||||
assert.equal(getDegradedModel("claude-opus-4-6"), "gemini-3-flash");
|
||||
assert.equal(getDegradedModel("gemini-2.5-pro"), "gemini-3-flash");
|
||||
assert.equal(getDegradedModel("gpt-4o"), "gpt-4o-mini");
|
||||
});
|
||||
|
||||
|
||||
52
tests/unit/call-logs-requested-model.test.mjs
Normal file
52
tests/unit/call-logs-requested-model.test.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-calllogs-rm-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const callLogs = await import("../../src/lib/usage/callLogs.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("call logs persist requestedModel and allow filtering by requested model", async () => {
|
||||
await callLogs.saveCallLog({
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
status: 200,
|
||||
model: "openai/gpt-5.2-mini",
|
||||
requestedModel: "openai/gpt-5.2-codex",
|
||||
provider: "openai",
|
||||
duration: 123,
|
||||
requestBody: { messages: [{ role: "user", content: "hello" }] },
|
||||
responseBody: { id: "resp_1" },
|
||||
});
|
||||
|
||||
const all = await callLogs.getCallLogs({ limit: 10 });
|
||||
assert.equal(all.length, 1);
|
||||
assert.equal(all[0].model, "openai/gpt-5.2-mini");
|
||||
assert.equal(all[0].requestedModel, "openai/gpt-5.2-codex");
|
||||
|
||||
const byRequested = await callLogs.getCallLogs({ model: "gpt-5.2-codex", limit: 10 });
|
||||
assert.equal(byRequested.length, 1);
|
||||
assert.equal(byRequested[0].requestedModel, "openai/gpt-5.2-codex");
|
||||
|
||||
const detail = await callLogs.getCallLogById(all[0].id);
|
||||
assert.equal(detail?.requestedModel, "openai/gpt-5.2-codex");
|
||||
});
|
||||
35
tests/unit/error-classifier.test.mjs
Normal file
35
tests/unit/error-classifier.test.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { classifyProviderError, PROVIDER_ERROR_TYPES } =
|
||||
await import("../../open-sse/services/errorClassifier.ts");
|
||||
|
||||
test("classifyProviderError: 401 + account_deactivated => ACCOUNT_DEACTIVATED", () => {
|
||||
const body = JSON.stringify({
|
||||
error: { message: "account_deactivated: this account has been disabled" },
|
||||
});
|
||||
const result = classifyProviderError(401, body);
|
||||
assert.equal(result, PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED);
|
||||
});
|
||||
|
||||
test("classifyProviderError: plain 401 => UNAUTHORIZED", () => {
|
||||
const result = classifyProviderError(401, { error: { message: "token expired" } });
|
||||
assert.equal(result, PROVIDER_ERROR_TYPES.UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test("classifyProviderError: 402 => QUOTA_EXHAUSTED", () => {
|
||||
const result = classifyProviderError(402, { error: { message: "payment required" } });
|
||||
assert.equal(result, PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED);
|
||||
});
|
||||
|
||||
test("classifyProviderError: 400 + billing signal => QUOTA_EXHAUSTED", () => {
|
||||
const result = classifyProviderError(400, {
|
||||
error: { message: "insufficient_quota: exceeded your current quota" },
|
||||
});
|
||||
assert.equal(result, PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED);
|
||||
});
|
||||
|
||||
test("classifyProviderError: 429 without billing signal => RATE_LIMITED", () => {
|
||||
const result = classifyProviderError(429, { error: { message: "too many requests" } });
|
||||
assert.equal(result, PROVIDER_ERROR_TYPES.RATE_LIMITED);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-fixes-"));
|
||||
@@ -342,11 +343,29 @@ test("proxy fetch rejects socks5 context when feature flag is disabled", async (
|
||||
|
||||
test("proxy fetch accepts socks5 context when feature flag is enabled", async () => {
|
||||
await withEnv("ENABLE_SOCKS5_PROXY", "true", async () => {
|
||||
const result = await proxyFetch.runWithProxyContext(
|
||||
{ type: "socks5", host: "127.0.0.1", port: "1080" },
|
||||
async () => "ok"
|
||||
);
|
||||
assert.equal(result, "ok");
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
|
||||
try {
|
||||
const result = await proxyFetch.runWithProxyContext(
|
||||
{ type: "socks5", host: "127.0.0.1", port: String(address.port) },
|
||||
async () => "ok"
|
||||
);
|
||||
assert.equal(result, "ok");
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
addToWhitelist,
|
||||
removeFromWhitelist,
|
||||
getIPFilterConfig,
|
||||
checkRequestIP,
|
||||
resetIPFilter,
|
||||
} = await import("../../open-sse/services/ipFilter.ts");
|
||||
|
||||
@@ -111,6 +112,48 @@ test("normalizes ::ffff: prefix", () => {
|
||||
assert.equal(checkIP("::ffff:1.2.3.4").allowed, false);
|
||||
});
|
||||
|
||||
// ─── T07: X-Forwarded-For validation ───────────────────────────────────────
|
||||
|
||||
test("checkRequestIP: skips invalid XFF entries and uses next valid IP", () => {
|
||||
configureIPFilter({ enabled: true, mode: "whitelist", whitelist: ["1.2.3.4"] });
|
||||
const req = {
|
||||
headers: {
|
||||
get(name) {
|
||||
if (name === "x-forwarded-for") return "unknown, 1.2.3.4";
|
||||
return null;
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.equal(checkRequestIP(req).allowed, true);
|
||||
});
|
||||
|
||||
test("checkRequestIP: all-invalid XFF falls back to x-real-ip", () => {
|
||||
configureIPFilter({ enabled: true, mode: "whitelist", whitelist: ["9.9.9.9"] });
|
||||
const req = {
|
||||
headers: {
|
||||
get(name) {
|
||||
if (name === "x-forwarded-for") return "unknown, -, not_an_ip";
|
||||
if (name === "x-real-ip") return "9.9.9.9";
|
||||
return null;
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.equal(checkRequestIP(req).allowed, true);
|
||||
});
|
||||
|
||||
test("checkRequestIP: empty headers fall back to request.ip", () => {
|
||||
configureIPFilter({ enabled: true, mode: "whitelist", whitelist: ["7.7.7.7"] });
|
||||
const req = {
|
||||
headers: {
|
||||
get() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
ip: "7.7.7.7",
|
||||
};
|
||||
assert.equal(checkRequestIP(req).allowed, true);
|
||||
});
|
||||
|
||||
// ─── Config API ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("getIPFilterConfig: returns serializable config", () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ test("handleImageGeneration(nanobanana): async submit+poll returns URL payload",
|
||||
if (u.includes("/generate-pro")) {
|
||||
const body = JSON.parse(options.body);
|
||||
assert.equal(body.prompt, "galaxy test");
|
||||
assert.equal(body.aspectRatio, "4:5");
|
||||
assert.equal(body.aspectRatio, "1:1");
|
||||
assert.equal(body.resolution, "2K");
|
||||
|
||||
return new Response(
|
||||
|
||||
107
tests/unit/openai-to-claude-strip-empty.test.mjs
Normal file
107
tests/unit/openai-to-claude-strip-empty.test.mjs
Normal file
@@ -0,0 +1,107 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { stripEmptyTextBlocks, openaiToClaudeRequest, normalizeContentToString } =
|
||||
await import("../../open-sse/translator/request/openai-to-claude.ts");
|
||||
|
||||
test("stripEmptyTextBlocks removes empty text recursively inside tool_result content", () => {
|
||||
const input = [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: "keep-top-level" },
|
||||
{
|
||||
type: "tool_result",
|
||||
content: [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: "keep-nested" },
|
||||
{
|
||||
type: "tool_result",
|
||||
content: [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: "keep-deep" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const out = stripEmptyTextBlocks(input);
|
||||
assert.deepEqual(out, [
|
||||
{ type: "text", text: "keep-top-level" },
|
||||
{
|
||||
type: "tool_result",
|
||||
content: [
|
||||
{ type: "text", text: "keep-nested" },
|
||||
{
|
||||
type: "tool_result",
|
||||
content: [{ type: "text", text: "keep-deep" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("openaiToClaudeRequest applies strip to tool message array content", () => {
|
||||
const request = {
|
||||
messages: [
|
||||
{ role: "user", content: "run tool" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "demo_tool", arguments: "{}" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
tool_call_id: "call_1",
|
||||
content: [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: "tool ok" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const translated = openaiToClaudeRequest("claude-sonnet-4", request, false);
|
||||
const toolMessage = translated.messages.find(
|
||||
(m) => Array.isArray(m.content) && m.content.some((b) => b.type === "tool_result")
|
||||
);
|
||||
assert.ok(toolMessage, "expected a translated tool_result user message");
|
||||
const toolResult = toolMessage.content.find((b) => b.type === "tool_result");
|
||||
assert.deepEqual(toolResult.content, [{ type: "text", text: "tool ok" }]);
|
||||
});
|
||||
|
||||
test("T15: normalizeContentToString supports array-form content blocks", () => {
|
||||
const text = normalizeContentToString([
|
||||
{ type: "text", text: "line 1" },
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,abc" } },
|
||||
{ type: "text", text: "line 2" },
|
||||
]);
|
||||
|
||||
assert.equal(text, "line 1\nline 2");
|
||||
});
|
||||
|
||||
test("T15: openaiToClaudeRequest converts system array content into a Claude system text block", () => {
|
||||
const request = {
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
{ type: "text", text: "System rules A" },
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,abc" } },
|
||||
{ type: "text", text: "System rules B" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "hello" },
|
||||
],
|
||||
};
|
||||
|
||||
const translated = openaiToClaudeRequest("claude-sonnet-4", request, false);
|
||||
assert.ok(Array.isArray(translated.system));
|
||||
// system[0] is the injected Claude prompt; user-provided system content is system[1].
|
||||
assert.equal(translated.system[1].text, "System rules A\nSystem rules B");
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user