mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 09:42:15 +03:00
Compare commits
6 Commits
v3.0.0-rc.
...
v3.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e47740e02e | ||
|
|
d9ff0035f5 | ||
|
|
7a7f3be0d2 | ||
|
|
91e45fbe95 | ||
|
|
7d7e9da28c | ||
|
|
24a9739604 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -89,6 +89,7 @@ docs/*
|
||||
!docs/MCP-SERVER.md
|
||||
!docs/CLI-TOOLS.md
|
||||
|
||||
|
||||
# open-sse tests
|
||||
open-sse/test/*
|
||||
|
||||
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -6,9 +6,9 @@
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-rc.5] — 2026-03-22 _(What's New vs v2.9.5 — will be released as v3.0.0)_
|
||||
## [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 · 832 tests passing.
|
||||
> **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
|
||||
|
||||
@@ -111,6 +111,43 @@ OmniRoute now automatically refreshes model lists for connected providers every
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 3.0.0-rc.5
|
||||
version: 3.0.0-rc.7
|
||||
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,
|
||||
|
||||
@@ -1241,6 +1241,68 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
],
|
||||
},
|
||||
|
||||
puter: {
|
||||
id: "puter",
|
||||
alias: "pu",
|
||||
format: "openai",
|
||||
executor: "puter",
|
||||
// OpenAI-compatible gateway with 500+ models (GPT, Claude, Gemini, Grok, DeepSeek, Qwen…)
|
||||
// Auth: Bearer <puter_auth_token> from puter.com/dashboard → Copy Auth Token
|
||||
// Model IDs use provider/model-name format for non-OpenAI models.
|
||||
// Only chat completions (incl. streaming) are available via REST.
|
||||
// Image gen, TTS, STT, video are puter.js SDK-only (browser).
|
||||
baseUrl: "https://api.puter.com/puterai/openai/v1/chat/completions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
// OpenAI — use bare IDs
|
||||
{ id: "gpt-4o-mini", name: "GPT-4o Mini (🆓 Puter)" },
|
||||
{ id: "gpt-4o", name: "GPT-4o (Puter)" },
|
||||
{ id: "gpt-4.1", name: "GPT-4.1 (Puter)" },
|
||||
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini (Puter)" },
|
||||
{ id: "gpt-5-nano", name: "GPT-5 Nano (Puter)" },
|
||||
{ id: "gpt-5-mini", name: "GPT-5 Mini (Puter)" },
|
||||
{ id: "gpt-5", name: "GPT-5 (Puter)" },
|
||||
{ id: "o3-mini", name: "OpenAI o3-mini (Puter)" },
|
||||
{ id: "o3", name: "OpenAI o3 (Puter)" },
|
||||
{ id: "o4-mini", name: "OpenAI o4-mini (Puter)" },
|
||||
// Anthropic Claude — use bare IDs (confirmed working)
|
||||
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (Puter)" },
|
||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (Puter)" },
|
||||
{ id: "claude-opus-4-5", name: "Claude Opus 4.5 (Puter)" },
|
||||
{ id: "claude-sonnet-4", name: "Claude Sonnet 4 (Puter)" },
|
||||
{ id: "claude-opus-4", name: "Claude Opus 4 (Puter)" },
|
||||
// Google Gemini — use google/ prefix (confirmed working)
|
||||
{ id: "google/gemini-2.0-flash", name: "Gemini 2.0 Flash (Puter)" },
|
||||
{ id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash (Puter)" },
|
||||
{ id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro (Puter)" },
|
||||
{ id: "google/gemini-3-flash", name: "Gemini 3 Flash (Puter)" },
|
||||
{ id: "google/gemini-3-pro", name: "Gemini 3 Pro (Puter)" },
|
||||
// DeepSeek — use deepseek/ prefix (confirmed working)
|
||||
{ id: "deepseek/deepseek-chat", name: "DeepSeek Chat (Puter)" },
|
||||
{ id: "deepseek/deepseek-r1", name: "DeepSeek R1 (Puter)" },
|
||||
{ id: "deepseek/deepseek-v3.2", name: "DeepSeek V3.2 (Puter)" },
|
||||
// xAI Grok — use x-ai/ prefix
|
||||
{ id: "x-ai/grok-3", name: "Grok 3 (Puter)" },
|
||||
{ id: "x-ai/grok-3-mini", name: "Grok 3 Mini (Puter)" },
|
||||
{ id: "x-ai/grok-4", name: "Grok 4 (Puter)" },
|
||||
{ id: "x-ai/grok-4-fast", name: "Grok 4 Fast (Puter)" },
|
||||
// Meta Llama — bare IDs (confirmed ✅)
|
||||
{ id: "llama-4-scout", name: "Llama 4 Scout (Puter)" },
|
||||
{ id: "llama-4-maverick", name: "Llama 4 Maverick (Puter)" },
|
||||
{ id: "llama-3.3-70b-instruct", name: "Llama 3.3 70B (Puter)" },
|
||||
// Mistral — bare IDs (confirmed ✅)
|
||||
{ id: "mistral-small-latest", name: "Mistral Small (Puter)" },
|
||||
{ id: "mistral-medium-latest", name: "Mistral Medium (Puter)" },
|
||||
{ id: "open-mistral-nemo", name: "Mistral Nemo (Puter)" },
|
||||
// Qwen — use qwen/ prefix (confirmed ✅)
|
||||
{ id: "qwen/qwen3-235b-a22b", name: "Qwen3 235B (Puter)" },
|
||||
{ id: "qwen/qwen3-32b", name: "Qwen3 32B (Puter)" },
|
||||
{ id: "qwen/qwen3-coder", name: "Qwen3 Coder 480B (Puter)" },
|
||||
],
|
||||
passthroughModels: true, // 500+ models available — users can type any Puter model ID
|
||||
},
|
||||
|
||||
"cloudflare-ai": {
|
||||
id: "cloudflare-ai",
|
||||
alias: "cf",
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DefaultExecutor } from "./default.ts";
|
||||
import { PollinationsExecutor } from "./pollinations.ts";
|
||||
import { CloudflareAIExecutor } from "./cloudflare-ai.ts";
|
||||
import { OpencodeExecutor } from "./opencode.ts";
|
||||
import { PuterExecutor } from "./puter.ts";
|
||||
|
||||
const executors = {
|
||||
antigravity: new AntigravityExecutor(),
|
||||
@@ -25,6 +26,8 @@ const executors = {
|
||||
cf: new CloudflareAIExecutor(), // Alias
|
||||
"opencode-zen": new OpencodeExecutor("opencode-zen"),
|
||||
"opencode-go": new OpencodeExecutor("opencode-go"),
|
||||
puter: new PuterExecutor(),
|
||||
pu: new PuterExecutor(), // Alias
|
||||
};
|
||||
|
||||
const defaultCache = new Map();
|
||||
@@ -51,3 +54,4 @@ export { DefaultExecutor } from "./default.ts";
|
||||
export { PollinationsExecutor } from "./pollinations.ts";
|
||||
export { CloudflareAIExecutor } from "./cloudflare-ai.ts";
|
||||
export { OpencodeExecutor } from "./opencode.ts";
|
||||
export { PuterExecutor } from "./puter.ts";
|
||||
|
||||
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;
|
||||
@@ -8,6 +8,46 @@ import {
|
||||
} from "../config/constants.ts";
|
||||
import { getProviderCategory } from "../config/providerRegistry.ts";
|
||||
|
||||
// T06 (sub2api PR #1037): Signals that indicate permanent account deactivation.
|
||||
// When a 401 body contains these strings, the account is permanently dead
|
||||
// and should NOT be retried after token refresh.
|
||||
export const ACCOUNT_DEACTIVATED_SIGNALS = [
|
||||
"account_deactivated",
|
||||
"account has been deactivated",
|
||||
"account has been disabled",
|
||||
"your account has been suspended",
|
||||
"this account is deactivated",
|
||||
];
|
||||
|
||||
// T10 (sub2api PR #1169): Signals that indicate billing credits are exhausted.
|
||||
// Distinct from rate-limit 429 — the account won't recover until credits are added.
|
||||
export const CREDITS_EXHAUSTED_SIGNALS = [
|
||||
"insufficient_quota",
|
||||
"billing_hard_limit_reached",
|
||||
"exceeded your current quota",
|
||||
"credit_balance_too_low",
|
||||
"your credit balance is too low",
|
||||
"credits exhausted",
|
||||
"out of credits",
|
||||
"payment required",
|
||||
];
|
||||
|
||||
/**
|
||||
* T06: Returns true if response body indicates the account is permanently deactivated.
|
||||
*/
|
||||
export function isAccountDeactivated(errorText: string): boolean {
|
||||
const lower = String(errorText || "").toLowerCase();
|
||||
return ACCOUNT_DEACTIVATED_SIGNALS.some((sig) => lower.includes(sig));
|
||||
}
|
||||
|
||||
/**
|
||||
* T10: Returns true if response body indicates credits/quota are permanently exhausted.
|
||||
*/
|
||||
export function isCreditsExhausted(errorText: string): boolean {
|
||||
const lower = String(errorText || "").toLowerCase();
|
||||
return CREDITS_EXHAUSTED_SIGNALS.some((sig) => lower.includes(sig));
|
||||
}
|
||||
|
||||
// ─── Provider Profile Helper ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -201,6 +241,14 @@ export function classifyErrorText(errorText) {
|
||||
) {
|
||||
return RateLimitReason.QUOTA_EXHAUSTED;
|
||||
}
|
||||
// T10: credits_exhausted signals
|
||||
if (isCreditsExhausted(errorText)) {
|
||||
return RateLimitReason.QUOTA_EXHAUSTED;
|
||||
}
|
||||
// T06: account_deactivated signals
|
||||
if (isAccountDeactivated(errorText)) {
|
||||
return RateLimitReason.AUTH_ERROR;
|
||||
}
|
||||
if (
|
||||
lower.includes("rate limit") ||
|
||||
lower.includes("too many requests") ||
|
||||
@@ -301,6 +349,26 @@ export function checkFallbackError(
|
||||
const errorStr = typeof errorText === "string" ? errorText : JSON.stringify(errorText);
|
||||
const lowerError = errorStr.toLowerCase();
|
||||
|
||||
// T06 (sub2api #1037): Permanent account deactivation — do NOT retry, mark as permanent failure
|
||||
if (isAccountDeactivated(errorStr)) {
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: 365 * 24 * 60 * 60 * 1000, // 1 year = effectively permanent
|
||||
reason: RateLimitReason.AUTH_ERROR,
|
||||
permanent: true,
|
||||
};
|
||||
}
|
||||
|
||||
// T10 (sub2api #1169): Credits/quota exhausted — long cooldown, distinct from rate limit
|
||||
if (isCreditsExhausted(errorStr)) {
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: COOLDOWN_MS.paymentRequired ?? 3600 * 1000, // 1h cooldown
|
||||
reason: RateLimitReason.QUOTA_EXHAUSTED,
|
||||
creditsExhausted: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerError.includes("no credentials")) {
|
||||
return {
|
||||
shouldFallback: true,
|
||||
|
||||
@@ -173,6 +173,95 @@ export function getActiveSessions(): Array<SessionEntry & { sessionId: string; a
|
||||
*/
|
||||
export function clearSessions(): void {
|
||||
sessions.clear();
|
||||
activeSessionsByKey.clear();
|
||||
}
|
||||
|
||||
// ─── T08: Per-API-Key Session Limit ─────────────────────────────────────────
|
||||
// Tracks concurrent sticky sessions per API key and enforces max_sessions limits.
|
||||
// Ref: sub2api PR #634 (fix: stabilize session hash + add user-level session limit)
|
||||
|
||||
// Map: apiKeyId → Set<sessionId>
|
||||
const activeSessionsByKey = new Map<string, Set<string>>();
|
||||
|
||||
/**
|
||||
* T08: Get the number of currently active sessions for an API key.
|
||||
* @param apiKeyId - The API key's UUID from the database
|
||||
*/
|
||||
export function getActiveSessionCountForKey(apiKeyId: string): number {
|
||||
return activeSessionsByKey.get(apiKeyId)?.size ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* T08: Register a session as belonging to an API key.
|
||||
* Call this after session creation is allowed (i.e., limit check passed).
|
||||
*/
|
||||
export function registerKeySession(apiKeyId: string, sessionId: string): void {
|
||||
if (!activeSessionsByKey.has(apiKeyId)) {
|
||||
activeSessionsByKey.set(apiKeyId, new Set());
|
||||
}
|
||||
activeSessionsByKey.get(apiKeyId)!.add(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* T08: Unregister a session from an API key's active set.
|
||||
* Call this when the request closes or the session TTL expires.
|
||||
*/
|
||||
export function unregisterKeySession(apiKeyId: string, sessionId: string): void {
|
||||
activeSessionsByKey.get(apiKeyId)?.delete(sessionId);
|
||||
// Clean up empty sets to avoid memory leaks
|
||||
if (activeSessionsByKey.get(apiKeyId)?.size === 0) {
|
||||
activeSessionsByKey.delete(apiKeyId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* T08: Check whether adding a new session would exceed the key's max_sessions limit.
|
||||
* Returns null if allowed, or an error object to return as a 429 response.
|
||||
*
|
||||
* @param apiKeyId - The API key's UUID
|
||||
* @param maxSessions - The limit from the DB (0 = unlimited)
|
||||
*/
|
||||
export function checkSessionLimit(
|
||||
apiKeyId: string,
|
||||
maxSessions: number
|
||||
): { code: "SESSION_LIMIT_EXCEEDED"; message: string; limit: number; current: number } | null {
|
||||
if (!maxSessions || maxSessions <= 0) return null; // unlimited
|
||||
const current = getActiveSessionCountForKey(apiKeyId);
|
||||
if (current < maxSessions) return null;
|
||||
return {
|
||||
code: "SESSION_LIMIT_EXCEEDED",
|
||||
message:
|
||||
`You have reached the maximum number of active sessions (${maxSessions}). ` +
|
||||
`Please close unused sessions or wait for them to expire.`,
|
||||
limit: maxSessions,
|
||||
current,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* T04: Extract an external session ID from request headers.
|
||||
* Accepts both hyphenated and underscore forms for Nginx compatibility.
|
||||
* Nginx drops headers with underscores by default — use `underscores_in_headers on`
|
||||
* in nginx.conf, or use X-Session-Id (hyphenated) which passes cleanly.
|
||||
*
|
||||
* Ref: sub2api README + PR #634
|
||||
*
|
||||
* @param headers - Request headers (Headers object or plain object with .get())
|
||||
* @returns External session ID with "ext:" prefix, or null
|
||||
*/
|
||||
export function extractExternalSessionId(
|
||||
headers: Headers | { get?: (n: string) => string | null } | null | undefined
|
||||
): string | null {
|
||||
if (!headers || typeof (headers as Headers).get !== "function") return null;
|
||||
const h = headers as Headers;
|
||||
const raw =
|
||||
h.get("x-session-id") ?? // Preferred: hyphenated (passes through Nginx)
|
||||
h.get("x-omniroute-session") ?? // OmniRoute-specific form
|
||||
h.get("session-id") ?? // Bare session-id
|
||||
null;
|
||||
if (!raw || !raw.trim()) return null;
|
||||
// Prefix "ext:" to ensure no collision with internal SHA-256 hash IDs
|
||||
return `ext:${raw.trim().slice(0, 64)}`; // max 64 chars to avoid abuse
|
||||
}
|
||||
|
||||
// ─── Internal Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -19,6 +19,8 @@ export const EFFORT_BUDGETS = {
|
||||
low: 1024,
|
||||
medium: 10240,
|
||||
high: 131072,
|
||||
max: 131072, // T11: Claude "max" / "xhigh" — full budget
|
||||
xhigh: 131072, // T11: explicit alias used internally
|
||||
};
|
||||
|
||||
// thinkingLevel string → budget token mapping
|
||||
@@ -28,6 +30,8 @@ export const THINKING_LEVEL_MAP = {
|
||||
low: 1024,
|
||||
medium: 10240,
|
||||
high: 131072,
|
||||
max: 131072, // T11: max = full Claude budget (sub2api: xhigh)
|
||||
xhigh: 131072, // T11: explicit xhigh alias
|
||||
};
|
||||
|
||||
// Default config (passthrough = backward compatible)
|
||||
@@ -198,7 +202,7 @@ function setCustomBudget(body, budget) {
|
||||
};
|
||||
}
|
||||
|
||||
// OpenAI reasoning_effort mapping
|
||||
// OpenAI reasoning_effort mapping (T11: add 'max' tier for full budget)
|
||||
if (result.reasoning_effort !== undefined || result.reasoning !== undefined) {
|
||||
if (budget <= 0) {
|
||||
delete result.reasoning_effort;
|
||||
@@ -207,8 +211,10 @@ function setCustomBudget(body, budget) {
|
||||
result.reasoning_effort = "low";
|
||||
} else if (budget <= 10240) {
|
||||
result.reasoning_effort = "medium";
|
||||
} else {
|
||||
} else if (budget < 131072) {
|
||||
result.reasoning_effort = "high";
|
||||
} else {
|
||||
result.reasoning_effort = "max"; // T11: full budget → "max"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.0.0-rc.5",
|
||||
"version": "3.0.0-rc.7",
|
||||
"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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
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)
|
||||
`
|
||||
@@ -374,6 +375,7 @@ export async function getCallLogs(filter: any = {}) {
|
||||
path: toStringOrNull(l.path),
|
||||
status: toNumber(l.status),
|
||||
model: toStringOrNull(l.model),
|
||||
requestedModel: toStringOrNull(l.requested_model), // T01: original model from client
|
||||
provider: toStringOrNull(l.provider),
|
||||
account: toStringOrNull(l.account),
|
||||
duration: toNumber(l.duration),
|
||||
|
||||
@@ -802,6 +802,30 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 1.8,
|
||||
cache_creation: 0.3,
|
||||
},
|
||||
// T12: MiniMax M2.7 — new default model (sub2api PR #1120)
|
||||
// Upgraded from M2.5, same API endpoint api.minimax.io
|
||||
// Pricing estimated, check https://platform.minimaxi.com/document/Price
|
||||
"minimax-m2.7": {
|
||||
input: 0.4,
|
||||
output: 1.6,
|
||||
cached: 0.2,
|
||||
reasoning: 2.4,
|
||||
cache_creation: 0.4,
|
||||
},
|
||||
"MiniMax-M2.7": {
|
||||
input: 0.4,
|
||||
output: 1.6,
|
||||
cached: 0.2,
|
||||
reasoning: 2.4,
|
||||
cache_creation: 0.4,
|
||||
},
|
||||
"minimax-m2.7-highspeed": {
|
||||
input: 0.4,
|
||||
output: 1.6,
|
||||
cached: 0.2,
|
||||
reasoning: 2.4,
|
||||
cache_creation: 0.4,
|
||||
},
|
||||
},
|
||||
|
||||
// ─── Free-tier API Key Providers (nominal $0 pricing) ───
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -101,7 +101,8 @@ test("CUSTOM: sets OpenAI reasoning_effort from budget", () => {
|
||||
reasoning_effort: "low",
|
||||
};
|
||||
const result = applyThinkingBudget(body);
|
||||
assert.equal(result.reasoning_effort, "high");
|
||||
// T11 (sub2api gap): full budget (131072) now maps to "max" instead of "high"
|
||||
assert.equal(result.reasoning_effort, "max");
|
||||
setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user