mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
* test(settings): add unit tests for debugMode and hiddenSidebarItems Tests cover: - PATCH debugMode=true/false - PATCH hiddenSidebarItems with array values - Combined updates with both fields * test(e2e): add Playwright tests for settings toggles Tests cover: - Debug mode toggle on/off - Sidebar visibility toggle - Settings persistence after page reload * fix(tests): address code review issues - Unit tests: fix async/await for getSettings, use direct db functions - E2E tests: remove conditional logic, use Playwright auto-waiting assertions * feat(logging): unify request log retention and artifacts * docs: add dashboard settings toggles to CONTRIBUTING Add section documenting: - Debug Mode toggle (Settings → Advanced) - Sidebar Visibility toggle (Settings → General) * fix(cache): only inject prompt_cache_key for supported providers Only inject prompt_cache_key for providers that support prompt caching (Claude, Anthropic, ZAI, Qwen, DeepSeek). This fixes issue #848 where NVIDIA API rejected the parameter. * fix(model-sync): log only channel-level model changes * feat(providers): add 4 free models to opencode-zen * feat(providers): add explicit contextLength for opencode-zen free models * feat(providers): add contextLength for all opencode-zen models * feat: Improve the Chinese translation * fix: preserve client cache_control for all Claude-protocol providers Previously, the cache control preservation logic only recognized a hardcoded list of providers (claude, anthropic, zai, qwen, deepseek). This caused OmniRoute to inject its own cache_control markers for Claude-protocol providers not in that list (bailian-coding-plan, glm, minimax, minimax-cn, etc.), overwriting the client's cache markers. The fix checks both: 1. Known caching providers list (existing behavior) 2. Whether targetFormat === 'claude' (all Claude-protocol providers) This ensures all Claude-compatible providers properly preserve client cache_control headers when appropriate (Claude Code client, deterministic routing, etc.). Also removes unused CacheStatsCard from settings/components (duplicate of the one in cache/ page). Fixes cache token calculation for GLM, Minimax, and other Claude-compatible providers. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: pure passthrough for Claude→Claude when cache_control preserved The Claude passthrough path round-trips through OpenAI format (claude→openai→claude) for structural normalization. This strips cache_control markers from every content block since OpenAI format has no equivalent, causing ~42k cache creation tokens per request with zero cache reads. When preserveCacheControl is true (Claude Code client, "always" setting, or deterministic combo), skip the round-trip entirely and forward the body as-is. Claude Code sends well-formed Messages API payloads — the normalization was only needed for non-Code clients. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: restore CacheStatsCard — was not a duplicate The first commit incorrectly deleted CacheStatsCard from settings/components/ as a "duplicate". It's the only copy — both settings/page.tsx and cache/page.tsx import from this location. Restored the i18n-ized version from main. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(429): parse long quota reset times from error body - Parse XhYmZs format from antigravity error messages (e.g., 27h41m36s) - Dynamic retry-after threshold (60s default) instead of hardcoded 10s - Add parseRetryFromErrorText() in accountFallback.ts for body parsing - Fix 403 'verify your account' to trigger permanent deactivation - Add keyword matching for 'quota will reset', 'exhausted capacity' - Add unit tests for retry parsing and keyword matching Fixes #858 (Antigravity 429 handling) Fixes #832 (Qwen quota 429 - same underlying bug) * chore: bump version to v3.4.0-dev * fix(migrations): rename 013 to 014 to avoid collision with v3.3.11 * chore(docs): update CHANGELOG for v3.4.0 integrations * fix: Claude token refresh, Antigravity quota, and 429 rate-limit handling - Fix Claude OAuth token refresh to use form-urlencoded format (standard OAuth2) - Add anthropic-beta header required by Claude OAuth API - Switch Antigravity quota to use retrieveUserQuota API (same as Gemini CLI) - Parse quota reset time for all providers (not just Antigravity) - Add quota reset keywords to error classifier - Cap maximum retry time at 24 hours to prevent infinite wait Closes #836, #857, #858, #832 * fix(dashboard): resolve /dashboard/limits hanging UI with 70+ accounts via chunk parallelization (#784) --------- Co-authored-by: oyi77 <oyi77@users.noreply.github.com> Co-authored-by: R.D. <rogerproself@gmail.com> Co-authored-by: kang-heewon <heewon.dev@gmail.com> Co-authored-by: gmw <rorschach1167@qq.com> Co-authored-by: tombii <github@tombii.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
230 lines
6.8 KiB
TypeScript
230 lines
6.8 KiB
TypeScript
/**
|
|
* Proxy Logger — Hybrid in-memory + SQLite persistence
|
|
*
|
|
* Keeps a fast in-memory ring buffer for real-time dashboard AND
|
|
* persists to SQLite so logs survive server restarts.
|
|
*
|
|
* Pattern follows callLogs.js (T-15 decomposition).
|
|
*/
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import { getDbInstance, isCloud, isBuildPhase } from "./db/core";
|
|
|
|
const shouldPersistToDisk = !isCloud && !isBuildPhase;
|
|
|
|
const MAX_IN_MEMORY_ENTRIES = 200;
|
|
|
|
interface ProxyInfo {
|
|
type: string;
|
|
host: string;
|
|
port: number | string;
|
|
}
|
|
|
|
interface ProxyLogEntry {
|
|
id: string;
|
|
timestamp: string;
|
|
status: string;
|
|
proxy: ProxyInfo | null;
|
|
level: string;
|
|
levelId: string | null;
|
|
provider: string | null;
|
|
targetUrl: string | null;
|
|
publicIp: string | null;
|
|
latencyMs: number;
|
|
error: string | null;
|
|
connectionId: string | null;
|
|
comboId: string | null;
|
|
account: string | null;
|
|
tlsFingerprint: boolean;
|
|
}
|
|
|
|
interface ProxyLogFilters {
|
|
status?: string;
|
|
type?: string;
|
|
provider?: string;
|
|
level?: string;
|
|
search?: string;
|
|
limit?: number;
|
|
}
|
|
|
|
const proxyLogs: ProxyLogEntry[] = [];
|
|
|
|
// ──────────────── Startup: hydrate from DB ────────────────
|
|
|
|
function loadFromDb() {
|
|
if (!shouldPersistToDisk) return;
|
|
try {
|
|
const db = getDbInstance();
|
|
const rows = db
|
|
.prepare("SELECT * FROM proxy_logs ORDER BY timestamp DESC LIMIT ?")
|
|
.all(MAX_IN_MEMORY_ENTRIES) as any[];
|
|
|
|
for (const row of rows) {
|
|
proxyLogs.push({
|
|
id: row.id,
|
|
timestamp: row.timestamp,
|
|
status: row.status || "success",
|
|
proxy: row.proxy_host
|
|
? { type: row.proxy_type, host: row.proxy_host, port: row.proxy_port }
|
|
: null,
|
|
level: row.level || "direct",
|
|
levelId: row.level_id || null,
|
|
provider: row.provider || null,
|
|
targetUrl: row.target_url || null,
|
|
publicIp: row.public_ip || null,
|
|
latencyMs: row.latency_ms || 0,
|
|
error: row.error || null,
|
|
connectionId: row.connection_id || null,
|
|
comboId: row.combo_id || null,
|
|
account: row.account || null,
|
|
tlsFingerprint: row.tls_fingerprint === 1,
|
|
});
|
|
}
|
|
|
|
if (proxyLogs.length > 0) {
|
|
console.log(`[proxyLogger] Loaded ${proxyLogs.length} proxy logs from SQLite`);
|
|
}
|
|
} catch (err: any) {
|
|
console.warn("[proxyLogger] Failed to load from DB:", err.message);
|
|
}
|
|
}
|
|
|
|
loadFromDb();
|
|
|
|
// ──────────────── Log a proxy event ────────────────
|
|
|
|
export function logProxyEvent(entry: Partial<ProxyLogEntry>) {
|
|
const log: ProxyLogEntry = {
|
|
id: uuidv4(),
|
|
timestamp: new Date().toISOString(),
|
|
status: entry.status || "success",
|
|
proxy: entry.proxy || null,
|
|
level: entry.level || "direct",
|
|
levelId: entry.levelId || null,
|
|
provider: entry.provider || null,
|
|
targetUrl: entry.targetUrl || null,
|
|
publicIp: entry.publicIp || null,
|
|
latencyMs: entry.latencyMs || 0,
|
|
error: entry.error || null,
|
|
connectionId: entry.connectionId || null,
|
|
comboId: entry.comboId || null,
|
|
account: entry.account || null,
|
|
tlsFingerprint: entry.tlsFingerprint || false,
|
|
};
|
|
|
|
// 1. In-memory ring buffer (newest first)
|
|
proxyLogs.unshift(log);
|
|
if (proxyLogs.length > MAX_IN_MEMORY_ENTRIES) {
|
|
proxyLogs.length = MAX_IN_MEMORY_ENTRIES;
|
|
}
|
|
|
|
// 2. Persist to SQLite
|
|
if (shouldPersistToDisk) {
|
|
try {
|
|
const db = getDbInstance();
|
|
db.prepare(
|
|
`INSERT INTO proxy_logs (id, timestamp, status, proxy_type, proxy_host, proxy_port,
|
|
level, level_id, provider, target_url, public_ip, latency_ms, error,
|
|
connection_id, combo_id, account, tls_fingerprint)
|
|
VALUES (@id, @timestamp, @status, @proxyType, @proxyHost, @proxyPort,
|
|
@level, @levelId, @provider, @targetUrl, @publicIp, @latencyMs, @error,
|
|
@connectionId, @comboId, @account, @tlsFingerprint)`
|
|
).run({
|
|
id: log.id,
|
|
timestamp: log.timestamp,
|
|
status: log.status,
|
|
proxyType: log.proxy?.type || null,
|
|
proxyHost: log.proxy?.host || null,
|
|
proxyPort: log.proxy?.port ? Number(log.proxy.port) : null,
|
|
level: log.level,
|
|
levelId: log.levelId,
|
|
provider: log.provider,
|
|
targetUrl: log.targetUrl,
|
|
publicIp: log.publicIp,
|
|
latencyMs: log.latencyMs,
|
|
error: log.error,
|
|
connectionId: log.connectionId,
|
|
comboId: log.comboId,
|
|
account: log.account,
|
|
tlsFingerprint: log.tlsFingerprint ? 1 : 0,
|
|
});
|
|
} catch (err: any) {
|
|
console.warn("[proxyLogger] Failed to persist:", err.message);
|
|
}
|
|
}
|
|
|
|
return log;
|
|
}
|
|
|
|
// ──────────────── Query ────────────────
|
|
|
|
/**
|
|
* Get proxy logs with optional filters.
|
|
* Reads from in-memory for speed (already hydrated from DB on startup).
|
|
*/
|
|
export function getProxyLogs(filters: ProxyLogFilters = {}) {
|
|
let logs = [...proxyLogs];
|
|
|
|
if (filters.status) {
|
|
if (filters.status === "ok") {
|
|
logs = logs.filter((l) => l.status === "success");
|
|
} else {
|
|
logs = logs.filter((l) => l.status === filters.status);
|
|
}
|
|
}
|
|
|
|
if (filters.type) {
|
|
logs = logs.filter((l) => l.proxy?.type === filters.type);
|
|
}
|
|
|
|
if (filters.provider) {
|
|
logs = logs.filter((l) => l.provider === filters.provider);
|
|
}
|
|
|
|
if (filters.level) {
|
|
logs = logs.filter((l) => l.level === filters.level);
|
|
}
|
|
|
|
if (filters.search) {
|
|
const q = filters.search.toLowerCase();
|
|
logs = logs.filter(
|
|
(l) =>
|
|
(l.proxy?.host || "").toLowerCase().includes(q) ||
|
|
(l.provider || "").toLowerCase().includes(q) ||
|
|
(l.targetUrl || "").toLowerCase().includes(q) ||
|
|
(l.publicIp || "").toLowerCase().includes(q) ||
|
|
(l.level || "").toLowerCase().includes(q) ||
|
|
(l.error || "").toLowerCase().includes(q) ||
|
|
(l.account || "").toLowerCase().includes(q)
|
|
);
|
|
}
|
|
|
|
const limit = filters.limit || 300;
|
|
return logs.slice(0, limit);
|
|
}
|
|
|
|
// ──────────────── Clear ────────────────
|
|
|
|
export function clearProxyLogs() {
|
|
proxyLogs.length = 0;
|
|
|
|
if (shouldPersistToDisk) {
|
|
try {
|
|
const db = getDbInstance();
|
|
db.prepare("DELETE FROM proxy_logs").run();
|
|
} catch (err: any) {
|
|
console.warn("[proxyLogger] Failed to clear DB:", err.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ──────────────── Stats ────────────────
|
|
|
|
export function getProxyLogStats() {
|
|
const total = proxyLogs.length;
|
|
const success = proxyLogs.filter((l) => l.status === "success").length;
|
|
const error = proxyLogs.filter((l) => l.status === "error").length;
|
|
const timeout = proxyLogs.filter((l) => l.status === "timeout").length;
|
|
const direct = proxyLogs.filter((l) => l.level === "direct").length;
|
|
return { total, success, error, timeout, direct };
|
|
}
|