From a6c20e5af8f05048cbb4151f10d8391ca755a310 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Sun, 15 Feb 2026 22:52:52 +0800 Subject: [PATCH 01/75] feat(observability): integrate Langfuse for LLM request tracing --- .env.example | 10 + package.json | 4 + src/app/v1/_lib/proxy-handler.ts | 5 +- src/app/v1/_lib/proxy/forwarder.ts | 2 + src/app/v1/_lib/proxy/response-handler.ts | 179 +++- src/app/v1/_lib/proxy/session.ts | 16 + src/instrumentation.ts | 19 + src/lib/config/env.schema.ts | 7 + src/lib/langfuse/index.ts | 92 ++ src/lib/langfuse/trace-proxy-request.ts | 363 +++++++ src/lib/utils/cost-calculation.ts | 208 ++++ tests/unit/langfuse/langfuse-trace.test.ts | 996 ++++++++++++++++++ .../lib/cost-calculation-breakdown.test.ts | 159 +++ .../proxy-handler-session-id-error.test.ts | 1 + 14 files changed, 2051 insertions(+), 10 deletions(-) create mode 100644 src/lib/langfuse/index.ts create mode 100644 src/lib/langfuse/trace-proxy-request.ts create mode 100644 tests/unit/langfuse/langfuse-trace.test.ts create mode 100644 tests/unit/lib/cost-calculation-breakdown.test.ts diff --git a/.env.example b/.env.example index 5e2c5031e..72ea5933c 100644 --- a/.env.example +++ b/.env.example @@ -128,6 +128,16 @@ FETCH_HEADERS_TIMEOUT=600000 FETCH_BODY_TIMEOUT=600000 MAX_RETRY_ATTEMPTS_DEFAULT=2 # 单供应商最大尝试次数(含首次调用),范围 1-10,留空使用默认值 2 +# Langfuse Observability (optional, auto-enabled when keys are set) +# 功能说明:企业级 LLM 可观测性集成,自动追踪所有代理请求的完整生命周期 +# - 配置 PUBLIC_KEY 和 SECRET_KEY 后自动启用 +# - 支持 Langfuse Cloud 和自托管实例 +LANGFUSE_PUBLIC_KEY= # Langfuse project public key (pk-lf-...) +LANGFUSE_SECRET_KEY= # Langfuse project secret key (sk-lf-...) +LANGFUSE_BASE_URL=https://cloud.langfuse.com # Langfuse server URL (self-hosted or cloud) +LANGFUSE_SAMPLE_RATE=1.0 # Trace sampling rate (0.0-1.0, default: 1.0 = 100%) +LANGFUSE_DEBUG=false # Enable Langfuse debug logging + # 智能探测配置 # 功能说明:当熔断器处于 OPEN 状态时,定期探测供应商以实现更快恢复 # - ENABLE_SMART_PROBING:是否启用智能探测(默认:false) diff --git a/package.json b/package.json index 6411d827c..fb7d0914c 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,11 @@ "@hono/zod-openapi": "^1", "@hookform/resolvers": "^5", "@iarna/toml": "^2.2.5", + "@langfuse/client": "^4.6.1", + "@langfuse/otel": "^4.6.1", + "@langfuse/tracing": "^4.6.1", "@lobehub/icons": "^2", + "@opentelemetry/sdk-node": "^0.212.0", "@radix-ui/react-alert-dialog": "^1", "@radix-ui/react-avatar": "^1", "@radix-ui/react-checkbox": "^1", diff --git a/src/app/v1/_lib/proxy-handler.ts b/src/app/v1/_lib/proxy-handler.ts index 5257ef670..5f2b90b4e 100644 --- a/src/app/v1/_lib/proxy-handler.ts +++ b/src/app/v1/_lib/proxy-handler.ts @@ -78,9 +78,12 @@ export async function handleProxyRequest(c: Context): Promise { }); } + session.recordForwardStart(); const response = await ProxyForwarder.send(session); const handled = await ProxyResponseHandler.dispatch(session, response); - return await attachSessionIdToErrorResponse(session.sessionId, handled); + const finalResponse = await attachSessionIdToErrorResponse(session.sessionId, handled); + + return finalResponse; } catch (error) { logger.error("Proxy handler error:", error); if (session) { diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 3759a8570..1abf4019c 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -1648,6 +1648,7 @@ export class ProxyForwarder { const bodyString = JSON.stringify(bodyToSerialize); requestBody = bodyString; + session.forwardedRequestBody = bodyString; } // 检测流式请求:Gemini 支持两种方式 @@ -1974,6 +1975,7 @@ export class ProxyForwarder { const bodyString = JSON.stringify(messageToSend); requestBody = bodyString; + session.forwardedRequestBody = bodyString; try { const parsed = JSON.parse(bodyString); diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index c2afe9d90..3dc16dc1b 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -8,7 +8,8 @@ import { RateLimitService } from "@/lib/rate-limit"; import type { LeaseWindowType } from "@/lib/rate-limit/lease"; import { SessionManager } from "@/lib/session-manager"; import { SessionTracker } from "@/lib/session-tracker"; -import { calculateRequestCost } from "@/lib/utils/cost-calculation"; +import type { CostBreakdown } from "@/lib/utils/cost-calculation"; +import { calculateRequestCost, calculateRequestCostBreakdown } from "@/lib/utils/cost-calculation"; import { hasValidPriceData } from "@/lib/utils/price-data"; import { isSSEText, parseSSEData } from "@/lib/utils/sse"; import { detectUpstreamErrorFromSseOrJsonText } from "@/lib/utils/upstream-error-detection"; @@ -39,6 +40,49 @@ export type UsageMetrics = { output_image_tokens?: number; }; +/** + * Fire Langfuse trace asynchronously. Non-blocking, error-tolerant. + */ +function emitLangfuseTrace( + session: ProxySession, + data: { + responseHeaders: Headers; + responseText: string; + usageMetrics: UsageMetrics | null; + costUsd: string | undefined; + costBreakdown?: CostBreakdown; + statusCode: number; + durationMs: number; + isStreaming: boolean; + sseEventCount?: number; + errorMessage?: string; + } +): void { + if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) return; + + void import("@/lib/langfuse/trace-proxy-request") + .then(({ traceProxyRequest }) => { + void traceProxyRequest({ + session, + responseHeaders: data.responseHeaders, + durationMs: data.durationMs, + statusCode: data.statusCode, + isStreaming: data.isStreaming, + responseText: data.responseText, + usageMetrics: data.usageMetrics, + costUsd: data.costUsd, + costBreakdown: data.costBreakdown, + sseEventCount: data.sseEventCount, + errorMessage: data.errorMessage, + }); + }) + .catch((err) => { + logger.warn("[ResponseHandler] Langfuse trace failed", { + error: err instanceof Error ? err.message : String(err), + }); + }); +} + /** * 清理 Response headers 中的传输相关 header * @@ -520,6 +564,18 @@ export class ProxyResponseHandler { duration, errorMessageForFinalize ); + + emitLangfuseTrace(session, { + responseHeaders: response.headers, + responseText, + usageMetrics: parseUsageFromResponseText(responseText, provider.providerType) + .usageMetrics, + costUsd: undefined, + statusCode, + durationMs: duration, + isStreaming: false, + errorMessage: errorMessageForFinalize, + }); } catch (error) { if (!isClientAbortError(error as Error)) { logger.error( @@ -687,10 +743,11 @@ export class ProxyResponseHandler { await trackCostToRedis(session, usageMetrics); } - // 更新 session 使用量到 Redis(用于实时监控) - if (session.sessionId && usageMetrics) { - // 计算成本(复用相同逻辑) - let costUsdStr: string | undefined; + // Calculate cost for session tracking (with multiplier) and Langfuse (raw) + let costUsdStr: string | undefined; + let rawCostUsdStr: string | undefined; + let costBreakdown: CostBreakdown | undefined; + if (usageMetrics) { try { if (session.request.model) { const priceData = await session.getCachedPriceDataByBillingSource(); @@ -704,6 +761,30 @@ export class ProxyResponseHandler { if (cost.gt(0)) { costUsdStr = cost.toString(); } + // Raw cost without multiplier for Langfuse + if (provider.costMultiplier !== 1) { + const rawCost = calculateRequestCost( + usageMetrics, + priceData, + 1.0, + session.getContext1mApplied() + ); + if (rawCost.gt(0)) { + rawCostUsdStr = rawCost.toString(); + } + } else { + rawCostUsdStr = costUsdStr; + } + // Cost breakdown for Langfuse (raw, no multiplier) + try { + costBreakdown = calculateRequestCostBreakdown( + usageMetrics, + priceData, + session.getContext1mApplied() + ); + } catch { + /* non-critical */ + } } } } catch (error) { @@ -711,7 +792,10 @@ export class ProxyResponseHandler { error: error instanceof Error ? error.message : String(error), }); } + } + // 更新 session 使用量到 Redis(用于实时监控) + if (session.sessionId && usageMetrics) { void SessionManager.updateSessionUsage(session.sessionId, { inputTokens: usageMetrics.input_tokens, outputTokens: usageMetrics.output_tokens, @@ -782,6 +866,17 @@ export class ProxyResponseHandler { providerName: provider.name, statusCode, }); + + emitLangfuseTrace(session, { + responseHeaders: response.headers, + responseText, + usageMetrics, + costUsd: rawCostUsdStr, + costBreakdown, + statusCode, + durationMs: Date.now() - session.startTime, + isStreaming: false, + }); } catch (error) { // 检测 AbortError 的来源:响应超时 vs 客户端中断 const err = error as Error; @@ -1220,6 +1315,18 @@ export class ProxyResponseHandler { finalized.errorMessage ?? undefined, finalized.providerIdForPersistence ?? undefined ); + + emitLangfuseTrace(session, { + responseHeaders: response.headers, + responseText: allContent, + usageMetrics: parseUsageFromResponseText(allContent, provider.providerType) + .usageMetrics, + costUsd: undefined, + statusCode: finalized.effectiveStatusCode, + durationMs: duration, + isStreaming: true, + errorMessage: finalized.errorMessage ?? undefined, + }); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); const clientAborted = session.clientAbortSignal?.aborted ?? false; @@ -1588,11 +1695,13 @@ export class ProxyResponseHandler { // 追踪消费到 Redis(用于限流) await trackCostToRedis(session, usageForCost); - // 更新 session 使用量到 Redis(用于实时监控) - if (session.sessionId) { - let costUsdStr: string | undefined; + // Calculate cost for session tracking (with multiplier) and Langfuse (raw) + let costUsdStr: string | undefined; + let rawCostUsdStr: string | undefined; + let costBreakdown: CostBreakdown | undefined; + if (usageForCost) { try { - if (usageForCost && session.request.model) { + if (session.request.model) { const priceData = await session.getCachedPriceDataByBillingSource(); if (priceData) { const cost = calculateRequestCost( @@ -1604,6 +1713,30 @@ export class ProxyResponseHandler { if (cost.gt(0)) { costUsdStr = cost.toString(); } + // Raw cost without multiplier for Langfuse + if (provider.costMultiplier !== 1) { + const rawCost = calculateRequestCost( + usageForCost, + priceData, + 1.0, + session.getContext1mApplied() + ); + if (rawCost.gt(0)) { + rawCostUsdStr = rawCost.toString(); + } + } else { + rawCostUsdStr = costUsdStr; + } + // Cost breakdown for Langfuse (raw, no multiplier) + try { + costBreakdown = calculateRequestCostBreakdown( + usageForCost, + priceData, + session.getContext1mApplied() + ); + } catch { + /* non-critical */ + } } } } catch (error) { @@ -1611,7 +1744,10 @@ export class ProxyResponseHandler { error: error instanceof Error ? error.message : String(error), }); } + } + // 更新 session 使用量到 Redis(用于实时监控) + if (session.sessionId) { const payload: SessionUsageUpdate = { status: effectiveStatusCode >= 200 && effectiveStatusCode < 300 ? "completed" : "error", statusCode: effectiveStatusCode, @@ -1650,6 +1786,19 @@ export class ProxyResponseHandler { providerId: providerIdForPersistence ?? session.provider?.id, // 更新最终供应商ID(重试切换后) context1mApplied: session.getContext1mApplied(), }); + + emitLangfuseTrace(session, { + responseHeaders: response.headers, + responseText: allContent, + usageMetrics: usageForCost, + costUsd: rawCostUsdStr, + costBreakdown, + statusCode: effectiveStatusCode, + durationMs: duration, + isStreaming: true, + sseEventCount: chunks.length, + errorMessage: streamErrorMessage ?? undefined, + }); }; try { @@ -2919,6 +3068,18 @@ async function persistRequestFailure(options: { }); } } + + // Emit Langfuse trace for error/abort paths + emitLangfuseTrace(session, { + responseHeaders: new Headers(), + responseText: "", + usageMetrics: null, + costUsd: undefined, + statusCode, + durationMs: duration, + isStreaming: phase === "stream", + errorMessage, + }); } /** diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index a163c772d..abae33872 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -67,6 +67,12 @@ export class ProxySession { // Time To First Byte (ms). Streaming: first chunk. Non-stream: equals durationMs. ttfbMs: number | null = null; + // Timestamp when guard pipeline finished and forwarding started (epoch ms). + forwardStartTime: number | null = null; + + // Actual serialized request body sent to upstream (after all preprocessing). + forwardedRequestBody: string | null = null; + // Session ID(用于会话粘性和并发限流) sessionId: string | null; @@ -313,6 +319,16 @@ export class ProxySession { return value; } + /** + * Record the timestamp when guard pipeline finished and upstream forwarding begins. + * Called once; subsequent calls are no-ops. + */ + recordForwardStart(): void { + if (this.forwardStartTime === null) { + this.forwardStartTime = Date.now(); + } + } + /** * 设置 session ID */ diff --git a/src/instrumentation.ts b/src/instrumentation.ts index a3303e51d..85ddfd3ba 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -140,6 +140,15 @@ function warmupApiKeyVacuumFilter(): void { export async function register() { // 仅在服务器端执行 if (process.env.NEXT_RUNTIME === "nodejs") { + // Initialize Langfuse observability (no-op if env vars not set) + try { + const { initLangfuse } = await import("@/lib/langfuse"); + await initLangfuse(); + } catch (error) { + logger.warn("[Instrumentation] Langfuse initialization failed (non-critical)", { + error: error instanceof Error ? error.message : String(error), + }); + } // Skip initialization in CI environment (no DB connection needed) if (process.env.CI === "true") { logger.warn( @@ -216,6 +225,16 @@ export async function register() { }); } + // Flush Langfuse pending spans + try { + const { shutdownLangfuse } = await import("@/lib/langfuse"); + await shutdownLangfuse(); + } catch (error) { + logger.warn("[Instrumentation] Failed to shutdown Langfuse", { + error: error instanceof Error ? error.message : String(error), + }); + } + // 尽力将 message_request 的异步批量更新刷入数据库(避免终止时丢失尾部日志) try { const { stopMessageRequestWriteBuffer } = await import( diff --git a/src/lib/config/env.schema.ts b/src/lib/config/env.schema.ts index a845a0db5..b7dacd738 100644 --- a/src/lib/config/env.schema.ts +++ b/src/lib/config/env.schema.ts @@ -127,6 +127,13 @@ export const EnvSchema = z.object({ FETCH_BODY_TIMEOUT: z.coerce.number().default(600_000), // 请求/响应体传输超时(默认 600 秒) FETCH_HEADERS_TIMEOUT: z.coerce.number().default(600_000), // 响应头接收超时(默认 600 秒) FETCH_CONNECT_TIMEOUT: z.coerce.number().default(30000), // TCP 连接建立超时(默认 30 秒) + + // Langfuse Observability (optional, auto-enabled when keys are set) + LANGFUSE_PUBLIC_KEY: z.string().optional(), + LANGFUSE_SECRET_KEY: z.string().optional(), + LANGFUSE_BASE_URL: z.string().default("https://cloud.langfuse.com"), + LANGFUSE_SAMPLE_RATE: z.coerce.number().min(0).max(1).default(1.0), + LANGFUSE_DEBUG: z.string().default("false").transform(booleanTransform), }); /** diff --git a/src/lib/langfuse/index.ts b/src/lib/langfuse/index.ts new file mode 100644 index 000000000..56889ed36 --- /dev/null +++ b/src/lib/langfuse/index.ts @@ -0,0 +1,92 @@ +import type { LangfuseSpanProcessor } from "@langfuse/otel"; + +import type { NodeSDK } from "@opentelemetry/sdk-node"; +import { logger } from "@/lib/logger"; + +let sdk: NodeSDK | null = null; +let spanProcessor: LangfuseSpanProcessor | null = null; +let initialized = false; + +export function isLangfuseEnabled(): boolean { + return !!(process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY); +} + +/** + * Initialize Langfuse OpenTelemetry SDK. + * Must be called early in the process (instrumentation.ts register()). + * No-op if LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY are not set. + */ +export async function initLangfuse(): Promise { + if (initialized || !isLangfuseEnabled()) { + return; + } + + try { + const { NodeSDK: OtelNodeSDK } = await import("@opentelemetry/sdk-node"); + const { LangfuseSpanProcessor: LfSpanProcessor } = await import("@langfuse/otel"); + + const sampleRate = Number.parseFloat(process.env.LANGFUSE_SAMPLE_RATE || "1.0"); + + spanProcessor = new LfSpanProcessor({ + publicKey: process.env.LANGFUSE_PUBLIC_KEY, + secretKey: process.env.LANGFUSE_SECRET_KEY, + baseUrl: process.env.LANGFUSE_BASE_URL || "https://cloud.langfuse.com", + // Only export spans from langfuse-sdk scope (avoid noise from other OTel instrumentations) + shouldExportSpan: ({ otelSpan }) => otelSpan.instrumentationScope.name === "langfuse-sdk", + }); + + const samplerConfig = + sampleRate < 1.0 + ? await (async () => { + const { TraceIdRatioBasedSampler } = await import("@opentelemetry/sdk-trace-base"); + return { sampler: new TraceIdRatioBasedSampler(sampleRate) }; + })() + : {}; + + sdk = new OtelNodeSDK({ + spanProcessors: [spanProcessor], + ...samplerConfig, + }); + + sdk.start(); + initialized = true; + + logger.info("[Langfuse] Observability initialized", { + baseUrl: process.env.LANGFUSE_BASE_URL || "https://cloud.langfuse.com", + sampleRate, + debug: process.env.LANGFUSE_DEBUG === "true", + }); + + if (process.env.LANGFUSE_DEBUG === "true") { + const { configureGlobalLogger, LogLevel } = await import("@langfuse/core"); + configureGlobalLogger({ level: LogLevel.DEBUG }); + } + } catch (error) { + logger.error("[Langfuse] Failed to initialize", { + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * Flush pending spans and shut down the SDK. + * Called during graceful shutdown (SIGTERM/SIGINT). + */ +export async function shutdownLangfuse(): Promise { + if (!initialized || !spanProcessor) { + return; + } + + try { + await spanProcessor.forceFlush(); + if (sdk) { + await sdk.shutdown(); + } + initialized = false; + logger.info("[Langfuse] Shutdown complete"); + } catch (error) { + logger.warn("[Langfuse] Shutdown error", { + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/src/lib/langfuse/trace-proxy-request.ts b/src/lib/langfuse/trace-proxy-request.ts new file mode 100644 index 000000000..cc940b394 --- /dev/null +++ b/src/lib/langfuse/trace-proxy-request.ts @@ -0,0 +1,363 @@ +import type { UsageMetrics } from "@/app/v1/_lib/proxy/response-handler"; +import type { ProxySession } from "@/app/v1/_lib/proxy/session"; +import { isLangfuseEnabled } from "@/lib/langfuse/index"; +import { logger } from "@/lib/logger"; +import type { CostBreakdown } from "@/lib/utils/cost-calculation"; + +function buildRequestBodySummary(session: ProxySession): Record { + const msg = session.request.message as Record; + return { + model: session.request.model, + messageCount: session.getMessagesLength(), + hasSystemPrompt: Array.isArray(msg.system) && msg.system.length > 0, + toolsCount: Array.isArray(msg.tools) ? msg.tools.length : 0, + stream: msg.stream === true, + maxTokens: typeof msg.max_tokens === "number" ? msg.max_tokens : undefined, + temperature: typeof msg.temperature === "number" ? msg.temperature : undefined, + }; +} + +function getStatusCategory(statusCode: number): string { + if (statusCode >= 200 && statusCode < 300) return "2xx"; + if (statusCode >= 400 && statusCode < 500) return "4xx"; + if (statusCode >= 500) return "5xx"; + return `${Math.floor(statusCode / 100)}xx`; +} + +/** + * Convert Headers to a plain record. + * + * Security note: session.headers are the CLIENT's original request headers + * (user -> CCH), which may include the user's own CCH auth key. These are + * safe to log -- the user already knows their own credentials. + * + * The upstream PROVIDER API key (outboundKey) is injected by ProxyForwarder + * into a separate Headers object and is NEVER present in session.headers or + * ctx.responseHeaders, so no redaction is needed here. + */ +function headersToRecord(headers: Headers): Record { + const result: Record = {}; + headers.forEach((value, key) => { + result[key] = value; + }); + return result; +} + +const SUCCESS_REASONS = new Set([ + "request_success", + "retry_success", + "initial_selection", + "session_reuse", +]); + +function isSuccessReason(reason: string | undefined): boolean { + return !!reason && SUCCESS_REASONS.has(reason); +} + +const ERROR_REASONS = new Set([ + "system_error", + "vendor_type_all_timeout", + "endpoint_pool_exhausted", +]); + +function isErrorReason(reason: string | undefined): boolean { + return !!reason && ERROR_REASONS.has(reason); +} + +type ObservationLevel = "DEBUG" | "DEFAULT" | "WARNING" | "ERROR"; + +export interface TraceContext { + session: ProxySession; + responseHeaders: Headers; + durationMs: number; + statusCode: number; + responseText?: string; + isStreaming: boolean; + sseEventCount?: number; + errorMessage?: string; + usageMetrics?: UsageMetrics | null; + costUsd?: string; + costBreakdown?: CostBreakdown; +} + +/** + * Send a trace to Langfuse for a completed proxy request. + * Fully async and non-blocking. Errors are caught and logged. + */ +export async function traceProxyRequest(ctx: TraceContext): Promise { + if (!isLangfuseEnabled()) { + return; + } + + try { + const { startObservation, propagateAttributes } = await import("@langfuse/tracing"); + + const { session, durationMs, statusCode, isStreaming } = ctx; + const provider = session.provider; + const messageContext = session.messageContext; + + // Compute actual request timing from session data + const requestStartTime = new Date(session.startTime); + const requestEndTime = new Date(session.startTime + durationMs); + + // Compute timing breakdown from forwardStartTime + const forwardStartDate = session.forwardStartTime ? new Date(session.forwardStartTime) : null; + const guardPipelineMs = session.forwardStartTime + ? session.forwardStartTime - session.startTime + : null; + + const timingBreakdown = { + guardPipelineMs, + upstreamTotalMs: + guardPipelineMs != null ? Math.max(0, durationMs - guardPipelineMs) : durationMs, + ttfbFromForwardMs: + guardPipelineMs != null && session.ttfbMs != null + ? Math.max(0, session.ttfbMs - guardPipelineMs) + : null, + tokenGenerationMs: session.ttfbMs != null ? Math.max(0, durationMs - session.ttfbMs) : null, + failedAttempts: session.getProviderChain().filter((i) => !isSuccessReason(i.reason)).length, + providersAttempted: new Set(session.getProviderChain().map((i) => i.id)).size, + }; + + // Compute observation level for root span + let rootSpanLevel: ObservationLevel = "DEFAULT"; + if (statusCode < 200 || statusCode >= 300) { + rootSpanLevel = "ERROR"; + } else { + const failedAttempts = session + .getProviderChain() + .filter((i) => !isSuccessReason(i.reason)).length; + if (failedAttempts >= 1) rootSpanLevel = "WARNING"; + } + + // Actual request body (forwarded to upstream after all preprocessing) - no truncation + const actualRequestBody = session.forwardedRequestBody + ? tryParseJsonSafe(session.forwardedRequestBody) + : session.request.message; + + // Actual response body - no truncation + const actualResponseBody = ctx.responseText + ? tryParseJsonSafe(ctx.responseText) + : isStreaming + ? { streaming: true, sseEventCount: ctx.sseEventCount } + : { statusCode }; + + // Root span metadata (former input/output summaries moved here) + const rootSpanMetadata: Record = { + endpoint: session.getEndpoint(), + method: session.method, + model: session.getCurrentModel(), + clientFormat: session.originalFormat, + providerName: provider?.name, + statusCode, + durationMs, + hasUsage: !!ctx.usageMetrics, + costUsd: ctx.costUsd, + timingBreakdown, + }; + + // Build tags - include provider name and model + const tags: string[] = []; + if (provider?.providerType) tags.push(provider.providerType); + if (provider?.name) tags.push(provider.name); + if (session.originalFormat) tags.push(session.originalFormat); + if (session.getCurrentModel()) tags.push(session.getCurrentModel()!); + tags.push(getStatusCategory(statusCode)); + + // Build trace-level metadata (propagateAttributes requires all values to be strings) + const traceMetadata: Record = { + keyName: messageContext?.key?.name ?? "", + endpoint: session.getEndpoint() ?? "", + method: session.method, + clientFormat: session.originalFormat, + userAgent: session.userAgent ?? "", + requestSequence: String(session.getRequestSequence()), + }; + + // Build generation metadata - all request detail fields, raw headers (no redaction) + const generationMetadata: Record = { + // Provider + providerId: provider?.id, + providerName: provider?.name, + providerType: provider?.providerType, + providerChain: session.getProviderChain(), + // Model + model: session.getCurrentModel(), + originalModel: session.getOriginalModel(), + modelRedirected: session.isModelRedirected(), + // Special settings + specialSettings: session.getSpecialSettings(), + // Request context + endpoint: session.getEndpoint(), + method: session.method, + clientFormat: session.originalFormat, + userAgent: session.userAgent, + requestSequence: session.getRequestSequence(), + sessionId: session.sessionId, + keyName: messageContext?.key?.name, + // Timing + durationMs, + ttfbMs: session.ttfbMs, + timingBreakdown, + // Flags + isStreaming, + cacheTtlApplied: session.getCacheTtlResolved(), + context1mApplied: session.getContext1mApplied(), + // Error + errorMessage: ctx.errorMessage, + // Request summary (quick overview) + requestSummary: buildRequestBodySummary(session), + // SSE + sseEventCount: ctx.sseEventCount, + // Headers (raw, no redaction) + requestHeaders: headersToRecord(session.headers), + responseHeaders: headersToRecord(ctx.responseHeaders), + }; + + // Build usage details for Langfuse generation + const usageDetails: Record | undefined = ctx.usageMetrics + ? { + ...(ctx.usageMetrics.input_tokens != null + ? { input: ctx.usageMetrics.input_tokens } + : {}), + ...(ctx.usageMetrics.output_tokens != null + ? { output: ctx.usageMetrics.output_tokens } + : {}), + ...(ctx.usageMetrics.cache_read_input_tokens != null + ? { cache_read_input_tokens: ctx.usageMetrics.cache_read_input_tokens } + : {}), + ...(ctx.usageMetrics.cache_creation_input_tokens != null + ? { cache_creation_input_tokens: ctx.usageMetrics.cache_creation_input_tokens } + : {}), + } + : undefined; + + // Build cost details (prefer breakdown, fallback to total-only) + const costDetails: Record | undefined = ctx.costBreakdown + ? { ...ctx.costBreakdown } + : ctx.costUsd && Number.parseFloat(ctx.costUsd) > 0 + ? { total: Number.parseFloat(ctx.costUsd) } + : undefined; + + // Create the root trace span with actual bodies, level, and metadata + const rootSpan = startObservation( + "proxy-request", + { + input: actualRequestBody, + output: actualResponseBody, + level: rootSpanLevel, + metadata: rootSpanMetadata, + }, + { + startTime: requestStartTime, + } + ); + + // Propagate trace attributes + await propagateAttributes( + { + userId: messageContext?.user?.name ?? undefined, + sessionId: session.sessionId ?? undefined, + tags, + metadata: traceMetadata, + traceName: `${session.method} ${session.getEndpoint() ?? "/"}`, + }, + async () => { + // 1. Guard pipeline span (if forwardStartTime was recorded) + if (forwardStartDate) { + const guardSpan = rootSpan.startObservation( + "guard-pipeline", + { + output: { durationMs: guardPipelineMs, passed: true }, + }, + { startTime: requestStartTime } as Record + ); + guardSpan.end(forwardStartDate); + } + + // 2. Provider attempt events (one per failed chain item) + for (const item of session.getProviderChain()) { + if (!isSuccessReason(item.reason)) { + const eventObs = rootSpan.startObservation( + "provider-attempt", + { + level: isErrorReason(item.reason) ? "ERROR" : "WARNING", + input: { + providerId: item.id, + providerName: item.name, + attempt: item.attemptNumber, + }, + output: { + reason: item.reason, + errorMessage: item.errorMessage, + statusCode: item.statusCode, + }, + metadata: { ...item }, + }, + { + asType: "event", + startTime: new Date(item.timestamp ?? session.startTime), + } as { asType: "event" } + ); + eventObs.end(); + } + } + + // 3. LLM generation (startTime = forwardStartTime when available) + const generationStartTime = forwardStartDate ?? requestStartTime; + + // Generation input/output = raw payload, no truncation + const generationInput = actualRequestBody; + const generationOutput = ctx.responseText + ? tryParseJsonSafe(ctx.responseText) + : isStreaming + ? { streaming: true, sseEventCount: ctx.sseEventCount } + : { statusCode }; + + // Create the LLM generation observation + const generation = rootSpan.startObservation( + "llm-call", + { + model: session.getCurrentModel() ?? undefined, + input: generationInput, + output: generationOutput, + ...(usageDetails && Object.keys(usageDetails).length > 0 ? { usageDetails } : {}), + ...(costDetails ? { costDetails } : {}), + metadata: generationMetadata, + }, + // SDK runtime supports startTime on child observations but types don't expose it + { asType: "generation", startTime: generationStartTime } as { asType: "generation" } + ); + + // Set TTFB as completionStartTime + if (session.ttfbMs != null) { + generation.update({ + completionStartTime: new Date(session.startTime + session.ttfbMs), + }); + } + + generation.end(requestEndTime); + } + ); + + // Explicitly set trace-level input/output (propagateAttributes does not support these) + rootSpan.updateTrace({ + input: actualRequestBody, + output: actualResponseBody, + }); + + rootSpan.end(requestEndTime); + } catch (error) { + logger.warn("[Langfuse] Failed to trace proxy request", { + error: error instanceof Error ? error.message : String(error), + }); + } +} + +function tryParseJsonSafe(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} diff --git a/src/lib/utils/cost-calculation.ts b/src/lib/utils/cost-calculation.ts index 1212a1f99..0be83453d 100644 --- a/src/lib/utils/cost-calculation.ts +++ b/src/lib/utils/cost-calculation.ts @@ -98,6 +98,214 @@ function calculateTieredCostWithSeparatePrices( return baseCost.add(premiumCost); } +export interface CostBreakdown { + input: number; + output: number; + cache_creation: number; + cache_read: number; + total: number; +} + +/** + * Calculate cost breakdown by category (always raw cost, multiplier=1.0). + * Returns per-category costs as plain numbers. + */ +export function calculateRequestCostBreakdown( + usage: UsageMetrics, + priceData: ModelPriceData, + context1mApplied: boolean = false +): CostBreakdown { + let inputBucket = new Decimal(0); + let outputBucket = new Decimal(0); + let cacheCreationBucket = new Decimal(0); + let cacheReadBucket = new Decimal(0); + + const inputCostPerToken = priceData.input_cost_per_token; + const outputCostPerToken = priceData.output_cost_per_token; + const inputCostPerRequest = priceData.input_cost_per_request; + + // Per-request cost -> input bucket + if ( + typeof inputCostPerRequest === "number" && + Number.isFinite(inputCostPerRequest) && + inputCostPerRequest >= 0 + ) { + const requestCost = toDecimal(inputCostPerRequest); + if (requestCost) { + inputBucket = inputBucket.add(requestCost); + } + } + + const cacheCreation5mCost = + priceData.cache_creation_input_token_cost ?? + (inputCostPerToken != null ? inputCostPerToken * 1.25 : undefined); + + const cacheCreation1hCost = + priceData.cache_creation_input_token_cost_above_1hr ?? + (inputCostPerToken != null ? inputCostPerToken * 2 : undefined) ?? + cacheCreation5mCost; + + const cacheReadCost = + priceData.cache_read_input_token_cost ?? + (inputCostPerToken != null + ? inputCostPerToken * 0.1 + : outputCostPerToken != null + ? outputCostPerToken * 0.1 + : undefined); + + // Derive cache creation tokens by TTL + let cache5mTokens = usage.cache_creation_5m_input_tokens; + let cache1hTokens = usage.cache_creation_1h_input_tokens; + + if (typeof usage.cache_creation_input_tokens === "number") { + const remaining = + usage.cache_creation_input_tokens - (cache5mTokens ?? 0) - (cache1hTokens ?? 0); + + if (remaining > 0) { + const target = usage.cache_ttl === "1h" ? "1h" : "5m"; + if (target === "1h") { + cache1hTokens = (cache1hTokens ?? 0) + remaining; + } else { + cache5mTokens = (cache5mTokens ?? 0) + remaining; + } + } + } + + const inputAbove200k = priceData.input_cost_per_token_above_200k_tokens; + const outputAbove200k = priceData.output_cost_per_token_above_200k_tokens; + + // Input tokens -> input bucket + if (context1mApplied && inputCostPerToken != null && usage.input_tokens != null) { + inputBucket = inputBucket.add( + calculateTieredCost( + usage.input_tokens, + inputCostPerToken, + CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER + ) + ); + } else if (inputAbove200k != null && inputCostPerToken != null && usage.input_tokens != null) { + inputBucket = inputBucket.add( + calculateTieredCostWithSeparatePrices(usage.input_tokens, inputCostPerToken, inputAbove200k) + ); + } else { + inputBucket = inputBucket.add(multiplyCost(usage.input_tokens, inputCostPerToken)); + } + + // Output tokens -> output bucket + if (context1mApplied && outputCostPerToken != null && usage.output_tokens != null) { + outputBucket = outputBucket.add( + calculateTieredCost( + usage.output_tokens, + outputCostPerToken, + CONTEXT_1M_OUTPUT_PREMIUM_MULTIPLIER + ) + ); + } else if (outputAbove200k != null && outputCostPerToken != null && usage.output_tokens != null) { + outputBucket = outputBucket.add( + calculateTieredCostWithSeparatePrices( + usage.output_tokens, + outputCostPerToken, + outputAbove200k + ) + ); + } else { + outputBucket = outputBucket.add(multiplyCost(usage.output_tokens, outputCostPerToken)); + } + + // Cache costs + const cacheCreationAbove200k = priceData.cache_creation_input_token_cost_above_200k_tokens; + const cacheReadAbove200k = priceData.cache_read_input_token_cost_above_200k_tokens; + const hasRealCacheCreationBase = priceData.cache_creation_input_token_cost != null; + const hasRealCacheReadBase = priceData.cache_read_input_token_cost != null; + + // Cache creation 5m -> cache_creation bucket + if (context1mApplied && cacheCreation5mCost != null && cache5mTokens != null) { + cacheCreationBucket = cacheCreationBucket.add( + calculateTieredCost(cache5mTokens, cacheCreation5mCost, CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER) + ); + } else if ( + hasRealCacheCreationBase && + cacheCreationAbove200k != null && + cacheCreation5mCost != null && + cache5mTokens != null + ) { + cacheCreationBucket = cacheCreationBucket.add( + calculateTieredCostWithSeparatePrices( + cache5mTokens, + cacheCreation5mCost, + cacheCreationAbove200k + ) + ); + } else { + cacheCreationBucket = cacheCreationBucket.add(multiplyCost(cache5mTokens, cacheCreation5mCost)); + } + + // Cache creation 1h -> cache_creation bucket + if (context1mApplied && cacheCreation1hCost != null && cache1hTokens != null) { + cacheCreationBucket = cacheCreationBucket.add( + calculateTieredCost(cache1hTokens, cacheCreation1hCost, CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER) + ); + } else if ( + hasRealCacheCreationBase && + cacheCreationAbove200k != null && + cacheCreation1hCost != null && + cache1hTokens != null + ) { + cacheCreationBucket = cacheCreationBucket.add( + calculateTieredCostWithSeparatePrices( + cache1hTokens, + cacheCreation1hCost, + cacheCreationAbove200k + ) + ); + } else { + cacheCreationBucket = cacheCreationBucket.add(multiplyCost(cache1hTokens, cacheCreation1hCost)); + } + + // Cache read -> cache_read bucket + if ( + hasRealCacheReadBase && + cacheReadAbove200k != null && + cacheReadCost != null && + usage.cache_read_input_tokens != null + ) { + cacheReadBucket = cacheReadBucket.add( + calculateTieredCostWithSeparatePrices( + usage.cache_read_input_tokens, + cacheReadCost, + cacheReadAbove200k + ) + ); + } else { + cacheReadBucket = cacheReadBucket.add( + multiplyCost(usage.cache_read_input_tokens, cacheReadCost) + ); + } + + // Image tokens -> respective buckets + if (usage.output_image_tokens != null && usage.output_image_tokens > 0) { + const imageCostPerToken = + priceData.output_cost_per_image_token ?? priceData.output_cost_per_token; + outputBucket = outputBucket.add(multiplyCost(usage.output_image_tokens, imageCostPerToken)); + } + + if (usage.input_image_tokens != null && usage.input_image_tokens > 0) { + const imageCostPerToken = + priceData.input_cost_per_image_token ?? priceData.input_cost_per_token; + inputBucket = inputBucket.add(multiplyCost(usage.input_image_tokens, imageCostPerToken)); + } + + const total = inputBucket.add(outputBucket).add(cacheCreationBucket).add(cacheReadBucket); + + return { + input: inputBucket.toDecimalPlaces(COST_SCALE).toNumber(), + output: outputBucket.toDecimalPlaces(COST_SCALE).toNumber(), + cache_creation: cacheCreationBucket.toDecimalPlaces(COST_SCALE).toNumber(), + cache_read: cacheReadBucket.toDecimalPlaces(COST_SCALE).toNumber(), + total: total.toDecimalPlaces(COST_SCALE).toNumber(), + }; +} + /** * 计算单次请求的费用 * @param usage - token使用量 diff --git a/tests/unit/langfuse/langfuse-trace.test.ts b/tests/unit/langfuse/langfuse-trace.test.ts new file mode 100644 index 000000000..c1760bb7d --- /dev/null +++ b/tests/unit/langfuse/langfuse-trace.test.ts @@ -0,0 +1,996 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; + +// Mock the langfuse modules at the top level +const mockStartObservation = vi.fn(); +const mockPropagateAttributes = vi.fn(); +const mockSpanEnd = vi.fn(); +const mockGenerationEnd = vi.fn(); +const mockGenerationUpdate = vi.fn(); +const mockGuardSpanEnd = vi.fn(); +const mockEventEnd = vi.fn(); + +const mockGeneration: any = { + update: (...args: unknown[]) => { + mockGenerationUpdate(...args); + return mockGeneration; + }, + end: mockGenerationEnd, +}; + +const mockGuardSpan: any = { + end: mockGuardSpanEnd, +}; + +const mockEventObs: any = { + end: mockEventEnd, +}; + +const mockUpdateTrace = vi.fn(); + +const mockRootSpan = { + startObservation: vi.fn(), + updateTrace: mockUpdateTrace, + end: mockSpanEnd, +}; + +// Default: route by observation name +function setupDefaultStartObservation() { + mockRootSpan.startObservation.mockImplementation((name: string) => { + if (name === "guard-pipeline") return mockGuardSpan; + if (name === "provider-attempt") return mockEventObs; + return mockGeneration; // "llm-call" + }); +} + +vi.mock("@langfuse/tracing", () => ({ + startObservation: (...args: unknown[]) => { + mockStartObservation(...args); + return mockRootSpan; + }, + propagateAttributes: async (attrs: unknown, fn: () => Promise) => { + mockPropagateAttributes(attrs); + await fn(); + }, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +let langfuseEnabled = true; +vi.mock("@/lib/langfuse/index", () => ({ + isLangfuseEnabled: () => langfuseEnabled, +})); + +function createMockSession(overrides: Record = {}) { + const startTime = (overrides.startTime as number) ?? Date.now() - 500; + return { + startTime, + method: "POST", + headers: new Headers({ + "content-type": "application/json", + "x-api-key": "test-mock-key-not-real", + "user-agent": "claude-code/1.0", + }), + request: { + message: { + model: "claude-sonnet-4-20250514", + messages: [{ role: "user", content: "Hello" }], + stream: true, + max_tokens: 4096, + tools: [{ name: "tool1" }], + }, + model: "claude-sonnet-4-20250514", + }, + originalFormat: "claude", + userAgent: "claude-code/1.0", + sessionId: "sess_abc12345_def67890", + provider: { + id: 1, + name: "anthropic-main", + providerType: "claude", + }, + messageContext: { + id: 42, + user: { id: 7, name: "testuser" }, + key: { name: "default-key" }, + }, + ttfbMs: 200, + forwardStartTime: startTime + 5, + forwardedRequestBody: null, + getEndpoint: () => "/v1/messages", + getRequestSequence: () => 3, + getMessagesLength: () => 1, + getCurrentModel: () => "claude-sonnet-4-20250514", + getOriginalModel: () => "claude-sonnet-4-20250514", + isModelRedirected: () => false, + getProviderChain: () => [ + { + id: 1, + name: "anthropic-main", + providerType: "claude", + reason: "initial_selection", + timestamp: startTime + 2, + }, + ], + getSpecialSettings: () => null, + getCacheTtlResolved: () => null, + getContext1mApplied: () => false, + ...overrides, + } as any; +} + +describe("traceProxyRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + langfuseEnabled = true; + setupDefaultStartObservation(); + }); + + test("should not trace when Langfuse is disabled", async () => { + langfuseEnabled = false; + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + await traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + }); + + expect(mockStartObservation).not.toHaveBeenCalled(); + }); + + test("should trace when Langfuse is enabled with actual bodies", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + const responseBody = { content: "Hi there" }; + + await traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers({ "content-type": "application/json" }), + durationMs: 500, + statusCode: 200, + isStreaming: false, + responseText: JSON.stringify(responseBody), + }); + + // Root span should have actual request body as input (not summary) + const rootCall = mockStartObservation.mock.calls[0]; + expect(rootCall[0]).toBe("proxy-request"); + // Input should be the actual request message (since forwardedRequestBody is null) + expect(rootCall[1].input).toEqual( + expect.objectContaining({ + model: "claude-sonnet-4-20250514", + messages: expect.any(Array), + }) + ); + // Output should be actual response body + expect(rootCall[1].output).toEqual(responseBody); + // Should have level + expect(rootCall[1].level).toBe("DEFAULT"); + // Should have metadata with former summaries + expect(rootCall[1].metadata).toEqual( + expect.objectContaining({ + endpoint: "/v1/messages", + method: "POST", + statusCode: 200, + durationMs: 500, + }) + ); + + // Should have child observations + const callNames = mockRootSpan.startObservation.mock.calls.map((c: unknown[]) => c[0]); + expect(callNames).toContain("guard-pipeline"); + expect(callNames).toContain("llm-call"); + + expect(mockSpanEnd).toHaveBeenCalledWith(expect.any(Date)); + expect(mockGenerationEnd).toHaveBeenCalledWith(expect.any(Date)); + }); + + test("should use actual request messages as generation input", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + const session = createMockSession(); + + await traceProxyRequest({ + session, + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + responseText: '{"content": "response"}', + }); + + // Find the llm-call invocation + const llmCall = mockRootSpan.startObservation.mock.calls.find( + (c: unknown[]) => c[0] === "llm-call" + ); + expect(llmCall).toBeDefined(); + expect(llmCall[1].input).toEqual(session.request.message); + }); + + test("should use actual response body as generation output", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + const responseBody = { content: [{ type: "text", text: "Hello!" }] }; + + await traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + responseText: JSON.stringify(responseBody), + }); + + const llmCall = mockRootSpan.startObservation.mock.calls.find( + (c: unknown[]) => c[0] === "llm-call" + ); + expect(llmCall[1].output).toEqual(responseBody); + }); + + test("should pass raw headers without redaction", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + await traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers({ "x-api-key": "secret-mock" }), + durationMs: 500, + statusCode: 200, + isStreaming: false, + }); + + const llmCall = mockRootSpan.startObservation.mock.calls.find( + (c: unknown[]) => c[0] === "llm-call" + ); + const metadata = llmCall[1].metadata; + expect(metadata.requestHeaders["x-api-key"]).toBe("test-mock-key-not-real"); + expect(metadata.requestHeaders["content-type"]).toBe("application/json"); + expect(metadata.responseHeaders["x-api-key"]).toBe("secret-mock"); + }); + + test("should include provider name and model in tags", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + await traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + }); + + expect(mockPropagateAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "testuser", + sessionId: "sess_abc12345_def67890", + tags: expect.arrayContaining([ + "claude", + "anthropic-main", + "claude-sonnet-4-20250514", + "2xx", + ]), + }) + ); + }); + + test("should include usage details when provided", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + await traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + usageMetrics: { + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 20, + }, + costUsd: "0.0015", + }); + + const llmCall = mockRootSpan.startObservation.mock.calls.find( + (c: unknown[]) => c[0] === "llm-call" + ); + expect(llmCall[1].usageDetails).toEqual({ + input: 100, + output: 50, + cache_read_input_tokens: 20, + }); + expect(llmCall[1].costDetails).toEqual({ + total: 0.0015, + }); + }); + + test("should include providerChain, specialSettings, and model in metadata", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + const providerChain = [ + { + id: 1, + name: "anthropic-main", + providerType: "claude", + reason: "initial_selection", + timestamp: Date.now(), + }, + ]; + + await traceProxyRequest({ + session: createMockSession({ + getSpecialSettings: () => ({ maxThinking: 8192 }), + getProviderChain: () => providerChain, + }), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + }); + + const llmCall = mockRootSpan.startObservation.mock.calls.find( + (c: unknown[]) => c[0] === "llm-call" + ); + const metadata = llmCall[1].metadata; + expect(metadata.providerChain).toEqual(providerChain); + expect(metadata.specialSettings).toEqual({ maxThinking: 8192 }); + expect(metadata.model).toBe("claude-sonnet-4-20250514"); + expect(metadata.originalModel).toBe("claude-sonnet-4-20250514"); + expect(metadata.providerName).toBe("anthropic-main"); + expect(metadata.requestSummary).toEqual( + expect.objectContaining({ + model: "claude-sonnet-4-20250514", + messageCount: 1, + }) + ); + }); + + test("should handle model redirect metadata", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + await traceProxyRequest({ + session: createMockSession({ + isModelRedirected: () => true, + getOriginalModel: () => "claude-sonnet-4-20250514", + getCurrentModel: () => "glm-4", + request: { + message: { model: "glm-4", messages: [] }, + model: "glm-4", + }, + }), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + }); + + const llmCall = mockRootSpan.startObservation.mock.calls.find( + (c: unknown[]) => c[0] === "llm-call" + ); + expect(llmCall[1].metadata.modelRedirected).toBe(true); + expect(llmCall[1].metadata.originalModel).toBe("claude-sonnet-4-20250514"); + }); + + test("should set completionStartTime from ttfbMs", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + const startTime = Date.now() - 500; + await traceProxyRequest({ + session: createMockSession({ startTime, ttfbMs: 200 }), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + }); + + expect(mockGenerationUpdate).toHaveBeenCalledWith({ + completionStartTime: new Date(startTime + 200), + }); + }); + + test("should pass correct startTime and endTime to observations", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + const startTime = 1700000000000; + const durationMs = 5000; + + await traceProxyRequest({ + session: createMockSession({ startTime, forwardStartTime: startTime + 5 }), + responseHeaders: new Headers(), + durationMs, + statusCode: 200, + isStreaming: false, + }); + + const expectedStart = new Date(startTime); + const expectedEnd = new Date(startTime + durationMs); + const expectedForwardStart = new Date(startTime + 5); + + // Root span gets startTime in options (3rd arg) + expect(mockStartObservation).toHaveBeenCalledWith("proxy-request", expect.any(Object), { + startTime: expectedStart, + }); + + // Generation gets forwardStartTime in options (3rd arg) + const llmCall = mockRootSpan.startObservation.mock.calls.find( + (c: unknown[]) => c[0] === "llm-call" + ); + expect(llmCall[2]).toEqual({ + asType: "generation", + startTime: expectedForwardStart, + }); + + // Both end() calls receive the computed endTime + expect(mockGenerationEnd).toHaveBeenCalledWith(expectedEnd); + expect(mockSpanEnd).toHaveBeenCalledWith(expectedEnd); + }); + + test("should handle errors gracefully without throwing", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + // Make startObservation throw + mockStartObservation.mockImplementationOnce(() => { + throw new Error("SDK error"); + }); + + await expect( + traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + }) + ).resolves.toBeUndefined(); + }); + + test("should include correct tags for error responses", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + await traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 502, + isStreaming: false, + errorMessage: "upstream error", + }); + + expect(mockPropagateAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + tags: expect.arrayContaining(["5xx"]), + }) + ); + }); + + test("should pass large input/output without truncation", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + // Generate a large response text + const largeContent = "x".repeat(200_000); + + await traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + responseText: largeContent, + }); + + const llmCall = mockRootSpan.startObservation.mock.calls.find( + (c: unknown[]) => c[0] === "llm-call" + ); + const output = llmCall[1].output as string; + // Should be the full content, no truncation + expect(output).toBe(largeContent); + expect(output).not.toContain("...[truncated]"); + }); + + test("should show streaming output with sseEventCount when no responseText", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + await traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: true, + sseEventCount: 42, + }); + + const llmCall = mockRootSpan.startObservation.mock.calls.find( + (c: unknown[]) => c[0] === "llm-call" + ); + expect(llmCall[1].output).toEqual({ + streaming: true, + sseEventCount: 42, + }); + }); + + test("should include costUsd in root span metadata", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + await traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + costUsd: "0.05", + }); + + const rootCall = mockStartObservation.mock.calls[0]; + expect(rootCall[1].metadata).toEqual( + expect.objectContaining({ + costUsd: "0.05", + }) + ); + }); + + test("should set trace-level input/output via updateTrace with actual bodies", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + const responseBody = { result: "ok" }; + + await traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + responseText: JSON.stringify(responseBody), + costUsd: "0.05", + }); + + expect(mockUpdateTrace).toHaveBeenCalledWith({ + input: expect.objectContaining({ + model: "claude-sonnet-4-20250514", + messages: expect.any(Array), + }), + output: responseBody, + }); + }); + + // --- New tests for multi-span hierarchy --- + + test("should create guard-pipeline span with correct timing", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + const startTime = 1700000000000; + const forwardStartTime = startTime + 8; // 8ms guard pipeline + + await traceProxyRequest({ + session: createMockSession({ startTime, forwardStartTime }), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + }); + + const guardCall = mockRootSpan.startObservation.mock.calls.find( + (c: unknown[]) => c[0] === "guard-pipeline" + ); + expect(guardCall).toBeDefined(); + expect(guardCall[1]).toEqual({ + output: { durationMs: 8, passed: true }, + }); + expect(guardCall[2]).toEqual({ startTime: new Date(startTime) }); + + // Guard span should end at forwardStartTime + expect(mockGuardSpanEnd).toHaveBeenCalledWith(new Date(forwardStartTime)); + }); + + test("should skip guard-pipeline span when forwardStartTime is null", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + await traceProxyRequest({ + session: createMockSession({ forwardStartTime: null }), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + }); + + const guardCall = mockRootSpan.startObservation.mock.calls.find( + (c: unknown[]) => c[0] === "guard-pipeline" + ); + expect(guardCall).toBeUndefined(); + expect(mockGuardSpanEnd).not.toHaveBeenCalled(); + }); + + test("should create provider-attempt events for failed chain items", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + const startTime = 1700000000000; + const failTimestamp = startTime + 100; + + await traceProxyRequest({ + session: createMockSession({ + startTime, + getProviderChain: () => [ + { + id: 1, + name: "provider-a", + providerType: "claude", + reason: "retry_failed", + errorMessage: "502 Bad Gateway", + statusCode: 502, + attemptNumber: 1, + timestamp: failTimestamp, + }, + { + id: 2, + name: "provider-b", + providerType: "claude", + reason: "system_error", + errorMessage: "ECONNREFUSED", + timestamp: failTimestamp + 50, + }, + { + id: 3, + name: "provider-c", + providerType: "claude", + reason: "request_success", + timestamp: failTimestamp + 200, + }, + ], + }), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + }); + + const eventCalls = mockRootSpan.startObservation.mock.calls.filter( + (c: unknown[]) => c[0] === "provider-attempt" + ); + // 2 failed items (retry_failed + system_error), success is skipped + expect(eventCalls).toHaveLength(2); + + // First event: retry_failed -> WARNING level + expect(eventCalls[0][1]).toEqual( + expect.objectContaining({ + level: "WARNING", + input: expect.objectContaining({ + providerId: 1, + providerName: "provider-a", + attempt: 1, + }), + output: expect.objectContaining({ + reason: "retry_failed", + errorMessage: "502 Bad Gateway", + statusCode: 502, + }), + }) + ); + expect(eventCalls[0][2]).toEqual({ + asType: "event", + startTime: new Date(failTimestamp), + }); + + // Second event: system_error -> ERROR level + expect(eventCalls[1][1].level).toBe("ERROR"); + expect(eventCalls[1][1].output.reason).toBe("system_error"); + }); + + test("should set generation startTime to forwardStartTime", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + const startTime = 1700000000000; + const forwardStartTime = startTime + 10; + + await traceProxyRequest({ + session: createMockSession({ startTime, forwardStartTime }), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + }); + + const llmCall = mockRootSpan.startObservation.mock.calls.find( + (c: unknown[]) => c[0] === "llm-call" + ); + expect(llmCall[2]).toEqual({ + asType: "generation", + startTime: new Date(forwardStartTime), + }); + }); + + test("should fall back to requestStartTime when forwardStartTime is null", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + const startTime = 1700000000000; + + await traceProxyRequest({ + session: createMockSession({ startTime, forwardStartTime: null }), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + }); + + const llmCall = mockRootSpan.startObservation.mock.calls.find( + (c: unknown[]) => c[0] === "llm-call" + ); + expect(llmCall[2]).toEqual({ + asType: "generation", + startTime: new Date(startTime), + }); + }); + + test("should include timingBreakdown in root span metadata and generation metadata", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + const startTime = 1700000000000; + const forwardStartTime = startTime + 5; + + await traceProxyRequest({ + session: createMockSession({ + startTime, + forwardStartTime, + ttfbMs: 105, + getProviderChain: () => [ + { id: 1, name: "p1", reason: "retry_failed", timestamp: startTime + 50 }, + { id: 2, name: "p2", reason: "request_success", timestamp: startTime + 100 }, + ], + }), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + }); + + const expectedTimingBreakdown = { + guardPipelineMs: 5, + upstreamTotalMs: 495, + ttfbFromForwardMs: 100, // ttfbMs(105) - guardPipelineMs(5) + tokenGenerationMs: 395, // durationMs(500) - ttfbMs(105) + failedAttempts: 1, // only retry_failed is non-success + providersAttempted: 2, // 2 unique provider ids + }; + + // Root span metadata should have timingBreakdown + const rootCall = mockStartObservation.mock.calls[0]; + expect(rootCall[1].metadata.timingBreakdown).toEqual(expectedTimingBreakdown); + + // Generation metadata should also have timingBreakdown + const llmCall = mockRootSpan.startObservation.mock.calls.find( + (c: unknown[]) => c[0] === "llm-call" + ); + expect(llmCall[1].metadata.timingBreakdown).toEqual(expectedTimingBreakdown); + }); + + test("should not create provider-attempt events when all providers succeeded", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + await traceProxyRequest({ + session: createMockSession({ + getProviderChain: () => [ + { id: 1, name: "p1", reason: "initial_selection", timestamp: Date.now() }, + { id: 1, name: "p1", reason: "request_success", timestamp: Date.now() }, + ], + }), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + }); + + const eventCalls = mockRootSpan.startObservation.mock.calls.filter( + (c: unknown[]) => c[0] === "provider-attempt" + ); + expect(eventCalls).toHaveLength(0); + }); + + // --- New tests for input/output, level, and cost breakdown --- + + test("should use forwardedRequestBody as trace input when available", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + const forwardedBody = JSON.stringify({ + model: "claude-sonnet-4-20250514", + messages: [{ role: "user", content: "Preprocessed Hello" }], + stream: true, + }); + + await traceProxyRequest({ + session: createMockSession({ + forwardedRequestBody: forwardedBody, + }), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + responseText: '{"ok": true}', + }); + + // Root span input should be the forwarded body (parsed JSON) + const rootCall = mockStartObservation.mock.calls[0]; + expect(rootCall[1].input).toEqual(JSON.parse(forwardedBody)); + + // updateTrace should also use forwarded body + expect(mockUpdateTrace).toHaveBeenCalledWith({ + input: JSON.parse(forwardedBody), + output: { ok: true }, + }); + }); + + test("should set root span level to DEFAULT for successful request", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + await traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + }); + + const rootCall = mockStartObservation.mock.calls[0]; + expect(rootCall[1].level).toBe("DEFAULT"); + }); + + test("should set root span level to WARNING when retries occurred", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + const startTime = Date.now() - 500; + await traceProxyRequest({ + session: createMockSession({ + startTime, + getProviderChain: () => [ + { id: 1, name: "p1", reason: "retry_failed", timestamp: startTime + 50 }, + { id: 2, name: "p2", reason: "request_success", timestamp: startTime + 200 }, + ], + }), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + }); + + const rootCall = mockStartObservation.mock.calls[0]; + expect(rootCall[1].level).toBe("WARNING"); + }); + + test("should set root span level to ERROR for non-200 status", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + await traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 502, + isStreaming: false, + }); + + const rootCall = mockStartObservation.mock.calls[0]; + expect(rootCall[1].level).toBe("ERROR"); + }); + + test("should set root span level to ERROR for 499 client abort", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + await traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 499, + isStreaming: false, + }); + + const rootCall = mockStartObservation.mock.calls[0]; + expect(rootCall[1].level).toBe("ERROR"); + }); + + test("should include cost breakdown in costDetails when provided", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + const costBreakdown = { + input: 0.001, + output: 0.002, + cache_creation: 0.0005, + cache_read: 0.0001, + total: 0.0036, + }; + + await traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + costUsd: "0.0036", + costBreakdown, + }); + + const llmCall = mockRootSpan.startObservation.mock.calls.find( + (c: unknown[]) => c[0] === "llm-call" + ); + expect(llmCall[1].costDetails).toEqual(costBreakdown); + }); + + test("should fallback to total-only costDetails when no breakdown", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + await traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + costUsd: "0.05", + }); + + const llmCall = mockRootSpan.startObservation.mock.calls.find( + (c: unknown[]) => c[0] === "llm-call" + ); + expect(llmCall[1].costDetails).toEqual({ total: 0.05 }); + }); + + test("should include former summaries in root span metadata", async () => { + const { traceProxyRequest } = await import("@/lib/langfuse/trace-proxy-request"); + + await traceProxyRequest({ + session: createMockSession(), + responseHeaders: new Headers(), + durationMs: 500, + statusCode: 200, + isStreaming: false, + costUsd: "0.05", + }); + + const rootCall = mockStartObservation.mock.calls[0]; + const metadata = rootCall[1].metadata; + // Former input summary fields + expect(metadata.endpoint).toBe("/v1/messages"); + expect(metadata.method).toBe("POST"); + expect(metadata.model).toBe("claude-sonnet-4-20250514"); + expect(metadata.clientFormat).toBe("claude"); + expect(metadata.providerName).toBe("anthropic-main"); + // Former output summary fields + expect(metadata.statusCode).toBe(200); + expect(metadata.durationMs).toBe(500); + expect(metadata.costUsd).toBe("0.05"); + expect(metadata.timingBreakdown).toBeDefined(); + }); +}); + +describe("isLangfuseEnabled", () => { + const originalPublicKey = process.env.LANGFUSE_PUBLIC_KEY; + const originalSecretKey = process.env.LANGFUSE_SECRET_KEY; + + afterEach(() => { + // Restore env + if (originalPublicKey !== undefined) { + process.env.LANGFUSE_PUBLIC_KEY = originalPublicKey; + } else { + delete process.env.LANGFUSE_PUBLIC_KEY; + } + if (originalSecretKey !== undefined) { + process.env.LANGFUSE_SECRET_KEY = originalSecretKey; + } else { + delete process.env.LANGFUSE_SECRET_KEY; + } + }); + + test("should return false when env vars are not set", () => { + delete process.env.LANGFUSE_PUBLIC_KEY; + delete process.env.LANGFUSE_SECRET_KEY; + + // Direct function test (not using the mock) + const isEnabled = !!(process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY); + expect(isEnabled).toBe(false); + }); + + test("should return true when both keys are set", () => { + process.env.LANGFUSE_PUBLIC_KEY = "pk-lf-test-mock"; + process.env.LANGFUSE_SECRET_KEY = "test-mock-not-real"; + + const isEnabled = !!(process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY); + expect(isEnabled).toBe(true); + }); +}); diff --git a/tests/unit/lib/cost-calculation-breakdown.test.ts b/tests/unit/lib/cost-calculation-breakdown.test.ts new file mode 100644 index 000000000..b8589ffb9 --- /dev/null +++ b/tests/unit/lib/cost-calculation-breakdown.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from "vitest"; +import { calculateRequestCostBreakdown, type CostBreakdown } from "@/lib/utils/cost-calculation"; +import type { ModelPriceData } from "@/types/model-price"; + +function makePriceData(overrides: Partial = {}): ModelPriceData { + return { + input_cost_per_token: 0.000003, // $3/MTok + output_cost_per_token: 0.000015, // $15/MTok + cache_creation_input_token_cost: 0.00000375, // 1.25x input + cache_read_input_token_cost: 0.0000003, // 0.1x input + ...overrides, + }; +} + +describe("calculateRequestCostBreakdown", () => { + test("basic input + output tokens", () => { + const result = calculateRequestCostBreakdown( + { input_tokens: 1000, output_tokens: 500 }, + makePriceData() + ); + + expect(result.input).toBeCloseTo(0.003, 6); // 1000 * 0.000003 + expect(result.output).toBeCloseTo(0.0075, 6); // 500 * 0.000015 + expect(result.cache_creation).toBe(0); + expect(result.cache_read).toBe(0); + expect(result.total).toBeCloseTo(0.0105, 6); + }); + + test("cache creation (5m + 1h) + cache read", () => { + const result = calculateRequestCostBreakdown( + { + input_tokens: 100, + output_tokens: 50, + cache_creation_5m_input_tokens: 200, + cache_creation_1h_input_tokens: 300, + cache_read_input_tokens: 1000, + }, + makePriceData({ + cache_creation_input_token_cost_above_1hr: 0.000006, // 2x input + }) + ); + + // cache_creation = 200 * 0.00000375 + 300 * 0.000006 + expect(result.cache_creation).toBeCloseTo(0.00255, 6); + // cache_read = 1000 * 0.0000003 + expect(result.cache_read).toBeCloseTo(0.0003, 6); + expect(result.total).toBeCloseTo( + result.input + result.output + result.cache_creation + result.cache_read, + 10 + ); + }); + + test("image tokens go to input/output buckets", () => { + const result = calculateRequestCostBreakdown( + { + input_tokens: 100, + output_tokens: 50, + input_image_tokens: 500, + output_image_tokens: 200, + }, + makePriceData({ + input_cost_per_image_token: 0.00001, + output_cost_per_image_token: 0.00005, + }) + ); + + // input = 100 * 0.000003 + 500 * 0.00001 + expect(result.input).toBeCloseTo(0.0053, 6); + // output = 50 * 0.000015 + 200 * 0.00005 + expect(result.output).toBeCloseTo(0.01075, 6); + }); + + test("tiered pricing with context1mApplied", () => { + const result = calculateRequestCostBreakdown( + { + input_tokens: 300000, // crosses 200k threshold + output_tokens: 100, + }, + makePriceData(), + true // context1mApplied + ); + + // input: 200000 * 0.000003 + 100000 * 0.000003 * 2.0 = 0.6 + 0.6 = 1.2 + expect(result.input).toBeCloseTo(1.2, 4); + // output: 100 tokens, below 200k threshold + expect(result.output).toBeCloseTo(0.0015, 6); + }); + + test("200k tier pricing (Gemini style)", () => { + const result = calculateRequestCostBreakdown( + { + input_tokens: 300000, // crosses 200k threshold + output_tokens: 100, + }, + makePriceData({ + input_cost_per_token_above_200k_tokens: 0.000006, // 2x base for >200k + }) + ); + + // input: 200000 * 0.000003 + 100000 * 0.000006 = 0.6 + 0.6 = 1.2 + expect(result.input).toBeCloseTo(1.2, 4); + }); + + test("categories sum to total", () => { + const result = calculateRequestCostBreakdown( + { + input_tokens: 5000, + output_tokens: 2000, + cache_creation_input_tokens: 1000, + cache_read_input_tokens: 3000, + }, + makePriceData() + ); + + const sum = result.input + result.output + result.cache_creation + result.cache_read; + expect(result.total).toBeCloseTo(sum, 10); + }); + + test("zero usage returns all zeros", () => { + const result = calculateRequestCostBreakdown({}, makePriceData()); + + expect(result).toEqual({ + input: 0, + output: 0, + cache_creation: 0, + cache_read: 0, + total: 0, + }); + }); + + test("per-request cost goes to input bucket", () => { + const result = calculateRequestCostBreakdown( + { input_tokens: 0 }, + makePriceData({ input_cost_per_request: 0.01 }) + ); + + expect(result.input).toBeCloseTo(0.01, 6); + expect(result.total).toBeCloseTo(0.01, 6); + }); + + test("cache_creation_input_tokens distributed by cache_ttl", () => { + // When only cache_creation_input_tokens is set (no 5m/1h split), + // it should be assigned based on cache_ttl + const result = calculateRequestCostBreakdown( + { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 1000, + cache_ttl: "1h", + }, + makePriceData({ + cache_creation_input_token_cost_above_1hr: 0.000006, + }) + ); + + // 1000 tokens should go to 1h tier at 0.000006 + expect(result.cache_creation).toBeCloseTo(0.006, 6); + }); +}); diff --git a/tests/unit/proxy/proxy-handler-session-id-error.test.ts b/tests/unit/proxy/proxy-handler-session-id-error.test.ts index d336e0dc4..18062cc79 100644 --- a/tests/unit/proxy/proxy-handler-session-id-error.test.ts +++ b/tests/unit/proxy/proxy-handler-session-id-error.test.ts @@ -13,6 +13,7 @@ const h = vi.hoisted(() => ({ }, isCountTokensRequest: () => false, setOriginalFormat: () => {}, + recordForwardStart: () => {}, messageContext: null, provider: null, } as any, From aacaae47f2580254d52ccfed271b88f00319ece4 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Mon, 16 Feb 2026 01:03:33 +0800 Subject: [PATCH 02/75] fix(logs): improve fake 200 error logs (#790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(logs): improve fake 200 error logs (#765) * fix(proxy): 识别 200+HTML 假200并触发故障转移 * fix(utils): 收紧 HTML 文档识别避免误判 * fix(proxy): 非流式假200补齐强信号 JSON error 检测 * fix(utils): 假200检测兼容 BOM * perf(proxy): 降低非流式嗅探读取上限 * fix(proxy): 客户端隐藏 FAKE_200_* 内部码 * fix(logs): 补齐 endpoint_pool_exhausted/404 错因展示 - endpoint_pool_exhausted 写入 attemptNumber,避免被 initial_selection/session_reuse 去重吞掉\n- 决策链/技术时间线补齐 resource_not_found 的失败态与说明\n- 更新 provider-chain i18n 文案并新增单测覆盖 * fix(proxy): 非流式 JSON 假200检测覆盖 Content-Length - 对 application/json 且 Content-Length<=32KiB 的 2xx 响应也做强信号嗅探\n- 补齐 200+JSON error(带 Content-Length)触发故障转移的回归测试 * chore: format code (fix-issue-749-fake-200-html-detection-005fad3) * fix(i18n): 修正 ru 端点池耗尽文案 - 修正俄语中 endpoint 的复数属格拼写(конечных точек)\n- 不影响 key,仅更新展示文案 * test(formatter): 补齐 resource_not_found 组合场景覆盖 - 覆盖 resource_not_found + retry_success 多供应商链路\n- 覆盖缺少 errorDetails.provider 的降级渲染路径 * fix(proxy): FAKE_200 客户端提示附带脱敏片段 * fix: 改进 FAKE_200 错误原因提示 * fix(proxy): verboseProviderError 回传假200原文 - fake-200/空响应:verboseProviderError 开启时在 error.details 返回详细报告与上游原文(不落库)\n- forwarder: 将检测到的原文片段挂到 ProxyError.upstreamError.rawBody\n- tests: 覆盖 verbose details 与 rawBody 透传 * fix(proxy): 强化 Content-Length 校验与假200片段防泄露 - forwarder: 将非法 Content-Length 视为无效,避免漏检 HTML/空响应\n- errors: FAKE_200 客户端 detail 二次截断 + 轻量脱敏(防御性)\n- tests: 覆盖非法 Content-Length 漏检回归 * docs(proxy): 说明非流式假200检测上限 * docs(settings): 补充 verboseProviderError 安全提示 * fix(proxy): verboseProviderError rawBody 基础脱敏 * chore: format code (fix-issue-749-fake-200-html-detection-b56b790) * docs(settings): 说明 verboseProviderError 基础脱敏 * fix(proxy/logs): 假200 推断状态码并显著标记 * fix(i18n): 回退 verboseProviderErrorDesc 原始文案 * fix(stream): 404 资源不存在不计入熔断 --------- Co-authored-by: tesgth032 Co-authored-by: github-actions[bot] Co-authored-by: Ding <44717411+ding113@users.noreply.github.com> * fix: add missing import for inferUpstreamErrorStatusCodeFromText The function inferUpstreamErrorStatusCodeFromText was used in response-handler.ts but was not imported, causing a TypeScript compilation error during build. Fixed: - Added inferUpstreamErrorStatusCodeFromText to imports from @/lib/utils/upstream-error-detection CI Run: https://github.com/ding113/claude-code-hub/actions/runs/22033028838 * fix(proxy): deduplicate getFake200ReasonKey and strengthen client-facing sanitization Extract duplicated getFake200ReasonKey() from SummaryTab and ProviderChainPopover into a shared fake200-reason.ts utility, eliminating the risk of silent drift when new FAKE_200_* codes are added. Replace the 3-pattern manual sanitization in getClientSafeMessage() with the existing sanitizeErrorTextForDetail() (6 patterns), closing a gap where JWT tokens, emails, and password/config paths could leak to clients via the FAKE_200 error detail path. Add unit tests verifying JWT, email, and password sanitization. * fix(proxy): address bugbot review comments on fake-200 error handling - Add i18n for HTTP status prefix in LogicTraceTab (5 languages) - Wrap verbose details gathering in try-catch to prevent cascading failures - Truncate rawBody to 4096 chars before sanitization in error-handler - Tighten not_found regex to require contextual prefixes, preventing false 404 inference - Add debug logging to silent catch blocks in readResponseTextUpTo - Add test assertion for fake200DetectedReason display --------- Co-authored-by: tesgth032 Co-authored-by: tesgth032 Co-authored-by: github-actions[bot] Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- messages/en/dashboard.json | 13 + messages/en/provider-chain.json | 5 + messages/ja/dashboard.json | 13 + messages/ja/provider-chain.json | 5 + messages/ru/dashboard.json | 13 + messages/ru/provider-chain.json | 5 + messages/zh-CN/dashboard.json | 13 + messages/zh-CN/provider-chain.json | 5 + messages/zh-TW/dashboard.json | 13 + messages/zh-TW/provider-chain.json | 5 + .../_components/error-details-dialog.test.tsx | 11 + .../components/LogicTraceTab.tsx | 16 +- .../components/SummaryTab.tsx | 10 + .../logs/_components/fake200-reason.ts | 15 + .../provider-chain-popover.test.tsx | 31 + .../_components/provider-chain-popover.tsx | 92 ++- src/app/v1/_lib/proxy/error-handler.ts | 65 +- src/app/v1/_lib/proxy/errors.ts | 82 ++- src/app/v1/_lib/proxy/forwarder.ts | 224 ++++++- src/app/v1/_lib/proxy/response-handler.ts | 89 ++- src/app/v1/_lib/proxy/session.ts | 2 + .../utils/provider-chain-formatter.test.ts | 107 ++++ src/lib/utils/provider-chain-formatter.ts | 63 +- .../utils/upstream-error-detection.test.ts | 108 +++- src/lib/utils/upstream-error-detection.ts | 178 +++++- src/types/message.ts | 9 + ...ler-verbose-provider-error-details.test.ts | 184 ++++++ .../proxy-forwarder-endpoint-audit.test.ts | 53 ++ .../proxy-forwarder-fake-200-html.test.ts | 573 ++++++++++++++++++ ...handler-endpoint-circuit-isolation.test.ts | 49 +- 30 files changed, 1973 insertions(+), 78 deletions(-) create mode 100644 src/app/[locale]/dashboard/logs/_components/fake200-reason.ts create mode 100644 tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts create mode 100644 tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 6256e867e..ece1301d6 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -244,6 +244,18 @@ }, "errorMessage": "Error Message", "fake200ForwardedNotice": "Note: For streaming requests, this failure may be detected only after the stream ends; the response content may already have been forwarded to the client.", + "fake200DetectedReason": "Detected reason: {reason}", + "fake200Reasons": { + "emptyBody": "Empty response body", + "htmlBody": "HTML document returned (likely an error page)", + "jsonErrorNonEmpty": "JSON has a non-empty `error` field", + "jsonErrorMessageNonEmpty": "JSON has a non-empty `error.message`", + "jsonMessageKeywordMatch": "JSON `message` contains the word \"error\" (heuristic)", + "unknown": "Response body indicates an error" + }, + "statusCodeInferredBadge": "Inferred", + "statusCodeInferredTooltip": "This status code is inferred from response body content (e.g., fake 200) and may differ from the upstream HTTP status.", + "statusCodeInferredSuffix": "(inferred)", "filteredProviders": "Filtered Providers", "providerChain": { "title": "Provider Decision Chain Timeline", @@ -315,6 +327,7 @@ "prioritySelection": "Priority Selection", "attemptProvider": "Attempt: {provider}", "retryAttempt": "Retry #{number}", + "httpStatus": "HTTP {code}{inferredSuffix}", "sessionReuse": "Session Reuse", "sessionReuseDesc": "Reusing provider from session cache", "sessionReuseTitle": "Session Binding", diff --git a/messages/en/provider-chain.json b/messages/en/provider-chain.json index e8f6678a5..bf9cb81a7 100644 --- a/messages/en/provider-chain.json +++ b/messages/en/provider-chain.json @@ -35,6 +35,7 @@ "candidate": "{name}({probability}%)", "requestChain": "Request Chain:", "systemError": "System Error", + "resourceNotFound": "Resource Not Found (404)", "concurrentLimit": "Concurrent Limit", "http2Fallback": "HTTP/2 Fallback", "clientError": "Client Error", @@ -46,6 +47,7 @@ "retry_success": "Retry Success", "retry_failed": "Retry Failed", "system_error": "System Error", + "resource_not_found": "Resource Not Found (404)", "client_error_non_retryable": "Client Error", "concurrent_limit_failed": "Concurrent Limit", "http2_fallback": "HTTP/2 Fallback", @@ -128,11 +130,13 @@ "candidateInfo": " • {name}: weight={weight} cost={cost} probability={probability}%", "selected": "✓ Selected: {provider}", "requestFailed": "Request Failed (Attempt {attempt})", + "resourceNotFoundFailed": "Resource Not Found (404) (Attempt {attempt})", "attemptNumber": "Attempt {number}", "firstAttempt": "First Attempt", "nthAttempt": "Attempt {attempt}", "provider": "Provider: {provider}", "statusCode": "Status Code: {code}", + "statusCodeInferred": "Status Code (inferred): {code}", "error": "Error: {error}", "requestDuration": "Request Duration: {duration}ms", "requestDurationSeconds": "Request Duration: {duration}s", @@ -158,6 +162,7 @@ "meaning": "Meaning", "notCountedInCircuit": "This error is not counted in provider circuit breaker", "systemErrorNote": "Note: This error is not counted in provider circuit breaker", + "resourceNotFoundNote": "Note: This error is not counted in the circuit breaker and will trigger failover after retries are exhausted.", "reselection": "Reselecting Provider", "reselect": "Reselecting Provider", "excluded": "Excluded: {providers}", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 3a4aae78d..a609b1c13 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -244,6 +244,18 @@ }, "errorMessage": "エラーメッセージ", "fake200ForwardedNotice": "注意:ストリーミング要求では、失敗判定がストリーム終了後になる場合があります。応答内容は既にクライアントへ転送されている可能性があります。", + "fake200DetectedReason": "検出理由:{reason}", + "fake200Reasons": { + "emptyBody": "レスポンス本文が空です", + "htmlBody": "HTML ドキュメントが返されました (エラーページの可能性)", + "jsonErrorNonEmpty": "JSON の `error` フィールドが空ではありません", + "jsonErrorMessageNonEmpty": "JSON の `error.message` が空ではありません", + "jsonMessageKeywordMatch": "JSON の `message` に \"error\" が含まれています (ヒューリスティック)", + "unknown": "レスポンス本文がエラーを示しています" + }, + "statusCodeInferredBadge": "推定", + "statusCodeInferredTooltip": "このステータスコードは応答本文の内容(例: fake 200)から推定されており、上流の HTTP ステータスと異なる場合があります。", + "statusCodeInferredSuffix": "(推定)", "filteredProviders": "フィルタされたプロバイダー", "providerChain": { "title": "プロバイダー決定チェーンタイムライン", @@ -315,6 +327,7 @@ "prioritySelection": "優先度選択", "attemptProvider": "試行: {provider}", "retryAttempt": "再試行 #{number}", + "httpStatus": "HTTP {code}{inferredSuffix}", "sessionReuse": "セッション再利用", "sessionReuseDesc": "セッションキャッシュからプロバイダーを再利用", "sessionReuseTitle": "セッションバインディング", diff --git a/messages/ja/provider-chain.json b/messages/ja/provider-chain.json index cf9ebdb78..d8e55285b 100644 --- a/messages/ja/provider-chain.json +++ b/messages/ja/provider-chain.json @@ -35,6 +35,7 @@ "candidate": "{name}({probability}%)", "requestChain": "リクエストチェーン:", "systemError": "システムエラー", + "resourceNotFound": "リソースが見つかりません(404)", "concurrentLimit": "同時実行制限", "http2Fallback": "HTTP/2 フォールバック", "clientError": "クライアントエラー", @@ -46,6 +47,7 @@ "retry_success": "リトライ成功", "retry_failed": "リトライ失敗", "system_error": "システムエラー", + "resource_not_found": "リソースが見つかりません(404)", "client_error_non_retryable": "クライアントエラー", "concurrent_limit_failed": "同時実行制限", "http2_fallback": "HTTP/2 フォールバック", @@ -128,11 +130,13 @@ "candidateInfo": " • {name}: 重み={weight} コスト={cost} 確率={probability}%", "selected": "✓ 選択: {provider}", "requestFailed": "リクエスト失敗(試行{attempt})", + "resourceNotFoundFailed": "リソースが見つかりません(404)(試行{attempt})", "attemptNumber": "試行 {number}", "firstAttempt": "初回試行", "nthAttempt": "試行{attempt}", "provider": "プロバイダー: {provider}", "statusCode": "ステータスコード: {code}", + "statusCodeInferred": "ステータスコード(推定): {code}", "error": "エラー: {error}", "requestDuration": "リクエスト時間: {duration}ms", "requestDurationSeconds": "リクエスト時間: {duration}s", @@ -158,6 +162,7 @@ "meaning": "意味", "notCountedInCircuit": "このエラーはプロバイダーサーキットブレーカーにカウントされません", "systemErrorNote": "注記:このエラーはプロバイダーサーキットブレーカーにカウントされません", + "resourceNotFoundNote": "注記:このエラーはサーキットブレーカーにカウントされず、リトライ枯渇後にフェイルオーバーします。", "reselection": "プロバイダー再選択", "reselect": "プロバイダー再選択", "excluded": "除外済み: {providers}", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 6c46440e6..0204a1430 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -244,6 +244,18 @@ }, "errorMessage": "Сообщение об ошибке", "fake200ForwardedNotice": "Примечание: для потоковых запросов эта ошибка может быть обнаружена только после завершения потока; содержимое ответа могло уже быть передано клиенту.", + "fake200DetectedReason": "Причина обнаружения: {reason}", + "fake200Reasons": { + "emptyBody": "Пустое тело ответа", + "htmlBody": "Получен HTML-документ (возможно, страница ошибки)", + "jsonErrorNonEmpty": "В JSON непустое поле `error`", + "jsonErrorMessageNonEmpty": "В JSON непустое `error.message`", + "jsonMessageKeywordMatch": "В JSON `message` содержит слово \"error\" (эвристика)", + "unknown": "Тело ответа указывает на ошибку" + }, + "statusCodeInferredBadge": "Предположено", + "statusCodeInferredTooltip": "Этот код состояния выведен по содержимому тела ответа (например, fake 200) и может отличаться от HTTP-кода апстрима.", + "statusCodeInferredSuffix": "(предп.)", "filteredProviders": "Отфильтрованные поставщики", "providerChain": { "title": "Хронология цепочки решений поставщика", @@ -315,6 +327,7 @@ "prioritySelection": "Выбор по приоритету", "attemptProvider": "Попытка: {provider}", "retryAttempt": "Повтор #{number}", + "httpStatus": "HTTP {code}{inferredSuffix}", "sessionReuse": "Повторное использование сессии", "sessionReuseDesc": "Провайдер из кэша сессии", "sessionReuseTitle": "Привязка сессии", diff --git a/messages/ru/provider-chain.json b/messages/ru/provider-chain.json index c123208b8..10019665b 100644 --- a/messages/ru/provider-chain.json +++ b/messages/ru/provider-chain.json @@ -35,6 +35,7 @@ "candidate": "{name}({probability}%)", "requestChain": "Цепочка запросов:", "systemError": "Системная ошибка", + "resourceNotFound": "Ресурс не найден (404)", "concurrentLimit": "Лимит параллельных запросов", "http2Fallback": "Откат HTTP/2", "clientError": "Ошибка клиента", @@ -46,6 +47,7 @@ "retry_success": "Повтор успешен", "retry_failed": "Повтор не удался", "system_error": "Системная ошибка", + "resource_not_found": "Ресурс не найден (404)", "client_error_non_retryable": "Ошибка клиента", "concurrent_limit_failed": "Лимит параллельных запросов", "http2_fallback": "Откат HTTP/2", @@ -128,11 +130,13 @@ "candidateInfo": " • {name}: вес={weight} стоимость={cost} вероятность={probability}%", "selected": "✓ Выбрано: {provider}", "requestFailed": "Запрос не выполнен (Попытка {attempt})", + "resourceNotFoundFailed": "Ресурс не найден (404) (Попытка {attempt})", "attemptNumber": "Попытка {number}", "firstAttempt": "Первая попытка", "nthAttempt": "Попытка {attempt}", "provider": "Провайдер: {provider}", "statusCode": "Код состояния: {code}", + "statusCodeInferred": "Код состояния (выведено): {code}", "error": "Ошибка: {error}", "requestDuration": "Длительность запроса: {duration}мс", "requestDurationSeconds": "Длительность запроса: {duration}с", @@ -158,6 +162,7 @@ "meaning": "Значение", "notCountedInCircuit": "Эта ошибка не учитывается в автомате защиты провайдера", "systemErrorNote": "Примечание: Эта ошибка не учитывается в автомате защиты провайдера", + "resourceNotFoundNote": "Примечание: Эта ошибка не учитывается в автомате защиты; после исчерпания повторов произойдёт переключение.", "reselection": "Повторный выбор провайдера", "reselect": "Повторный выбор провайдера", "excluded": "Исключено: {providers}", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index e08a15acf..25354fe2d 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -244,6 +244,18 @@ }, "errorMessage": "错误信息", "fake200ForwardedNotice": "提示:对于流式请求,该失败可能在流结束后才被识别;响应内容可能已原样透传给客户端。", + "fake200DetectedReason": "检测原因:{reason}", + "fake200Reasons": { + "emptyBody": "响应体为空", + "htmlBody": "返回了 HTML 文档(可能是错误页)", + "jsonErrorNonEmpty": "JSON 顶层 error 字段非空", + "jsonErrorMessageNonEmpty": "JSON 中 error.message 非空", + "jsonMessageKeywordMatch": "JSON message 字段包含 \"error\"(启发式)", + "unknown": "响应体内容指示错误" + }, + "statusCodeInferredBadge": "推测", + "statusCodeInferredTooltip": "该状态码根据响应体内容推断(例如假200),可能与上游真实 HTTP 状态码不同。", + "statusCodeInferredSuffix": "(推测)", "filteredProviders": "被过滤的供应商", "providerChain": { "title": "供应商决策链时间线", @@ -315,6 +327,7 @@ "prioritySelection": "优先级选择", "attemptProvider": "尝试: {provider}", "retryAttempt": "重试 #{number}", + "httpStatus": "HTTP {code}{inferredSuffix}", "sessionReuse": "会话复用", "sessionReuseDesc": "从会话缓存复用供应商", "sessionReuseTitle": "会话绑定", diff --git a/messages/zh-CN/provider-chain.json b/messages/zh-CN/provider-chain.json index 8691d9aa1..dfe3daad7 100644 --- a/messages/zh-CN/provider-chain.json +++ b/messages/zh-CN/provider-chain.json @@ -35,6 +35,7 @@ "candidate": "{name}({probability}%)", "requestChain": "请求链路:", "systemError": "系统错误", + "resourceNotFound": "资源不存在(404)", "concurrentLimit": "并发限制", "http2Fallback": "HTTP/2 回退", "clientError": "客户端错误", @@ -46,6 +47,7 @@ "retry_success": "重试成功", "retry_failed": "重试失败", "system_error": "系统错误", + "resource_not_found": "资源不存在(404)", "client_error_non_retryable": "客户端错误", "concurrent_limit_failed": "并发限制", "http2_fallback": "HTTP/2 回退", @@ -128,11 +130,13 @@ "candidateInfo": " • {name}: 权重={weight} 成本={cost} 概率={probability}%", "selected": "✓ 选择: {provider}", "requestFailed": "请求失败(第 {attempt} 次尝试)", + "resourceNotFoundFailed": "资源不存在(404,第 {attempt} 次尝试)", "attemptNumber": "第 {number} 次", "firstAttempt": "首次尝试", "nthAttempt": "第 {attempt} 次尝试", "provider": "供应商: {provider}", "statusCode": "状态码: {code}", + "statusCodeInferred": "状态码(推测): {code}", "error": "错误: {error}", "requestDuration": "请求耗时: {duration}ms", "requestDurationSeconds": "请求耗时: {duration}s", @@ -158,6 +162,7 @@ "meaning": "含义", "notCountedInCircuit": "此错误不计入供应商熔断器", "systemErrorNote": "说明:此错误不计入供应商熔断器", + "resourceNotFoundNote": "说明:该错误不计入熔断器;重试耗尽后将触发故障转移。", "reselection": "重新选择供应商", "reselect": "重新选择供应商", "excluded": "已排除: {providers}", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index a4d2d8fe7..7f30dcc9b 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -244,6 +244,18 @@ }, "errorMessage": "錯誤訊息", "fake200ForwardedNotice": "提示:對於串流請求,此失敗可能在串流結束後才被識別;回應內容可能已原樣透傳給用戶端。", + "fake200DetectedReason": "檢測原因:{reason}", + "fake200Reasons": { + "emptyBody": "回應本文為空", + "htmlBody": "回傳了 HTML 文件(可能是錯誤頁)", + "jsonErrorNonEmpty": "JSON 頂層 error 欄位非空", + "jsonErrorMessageNonEmpty": "JSON 中 error.message 非空", + "jsonMessageKeywordMatch": "JSON message 欄位包含 \"error\"(啟發式)", + "unknown": "回應本文內容顯示錯誤" + }, + "statusCodeInferredBadge": "推測", + "statusCodeInferredTooltip": "此狀態碼係根據回應內容推測(例如假200),可能與上游真實 HTTP 狀態碼不同。", + "statusCodeInferredSuffix": "(推測)", "filteredProviders": "被過濾的供應商", "providerChain": { "title": "供應商決策鏈時間軸", @@ -315,6 +327,7 @@ "prioritySelection": "優先順序選擇", "attemptProvider": "嘗試: {provider}", "retryAttempt": "重試 #{number}", + "httpStatus": "HTTP {code}{inferredSuffix}", "sessionReuse": "會話複用", "sessionReuseDesc": "從會話快取複用供應商", "sessionReuseTitle": "會話綁定", diff --git a/messages/zh-TW/provider-chain.json b/messages/zh-TW/provider-chain.json index 699b37bc6..c9846479c 100644 --- a/messages/zh-TW/provider-chain.json +++ b/messages/zh-TW/provider-chain.json @@ -35,6 +35,7 @@ "candidate": "{name}({probability}%)", "requestChain": "請求鏈路:", "systemError": "系統錯誤", + "resourceNotFound": "資源不存在(404)", "concurrentLimit": "並發限制", "http2Fallback": "HTTP/2 回退", "clientError": "客戶端錯誤", @@ -46,6 +47,7 @@ "retry_success": "重試成功", "retry_failed": "重試失敗", "system_error": "系統錯誤", + "resource_not_found": "資源不存在(404)", "client_error_non_retryable": "客戶端錯誤", "concurrent_limit_failed": "並發限制", "http2_fallback": "HTTP/2 回退", @@ -128,11 +130,13 @@ "candidateInfo": " • {name}: 權重={weight} 成本={cost} 概率={probability}%", "selected": "✓ 選擇: {provider}", "requestFailed": "請求失敗(第 {attempt} 次嘗試)", + "resourceNotFoundFailed": "資源不存在(404,第 {attempt} 次嘗試)", "attemptNumber": "第 {number} 次", "firstAttempt": "首次嘗試", "nthAttempt": "第 {attempt} 次嘗試", "provider": "供應商: {provider}", "statusCode": "狀態碼: {code}", + "statusCodeInferred": "狀態碼(推測): {code}", "error": "錯誤: {error}", "requestDuration": "請求耗時: {duration}ms", "requestDurationSeconds": "請求耗時: {duration}s", @@ -158,6 +162,7 @@ "meaning": "含義", "notCountedInCircuit": "此錯誤不計入供應商熔斷器", "systemErrorNote": "說明:此錯誤不計入供應商熔斷器", + "resourceNotFoundNote": "說明:該錯誤不計入熔斷器;重試耗盡後將觸發故障轉移。", "reselection": "重新選擇供應商", "reselect": "重新選擇供應商", "excluded": "已排除: {providers}", diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx index 9cb749975..8fdcd5f7b 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx @@ -245,6 +245,7 @@ const messages = { prioritySelection: "Priority Selection", attemptProvider: "Attempt: {provider}", retryAttempt: "Retry #{number}", + httpStatus: "HTTP {code}{inferredSuffix}", }, noError: { processing: "No error (processing)", @@ -253,6 +254,15 @@ const messages = { }, errorMessage: "Error message", fake200ForwardedNotice: "Note: detected after stream end; payload may have been forwarded", + fake200DetectedReason: "Detected reason: {reason}", + fake200Reasons: { + emptyBody: "Empty response body", + htmlBody: "HTML document returned", + jsonErrorNonEmpty: "JSON has non-empty error field", + jsonErrorMessageNonEmpty: "JSON has non-empty error.message", + jsonMessageKeywordMatch: 'JSON message contains "error"', + unknown: "Response body indicates an error", + }, viewDetails: "View details", filteredProviders: "Filtered providers", providerChain: { @@ -339,6 +349,7 @@ describe("error-details-dialog layout", () => { expect(html).toContain("FAKE_200_EMPTY_BODY"); expect(html).toContain("Note: detected after stream end; payload may have been forwarded"); + expect(html).toContain("Detected reason: Empty response body"); }); test("renders special settings section when specialSettings exists", () => { diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx index 2a10408ec..b928e8504 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx @@ -39,7 +39,9 @@ function getRequestStatus(item: ProviderChainItem): StepStatus { if ( item.reason === "retry_failed" || item.reason === "system_error" || + item.reason === "resource_not_found" || item.reason === "client_error_non_retryable" || + item.reason === "endpoint_pool_exhausted" || item.reason === "concurrent_limit_failed" ) { return "failure"; @@ -464,10 +466,20 @@ export function LogicTraceTab({ subtitle={ isSessionReuse ? item.statusCode - ? `HTTP ${item.statusCode}` + ? t("logicTrace.httpStatus", { + code: item.statusCode, + inferredSuffix: item.statusCodeInferred + ? ` ${t("statusCodeInferredSuffix")}` + : "", + }) : item.name : item.statusCode - ? `HTTP ${item.statusCode}` + ? t("logicTrace.httpStatus", { + code: item.statusCode, + inferredSuffix: item.statusCodeInferred + ? ` ${t("statusCodeInferredSuffix")}` + : "", + }) : item.reason ? tChain(`reasons.${item.reason}`) : undefined diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx index 3052c02d8..d502d9637 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx @@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button"; import { Link } from "@/i18n/routing"; import { cn, formatTokenAmount } from "@/lib/utils"; import { formatCurrency } from "@/lib/utils/currency"; +import { getFake200ReasonKey } from "../../fake200-reason"; import { calculateOutputRate, isInProgressStatus, @@ -67,6 +68,10 @@ export function SummaryTab({ specialSettings && specialSettings.length > 0 ? JSON.stringify(specialSettings, null, 2) : null; const isFake200PostStreamFailure = typeof errorMessage === "string" && errorMessage.startsWith("FAKE_200_"); + const fake200Reason = + isFake200PostStreamFailure && typeof errorMessage === "string" + ? t(getFake200ReasonKey(errorMessage, "fake200Reasons")) + : null; return (
@@ -426,6 +431,11 @@ export function SummaryTab({

{errorMessage.length > 200 ? `${errorMessage.slice(0, 200)}...` : errorMessage}

+ {isFake200PostStreamFailure && fake200Reason && ( +

+ {t("fake200DetectedReason", { reason: fake200Reason })} +

+ )} {/* 注意:假 200 检测发生在 SSE 流式结束后;此时内容已可能透传给客户端,因此需要提示用户避免误解。 */} {isFake200PostStreamFailure && (
diff --git a/src/app/[locale]/dashboard/logs/_components/fake200-reason.ts b/src/app/[locale]/dashboard/logs/_components/fake200-reason.ts new file mode 100644 index 000000000..9b23e972f --- /dev/null +++ b/src/app/[locale]/dashboard/logs/_components/fake200-reason.ts @@ -0,0 +1,15 @@ +// Shared mapping from internal FAKE_200_* error codes to i18n suffix keys. +// These codes represent: upstream returned 2xx but the body looks like an error page / error JSON. +// UI-only: does not participate in detection logic. + +const FAKE_200_REASON_KEYS: Record = { + FAKE_200_EMPTY_BODY: "emptyBody", + FAKE_200_HTML_BODY: "htmlBody", + FAKE_200_JSON_ERROR_NON_EMPTY: "jsonErrorNonEmpty", + FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY: "jsonErrorMessageNonEmpty", + FAKE_200_JSON_MESSAGE_KEYWORD_MATCH: "jsonMessageKeywordMatch", +}; + +export function getFake200ReasonKey(code: string, prefix: string): string { + return `${prefix}.${FAKE_200_REASON_KEYS[code] ?? "unknown"}`; +} diff --git a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx index c4f77d5df..c93284a78 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx @@ -86,6 +86,18 @@ const messages = { details: { clickStatusCode: "Click status code", fake200ForwardedNotice: "Note: payload may have been forwarded", + fake200DetectedReason: "Detected reason: {reason}", + statusCodeInferredBadge: "Inferred", + statusCodeInferredTooltip: "This status code is inferred from response body content.", + statusCodeInferredSuffix: "(inferred)", + fake200Reasons: { + emptyBody: "Empty response body", + htmlBody: "HTML document returned", + jsonErrorNonEmpty: "JSON has non-empty error field", + jsonErrorMessageNonEmpty: "JSON has non-empty error.message", + jsonMessageKeywordMatch: 'JSON message contains "error"', + unknown: "Response body indicates an error", + }, }, }, }, @@ -276,6 +288,25 @@ describe("provider-chain-popover layout", () => { expect(html).toContain("Note: payload may have been forwarded"); }); + test("renders inferred status code badge when statusCodeInferred=true", () => { + const html = renderWithIntl( + + ); + + expect(html).toContain("Inferred"); + }); + test("requestCount<=1 branch keeps truncation container shrinkable", () => { const html = renderWithIntl( typeof item.errorMessage === "string" && item.errorMessage.startsWith("FAKE_200_") ); + const fake200CodeForDisplay = chain.find( + (item) => typeof item.errorMessage === "string" && item.errorMessage.startsWith("FAKE_200_") + )?.errorMessage; // Calculate actual request count (excluding intermediate states) const requestCount = chain.filter(isActualRequest).length; @@ -144,6 +149,7 @@ export function ProviderChainPopover({ (item) => item.reason === "session_reuse" || item.selectionMethod === "session_reuse" ); const sessionReuseContext = sessionReuseItem?.decisionContext; + const singleRequestItem = chain.find(isActualRequest); return (
@@ -166,12 +172,50 @@ export function ProviderChainPopover({
{/* Provider name */}
{displayName}
+ {singleRequestItem?.statusCode && ( +
+ = 200 && singleRequestItem.statusCode < 300 + ? "border-emerald-500 text-emerald-600" + : "border-rose-500 text-rose-600" + )} + > + {singleRequestItem.statusCode} + + {singleRequestItem.statusCodeInferred && ( + + {t("logs.details.statusCodeInferredBadge")} + + )} +
+ )} {/* 注意:假 200 检测发生在 SSE 流式结束后;此时内容已可能透传给客户端。 */} {hasFake200PostStreamFailure && (
)} @@ -458,6 +502,15 @@ export function ProviderChainPopover({ {item.statusCode} )} + {item.statusCode && item.statusCodeInferred && ( + + {t("logs.details.statusCodeInferredBadge")} + + )} {item.reason && !item.statusCode && ( {tChain(`reasons.${item.reason}`)} @@ -465,9 +518,24 @@ export function ProviderChainPopover({ )}
{item.errorMessage && ( -

- {item.errorMessage} -

+ <> +

+ {item.errorMessage} +

+ {typeof item.errorMessage === "string" && + item.errorMessage.startsWith("FAKE_200_") && ( +

+ {t("logs.details.fake200DetectedReason", { + reason: t( + getFake200ReasonKey( + item.errorMessage, + "logs.details.fake200Reasons" + ) + ), + })} +

+ )} + )}
diff --git a/src/app/v1/_lib/proxy/error-handler.ts b/src/app/v1/_lib/proxy/error-handler.ts index 977f7f92f..0b457a3b3 100644 --- a/src/app/v1/_lib/proxy/error-handler.ts +++ b/src/app/v1/_lib/proxy/error-handler.ts @@ -1,3 +1,4 @@ +import { getCachedSystemSettings } from "@/lib/config/system-settings-cache"; import { isClaudeErrorFormat, isGeminiErrorFormat, @@ -6,6 +7,7 @@ import { } from "@/lib/error-override-validator"; import { logger } from "@/lib/logger"; import { ProxyStatusTracker } from "@/lib/proxy-status-tracker"; +import { sanitizeErrorTextForDetail } from "@/lib/utils/upstream-error-detection"; import { updateMessageRequestDetails, updateMessageRequestDuration } from "@/repository/message"; import { attachSessionIdToErrorResponse } from "./error-session-id"; import { @@ -236,9 +238,70 @@ export class ProxyErrorHandler { overridden: false, }); + // verboseProviderError(系统设置)开启时:对“假 200/空响应”等上游异常返回更详细的报告,便于排查。 + // 注意: + // - 该逻辑放在 error override 之后:确保优先级更低,不覆盖用户自定义覆写。 + // - rawBody 仅用于本次错误响应回传(受系统设置控制),不写入数据库/决策链; + // - 出于安全考虑,这里会对 rawBody 做基础脱敏(Bearer/key/JWT/email 等),避免上游错误页意外回显敏感信息。 + let details: Record | undefined; + let upstreamRequestId: string | undefined; + const shouldAttachVerboseDetails = + (error instanceof ProxyError && error.message.startsWith("FAKE_200_")) || + isEmptyResponseError(error); + + if (shouldAttachVerboseDetails) { + try { + const settings = await getCachedSystemSettings(); + if (settings.verboseProviderError) { + if (error instanceof ProxyError) { + upstreamRequestId = error.upstreamError?.requestId; + const rawBodySrc = error.upstreamError?.rawBody; + const rawBody = + typeof rawBodySrc === "string" && rawBodySrc + ? sanitizeErrorTextForDetail( + rawBodySrc.length > 4096 ? rawBodySrc.slice(0, 4096) : rawBodySrc + ) + : rawBodySrc; + details = { + upstreamError: { + kind: "fake_200", + code: error.message, + statusCode: error.statusCode, + statusCodeInferred: error.upstreamError?.statusCodeInferred ?? false, + statusCodeInferenceMatcherId: + error.upstreamError?.statusCodeInferenceMatcherId ?? null, + clientSafeMessage: error.getClientSafeMessage(), + rawBody, + rawBodyTruncated: error.upstreamError?.rawBodyTruncated ?? false, + }, + }; + } else if (isEmptyResponseError(error)) { + details = { + upstreamError: { + kind: "empty_response", + reason: error.reason, + clientSafeMessage: error.getClientSafeMessage(), + rawBody: "", + rawBodyTruncated: false, + }, + }; + } + } + } catch (verboseError) { + logger.warn("ProxyErrorHandler: failed to gather verbose details, skipping", { + error: verboseError instanceof Error ? verboseError.message : String(verboseError), + }); + } + } + + const safeRequestId = + typeof upstreamRequestId === "string" && upstreamRequestId.trim() + ? upstreamRequestId.trim() + : undefined; + return await attachSessionIdToErrorResponse( session.sessionId, - ProxyResponses.buildError(statusCode, clientErrorMessage) + ProxyResponses.buildError(statusCode, clientErrorMessage, undefined, details, safeRequestId) ); } diff --git a/src/app/v1/_lib/proxy/errors.ts b/src/app/v1/_lib/proxy/errors.ts index 13d0ad9d0..5deb4c380 100644 --- a/src/app/v1/_lib/proxy/errors.ts +++ b/src/app/v1/_lib/proxy/errors.ts @@ -9,6 +9,7 @@ import { getEnvConfig } from "@/lib/config/env.schema"; import { type ErrorDetectionResult, errorRuleDetector } from "@/lib/error-rule-detector"; import { redactJsonString } from "@/lib/utils/message-redaction"; +import { sanitizeErrorTextForDetail } from "@/lib/utils/upstream-error-detection"; import type { ErrorOverrideResponse } from "@/repository/error-rules"; import type { ProviderChainItem } from "@/types/message"; import type { ProxySession } from "./session"; @@ -18,11 +19,41 @@ export class ProxyError extends Error { message: string, public readonly statusCode: number, public readonly upstreamError?: { - body: string; // 原始响应体(智能截断) + /** + * 上游响应体(智能截断)。 + * + * 注意:该字段会进入 getDetailedErrorMessage(),并被记录到数据库中, + * 因此不要在这里放入“大段原文”或未脱敏的敏感内容。 + */ + body: string; parsed?: unknown; // 解析后的 JSON(如果有) providerId?: number; providerName?: string; requestId?: string; // 上游请求 ID(用于覆写响应时注入) + + /** + * 上游响应体原文(通常为前缀片段)。 + * + * 设计目标: + * - 仅用于“本次错误响应”返回给客户端(受系统设置控制); + * - 不参与规则匹配与持久化(避免污染数据库/日志)。 + * + * 目前主要用于“假 200”检测:HTTP 状态码为 2xx,但 body 实际为错误页/错误 JSON。 + */ + rawBody?: string; + rawBodyTruncated?: boolean; + + /** + * 标记该 ProxyError 的 statusCode 是否由“响应体内容”推断得出(而非上游真实 HTTP 状态码)。 + * + * 典型场景:上游返回 HTTP 200,但 body 为错误页/错误 JSON(假 200)。此时 CCH 会根据响应体内容推断更贴近语义的 4xx/5xx, + * 以便让故障转移/熔断/会话绑定逻辑与“真实上游错误状态码”保持一致。 + */ + statusCodeInferred?: boolean; + /** + * 命中的推断规则 id(仅用于内部调试/审计,不应用于用户展示文案)。 + */ + statusCodeInferenceMatcherId?: string; } ) { super(message); @@ -447,6 +478,55 @@ export class ProxyError extends Error { * - getClientSafeMessage(): 不包含供应商名称,用于返回给客户端 */ getClientSafeMessage(): string { + // 注意:一些内部检测/统计用的“错误码”(例如 FAKE_200_*)不适合直接暴露给客户端。 + // 这里做最小映射:当 message 为 FAKE_200_* 时返回“可读原因说明”,并附带安全的上游片段(若有)。 + if (this.message.startsWith("FAKE_200_")) { + // 说明:这些 code 都来自内部的“假 200”检测,代表:上游返回 HTTP 200,但响应体内容更像错误页/错误 JSON。 + // 我们需要: + // 1) 给用户清晰的错误原因(避免只看到一个内部 code); + // 2) 不泄露内部错误码/供应商名称; + // 3) 在有 detail 时附带一小段“脱敏 + 截断”的上游片段,帮助排查。 + const reason = (() => { + switch (this.message) { + case "FAKE_200_EMPTY_BODY": + return "Upstream returned HTTP 200, but the response body was empty."; + case "FAKE_200_HTML_BODY": + return "Upstream returned HTTP 200, but the response body looks like an HTML document (likely an error page)."; + case "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY": + return "Upstream returned HTTP 200, but the JSON body contains a non-empty `error.message`."; + case "FAKE_200_JSON_ERROR_NON_EMPTY": + return "Upstream returned HTTP 200, but the JSON body contains a non-empty `error` field."; + case "FAKE_200_JSON_MESSAGE_KEYWORD_MATCH": + return "Upstream returned HTTP 200, but the JSON `message` suggests an error (heuristic)."; + default: + return "Upstream returned HTTP 200, but the response body indicates an error."; + } + })(); + + const inferredNote = this.upstreamError?.statusCodeInferred + ? ` Inferred HTTP status: ${this.statusCode}.` + : ""; + + const detail = this.upstreamError?.body?.trim(); + if (detail) { + // 注意:对 FAKE_200_* 路径,我们期望 upstreamError.body 来自内部检测得到的“脱敏 + 截断片段”(详见 upstream-error-detection.ts)。 + // + // 但为避免未来调用方误把“未脱敏的大段原文”塞进 upstreamError.body 导致泄露, + // 这里再做一次防御性处理: + // - whitespace 归一化(避免多行污染客户端日志) + // - 二次截断(上限 200 字符) + // - 轻量脱敏(避免明显的 token/key 泄露) + const normalized = detail.replace(/\s+/g, " ").trim(); + const maxChars = 200; + const clipped = + normalized.length > maxChars ? `${normalized.slice(0, maxChars)}…` : normalized; + const safe = sanitizeErrorTextForDetail(clipped); + return `${reason}${inferredNote} Upstream detail: ${safe}`; + } + + return `${reason}${inferredNote}`; + } + return this.message; } } diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 1abf4019c..3f9be35a1 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -25,6 +25,10 @@ import { import { getGlobalAgentPool, getProxyAgentForProvider } from "@/lib/proxy-agent"; import { SessionManager } from "@/lib/session-manager"; import { CONTEXT_1M_BETA_HEADER, shouldApplyContext1m } from "@/lib/special-attributes"; +import { + detectUpstreamErrorFromSseOrJsonText, + inferUpstreamErrorStatusCodeFromText, +} from "@/lib/utils/upstream-error-detection"; import { isVendorTypeCircuitOpen, recordVendorTypeAllEndpointsTimeout, @@ -84,6 +88,81 @@ const MAX_PROVIDER_SWITCHES = 20; // 保险栓:最多切换 20 次供应商( type CacheTtlOption = CacheTtlPreference | null | undefined; +// 非流式响应体检查的上限(字节):避免上游在 2xx 场景返回超大内容导致内存占用失控。 +// 说明: +// - 该检查仅用于“空响应/假 200”启发式判定,不用于业务逻辑解析; +// - 超过上限时,仍认为“非空”,但会跳过 JSON 内容结构检查(避免截断导致误判)。 +const NON_STREAM_BODY_INSPECTION_MAX_BYTES = 32 * 1024; // 32 KiB + +/** + * 读取响应体文本,但最多读取 `maxBytes` 字节(用于非流式 2xx 的“空响应/假 200”嗅探)。 + * + * 注意: + * - 该函数只用于启发式检测,不用于业务逻辑解析; + * - 超过上限时会 `cancel()` reader,避免继续占用资源; + * - 调用方应使用 `response.clone()`,避免消费掉原始响应体,影响后续透传/解析。 + */ +async function readResponseTextUpTo( + response: Response, + maxBytes: number +): Promise<{ text: string; truncated: boolean }> { + const reader = response.body?.getReader(); + if (!reader) { + return { text: "", truncated: false }; + } + + const decoder = new TextDecoder(); + const chunks: string[] = []; + let bytesRead = 0; + let truncated = false; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value || value.byteLength === 0) continue; + + const remaining = maxBytes - bytesRead; + // 注意:remaining<=0 发生在“已经读到下一块 chunk”之后。 + // 对启发式嗅探而言,直接标记 truncated 并退出即可(等价于丢弃超出上限的后续字节), + // 避免对超出部分做无谓的解码开销。 + if (remaining <= 0) { + truncated = true; + break; + } + + if (value.byteLength > remaining) { + chunks.push(decoder.decode(value.subarray(0, remaining), { stream: true })); + bytesRead += remaining; + truncated = true; + break; + } + + chunks.push(decoder.decode(value, { stream: true })); + bytesRead += value.byteLength; + } + + const flushed = decoder.decode(); + if (flushed) chunks.push(flushed); + } finally { + if (truncated) { + try { + await reader.cancel(); + } catch (cancelErr) { + logger.debug("readResponseTextUpTo: failed to cancel reader", { error: cancelErr }); + } + } + + try { + reader.releaseLock(); + } catch (releaseErr) { + logger.debug("readResponseTextUpTo: failed to release reader lock", { error: releaseErr }); + } + } + + return { text: chunks.join(""), truncated }; +} + function resolveCacheTtlPreference( keyPref: CacheTtlOption, providerPref: CacheTtlOption @@ -523,6 +602,9 @@ export class ProxyForwarder { session.addProviderToChain(currentProvider, { reason: "endpoint_pool_exhausted", strictBlockCause: strictBlockCause as ProviderChainItem["strictBlockCause"], + // 为避免被 initial_selection/session_reuse 去重吞掉,这里需要写入 attemptNumber。 + // 同时也能让“决策链/技术时间线”把它当作一次实际尝试(虽然请求未发出)。 + attemptNumber: 1, ...(filterStats ? { endpointFilterStats: filterStats } : {}), errorMessage: endpointSelectionError?.message, }); @@ -619,7 +701,14 @@ export class ProxyForwarder { // ========== 空响应检测(仅非流式)========== const contentType = response.headers.get("content-type") || ""; - const isSSE = contentType.includes("text/event-stream"); + const normalizedContentType = contentType.toLowerCase(); + const isSSE = normalizedContentType.includes("text/event-stream"); + const isHtml = + normalizedContentType.includes("text/html") || + normalizedContentType.includes("application/xhtml+xml"); + const isJson = + normalizedContentType.includes("application/json") || + normalizedContentType.includes("+json"); // ========== 流式响应:延迟成功判定(避免“假 200”)========== // 背景:上游可能返回 HTTP 200,但 SSE 内容为错误 JSON(如 {"error": "..."})。 @@ -655,29 +744,111 @@ export class ProxyForwarder { return response; } - if (!isSSE) { - // 非流式响应:检测空响应 - const contentLength = response.headers.get("content-length"); + // 非流式响应:检测空响应 + const contentLengthHeader = response.headers.get("content-length"); + const contentLength = contentLengthHeader?.trim() || undefined; + const contentLengthBytes = (() => { + if (!contentLength) return null; + + // Content-Length 必须是纯数字;parseInt("12abc") 会返回 12,容易误判为合法值, + // 从而跳过 “!hasValidContentLength” 的检查分支。 + if (!/^\d+$/.test(contentLength)) return null; + + const num = Number(contentLength); + if (!Number.isSafeInteger(num) || num < 0) return null; + return num; + })(); + const hasValidContentLength = contentLengthBytes !== null; + + // 检测 Content-Length: 0 的情况 + if (contentLengthBytes === 0) { + throw new EmptyResponseError(currentProvider.id, currentProvider.name, "empty_body"); + } - // 检测 Content-Length: 0 的情况 - if (contentLength === "0") { + // 200 + text/html(或 xhtml)通常是上游网关/WAF/Cloudflare 的错误页,但被包装成了 HTTP 200。 + // 这种“假 200”会导致: + // - 熔断/故障转移统计被误记为成功; + // - session 智能绑定被更新到不可用 provider(影响后续重试)。 + // 因此这里在进入成功分支前做一次强信号检测:仅当 body 看起来是完整 HTML 文档时才视为错误。 + let inspectedText: string | undefined; + let inspectedTruncated = false; + // 注意:这里不会对“大体积 JSON”做假 200 检测(例如 Content-Length > 32KiB)。 + // 原因: + // - 非流式路径需要 clone 并额外读取响应体,会带来额外的内存/延迟开销; + // - 大体积 JSON 更可能是正常响应(而不是网关/WAF 的短错误 JSON)。 + // 这意味着:极少数“超大 JSON 错误体 + HTTP 200”的上游异常可能会漏检。 + const shouldInspectJson = + isJson && + hasValidContentLength && + contentLengthBytes <= NON_STREAM_BODY_INSPECTION_MAX_BYTES; + const shouldInspectBody = isHtml || !hasValidContentLength || shouldInspectJson; + if (shouldInspectBody) { + // 注意:Response.clone() 会 tee 底层 ReadableStream,可能带来一定的瞬时内存开销; + // 这里通过“最多读取 32 KiB”并在截断时 cancel 克隆分支来控制开销。 + const clonedResponse = response.clone(); + const inspected = await readResponseTextUpTo( + clonedResponse, + NON_STREAM_BODY_INSPECTION_MAX_BYTES + ); + inspectedText = inspected.text; + inspectedTruncated = inspected.truncated; + } + + if (inspectedText !== undefined) { + // 对非流式 2xx 响应:只启用“强信号”判定(HTML 文档 / 顶层 error 非空 / 空 body)。 + // `message` 关键字匹配属于弱信号,误判风险更高;该规则主要用于 SSE 结束后的补充检测。 + const detected = detectUpstreamErrorFromSseOrJsonText(inspectedText, { + maxJsonCharsForMessageCheck: 0, + }); + + if (detected.isError && detected.code === "FAKE_200_EMPTY_BODY") { throw new EmptyResponseError(currentProvider.id, currentProvider.name, "empty_body"); } - // 对于没有 Content-Length 的情况,需要 clone 并检查响应体 - // 注意:这会增加一定的性能开销,但对于非流式响应是可接受的 - if (!contentLength) { - const clonedResponse = response.clone(); - const responseText = await clonedResponse.text(); - - if (!responseText || responseText.trim() === "") { - throw new EmptyResponseError( - currentProvider.id, - currentProvider.name, - "empty_body" - ); - } + const isStrongFake200 = + detected.isError && + (detected.code === "FAKE_200_HTML_BODY" || + detected.code === "FAKE_200_JSON_ERROR_NON_EMPTY" || + detected.code === "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY"); + + if (isStrongFake200) { + const inferredStatus = inferUpstreamErrorStatusCodeFromText(inspectedText); + const inferredStatusCode = inferredStatus?.statusCode; + + throw new ProxyError(detected.code, inferredStatusCode ?? 502, { + body: detected.detail ?? "", + providerId: currentProvider.id, + providerName: currentProvider.name, + // 注意:rawBody 仅用于“本次错误响应”向客户端提供更多排查信息(受系统设置控制), + // 不参与规则匹配/持久化,避免污染数据库或误触发覆写规则。 + rawBody: inspectedText, + rawBodyTruncated: inspectedTruncated, + statusCodeInferred: inferredStatusCode !== undefined, + statusCodeInferenceMatcherId: inferredStatus?.matcherId, + }); + } + } + + // 对于缺失或非法 Content-Length 的情况,需要 clone 并检查响应体 + // 注意:这会增加一定的性能开销,但对于非流式响应是可接受的 + if (!contentLength || !hasValidContentLength) { + const responseText = inspectedText ?? ""; + if (!responseText || responseText.trim() === "") { + throw new EmptyResponseError(currentProvider.id, currentProvider.name, "empty_body"); + } + + if (inspectedTruncated) { + logger.debug( + "ProxyForwarder: Response body too large for non-stream content check, skipping JSON parse", + { + providerId: currentProvider.id, + providerName: currentProvider.name, + contentType, + maxBytes: NON_STREAM_BODY_INSPECTION_MAX_BYTES, + } + ); + } else { // 尝试解析 JSON 并检查是否有输出内容 try { const responseJson = JSON.parse(responseText) as Record; @@ -722,7 +893,12 @@ export class ProxyForwarder { // 注意:不抛出错误,因为某些请求(如 count_tokens)可能合法地返回 0 output tokens } } - } catch (_parseError) { + } catch (_parseOrContentError) { + // EmptyResponseError 会触发重试/故障转移,不能在这里被当作 JSON 解析错误吞掉。 + if (isEmptyResponseError(_parseOrContentError)) { + throw _parseOrContentError; + } + // JSON 解析失败但响应体不为空,不视为空响应错误 logger.debug("ProxyForwarder: Non-JSON response body, skipping content check", { providerId: currentProvider.id, @@ -964,6 +1140,7 @@ export class ProxyForwarder { attemptNumber: attemptCount, errorMessage, statusCode: lastError.statusCode, + statusCodeInferred: lastError.upstreamError?.statusCodeInferred ?? false, errorDetails: { provider: { id: currentProvider.id, @@ -1096,6 +1273,7 @@ export class ProxyForwarder { attemptNumber: attemptCount, errorMessage, statusCode: lastError.statusCode, + statusCodeInferred: lastError.upstreamError?.statusCodeInferred ?? false, errorDetails: { provider: { id: currentProvider.id, @@ -1160,6 +1338,7 @@ export class ProxyForwarder { providerId: currentProvider.id, providerName: currentProvider.name, statusCode: statusCode, + statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false, error: errorMessage, attemptNumber: attemptCount, totalProvidersAttempted, @@ -1176,6 +1355,7 @@ export class ProxyForwarder { attemptNumber: attemptCount, errorMessage: errorMessage, statusCode: statusCode, + statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false, errorDetails: { provider: { id: currentProvider.id, @@ -1298,6 +1478,7 @@ export class ProxyForwarder { providerId: currentProvider.id, providerName: currentProvider.name, statusCode: 404, + statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false, error: errorMessage, attemptNumber: attemptCount, totalProvidersAttempted, @@ -1312,6 +1493,7 @@ export class ProxyForwarder { attemptNumber: attemptCount, errorMessage: errorMessage, statusCode: 404, + statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false, errorDetails: { provider: { id: currentProvider.id, @@ -1454,6 +1636,7 @@ export class ProxyForwarder { providerId: currentProvider.id, providerName: currentProvider.name, statusCode: statusCode, + statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false, error: errorMessage, attemptNumber: attemptCount, totalProvidersAttempted, @@ -1473,6 +1656,7 @@ export class ProxyForwarder { circuitFailureCount: health.failureCount + 1, // 包含本次失败 circuitFailureThreshold: config.failureThreshold, statusCode: statusCode, + statusCodeInferred: proxyError.upstreamError?.statusCodeInferred ?? false, errorDetails: { provider: { id: currentProvider.id, diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 3dc16dc1b..13abdc3bf 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -12,7 +12,10 @@ import type { CostBreakdown } from "@/lib/utils/cost-calculation"; import { calculateRequestCost, calculateRequestCostBreakdown } from "@/lib/utils/cost-calculation"; import { hasValidPriceData } from "@/lib/utils/price-data"; import { isSSEText, parseSSEData } from "@/lib/utils/sse"; -import { detectUpstreamErrorFromSseOrJsonText } from "@/lib/utils/upstream-error-detection"; +import { + detectUpstreamErrorFromSseOrJsonText, + inferUpstreamErrorStatusCodeFromText, +} from "@/lib/utils/upstream-error-detection"; import { updateMessageRequestCost, updateMessageRequestDetails, @@ -135,7 +138,8 @@ type FinalizeDeferredStreamingResult = { * - 如果内容看起来是上游错误 JSON(假 200),则: * - 计入熔断器失败; * - 不更新 session 智能绑定(避免把会话粘到坏 provider); - * - 内部状态码改为 502(只影响统计与后续重试选择,不影响本次客户端响应)。 + * - 内部状态码改为“推断得到的 4xx/5xx”(未命中则回退 502), + * 仅影响统计与后续重试选择,不影响本次客户端响应。 * - 如果流正常结束且未命中错误判定,则按成功结算并更新绑定/熔断/endpoint 成功率。 * * @param streamEndedNormally - 必须是 reader 读到 done=true 的“自然结束”;超时/中断等异常结束由其它逻辑处理。 @@ -166,12 +170,21 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( : ({ isError: false } as const); // “内部结算用”的状态码(不会改变客户端实际 HTTP 状态码)。 - // - 假 200:映射为 502,确保内部统计/熔断/会话绑定把它当作失败。 + // - 假 200:优先映射为“推断得到的 4xx/5xx”(未命中则回退 502),确保内部统计/熔断/会话绑定把它当作失败。 // - 未自然结束:也应映射为失败(避免把中断/部分流误记为 200 completed)。 let effectiveStatusCode: number; let errorMessage: string | null; + let statusCodeInferred = false; + let statusCodeInferenceMatcherId: string | undefined; if (detected.isError) { - effectiveStatusCode = 502; + const inferred = inferUpstreamErrorStatusCodeFromText(allContent); + if (inferred) { + effectiveStatusCode = inferred.statusCode; + statusCodeInferred = true; + statusCodeInferenceMatcherId = inferred.matcherId; + } else { + effectiveStatusCode = 502; + } errorMessage = detected.code; } else if (!streamEndedNormally) { effectiveStatusCode = clientAborted ? 499 : 502; @@ -277,21 +290,29 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( providerName: meta.providerName, upstreamStatusCode: meta.upstreamStatusCode, effectiveStatusCode, + statusCodeInferred, + statusCodeInferenceMatcherId: statusCodeInferenceMatcherId ?? null, code: detected.code, detail: detected.detail ?? null, }); - // 计入熔断器:让后续请求能正确触发故障转移/熔断 - try { - // 动态导入:避免 proxy 模块与熔断器模块之间潜在的循环依赖。 - const { recordFailure } = await import("@/lib/circuit-breaker"); - await recordFailure(meta.providerId, new Error(detected.code)); - } catch (cbError) { - logger.warn("[ResponseHandler] Failed to record fake-200 error in circuit breaker", { - providerId: meta.providerId, - sessionId: session.sessionId ?? null, - error: cbError, - }); + const chainReason = effectiveStatusCode === 404 ? "resource_not_found" : "retry_failed"; + + // 计入熔断器:让后续请求能正确触发故障转移/熔断。 + // + // 注意:404 语义在 forwarder 中属于 RESOURCE_NOT_FOUND,不计入熔断器(避免把“资源/模型不存在”当作供应商故障)。 + if (effectiveStatusCode !== 404) { + try { + // 动态导入:避免 proxy 模块与熔断器模块之间潜在的循环依赖。 + const { recordFailure } = await import("@/lib/circuit-breaker"); + await recordFailure(meta.providerId, new Error(detected.code)); + } catch (cbError) { + logger.warn("[ResponseHandler] Failed to record fake-200 error in circuit breaker", { + providerId: meta.providerId, + sessionId: session.sessionId ?? null, + error: cbError, + }); + } } // NOTE: Do NOT call recordEndpointFailure here. Fake-200 errors are key-level @@ -299,14 +320,16 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( // the error is in the response content, not endpoint connectivity. // 记录到决策链(用于日志展示与 DB 持久化)。 - // 注意:这里用 effectiveStatusCode(502)而不是 upstreamStatusCode(200), - // 以便让内部链路明确显示这是一次失败(否则会被误读为成功)。 + // 注意:这里用 effectiveStatusCode(推断得到的 4xx/5xx,或回退 502) + // 而不是 upstreamStatusCode(200),以便让内部链路明确显示这是一次失败 + // (否则会被误读为成功)。 session.addProviderToChain(providerForChain, { endpointId: meta.endpointId, endpointUrl: meta.endpointUrl, - reason: "retry_failed", + reason: chainReason, attemptNumber: meta.attemptNumber, statusCode: effectiveStatusCode, + statusCodeInferred, errorMessage: detected.code, }); @@ -323,16 +346,21 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( errorMessage, }); - // 计入熔断器:让后续请求能正确触发故障转移/熔断 - try { - const { recordFailure } = await import("@/lib/circuit-breaker"); - await recordFailure(meta.providerId, new Error(errorMessage)); - } catch (cbError) { - logger.warn("[ResponseHandler] Failed to record non-200 error in circuit breaker", { - providerId: meta.providerId, - sessionId: session.sessionId ?? null, - error: cbError, - }); + const chainReason = effectiveStatusCode === 404 ? "resource_not_found" : "retry_failed"; + + // 计入熔断器:让后续请求能正确触发故障转移/熔断。 + // 注意:与 forwarder 口径保持一致:404 不计入熔断器(资源不存在不是供应商故障)。 + if (effectiveStatusCode !== 404) { + try { + const { recordFailure } = await import("@/lib/circuit-breaker"); + await recordFailure(meta.providerId, new Error(errorMessage)); + } catch (cbError) { + logger.warn("[ResponseHandler] Failed to record non-200 error in circuit breaker", { + providerId: meta.providerId, + sessionId: session.sessionId ?? null, + error: cbError, + }); + } } // NOTE: Do NOT call recordEndpointFailure here. Non-200 HTTP errors (401, 429, @@ -343,7 +371,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( session.addProviderToChain(providerForChain, { endpointId: meta.endpointId, endpointUrl: meta.endpointUrl, - reason: "retry_failed", + reason: chainReason, attemptNumber: meta.attemptNumber, statusCode: effectiveStatusCode, errorMessage: errorMessage, @@ -2750,7 +2778,8 @@ async function updateRequestCostFromUsage( * 统一的请求统计处理方法 * 用于消除 Gemini 透传、普通非流式、普通流式之间的重复统计逻辑 * - * @param statusCode - 内部结算状态码(可能与客户端实际收到的 HTTP 状态不同,例如“假 200”会被映射为 502) + * @param statusCode - 内部结算状态码(可能与客户端实际收到的 HTTP 状态不同,例如“假 200”会被推断并映射为更贴近语义的 4xx/5xx; + * 未命中推断规则时回退为 502) * @param errorMessage - 可选的内部错误原因(用于把假 200/解析失败等信息写入 DB 与监控) */ export async function finalizeRequestStats( diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index abae33872..dda2c4a46 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -457,6 +457,7 @@ export class ProxySession { endpointUrl?: string; // 修复:添加新字段 statusCode?: number; // 成功时的状态码 + statusCodeInferred?: boolean; // statusCode 是否为响应体推断 circuitFailureCount?: number; // 熔断失败计数 circuitFailureThreshold?: number; // 熔断阈值 errorDetails?: ProviderChainItem["errorDetails"]; // 结构化错误详情 @@ -485,6 +486,7 @@ export class ProxySession { errorMessage: metadata?.errorMessage, // 记录错误信息 // 修复:记录新字段 statusCode: metadata?.statusCode, + statusCodeInferred: metadata?.statusCodeInferred, circuitFailureCount: metadata?.circuitFailureCount, circuitFailureThreshold: metadata?.circuitFailureThreshold, errorDetails: metadata?.errorDetails, // 结构化错误详情 diff --git a/src/lib/utils/provider-chain-formatter.test.ts b/src/lib/utils/provider-chain-formatter.test.ts index ace105ca8..d1f9f6950 100644 --- a/src/lib/utils/provider-chain-formatter.test.ts +++ b/src/lib/utils/provider-chain-formatter.test.ts @@ -375,6 +375,113 @@ describe("vendor_type_all_timeout", () => { }); }); +// ============================================================================= +// resource_not_found reason tests +// ============================================================================= + +describe("resource_not_found", () => { + const baseNotFoundItem: ProviderChainItem = { + id: 1, + name: "provider-a", + reason: "resource_not_found", + attemptNumber: 1, + statusCode: 404, + errorMessage: "Not Found", + timestamp: 1000, + errorDetails: { + provider: { + id: 1, + name: "provider-a", + statusCode: 404, + statusText: "Not Found", + }, + }, + }; + + describe("formatProviderSummary", () => { + test("renders resource_not_found item as failure in summary", () => { + const chain: ProviderChainItem[] = [baseNotFoundItem]; + const result = formatProviderSummary(chain, mockT); + + expect(result).toContain("provider-a"); + expect(result).toContain("✗"); + }); + + test("renders resource_not_found alongside a successful retry in multi-provider chain", () => { + const chain: ProviderChainItem[] = [ + baseNotFoundItem, + { + id: 2, + name: "provider-b", + reason: "retry_success", + statusCode: 200, + timestamp: 2000, + attemptNumber: 1, + }, + ]; + const result = formatProviderSummary(chain, mockT); + + expect(result).toContain("provider-a"); + expect(result).toContain("provider-b"); + expect(result).toMatch(/provider-a\(.*\).*provider-b\(.*\)/); + }); + }); + + describe("formatProviderDescription", () => { + test("shows resource not found label in request chain", () => { + const chain: ProviderChainItem[] = [baseNotFoundItem]; + const result = formatProviderDescription(chain, mockT); + + expect(result).toContain("provider-a"); + expect(result).toContain("description.resourceNotFound"); + }); + }); + + describe("formatProviderTimeline", () => { + test("renders resource_not_found with status code and note", () => { + const chain: ProviderChainItem[] = [baseNotFoundItem]; + const { timeline } = formatProviderTimeline(chain, mockT); + + expect(timeline).toContain("timeline.resourceNotFoundFailed [attempt=1]"); + expect(timeline).toContain("timeline.statusCode [code=404]"); + expect(timeline).toContain("timeline.resourceNotFoundNote"); + }); + + test("renders inferred status code label when statusCodeInferred=true", () => { + const chain: ProviderChainItem[] = [{ ...baseNotFoundItem, statusCodeInferred: true }]; + const { timeline } = formatProviderTimeline(chain, mockT); + + expect(timeline).toContain("timeline.resourceNotFoundFailed [attempt=1]"); + expect(timeline).toContain("timeline.statusCodeInferred [code=404]"); + expect(timeline).toContain("timeline.resourceNotFoundNote"); + }); + + test("degrades gracefully when errorDetails.provider is missing", () => { + const chain: ProviderChainItem[] = [ + { + ...baseNotFoundItem, + errorDetails: { + request: { + method: "POST", + url: "https://example.com/v1/messages", + headers: "{}", + body: "{}", + bodyTruncated: false, + }, + }, + }, + ]; + const { timeline } = formatProviderTimeline(chain, mockT); + + expect(timeline).toContain("timeline.resourceNotFoundFailed [attempt=1]"); + expect(timeline).toContain("timeline.provider [provider=provider-a]"); + expect(timeline).toContain("timeline.statusCode [code=404]"); + expect(timeline).toContain("timeline.error [error=Not Found]"); + expect(timeline).toContain("timeline.resourceNotFoundNote"); + }); + }); +}); + // ============================================================================= // Unknown reason graceful degradation // ============================================================================= diff --git a/src/lib/utils/provider-chain-formatter.ts b/src/lib/utils/provider-chain-formatter.ts index 46d2f4e24..98e3188f5 100644 --- a/src/lib/utils/provider-chain-formatter.ts +++ b/src/lib/utils/provider-chain-formatter.ts @@ -63,6 +63,7 @@ function getProviderStatus(item: ProviderChainItem): "✓" | "✗" | "⚡" | " if ( item.reason === "retry_failed" || item.reason === "system_error" || + item.reason === "resource_not_found" || item.reason === "client_error_non_retryable" || item.reason === "endpoint_pool_exhausted" || item.reason === "vendor_type_all_timeout" @@ -92,6 +93,7 @@ function isActualRequest(item: ProviderChainItem): boolean { if ( item.reason === "retry_failed" || item.reason === "system_error" || + item.reason === "resource_not_found" || item.reason === "client_error_non_retryable" || item.reason === "endpoint_pool_exhausted" || item.reason === "vendor_type_all_timeout" @@ -127,6 +129,16 @@ function translateCircuitState(state: string | undefined, t: (key: string) => st } } +function formatTimelineStatusCode( + item: ProviderChainItem, + code: number, + t: (key: string, values?: Record) => string +): string { + return item.statusCodeInferred + ? t("timeline.statusCodeInferred", { code }) + : t("timeline.statusCode", { code }); +} + /** * 辅助函数:获取错误码含义 */ @@ -313,6 +325,8 @@ export function formatProviderDescription( desc += ` ${t("description.http2Fallback")}`; } else if (item.reason === "client_error_non_retryable") { desc += ` ${t("description.clientError")}`; + } else if (item.reason === "resource_not_found") { + desc += ` ${t("description.resourceNotFound")}`; } else if (item.reason === "endpoint_pool_exhausted") { desc += ` ${t("description.endpointPoolExhausted")}`; } else if (item.reason === "vendor_type_all_timeout") { @@ -445,6 +459,47 @@ export function formatProviderTimeline( continue; } + // === 资源不存在(上游 404) === + if (item.reason === "resource_not_found") { + const attempt = actualAttemptNumber ?? item.attemptNumber ?? 0; + timeline += `${t("timeline.resourceNotFoundFailed", { attempt })}\n\n`; + + if (item.errorDetails?.provider) { + const p = item.errorDetails.provider; + timeline += `${t("timeline.provider", { provider: p.name })}\n`; + timeline += `${formatTimelineStatusCode(item, p.statusCode, t)}\n`; + timeline += `${t("timeline.error", { error: p.statusText })}\n`; + + // 计算请求耗时 + if (i > 0 && item.timestamp && chain[i - 1]?.timestamp) { + const duration = item.timestamp - (chain[i - 1]?.timestamp || 0); + timeline += `${t("timeline.requestDuration", { duration })}\n`; + } + + // 错误详情(格式化 JSON) + if (p.upstreamParsed) { + timeline += `\n${t("timeline.errorDetails")}:\n`; + timeline += JSON.stringify(p.upstreamParsed, null, 2); + } else if (p.upstreamBody) { + timeline += `\n${t("timeline.errorDetails")}:\n${p.upstreamBody}`; + } + } else { + timeline += `${t("timeline.provider", { provider: item.name })}\n`; + if (item.statusCode) { + timeline += `${formatTimelineStatusCode(item, item.statusCode, t)}\n`; + } + timeline += t("timeline.error", { error: item.errorMessage || t("timeline.unknown") }); + } + + // 请求详情(用于问题排查) + if (item.errorDetails?.request) { + timeline += formatRequestDetails(item.errorDetails.request, t); + } + + timeline += `\n${t("timeline.resourceNotFoundNote")}`; + continue; + } + // === 供应商错误(请求失败) === if (item.reason === "retry_failed") { timeline += `${t("timeline.requestFailed", { attempt: actualAttemptNumber ?? 0 })}\n\n`; @@ -453,7 +508,7 @@ export function formatProviderTimeline( if (item.errorDetails?.provider) { const p = item.errorDetails.provider; timeline += `${t("timeline.provider", { provider: p.name })}\n`; - timeline += `${t("timeline.statusCode", { code: p.statusCode })}\n`; + timeline += `${formatTimelineStatusCode(item, p.statusCode, t)}\n`; timeline += `${t("timeline.error", { error: p.statusText })}\n`; // 计算请求耗时 @@ -500,7 +555,7 @@ export function formatProviderTimeline( // 降级:使用 errorMessage timeline += `${t("timeline.provider", { provider: item.name })}\n`; if (item.statusCode) { - timeline += `${t("timeline.statusCode", { code: item.statusCode })}\n`; + timeline += `${formatTimelineStatusCode(item, item.statusCode, t)}\n`; } timeline += t("timeline.error", { error: item.errorMessage || t("timeline.unknown") }); @@ -588,12 +643,12 @@ export function formatProviderTimeline( if (item.errorDetails?.provider) { const p = item.errorDetails.provider; timeline += `${t("timeline.provider", { provider: p.name })}\n`; - timeline += `${t("timeline.statusCode", { code: p.statusCode })}\n`; + timeline += `${formatTimelineStatusCode(item, p.statusCode, t)}\n`; timeline += `${t("timeline.error", { error: p.statusText })}\n`; } else { timeline += `${t("timeline.provider", { provider: item.name })}\n`; if (item.statusCode) { - timeline += `${t("timeline.statusCode", { code: item.statusCode })}\n`; + timeline += `${formatTimelineStatusCode(item, item.statusCode, t)}\n`; } timeline += `${t("timeline.error", { error: item.errorMessage || t("timeline.unknown") })}\n`; } diff --git a/src/lib/utils/upstream-error-detection.test.ts b/src/lib/utils/upstream-error-detection.test.ts index d1facd969..957ef374b 100644 --- a/src/lib/utils/upstream-error-detection.test.ts +++ b/src/lib/utils/upstream-error-detection.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "vitest"; -import { detectUpstreamErrorFromSseOrJsonText } from "@/lib/utils/upstream-error-detection"; +import { + detectUpstreamErrorFromSseOrJsonText, + inferUpstreamErrorStatusCodeFromText, +} from "@/lib/utils/upstream-error-detection"; describe("detectUpstreamErrorFromSseOrJsonText", () => { test("空响应体视为错误", () => { @@ -16,6 +19,49 @@ describe("detectUpstreamErrorFromSseOrJsonText", () => { }); }); + test("明显的 HTML 文档视为错误(覆盖 200+text/html 的“假 200”)", () => { + const html = [ + "", + '', + "New API", + "Something went wrong", + "", + ].join("\n"); + const res = detectUpstreamErrorFromSseOrJsonText(html); + expect(res).toEqual({ + isError: true, + code: "FAKE_200_HTML_BODY", + detail: expect.any(String), + }); + }); + + test("带 BOM 的 HTML 文档也应视为错误", () => { + const htmlWithBom = "\uFEFF \n\nblocked"; + const res = detectUpstreamErrorFromSseOrJsonText(htmlWithBom); + expect(res.isError).toBe(true); + if (res.isError) { + expect(res.code).toBe("FAKE_200_HTML_BODY"); + } + }); + + test("带 BOM 的 JSON error 也应正常识别", () => { + const jsonWithBom = '\uFEFF \n{"error":"当前无可用凭证"}'; + const res = detectUpstreamErrorFromSseOrJsonText(jsonWithBom); + expect(res.isError).toBe(true); + if (res.isError) { + expect(res.code).toBe("FAKE_200_JSON_ERROR_NON_EMPTY"); + } + }); + + test("纯 JSON:content 内包含 文本不应误判为 HTML 错误", () => { + const body = JSON.stringify({ + type: "message", + content: [{ type: "text", text: "not an error" }], + }); + const res = detectUpstreamErrorFromSseOrJsonText(body); + expect(res.isError).toBe(false); + }); + test("纯 JSON:error 字段非空视为错误", () => { const res = detectUpstreamErrorFromSseOrJsonText('{"error":"当前无可用凭证"}'); expect(res.isError).toBe(true); @@ -211,3 +257,63 @@ describe("detectUpstreamErrorFromSseOrJsonText", () => { expect(res.isError).toBe(false); }); }); + +describe("inferUpstreamErrorStatusCodeFromText", () => { + test("空文本不推断状态码", () => { + expect(inferUpstreamErrorStatusCodeFromText("")).toBeNull(); + expect(inferUpstreamErrorStatusCodeFromText(" \n\t ")).toBeNull(); + }); + + test("可从错误文本中推断 429(rate limit)", () => { + expect(inferUpstreamErrorStatusCodeFromText('{"error":"Rate limit exceeded"}')).toEqual({ + statusCode: 429, + matcherId: "rate_limit", + }); + }); + + test("可从错误文本中推断 401(invalid api key)", () => { + expect(inferUpstreamErrorStatusCodeFromText('{"error":"Invalid API key"}')).toEqual({ + statusCode: 401, + matcherId: "unauthorized", + }); + }); + + test("可从错误文本中推断 403(access denied)", () => { + expect(inferUpstreamErrorStatusCodeFromText("Access denied")).toEqual({ + statusCode: 403, + matcherId: "forbidden", + }); + }); + + test("可从错误文本中推断 402(billing hard limit)", () => { + expect(inferUpstreamErrorStatusCodeFromText("billing_hard_limit_reached")).toEqual({ + statusCode: 402, + matcherId: "payment_required", + }); + }); + + test("可从错误文本中推断 404(model not found)", () => { + expect(inferUpstreamErrorStatusCodeFromText("model not found")).toEqual({ + statusCode: 404, + matcherId: "not_found", + }); + }); + + test("可从错误文本中推断 413(payload too large)", () => { + expect(inferUpstreamErrorStatusCodeFromText("payload too large")).toEqual({ + statusCode: 413, + matcherId: "payload_too_large", + }); + }); + + test("可从错误文本中推断 415(unsupported media type)", () => { + expect(inferUpstreamErrorStatusCodeFromText("Unsupported Media Type")).toEqual({ + statusCode: 415, + matcherId: "unsupported_media_type", + }); + }); + + test("仅包含泛化 error 字样时不推断(避免误判)", () => { + expect(inferUpstreamErrorStatusCodeFromText('{"message":"some error happened"}')).toBeNull(); + }); +}); diff --git a/src/lib/utils/upstream-error-detection.ts b/src/lib/utils/upstream-error-detection.ts index 066f1bc8f..f8231a4ae 100644 --- a/src/lib/utils/upstream-error-detection.ts +++ b/src/lib/utils/upstream-error-detection.ts @@ -18,6 +18,7 @@ import { parseSSEData } from "@/lib/utils/sse"; * * 设计目标(偏保守) * - 仅基于结构化字段做启发式判断:`error` 与 `message`; + * - 对明显的 HTML 文档(doctype/html 标签)做强信号判定,覆盖部分网关/WAF/Cloudflare 返回的“假 200”; * - 不扫描模型生成的正文内容(例如 content/choices),避免把用户/模型自然语言里的 "error" 误判为上游错误; * - message 关键字检测仅对“小体积 JSON”启用,降低误判与性能开销。 * - 返回的 `code` 是语言无关的错误码(便于写入 DB/监控/告警); @@ -31,6 +32,22 @@ export type UpstreamErrorDetectionResult = detail?: string; }; +/** + * 基于“响应体文本内容”的状态码推断结果。 + * + * 设计目标(偏保守): + * - 仅用于“假 200”场景:上游返回 HTTP 200,但 body 明显是错误页/错误 JSON; + * - 用于把内部结算/熔断/故障转移的 statusCode 调整为更贴近真实错误语义的 4xx/5xx; + * - 若未命中任何规则,应保持调用方既有默认行为(通常回退为 502)。 + */ +export type UpstreamErrorStatusInferenceResult = { + statusCode: number; + /** + * 命中的规则 id(用于内部审计/调试;不应作为用户展示文案)。 + */ + matcherId: string; +}; + type DetectionOptions = { /** * 仅对小体积 JSON 启用 message 关键字检测,避免误判与无谓开销。 @@ -53,6 +70,7 @@ const DEFAULT_MESSAGE_KEYWORD = /error/i; const FAKE_200_CODES = { EMPTY_BODY: "FAKE_200_EMPTY_BODY", + HTML_BODY: "FAKE_200_HTML_BODY", JSON_ERROR_NON_EMPTY: "FAKE_200_JSON_ERROR_NON_EMPTY", JSON_ERROR_MESSAGE_NON_EMPTY: "FAKE_200_JSON_ERROR_MESSAGE_NON_EMPTY", JSON_MESSAGE_KEYWORD_MATCH: "FAKE_200_JSON_MESSAGE_KEYWORD_MATCH", @@ -63,6 +81,142 @@ const FAKE_200_CODES = { const MAY_HAVE_JSON_ERROR_KEY = /"error"\s*:/; const MAY_HAVE_JSON_MESSAGE_KEY = /"message"\s*:/; +const HTML_DOC_SNIFF_MAX_CHARS = 1024; +const HTML_DOCTYPE_RE = /^]/i; +const HTML_HTML_TAG_RE = /^]/i; + +// 状态码推断:为避免在极端大响应体上执行正则带来额外开销,仅取前缀做匹配。 +// 说明:对“假 200”错误页/错误 JSON 来说,关键错误信息通常会出现在前段。 +const STATUS_INFERENCE_MAX_CHARS = 64 * 1024; + +// 注意:这些正则只用于“假 200”场景,且仅在 detectUpstreamErrorFromSseOrJsonText 已判定 isError=true 时才会被调用。 +// 因此允许包含少量“关键词启发式”,但仍应尽量避免过宽匹配,降低误判导致“错误码误推断”的概率。 +const ERROR_STATUS_MATCHERS: Array<{ statusCode: number; matcherId: string; re: RegExp }> = [ + { + statusCode: 429, + matcherId: "rate_limit", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+429\b|\b429\s+too\s+many\s+requests\b|\btoo\s+many\s+requests\b|\brate\s*limit(?:ed|ing)?\b|\bthrottl(?:e|ed|ing)\b|\bretry-after\b|\bRESOURCE_EXHAUSTED\b|\bRequestLimitExceeded\b|\bThrottling(?:Exception)?\b|\bError\s*1015\b|超出频率|请求过于频繁|限流|稍后重试)/iu, + }, + { + statusCode: 402, + matcherId: "payment_required", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+402\b|\bpayment\s+required\b|\binsufficient\s+(?:balance|funds|credits)\b|\b(?:out\s+of|no)\s+credits\b|\binsufficient_balance\b|\bbilling_hard_limit_reached\b|\bcard\s+(?:declined|expired)\b|\bpayment\s+(?:method|failed)\b|余额不足|欠费|请充值|支付(?:失败|方式))/iu, + }, + { + statusCode: 401, + matcherId: "unauthorized", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+401\b|\bunauthori(?:sed|zed)\b|\bunauthenticated\b|\bauthentication\s+failed\b|\b(?:invalid|incorrect|missing)\s+api[-_ ]?key\b|\binvalid\s+token\b|\bexpired\s+token\b|\bsignature\s+(?:invalid|mismatch)\b|\bUNAUTHENTICATED\b|未授权|鉴权失败|密钥无效|token\s*过期)/iu, + }, + { + statusCode: 403, + matcherId: "forbidden", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+403\b|\bforbidden\b|\bpermission\s+denied\b|\baccess\s+denied\b|\bnot\s+allowed\b|\baccount\s+(?:disabled|suspended|banned)\b|\bnot\s+whitelisted\b|\bPERMISSION_DENIED\b|\bAccessDenied(?:Exception)?\b|\bError\s*1020\b|\b(?:region|country)\b[\s\S]{0,40}\b(?:not\s+supported|blocked)\b|地区不支持|禁止访问|无权限|权限不足|账号被封|地区(?:限制|屏蔽))/iu, + }, + { + statusCode: 404, + matcherId: "not_found", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+404\b|\b(?:model|deployment|endpoint|resource|route|path|api|service|url)\s+not\s+found\b|\bunknown\s+model\b|\bdoes\s+not\s+exist\b|\bNOT_FOUND\b|\bResourceNotFoundException\b|未找到|不存在|模型不存在)/iu, + }, + { + statusCode: 413, + matcherId: "payload_too_large", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+413\b|\bpayload\s+too\s+large\b|\brequest\s+entity\s+too\s+large\b|\bbody\s+too\s+large\b|\bContent-Length\b[\s\S]{0,40}\btoo\s+large\b|\bexceed(?:s|ed)?\b[\s\S]{0,40}\b(?:max(?:imum)?|limit)\b[\s\S]{0,40}\b(?:size|length)\b|请求体过大|内容过大|超过最大)/iu, + }, + { + statusCode: 415, + matcherId: "unsupported_media_type", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+415\b|\bunsupported\s+media\s+type\b|\binvalid\s+content-type\b|\bContent-Type\b[\s\S]{0,40}\b(?:must\s+be|required)\b|不支持的媒体类型|Content-Type\s*错误)/iu, + }, + { + statusCode: 409, + matcherId: "conflict", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+409\b|\bconflict\b|\bidempotency(?:-key)?\b|\bABORTED\b|冲突|幂等)/iu, + }, + { + statusCode: 422, + matcherId: "unprocessable_entity", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+422\b|\bunprocessable\s+entity\b|\bINVALID_ARGUMENT\b[\s\S]{0,40}\bvalidation\b|\bschema\s+validation\b|实体无法处理)/iu, + }, + { + statusCode: 408, + matcherId: "request_timeout", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+408\b|\brequest\s+timeout\b|请求\s*超时)/iu, + }, + { + statusCode: 451, + matcherId: "legal_restriction", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+451\b|\bunavailable\s+for\s+legal\s+reasons\b|\bexport\s+control\b|\bsanctions?\b|法律原因不可用|合规限制|出口管制)/iu, + }, + { + statusCode: 503, + matcherId: "service_unavailable", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+503\b|\bservice\s+unavailable\b|\boverloaded\b|\bserver\s+is\s+busy\b|\btry\s+again\s+later\b|\btemporarily\s+unavailable\b|\bmaintenance\b|\bUNAVAILABLE\b|\bServiceUnavailableException\b|\bError\s*521\b|服务不可用|过载|系统繁忙|维护中)/iu, + }, + { + statusCode: 504, + matcherId: "gateway_timeout", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+504\b|\bgateway\s+timeout\b|\bupstream\b[\s\S]{0,40}\btim(?:e|ed)\s*out\b|\bDEADLINE_EXCEEDED\b|\bError\s*522\b|\bError\s*524\b|网关超时|上游超时)/iu, + }, + { + statusCode: 500, + matcherId: "internal_server_error", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+500\b|\binternal\s+server\s+error\b|\bInternalServerException\b|\bINTERNAL\b|内部错误|服务器错误)/iu, + }, + { + statusCode: 400, + matcherId: "bad_request", + re: /(?:\bHTTP\/\d(?:\.\d)?\s+400\b|\bbad\s+request\b|\bINVALID_ARGUMENT\b|\bjson\s+parse\b|\binvalid\s+json\b|\bunexpected\s+token\b|无效请求|格式错误|JSON\s*解析失败)/iu, + }, +]; + +/** + * 从上游响应体文本中推断一个“更贴近错误语义”的 HTTP 状态码(用于假200修正)。 + * + * 注意: + * - 该函数不会判断“是否为错误”,只做“状态码推断”;调用方应确保仅在已判定错误时才调用。 + * - 未命中时返回 null,调用方应保持现有默认错误码(通常为 502)。 + */ +export function inferUpstreamErrorStatusCodeFromText( + text: string +): UpstreamErrorStatusInferenceResult | null { + let trimmed = text.trim(); + if (!trimmed) return null; + + // 与 detectUpstreamErrorFromSseOrJsonText 保持一致:移除 UTF-8 BOM,避免关键字匹配失效。 + if (trimmed.charCodeAt(0) === 0xfeff) { + trimmed = trimmed.slice(1).trimStart(); + } + + const limited = + trimmed.length > STATUS_INFERENCE_MAX_CHARS + ? trimmed.slice(0, STATUS_INFERENCE_MAX_CHARS) + : trimmed; + + for (const matcher of ERROR_STATUS_MATCHERS) { + if (matcher.re.test(limited)) { + return { statusCode: matcher.statusCode, matcherId: matcher.matcherId }; + } + } + + return null; +} + +/** + * 判断文本是否“很像”一个完整的 HTML 文档(强信号)。 + * + * 规则(偏保守): + * - 仅当文本以 `<` 开头时才继续; + * - 仅在前 1024 字符内检测 `` 或以 `` 开头; + * - 不做 HTML 解析/清洗,避免误判与额外开销。 + * + * 说明:调用方应先对文本做 `trim()`,并在需要时移除 BOM(`\uFEFF`)。 + */ +function isLikelyHtmlDocument(trimmedText: string): boolean { + if (!trimmedText.startsWith("<")) return false; + const head = trimmedText.slice(0, HTML_DOC_SNIFF_MAX_CHARS); + return HTML_DOCTYPE_RE.test(head) || HTML_HTML_TAG_RE.test(head); +} + function isPlainRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -82,7 +236,7 @@ function hasNonEmptyValue(value: unknown): boolean { return true; } -function sanitizeErrorTextForDetail(text: string): string { +export function sanitizeErrorTextForDetail(text: string): string { // 注意:这里的目的不是“完美脱敏”,而是尽量降低上游错误信息中意外夹带敏感内容的风险。 // 若后续发现更多敏感模式,可在不改变检测语义的前提下补充。 let sanitized = text; @@ -189,11 +343,31 @@ export function detectUpstreamErrorFromSseOrJsonText( messageKeyword: options.messageKeyword ?? DEFAULT_MESSAGE_KEYWORD, }; - const trimmed = text.trim(); + let trimmed = text.trim(); if (!trimmed) { return { isError: true, code: FAKE_200_CODES.EMPTY_BODY }; } + // 某些上游会带 UTF-8 BOM(\uFEFF),会导致 startsWith("{") / startsWith("<") 等快速判断失效。 + // 这里仅剥离首字符 BOM,并再做一次 trimStart,避免误判。 + if (trimmed.charCodeAt(0) === 0xfeff) { + trimmed = trimmed.slice(1).trimStart(); + } + + // 情况 0:明显的 HTML 文档(通常是网关/WAF/Cloudflare 返回的错误页) + // + // 说明: + // - 此处不依赖 Content-Type:部分上游会缺失/错误设置该字段; + // - 仅匹配 doctype/html 标签等“强信号”,避免把普通 `<...>` 文本误判为 HTML 页面。 + if (isLikelyHtmlDocument(trimmed)) { + return { + isError: true, + code: FAKE_200_CODES.HTML_BODY, + // 避免对超大 HTML 做无谓处理:仅截取前段用于脱敏/截断与排查 + detail: truncateForDetail(trimmed.slice(0, 4096)), + }; + } + // 情况 1:纯 JSON(对象) // 上游可能 Content-Type 设置为 SSE,但实际上返回 JSON;此处只处理对象格式({...}), // 不处理数组([...])以避免误判(数组场景的语义差异较大,后续若确认需要再扩展)。 diff --git a/src/types/message.ts b/src/types/message.ts index 56fab4abd..faa2e3f6f 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -71,6 +71,15 @@ export interface ProviderChainItem { // 修复:新增成功时的状态码 statusCode?: number; + /** + * 标记 statusCode 是否为“基于响应体内容推断”的结果(而非上游真实返回的 HTTP 状态码)。 + * + * 典型场景:上游返回 HTTP 200,但 body 为错误页/错误 JSON(假 200)。 + * 此时为了让熔断/故障转移/会话绑定与“真实错误语义”保持一致,CCH 会推断更合理的 4xx/5xx。 + * + * 该字段用于在决策链 / 技术时间线 / UI 中显著提示“此状态码为推断”,避免误读。 + */ + statusCodeInferred?: boolean; // 模型重定向信息(在供应商级别记录) modelRedirect?: { diff --git a/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts b/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts new file mode 100644 index 000000000..c4c01dece --- /dev/null +++ b/tests/unit/proxy/error-handler-verbose-provider-error-details.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + return { + getCachedSystemSettings: vi.fn(async () => ({ verboseProviderError: false }) as any), + getErrorOverrideAsync: vi.fn(async () => undefined), + }; +}); + +vi.mock("@/lib/config/system-settings-cache", () => ({ + getCachedSystemSettings: mocks.getCachedSystemSettings, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }, +})); + +vi.mock("@/app/v1/_lib/proxy/errors", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getErrorOverrideAsync: mocks.getErrorOverrideAsync, + }; +}); + +import { ProxyErrorHandler } from "@/app/v1/_lib/proxy/error-handler"; +import { EmptyResponseError, ProxyError } from "@/app/v1/_lib/proxy/errors"; + +function createSession(): any { + return { + sessionId: null, + messageContext: null, + startTime: Date.now(), + getProviderChain: () => [], + getCurrentModel: () => null, + getContext1mApplied: () => false, + provider: null, + }; +} + +describe("ProxyErrorHandler.handle - verboseProviderError details", () => { + beforeEach(() => { + mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: false } as any); + mocks.getErrorOverrideAsync.mockResolvedValue(undefined); + }); + + test("verboseProviderError=false 时,不应附带 fake-200 raw body/details", async () => { + const session = createSession(); + const err = new ProxyError("FAKE_200_JSON_ERROR_NON_EMPTY", 429, { + body: "sanitized", + providerId: 1, + providerName: "p1", + requestId: "req_123", + rawBody: '{"error":"boom"}', + rawBodyTruncated: false, + statusCodeInferred: true, + statusCodeInferenceMatcherId: "rate_limit", + }); + + const res = await ProxyErrorHandler.handle(session, err); + expect(res.status).toBe(429); + + const body = await res.json(); + expect(body.error.details).toBeUndefined(); + expect(body.request_id).toBeUndefined(); + }); + + test("verboseProviderError=true 时,fake-200 应返回详细报告与上游原文", async () => { + mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: true } as any); + + const session = createSession(); + const err = new ProxyError("FAKE_200_HTML_BODY", 429, { + body: "redacted snippet", + providerId: 1, + providerName: "p1", + requestId: "req_123", + rawBody: "blocked", + rawBodyTruncated: false, + statusCodeInferred: true, + statusCodeInferenceMatcherId: "rate_limit", + }); + + const res = await ProxyErrorHandler.handle(session, err); + expect(res.status).toBe(429); + + const body = await res.json(); + expect(body.request_id).toBe("req_123"); + expect(body.error.details).toEqual({ + upstreamError: { + kind: "fake_200", + code: "FAKE_200_HTML_BODY", + statusCode: 429, + statusCodeInferred: true, + statusCodeInferenceMatcherId: "rate_limit", + clientSafeMessage: expect.any(String), + rawBody: "blocked", + rawBodyTruncated: false, + }, + }); + }); + + test("verboseProviderError=true 时,rawBody 应做基础脱敏(避免泄露 token/key)", async () => { + mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: true } as any); + + const session = createSession(); + const err = new ProxyError("FAKE_200_HTML_BODY", 429, { + body: "redacted snippet", + providerId: 1, + providerName: "p1", + requestId: "req_123", + rawBody: + "Authorization: Bearer abc123 sk-1234567890abcdef1234567890 test@example.com", + rawBodyTruncated: false, + statusCodeInferred: true, + statusCodeInferenceMatcherId: "rate_limit", + }); + + const res = await ProxyErrorHandler.handle(session, err); + expect(res.status).toBe(429); + + const body = await res.json(); + expect(body.request_id).toBe("req_123"); + expect(body.error.details.upstreamError.kind).toBe("fake_200"); + + const rawBody = body.error.details.upstreamError.rawBody as string; + expect(rawBody).toContain("Bearer [REDACTED]"); + expect(rawBody).toContain("[REDACTED_KEY]"); + expect(rawBody).toContain("[EMAIL]"); + expect(rawBody).not.toContain("Bearer abc123"); + expect(rawBody).not.toContain("sk-1234567890abcdef1234567890"); + expect(rawBody).not.toContain("test@example.com"); + }); + + test("verboseProviderError=true 时,空响应错误也应返回详细报告(rawBody 为空字符串)", async () => { + mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: true } as any); + + const session = createSession(); + const err = new EmptyResponseError(1, "p1", "empty_body"); + + const res = await ProxyErrorHandler.handle(session, err); + expect(res.status).toBe(502); + + const body = await res.json(); + expect(body.error.details).toEqual({ + upstreamError: { + kind: "empty_response", + reason: "empty_body", + clientSafeMessage: "Empty response: Response body is empty", + rawBody: "", + rawBodyTruncated: false, + }, + }); + }); + + test("有 error override 时,verbose details 不应覆盖覆写逻辑(优先级更低)", async () => { + mocks.getCachedSystemSettings.mockResolvedValue({ verboseProviderError: true } as any); + mocks.getErrorOverrideAsync.mockResolvedValue({ response: null, statusCode: 418 }); + + const session = createSession(); + const err = new ProxyError("FAKE_200_JSON_ERROR_NON_EMPTY", 429, { + body: "sanitized", + providerId: 1, + providerName: "p1", + requestId: "req_123", + rawBody: '{"error":"boom"}', + rawBodyTruncated: false, + statusCodeInferred: true, + statusCodeInferenceMatcherId: "rate_limit", + }); + + const res = await ProxyErrorHandler.handle(session, err); + expect(res.status).toBe(418); + + const body = await res.json(); + expect(body.error.details).toBeUndefined(); + }); +}); diff --git a/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts b/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts index 6891fff23..0a3e23e53 100644 --- a/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts +++ b/tests/unit/proxy/proxy-forwarder-endpoint-audit.test.ts @@ -562,6 +562,59 @@ describe("ProxyForwarder - endpoint audit", () => { expect(exhaustedItem!.errorMessage).toBeUndefined(); }); + test("endpoint_pool_exhausted should not be deduped away when initial_selection already recorded", async () => { + const requestPath = "/v1/messages"; + const session = createSession(new URL(`https://example.com${requestPath}`)); + const provider = createProvider({ + providerType: "claude", + providerVendorId: 123, + url: "https://provider.example.com/v1/messages", + }); + session.setProvider(provider); + + // Simulate ProviderSelector already recorded initial_selection for the same provider + session.addProviderToChain(provider, { reason: "initial_selection" }); + + mocks.getPreferredProviderEndpoints.mockResolvedValueOnce([]); + mocks.getEndpointFilterStats.mockResolvedValueOnce({ + total: 0, + enabled: 0, + circuitOpen: 0, + available: 0, + }); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown }, + "doForward" + ); + + await expect(ProxyForwarder.send(session)).rejects.toThrow(); + + expect(doForward).not.toHaveBeenCalled(); + + const chain = session.getProviderChain(); + expect(chain.some((item) => item.reason === "initial_selection")).toBe(true); + + const exhaustedItems = chain.filter((item) => item.reason === "endpoint_pool_exhausted"); + expect(exhaustedItems).toHaveLength(1); + + expect(exhaustedItems[0]).toEqual( + expect.objectContaining({ + id: provider.id, + name: provider.name, + reason: "endpoint_pool_exhausted", + strictBlockCause: "no_endpoint_candidates", + attemptNumber: 1, + endpointFilterStats: { + total: 0, + enabled: 0, + circuitOpen: 0, + available: 0, + }, + }) + ); + }); + test("endpoint pool exhausted (selector_error) should record endpoint_pool_exhausted with selectorError in decisionContext", async () => { const requestPath = "/v1/responses"; const session = createSession(new URL(`https://example.com${requestPath}`)); diff --git a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts new file mode 100644 index 000000000..2aa054a10 --- /dev/null +++ b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts @@ -0,0 +1,573 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + return { + pickRandomProviderWithExclusion: vi.fn(), + recordSuccess: vi.fn(), + recordFailure: vi.fn(async () => {}), + getCircuitState: vi.fn(() => "closed"), + getProviderHealthInfo: vi.fn(async () => ({ + health: { failureCount: 0 }, + config: { failureThreshold: 3 }, + })), + updateMessageRequestDetails: vi.fn(async () => {}), + isHttp2Enabled: vi.fn(async () => false), + getPreferredProviderEndpoints: vi.fn(async () => []), + getEndpointFilterStats: vi.fn(async () => null), + recordEndpointSuccess: vi.fn(async () => {}), + recordEndpointFailure: vi.fn(async () => {}), + isVendorTypeCircuitOpen: vi.fn(async () => false), + recordVendorTypeAllEndpointsTimeout: vi.fn(async () => {}), + // ErrorCategory.PROVIDER_ERROR + categorizeErrorAsync: vi.fn(async () => 0), + }; +}); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }, +})); + +vi.mock("@/lib/config", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isHttp2Enabled: mocks.isHttp2Enabled, + }; +}); + +vi.mock("@/lib/provider-endpoints/endpoint-selector", () => ({ + getPreferredProviderEndpoints: mocks.getPreferredProviderEndpoints, + getEndpointFilterStats: mocks.getEndpointFilterStats, +})); + +vi.mock("@/lib/endpoint-circuit-breaker", () => ({ + recordEndpointSuccess: mocks.recordEndpointSuccess, + recordEndpointFailure: mocks.recordEndpointFailure, +})); + +vi.mock("@/lib/circuit-breaker", () => ({ + getCircuitState: mocks.getCircuitState, + getProviderHealthInfo: mocks.getProviderHealthInfo, + recordFailure: mocks.recordFailure, + recordSuccess: mocks.recordSuccess, +})); + +vi.mock("@/lib/vendor-type-circuit-breaker", () => ({ + isVendorTypeCircuitOpen: mocks.isVendorTypeCircuitOpen, + recordVendorTypeAllEndpointsTimeout: mocks.recordVendorTypeAllEndpointsTimeout, +})); + +vi.mock("@/repository/message", () => ({ + updateMessageRequestDetails: mocks.updateMessageRequestDetails, +})); + +vi.mock("@/app/v1/_lib/proxy/provider-selector", () => ({ + ProxyProviderResolver: { + pickRandomProviderWithExclusion: mocks.pickRandomProviderWithExclusion, + }, +})); + +vi.mock("@/app/v1/_lib/proxy/errors", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + categorizeErrorAsync: mocks.categorizeErrorAsync, + }; +}); + +import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; +import { ProxyError } from "@/app/v1/_lib/proxy/errors"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import type { Provider } from "@/types/provider"; + +function createProvider(overrides: Partial = {}): Provider { + return { + id: 1, + name: "p1", + url: "https://provider.example.com", + key: "k", + providerVendorId: null, + isEnabled: true, + weight: 1, + priority: 0, + groupPriorities: null, + costMultiplier: 1, + groupTag: null, + providerType: "claude", + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + totalCostResetAt: null, + limitConcurrentSessions: 0, + maxRetryAttempts: 1, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 1_800_000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 30_000, + streamingIdleTimeoutMs: 10_000, + requestTimeoutNonStreamingMs: 1_000, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: null, + tpm: 0, + rpm: 0, + rpd: 0, + cc: 0, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + }; +} + +function createSession(): ProxySession { + const headers = new Headers(); + const session = Object.create(ProxySession.prototype); + + Object.assign(session, { + startTime: Date.now(), + method: "POST", + requestUrl: new URL("https://example.com/v1/messages"), + headers, + originalHeaders: new Headers(headers), + headerLog: JSON.stringify(Object.fromEntries(headers.entries())), + request: { + model: "claude-test", + log: "(test)", + message: { + model: "claude-test", + messages: [{ role: "user", content: "hi" }], + }, + }, + userAgent: null, + context: null, + clientAbortSignal: null, + userName: "test-user", + authState: { success: true, user: null, key: null, apiKey: null }, + provider: null, + messageContext: null, + sessionId: null, + requestSequence: 1, + originalFormat: "claude", + providerType: null, + originalModelName: null, + originalUrlPathname: null, + providerChain: [], + cacheTtlResolved: null, + context1mApplied: false, + specialSettings: [], + cachedPriceData: undefined, + cachedBillingModelSource: undefined, + isHeaderModified: () => false, + }); + + return session as ProxySession; +} + +describe("ProxyForwarder - fake 200 HTML body", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("200 + text/html 的 HTML 页面应视为失败并切换供应商", async () => { + const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 }); + const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 }); + + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + const htmlBody = [ + "", + "New API", + "blocked", + ].join("\n"); + const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] }); + + doForward.mockResolvedValueOnce( + new Response(htmlBody, { + status: 200, + headers: { + "content-type": "text/html; charset=utf-8", + "content-length": String(htmlBody.length), + }, + }) + ); + + doForward.mockResolvedValueOnce( + new Response(okJson, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "content-length": String(okJson.length), + }, + }) + ); + + const response = await ProxyForwarder.send(session); + expect(await response.text()).toContain("ok"); + + expect(doForward).toHaveBeenCalledTimes(2); + expect(doForward.mock.calls[0][1].id).toBe(1); + expect(doForward.mock.calls[1][1].id).toBe(2); + + expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]); + expect(mocks.recordFailure).toHaveBeenCalledWith( + 1, + expect.objectContaining({ message: "FAKE_200_HTML_BODY" }) + ); + const failure1 = mocks.recordFailure.mock.calls[0]?.[1]; + expect(failure1).toBeInstanceOf(ProxyError); + expect((failure1 as ProxyError).getClientSafeMessage()).toContain("HTML document"); + expect((failure1 as ProxyError).getClientSafeMessage()).toContain("Upstream detail:"); + expect(mocks.recordSuccess).toHaveBeenCalledWith(2); + expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); + }); + + test("200 + text/html 但 body 是 JSON error 也应视为失败并切换供应商", async () => { + const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 }); + const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 }); + + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + const jsonErrorBody = JSON.stringify({ error: "upstream blocked" }); + const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] }); + + doForward.mockResolvedValueOnce( + new Response(jsonErrorBody, { + status: 200, + headers: { + // 故意使用 text/html:模拟部分上游 Content-Type 错配但 body 仍为错误 JSON 的情况 + "content-type": "text/html; charset=utf-8", + "content-length": String(jsonErrorBody.length), + }, + }) + ); + + doForward.mockResolvedValueOnce( + new Response(okJson, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "content-length": String(okJson.length), + }, + }) + ); + + const response = await ProxyForwarder.send(session); + expect(await response.text()).toContain("ok"); + + expect(doForward).toHaveBeenCalledTimes(2); + expect(doForward.mock.calls[0][1].id).toBe(1); + expect(doForward.mock.calls[1][1].id).toBe(2); + + expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]); + expect(mocks.recordFailure).toHaveBeenCalledWith( + 1, + expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" }) + ); + const failure2 = mocks.recordFailure.mock.calls[0]?.[1]; + expect(failure2).toBeInstanceOf(ProxyError); + expect((failure2 as ProxyError).getClientSafeMessage()).toContain("JSON body"); + expect((failure2 as ProxyError).getClientSafeMessage()).toContain("`error`"); + expect((failure2 as ProxyError).getClientSafeMessage()).toContain("upstream blocked"); + expect((failure2 as ProxyError).upstreamError?.rawBody).toBe(jsonErrorBody); + expect((failure2 as ProxyError).upstreamError?.rawBodyTruncated).toBe(false); + expect(mocks.recordSuccess).toHaveBeenCalledWith(2); + expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); + }); + + test("200 + application/json 且有 Content-Length 的 JSON error 也应视为失败并切换供应商", async () => { + const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 }); + const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 }); + + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + const jsonErrorBody = JSON.stringify({ error: "upstream blocked" }); + const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] }); + + doForward.mockResolvedValueOnce( + new Response(jsonErrorBody, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "content-length": String(jsonErrorBody.length), + }, + }) + ); + + doForward.mockResolvedValueOnce( + new Response(okJson, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "content-length": String(okJson.length), + }, + }) + ); + + const response = await ProxyForwarder.send(session); + expect(await response.text()).toContain("ok"); + + expect(doForward).toHaveBeenCalledTimes(2); + expect(doForward.mock.calls[0][1].id).toBe(1); + expect(doForward.mock.calls[1][1].id).toBe(2); + + expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]); + expect(mocks.recordFailure).toHaveBeenCalledWith( + 1, + expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" }) + ); + const failure3 = mocks.recordFailure.mock.calls[0]?.[1]; + expect(failure3).toBeInstanceOf(ProxyError); + expect((failure3 as ProxyError).getClientSafeMessage()).toContain("JSON body"); + expect((failure3 as ProxyError).getClientSafeMessage()).toContain("`error`"); + expect((failure3 as ProxyError).getClientSafeMessage()).toContain("upstream blocked"); + expect((failure3 as ProxyError).upstreamError?.rawBody).toBe(jsonErrorBody); + expect((failure3 as ProxyError).upstreamError?.rawBodyTruncated).toBe(false); + expect(mocks.recordSuccess).toHaveBeenCalledWith(2); + expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); + }); + + test("假200 JSON error 命中 rate limit 关键字时,应推断为 429 并在决策链中标记为推断", async () => { + const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 }); + const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 }); + + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + const jsonErrorBody = JSON.stringify({ error: "Rate limit exceeded" }); + const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] }); + + doForward.mockResolvedValueOnce( + new Response(jsonErrorBody, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "content-length": String(jsonErrorBody.length), + }, + }) + ); + + doForward.mockResolvedValueOnce( + new Response(okJson, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "content-length": String(okJson.length), + }, + }) + ); + + const response = await ProxyForwarder.send(session); + expect(await response.text()).toContain("ok"); + + expect(mocks.recordFailure).toHaveBeenCalledWith( + 1, + expect.objectContaining({ message: "FAKE_200_JSON_ERROR_NON_EMPTY" }) + ); + + const failure = mocks.recordFailure.mock.calls[0]?.[1]; + expect(failure).toBeInstanceOf(ProxyError); + expect((failure as ProxyError).statusCode).toBe(429); + expect((failure as ProxyError).upstreamError?.statusCodeInferred).toBe(true); + + const chain = session.getProviderChain(); + expect( + chain.some( + (item) => + item.id === 1 && + item.reason === "retry_failed" && + item.statusCode === 429 && + item.statusCodeInferred === true + ) + ).toBe(true); + }); + + test("200 + 非法 Content-Length 时应按缺失处理,避免漏检 HTML 假200", async () => { + const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 }); + const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 }); + + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + const htmlErrorBody = "blocked"; + const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] }); + + doForward.mockResolvedValueOnce( + new Response(htmlErrorBody, { + status: 200, + headers: { + // 故意不提供 html/json 的 Content-Type,覆盖“仅靠 body 嗅探”的假200检测分支 + "content-type": "text/plain; charset=utf-8", + // 非法 Content-Length:parseInt("12abc") 会返回 12;修复后应视为非法并进入 body 检查分支 + "content-length": "12abc", + }, + }) + ); + + doForward.mockResolvedValueOnce( + new Response(okJson, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "content-length": String(okJson.length), + }, + }) + ); + + const response = await ProxyForwarder.send(session); + expect(await response.text()).toContain("ok"); + + expect(doForward).toHaveBeenCalledTimes(2); + expect(doForward.mock.calls[0][1].id).toBe(1); + expect(doForward.mock.calls[1][1].id).toBe(2); + + expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]); + expect(mocks.recordFailure).toHaveBeenCalledWith( + 1, + expect.objectContaining({ message: "FAKE_200_HTML_BODY" }) + ); + + const failure = mocks.recordFailure.mock.calls[0]?.[1]; + expect(failure).toBeInstanceOf(ProxyError); + expect((failure as ProxyError).upstreamError?.rawBody).toBe(htmlErrorBody); + expect(mocks.recordSuccess).toHaveBeenCalledWith(2); + expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); + }); + + test("缺少 content 字段(missing_content)不应被 JSON 解析 catch 吞掉,应触发切换供应商", async () => { + const provider1 = createProvider({ id: 1, name: "p1", key: "k1", maxRetryAttempts: 1 }); + const provider2 = createProvider({ id: 2, name: "p2", key: "k2", maxRetryAttempts: 1 }); + + const session = createSession(); + session.setProvider(provider1); + + mocks.pickRandomProviderWithExclusion.mockResolvedValueOnce(provider2); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + const missingContentJson = JSON.stringify({ type: "message", content: [] }); + const okJson = JSON.stringify({ type: "message", content: [{ type: "text", text: "ok" }] }); + + doForward.mockResolvedValueOnce( + new Response(missingContentJson, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + // 故意不提供 content-length:覆盖 forwarder 的 clone + JSON 内容结构检查分支 + }, + }) + ); + + doForward.mockResolvedValueOnce( + new Response(okJson, { + status: 200, + headers: { + "content-type": "application/json; charset=utf-8", + "content-length": String(okJson.length), + }, + }) + ); + + const response = await ProxyForwarder.send(session); + expect(await response.text()).toContain("ok"); + + expect(doForward).toHaveBeenCalledTimes(2); + expect(doForward.mock.calls[0][1].id).toBe(1); + expect(doForward.mock.calls[1][1].id).toBe(2); + + expect(mocks.pickRandomProviderWithExclusion).toHaveBeenCalledWith(session, [1]); + expect(mocks.recordFailure).toHaveBeenCalledWith( + 1, + expect.objectContaining({ reason: "missing_content" }) + ); + expect(mocks.recordSuccess).toHaveBeenCalledWith(2); + expect(mocks.recordSuccess).not.toHaveBeenCalledWith(1); + }); +}); + +describe("ProxyError.getClientSafeMessage - FAKE_200 sanitization", () => { + test("upstream body 包含 JWT 和 email 时应被脱敏为 [JWT] / [EMAIL]", () => { + const jwtToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"; + const email = "admin@example.com"; + const body = `Authentication failed for ${email} with token ${jwtToken}`; + + const error = new ProxyError("FAKE_200_JSON_ERROR_NON_EMPTY", 502, { + body, + providerId: 1, + providerName: "p1", + }); + + const msg = error.getClientSafeMessage(); + expect(msg).toContain("[JWT]"); + expect(msg).toContain("[EMAIL]"); + expect(msg).not.toContain(jwtToken); + expect(msg).not.toContain(email); + expect(msg).toContain("Upstream detail:"); + }); + + test("upstream body 包含 password=xxx 时应被脱敏", () => { + const body = "Config error: password=s3cretValue in /etc/app.json"; + + const error = new ProxyError("FAKE_200_HTML_BODY", 502, { + body, + providerId: 1, + providerName: "p1", + }); + + const msg = error.getClientSafeMessage(); + expect(msg).not.toContain("s3cretValue"); + expect(msg).toContain("[PATH]"); + expect(msg).toContain("Upstream detail:"); + }); +}); diff --git a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts index 16c531d24..533f8247f 100644 --- a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts +++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts @@ -183,7 +183,7 @@ function createSession(opts?: { sessionId?: string | null }): ProxySession { ttfbMs: null, getRequestSequence: () => 1, addProviderToChain: function ( - this: ProxySession & { providerChain: unknown[] }, + this: ProxySession & { providerChain: Record[] }, prov: { id: number; name: string; @@ -193,7 +193,8 @@ function createSession(opts?: { sessionId?: string | null }): ProxySession { costMultiplier: number; groupTag: string; providerVendorId?: string; - } + }, + metadata?: Record ) { this.providerChain.push({ id: prov.id, @@ -204,7 +205,11 @@ function createSession(opts?: { sessionId?: string | null }): ProxySession { weight: prov.weight, costMultiplier: prov.costMultiplier, groupTag: prov.groupTag, - timestamp: Date.now(), + timestamp: + typeof metadata?.timestamp === "number" && Number.isFinite(metadata.timestamp) + ? metadata.timestamp + : Date.now(), + ...(metadata ?? {}), }); }, }); @@ -249,8 +254,8 @@ function setDeferredMeta(session: ProxySession, endpointId: number | null = 42) } /** Create an SSE stream that emits a fake-200 error body (valid HTTP 200 but error in content). */ -function createFake200StreamResponse(): Response { - const body = `data: ${JSON.stringify({ error: { message: "invalid api key" } })}\n\n`; +function createFake200StreamResponse(errorMessage: string = "invalid api key"): Response { + const body = `data: ${JSON.stringify({ error: { message: errorMessage } })}\n\n`; const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { @@ -353,6 +358,40 @@ describe("Endpoint circuit breaker isolation", () => { expect.objectContaining({ message: expect.stringContaining("FAKE_200") }) ); expect(mockRecordEndpointFailure).not.toHaveBeenCalled(); + + const chain = session.getProviderChain(); + expect( + chain.some( + (item) => + item.id === 1 && + item.reason === "retry_failed" && + item.statusCode === 401 && + item.statusCodeInferred === true + ) + ).toBe(true); + }); + + it("fake-200 inferred 404 should NOT call recordFailure and should be marked as resource_not_found", async () => { + const session = createSession(); + setDeferredMeta(session, 42); + + const response = createFake200StreamResponse("model not found"); + await ProxyResponseHandler.dispatch(session, response); + await drainAsyncTasks(); + + expect(mockRecordFailure).not.toHaveBeenCalled(); + expect(mockRecordEndpointFailure).not.toHaveBeenCalled(); + + const chain = session.getProviderChain(); + expect( + chain.some( + (item) => + item.id === 1 && + item.reason === "resource_not_found" && + item.statusCode === 404 && + item.statusCodeInferred === true + ) + ).toBe(true); }); it("non-200 HTTP status should call recordFailure but NOT recordEndpointFailure", async () => { From de634d9fe9d811292a7c10bdb7069a2658c094d8 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Mon, 16 Feb 2026 01:07:36 +0800 Subject: [PATCH 03/75] refactor(provider): improve provider page performance (#789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(provider): improve provider page performance (#782) * fix: Providers 管理页批量化端点统计与测活日志 * perf: 优化 provider 统计与 my-usage 查询性能 * perf: Providers 管理页移除 refresh 放大器并按需加载端点区块 * fix: 跟进 review 补齐 Providers 批量与统计健壮性 * fix: 跟进 CodeRabbit 修复 in-view 与测活数据校验 * perf: 补齐 in-view 稳定化与 batch 404 复原 * perf: my-usage 配额/汇总减少 DB 往返 * perf(providers): 端点池热路径批量熔断查询与索引迁移 (#779) - 运行时端点选择与严格审计统计改为批量读取端点熔断状态,减少 Redis 往返\n- probe 写入在端点并发删除时静默忽略,避免 FK 失败导致任务中断\n- 新增索引迁移:idx_provider_endpoints_pick_enabled / idx_providers_vendor_type_url_active\n- repository 批量查询模块改为 server-only,避免误暴露为 Server Action * fix: 跟进 review 去重熔断 reset 与 scanEnd (#779) * fix: 精确熔断 reset + repo 使用 server-only (#779) * fix: my-usage 补齐 sessionId/warmup 过滤 (#779) * perf: provider 统计 in-flight 去重更稳健 (#779) * fix: ProviderForm 统一失效相关缓存 (#779) * fix: Providers/Usage 细节修正与用例补齐 (#779) * style: biome 格式化补齐 (#779) * fix(#779): 熔断状态同步与 probeLogs 批量查询改进 * fix(#781): 清理孤儿端点并修正 Endpoint Health * perf: 优化 usage logs 与端点同步(#779/#781) * refactor: 移除端点冗余过滤(#779) * fix: 熔断状态批量查询仅覆盖启用端点(#779) * fix: Provider 统计兼容脏数据并稳定 probe logs 排序(#779) * perf: 禁用 Providers 重查询的 window focus 自动刷新(#779) * fix: 多实例熔断状态定期同步,并修复 backfill 遗留软删除端点(#779/#781) * perf: probe scheduler 仅探测启用 provider 的端点(#781) * perf: ProviderForm 避免重复 refetch 并稳定 hover circuit key(#779) * perf: 全局 QueryClient 策略与 usage/user 索引优化(#779) * perf: 时区统计索引命中与批量删除优化(#779) * perf: 降低 logs/users 页面无效重算 * fix(provider): endpoint pool 仅基于启用 provider - sync/backfill/delete:引用判断与回填仅考虑 is_enabled=true 的 provider,避免 disabled provider 复活旧 endpoint - updateProvider:provider 从禁用启用时确保端点存在 - Dashboard Endpoint Health:避免并发刷新覆盖用户切换,vendor/type 仅从启用 provider 推导 - probe logs 批量接口:滚动发布场景下部分 404 不全局禁用 batch - 补齐 endpoint-selector 单测以匹配 findEnabled* 语义 * perf: Dashboard vendor/type 轻量查询与 usage logs 并行查询 * fix(migrate): advisory lock 串行迁移并移除 emoji 日志 * fix: endpoint hover 兜底并规范 batch probe logs SQL * perf(settings/providers): 减少冗余刷新并复用 endpoint/circuit 缓存 * perf(probe/statistics): 修正 probe 锁/计数并收敛统计与 usage 扫描 * perf(probe/ui): 优化 probe 目标筛选 SQL 并减少 sparkline 闪烁 * fix(db): 修复 Drizzle snapshot 链 * fix(perf): 补强 Providers 批量与缓存一致性 - Provider 统计:消除隐式 cross join,收敛 in-flight 清理;deleteProvidersBatch 降低事务内往返\n- Providers hover:按 QueryClient 隔离微批量并支持 AbortSignal,减少串扰与潜在泄漏\n- Probe/熔断/缓存:probe 目标查询改为 join;Redis 同步时更新计数字段;统计缓存保持 FIFO 语义\n- My Usage:userBreakdown 补齐 5m/1h cache 聚合列(当前 UI 未展示) * chore: format code (issue-779-provider-performance-23b338e) * chore: 触发 CI 重跑 * fix(provider): 批量启用时补齐 endpoint pool - batchUpdateProviders 会走 updateProvidersBatch;当供应商从 disabled 批量启用时,best-effort 插入缺失的 provider_endpoints 记录\n- 避免历史/竞态导致启用后严格端点策略下无可用 endpoint 而被阻断 * fix(perf): 收敛 Providers 刷新放大并优化探测/分页 * perf: 收敛 availability/probe 轮询并优化 my-usage (#779/#781) - AvailabilityDashboard: 抑制重叠/乱序刷新,前后台切换节流强刷\n- Probe scheduler/cleanup: idle DB poll + 锁续租,降低无意义扫描与并发清理\n- Endpoint circuit: Redis 同步节流(1s)\n- My Usage: key/user breakdown 合并为单次聚合\n- DB: 新增 message_request key+model/endpoint 部分索引迁移;修复 journal 单调性校验与迁移表 created_at 自愈 * fix(ui): 恢复全局 react-query 默认配置 * fix(availability): 刷新 vendors 时清理旧 endpoint 选择 * perf: 补强 Providers 探测与 Usage Logs 性能 * perf(ui): useInViewOnce 共享 IntersectionObserver 降低资源占用 - 按 (root+options) 复用 observer pool,减少长列表/大表格下的 observer 实例数\n- 补齐单测覆盖(test env 直通 + 共享/释放语义) * perf: providers batch where 优化与 sparkline 降级并发修正 * perf: my-usage breakdown 补齐缓存字段并优化筛选缓存 * perf: 优化端点熔断 Redis 负载与探测候选 * fix(#781): Endpoint Health 仅展示启用 provider 引用端点 * 修正端点健康筛选并增强URL解析容错 * docs(provider-endpoints): 说明 keepPreviousWhenReferenced 语义 * perf(availability): EndpointTab 前后台切换节流刷新 * docs(availability): 补充 EndpointTab 刷新节流注释 * chore(review): 按 AI 审阅补齐注释并收敛细节 * fix: 修正 provider 统计 SQL 的 DST 日界 --------- Co-authored-by: tesgth032 Co-authored-by: github-actions[bot] * refactor: consolidate migrations, extract shared utilities, fix bugbot issues Merge 6 index migrations (0068-0073) into single idempotent migration. Extract reusable utilities from duplicated code across the codebase: - TTLMap: generic LRU+TTL cache replacing 3 inline implementations - createAbortError: shared abort error factory from 2 components - startLeaderLockKeepAlive: shared leader lock renewal from 2 schedulers - ProbeLogsBatcher: data-fetching infra extracted from sparkline component - buildUsageLogConditions: shared SQL filter builder from 3 query functions Additional cleanup: - Simplify useInViewOnce hook (remove unused options, keep shared observer pool) - Remove dead code (sumKeyTotalCostById, unexport internal types) - Hardcode env var defaults (ENDPOINT_CIRCUIT_HEALTH_CACHE_MAX_SIZE, ENDPOINT_PROBE_IDLE_DB_POLL_INTERVAL_MS) - Fix in-flight dedup race condition in getProviderStatistics - Fix yesterday/today interval boundary inconsistency (lte -> lt) - Add NaN guard for limitPerEndpoint in batch probe logs - Add updatedAt to deleteProvider for audit consistency - Log swallowed flush() errors in batchers instead of silently catching * fix: resolve loading state reset and advisory lock client close errors Remove silent option guard so vendor loading state always resets when the request completes, preventing stale loading indicators. Wrap advisory lock client.end() in try-catch to avoid unhandled errors during connection teardown. --------- Co-authored-by: tesgth032 Co-authored-by: tesgth032 Co-authored-by: github-actions[bot] --- .env.example | 3 + biome.json | 2 +- drizzle/0068_flaky_swarm.sql | 14 + drizzle/meta/0068_snapshot.json | 3237 +++++++++++++++++ drizzle/meta/_journal.json | 7 + scripts/validate-migrations.js | 76 + src/actions/my-usage.ts | 307 +- src/actions/provider-endpoints.ts | 382 +- src/actions/providers.ts | 8 +- .../_components/availability-dashboard.tsx | 197 +- .../_components/endpoint-probe-history.tsx | 114 +- .../_components/endpoint/endpoint-tab.tsx | 349 +- .../_components/endpoint/probe-grid.tsx | 25 +- .../usage-logs-view-virtualized.tsx | 35 +- .../_components/virtualized-logs-table.tsx | 8 +- .../dashboard/users/users-page-client.tsx | 36 +- .../_components/add-provider-dialog.tsx | 8 - .../endpoint-latency-sparkline.tsx | 91 +- .../_components/forms/provider-form/index.tsx | 20 +- .../_components/provider-endpoint-hover.tsx | 389 +- .../_components/provider-endpoints-table.tsx | 95 +- .../providers/_components/provider-list.tsx | 15 + .../_components/provider-rich-list-item.tsx | 31 +- .../_components/provider-vendor-view.tsx | 13 +- .../_components/vendor-keys-compact-list.tsx | 39 +- src/app/api/actions/[...route]/route.ts | 40 + src/app/api/availability/endpoints/route.ts | 7 +- src/drizzle/schema.ts | 49 + src/instrumentation.ts | 86 +- src/lib/abort-utils.ts | 10 + src/lib/cache/ttl-map.ts | 79 + src/lib/endpoint-circuit-breaker.ts | 221 +- src/lib/hooks/use-in-view-once.ts | 149 + src/lib/migrate.ts | 161 +- .../provider-endpoints/endpoint-selector.ts | 75 +- src/lib/provider-endpoints/leader-lock.ts | 61 +- .../provider-endpoints/probe-log-cleanup.ts | 20 + .../provider-endpoints/probe-logs-batcher.ts | 387 ++ src/lib/provider-endpoints/probe-scheduler.ts | 159 +- src/lib/provider-endpoints/probe.ts | 27 +- src/lib/redis/client.ts | 5 +- .../redis/endpoint-circuit-breaker-state.ts | 94 +- src/repository/_shared/usage-log-filters.ts | 57 + src/repository/index.ts | 2 + src/repository/leaderboard.ts | 38 +- src/repository/overview.ts | 26 +- src/repository/provider-endpoints-batch.ts | 174 + src/repository/provider-endpoints.ts | 412 ++- src/repository/provider.ts | 507 ++- src/repository/statistics.ts | 481 ++- src/repository/usage-logs.ts | 432 ++- .../provider-endpoint-sync-race.test.ts | 7 +- .../my-usage-concurrent-inherit.test.ts | 15 +- .../unit/actions/my-usage-consistency.test.ts | 7 +- .../actions/my-usage-date-range-dst.test.ts | 16 +- .../my-usage-token-aggregation.test.ts | 32 +- tests/unit/actions/provider-endpoints.test.ts | 203 +- .../unit/actions/providers-recluster.test.ts | 4 + .../actions/total-usage-semantics.test.ts | 64 +- .../unit/lib/endpoint-circuit-breaker.test.ts | 149 +- .../endpoint-selector.test.ts | 131 +- .../probe-scheduler.test.ts | 1 + tests/unit/lib/ttl-map.test.ts | 108 + tests/unit/lib/use-in-view-once.test.tsx | 167 + .../provider-endpoint-sync-helper.test.ts | 21 +- .../provider-endpoints-probe-result.test.ts | 94 + .../statistics-quota-costs-all-time.test.ts | 142 + .../endpoint-latency-sparkline-ui.test.tsx | 5 +- ...provider-rich-list-item-endpoints.test.tsx | 9 + 69 files changed, 9028 insertions(+), 1407 deletions(-) create mode 100644 drizzle/0068_flaky_swarm.sql create mode 100644 drizzle/meta/0068_snapshot.json create mode 100644 src/lib/abort-utils.ts create mode 100644 src/lib/cache/ttl-map.ts create mode 100644 src/lib/hooks/use-in-view-once.ts create mode 100644 src/lib/provider-endpoints/probe-logs-batcher.ts create mode 100644 src/repository/_shared/usage-log-filters.ts create mode 100644 src/repository/provider-endpoints-batch.ts create mode 100644 tests/unit/lib/ttl-map.test.ts create mode 100644 tests/unit/lib/use-in-view-once.test.tsx create mode 100644 tests/unit/repository/provider-endpoints-probe-result.test.ts create mode 100644 tests/unit/repository/statistics-quota-costs-all-time.test.ts diff --git a/.env.example b/.env.example index 72ea5933c..a9216eebb 100644 --- a/.env.example +++ b/.env.example @@ -162,6 +162,9 @@ PROBE_TIMEOUT_MS=5000 # # ENDPOINT_PROBE_INTERVAL_MS controls the base interval. Single-vendor and timeout intervals are fixed. ENDPOINT_PROBE_INTERVAL_MS=60000 +# When no endpoints are due, scheduler will still poll DB periodically to pick up config changes. +# Default: min(ENDPOINT_PROBE_INTERVAL_MS, 30000) +ENDPOINT_PROBE_IDLE_DB_POLL_INTERVAL_MS=30000 ENDPOINT_PROBE_TIMEOUT_MS=5000 ENDPOINT_PROBE_CONCURRENCY=10 ENDPOINT_PROBE_CYCLE_JITTER_MS=1000 diff --git a/biome.json b/biome.json index 4e430dd39..b4820643f 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.15/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/drizzle/0068_flaky_swarm.sql b/drizzle/0068_flaky_swarm.sql new file mode 100644 index 000000000..5d09a506e --- /dev/null +++ b/drizzle/0068_flaky_swarm.sql @@ -0,0 +1,14 @@ +-- Note: message_request is a high-write table. Standard CREATE INDEX may block writes during index creation. +-- Drizzle migrator does not support CREATE INDEX CONCURRENTLY. If write blocking is a concern, +-- manually pre-create indexes with CONCURRENTLY before running this migration (IF NOT EXISTS prevents conflicts). +CREATE INDEX IF NOT EXISTS "idx_keys_key" ON "keys" USING btree ("key");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_message_request_key_created_at_id" ON "message_request" USING btree ("key","created_at" DESC NULLS LAST,"id" DESC NULLS LAST) WHERE "message_request"."deleted_at" IS NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_message_request_key_model_active" ON "message_request" USING btree ("key","model") WHERE "message_request"."deleted_at" IS NULL AND "message_request"."model" IS NOT NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> 'warmup');--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_message_request_key_endpoint_active" ON "message_request" USING btree ("key","endpoint") WHERE "message_request"."deleted_at" IS NULL AND "message_request"."endpoint" IS NOT NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> 'warmup');--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_message_request_created_at_id_active" ON "message_request" USING btree ("created_at" DESC NULLS LAST,"id" DESC NULLS LAST) WHERE "message_request"."deleted_at" IS NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_message_request_model_active" ON "message_request" USING btree ("model") WHERE "message_request"."deleted_at" IS NULL AND "message_request"."model" IS NOT NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_message_request_status_code_active" ON "message_request" USING btree ("status_code") WHERE "message_request"."deleted_at" IS NULL AND "message_request"."status_code" IS NOT NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_provider_endpoints_pick_enabled" ON "provider_endpoints" USING btree ("vendor_id","provider_type","is_enabled","sort_order","id") WHERE "provider_endpoints"."deleted_at" IS NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_providers_vendor_type_url_active" ON "providers" USING btree ("provider_vendor_id","provider_type","url") WHERE "providers"."deleted_at" IS NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_providers_enabled_vendor_type" ON "providers" USING btree ("provider_vendor_id","provider_type") WHERE "providers"."deleted_at" IS NULL AND "providers"."is_enabled" = true AND "providers"."provider_vendor_id" IS NOT NULL AND "providers"."provider_vendor_id" > 0;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_users_tags_gin" ON "users" USING gin ("tags") WHERE "users"."deleted_at" IS NULL; diff --git a/drizzle/meta/0068_snapshot.json b/drizzle/meta/0068_snapshot.json new file mode 100644 index 000000000..400e6174c --- /dev/null +++ b/drizzle/meta/0068_snapshot.json @@ -0,0 +1,3237 @@ +{ + "id": "81847b3d-5ce4-4fb0-bad1-9e4570f3c5fb", + "prevId": "c4820501-71eb-4492-b9cb-dd19c5def277", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 900a6f6a6..6a74178de 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -477,6 +477,13 @@ "when": 1771045274665, "tag": "0067_gorgeous_mulholland_black", "breakpoints": true + }, + { + "idx": 68, + "version": "7", + "when": 1771164248361, + "tag": "0068_flaky_swarm", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/validate-migrations.js b/scripts/validate-migrations.js index 6b94cbaca..c85637243 100755 --- a/scripts/validate-migrations.js +++ b/scripts/validate-migrations.js @@ -132,6 +132,63 @@ function validateMigrationFile(filePath) { return { fileName, issues }; } +/** + * 校验 Drizzle journal 的时间戳单调性 + * + * Drizzle PG migrator 仅通过 `created_at(folderMillis)` 与 DB 中最新一条迁移记录做比较来决定是否执行迁移: + * - 若 journal 中 `when` 非严格递增,可能导致“后续迁移被永久跳过”(无感升级会漏执行) + */ +function validateJournalMonotonicity(journalPath) { + const content = fs.readFileSync(journalPath, "utf-8"); + const journal = JSON.parse(content); + + if (!journal || !Array.isArray(journal.entries)) { + return { + fileName: path.basename(journalPath), + issues: [ + { + type: "JOURNAL", + line: 0, + statement: "Invalid journal format: entries[] is missing", + suggestion: "Ensure drizzle/meta/_journal.json contains a valid { entries: [...] }", + }, + ], + }; + } + + const issues = []; + let previousWhen = Number.NEGATIVE_INFINITY; + let previousTag = ""; + + for (const entry of journal.entries) { + const tag = typeof entry?.tag === "string" ? entry.tag : "(unknown)"; + const when = entry?.when; + if (typeof when !== "number" || !Number.isFinite(when)) { + issues.push({ + type: "JOURNAL", + line: 0, + statement: `Invalid journal entry 'when' for tag=${tag}`, + suggestion: "Ensure each journal entry has a numeric 'when' (folderMillis).", + }); + continue; + } + + if (when <= previousWhen) { + issues.push({ + type: "JOURNAL", + line: 0, + statement: `Non-monotonic journal 'when': ${tag}(${when}) <= ${previousTag}(${previousWhen})`, + suggestion: "Ensure journal entries' 'when' are strictly increasing in execution order.", + }); + } + + previousWhen = when; + previousTag = tag; + } + + return { fileName: path.basename(journalPath), issues }; +} + /** * 主函数 */ @@ -155,6 +212,25 @@ function main() { let totalIssues = 0; const filesWithIssues = []; + // 校验 meta/_journal.json 的单调性(避免漏迁移) + const journalPath = path.join(MIGRATIONS_DIR, "meta/_journal.json"); + if (fs.existsSync(journalPath)) { + const journalResult = validateJournalMonotonicity(journalPath); + if (journalResult.issues.length > 0) { + totalIssues += journalResult.issues.length; + filesWithIssues.push(journalResult); + error(`${journalResult.fileName} - 发现 ${journalResult.issues.length} 个问题:`); + journalResult.issues.forEach((issue, index) => { + console.log(`\n ${index + 1}. ${issue.type}`); + console.log(` ${colors.red}✗${colors.reset} ${issue.statement}`); + console.log(` ${colors.green}✓${colors.reset} ${issue.suggestion}`); + }); + console.log(""); + } + } else { + warn("未找到 meta/_journal.json,无法校验迁移顺序与时间戳单调性"); + } + // 检查每个文件 files.forEach((filePath) => { const result = validateMigrationFile(filePath); diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index 743b2ce11..a1dfdcb09 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -3,7 +3,7 @@ import { fromZonedTime } from "date-fns-tz"; import { and, eq, gte, isNull, lt, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; -import { keys as keysTable, messageRequest } from "@/drizzle/schema"; +import { messageRequest } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; import { logger } from "@/lib/logger"; import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit"; @@ -14,11 +14,9 @@ import { resolveSystemTimezone } from "@/lib/utils/timezone"; import { EXCLUDE_WARMUP_CONDITION } from "@/repository/_shared/message-request-conditions"; import { getSystemSettings } from "@/repository/system-config"; import { - findUsageLogsStats, - findUsageLogsWithDetails, + findUsageLogsForKeySlim, getDistinctEndpointsForKey, getDistinctModelsForKey, - type UsageLogFilters, type UsageLogSummary, } from "@/repository/usage-logs"; import type { BillingModelSource } from "@/types/system-config"; @@ -179,27 +177,6 @@ export interface MyUsageLogsResult { // Infinity means "all time" - no date filter applied to the query const ALL_TIME_MAX_AGE_DAYS = Infinity; -/** - * 查询用户在指定周期内的消费 - * 使用与 Key 层级和限额检查相同的时间范围计算逻辑 - * - * @deprecated 此函数已被重构为使用统一的时间范围计算逻辑 - */ -async function sumUserCost(userId: number, period: "5h" | "weekly" | "monthly" | "total") { - // 动态导入避免循环依赖 - const { sumUserCostInTimeRange, sumUserTotalCost } = await import("@/repository/statistics"); - const { getTimeRangeForPeriod } = await import("@/lib/rate-limit/time-utils"); - - // 总消费:使用专用函数,传递 ALL_TIME_MAX_AGE_DAYS 实现全时间语义 - if (period === "total") { - return await sumUserTotalCost(userId, ALL_TIME_MAX_AGE_DAYS); - } - - // 其他周期:使用统一的时间范围计算 - const { startTime, endTime } = await getTimeRangeForPeriod(period); - return await sumUserCostInTimeRange(userId, startTime, endTime); -} - export async function getMyUsageMetadata(): Promise> { try { const session = await getSession({ allowReadOnlyAccess: true }); @@ -242,9 +219,7 @@ export async function getMyQuota(): Promise> { const { getTimeRangeForPeriodWithMode, getTimeRangeForPeriod } = await import( "@/lib/rate-limit/time-utils" ); - const { sumUserCostInTimeRange, sumKeyCostInTimeRange, sumKeyTotalCostById } = await import( - "@/repository/statistics" - ); + const { sumKeyQuotaCostsById, sumUserQuotaCosts } = await import("@/repository/statistics"); // 计算各周期的时间范围 // Key 使用 Key 的 dailyResetTime/dailyResetMode 配置 @@ -271,36 +246,48 @@ export async function getMyQuota(): Promise> { user.limitConcurrentSessions ?? null ); - const [ - keyCost5h, - keyCostDaily, - keyCostWeekly, - keyCostMonthly, - keyTotalCost, - keyConcurrent, - userCost5h, - userCostDaily, - userCostWeekly, - userCostMonthly, - userTotalCost, - userKeyConcurrent, - ] = await Promise.all([ + const [keyCosts, keyConcurrent, userCosts, userKeyConcurrent] = await Promise.all([ // Key 配额:直接查 DB(与 User 保持一致,解决数据源不一致问题) - sumKeyCostInTimeRange(key.id, range5h.startTime, range5h.endTime), - sumKeyCostInTimeRange(key.id, keyDailyTimeRange.startTime, keyDailyTimeRange.endTime), - sumKeyCostInTimeRange(key.id, rangeWeekly.startTime, rangeWeekly.endTime), - sumKeyCostInTimeRange(key.id, rangeMonthly.startTime, rangeMonthly.endTime), - sumKeyTotalCostById(key.id, ALL_TIME_MAX_AGE_DAYS), + sumKeyQuotaCostsById( + key.id, + { + range5h, + rangeDaily: keyDailyTimeRange, + rangeWeekly, + rangeMonthly, + }, + ALL_TIME_MAX_AGE_DAYS + ), SessionTracker.getKeySessionCount(key.id), // User 配额:直接查 DB - sumUserCost(user.id, "5h"), - sumUserCostInTimeRange(user.id, userDailyTimeRange.startTime, userDailyTimeRange.endTime), - sumUserCost(user.id, "weekly"), - sumUserCost(user.id, "monthly"), - sumUserCost(user.id, "total"), + sumUserQuotaCosts( + user.id, + { + range5h, + rangeDaily: userDailyTimeRange, + rangeWeekly, + rangeMonthly, + }, + ALL_TIME_MAX_AGE_DAYS + ), getUserConcurrentSessions(user.id), ]); + const { + cost5h: keyCost5h, + costDaily: keyCostDaily, + costWeekly: keyCostWeekly, + costMonthly: keyCostMonthly, + costTotal: keyTotalCost, + } = keyCosts; + const { + cost5h: userCost5h, + costDaily: userCostDaily, + costWeekly: userCostWeekly, + costMonthly: userCostMonthly, + costTotal: userTotalCost, + } = userCosts; + const quota: MyUsageQuota = { keyLimit5hUsd: key.limit5hUsd ?? null, keyLimitDailyUsd: key.limitDailyUsd ?? null, @@ -370,24 +357,6 @@ export async function getMyTodayStats(): Promise> { (session.key.dailyResetMode as DailyResetMode | undefined) ?? "fixed" ); - const [aggregate] = await db - .select({ - calls: sql`count(*)::int`, - inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::double precision`, - outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::double precision`, - costUsd: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, - }) - .from(messageRequest) - .where( - and( - eq(messageRequest.key, session.key.key), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - gte(messageRequest.createdAt, timeRange.startTime), - lt(messageRequest.createdAt, timeRange.endTime) - ) - ); - const breakdown = await db .select({ model: messageRequest.model, @@ -409,23 +378,36 @@ export async function getMyTodayStats(): Promise> { ) .groupBy(messageRequest.model, messageRequest.originalModel); + let totalCalls = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCostUsd = 0; + const modelBreakdown = breakdown.map((row) => { const billingModel = billingModelSource === "original" ? row.originalModel : row.model; + const rawCostUsd = Number(row.costUsd ?? 0); + const costUsd = Number.isFinite(rawCostUsd) ? rawCostUsd : 0; + + totalCalls += row.calls ?? 0; + totalInputTokens += row.inputTokens ?? 0; + totalOutputTokens += row.outputTokens ?? 0; + totalCostUsd += costUsd; + return { model: row.model, billingModel, calls: row.calls, - costUsd: Number(row.costUsd ?? 0), + costUsd, inputTokens: row.inputTokens, outputTokens: row.outputTokens, }; }); const stats: MyTodayStats = { - calls: aggregate?.calls ?? 0, - inputTokens: aggregate?.inputTokens ?? 0, - outputTokens: aggregate?.outputTokens ?? 0, - costUsd: Number(aggregate?.costUsd ?? 0), + calls: totalCalls, + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + costUsd: totalCostUsd, modelBreakdown, currencyCode, billingModelSource, @@ -441,6 +423,8 @@ export async function getMyTodayStats(): Promise> { export interface MyUsageLogsFilters { startDate?: string; endDate?: string; + /** Session ID(精确匹配;空字符串/空白视为不筛选) */ + sessionId?: string; model?: string; statusCode?: number; excludeStatusCode200?: boolean; @@ -469,9 +453,9 @@ export async function getMyUsageLogs( filters.endDate, timezone ); - - const usageFilters: UsageLogFilters = { - keyId: session.key.id, + const result = await findUsageLogsForKeySlim({ + keyString: session.key.key, + sessionId: filters.sessionId, startTime, endTime, model: filters.model, @@ -481,9 +465,7 @@ export async function getMyUsageLogs( minRetryCount: filters.minRetryCount, page, pageSize, - }; - - const result = await findUsageLogsWithDetails(usageFilters); + }); const logs: MyUsageLogEntry[] = result.logs.map((log) => { const modelRedirect = @@ -559,13 +541,8 @@ export async function getMyAvailableEndpoints(): Promise> async function getUserConcurrentSessions(userId: number): Promise { try { - const keys = await db - .select({ id: keysTable.id }) - .from(keysTable) - .where(and(eq(keysTable.userId, userId), isNull(keysTable.deletedAt))); - - const counts = await Promise.all(keys.map((k) => SessionTracker.getKeySessionCount(k.id))); - return counts.reduce((sum, value) => sum + value, 0); + // 直接使用 user 维度的活跃 session 集合,避免 keys × Redis 查询的 N+1 + return await SessionTracker.getUserSessionCount(userId); } catch (error) { logger.error("[my-usage] getUserConcurrentSessions failed", error); return 0; @@ -585,6 +562,8 @@ export interface ModelBreakdownItem { outputTokens: number; cacheCreationTokens: number; cacheReadTokens: number; + cacheCreation5mTokens: number; + cacheCreation1hTokens: number; } export interface MyStatsSummary extends UsageLogSummary { @@ -595,7 +574,7 @@ export interface MyStatsSummary extends UsageLogSummary { /** * Get aggregated statistics for a date range - * Uses findUsageLogsStats for efficient aggregation + * 通过 model breakdown 聚合,避免额外的 summary 聚合查询 */ export async function getMyStatsSummary( filters: MyStatsSummaryFilters = {} @@ -614,80 +593,118 @@ export async function getMyStatsSummary( timezone ); - // Get aggregated stats using existing repository function - const stats = await findUsageLogsStats({ - keyId: session.key.id, - startTime, - endTime, - }); + const startDate = startTime ? new Date(startTime) : undefined; + const endDate = endTime ? new Date(endTime) : undefined; - // Get model breakdown for current key - const keyBreakdown = await db - .select({ - model: messageRequest.model, - requests: sql`count(*)::int`, - cost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, - inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::double precision`, - outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::double precision`, - cacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::double precision`, - cacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::double precision`, - }) - .from(messageRequest) - .where( - and( - eq(messageRequest.key, session.key.key), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - startTime ? gte(messageRequest.createdAt, new Date(startTime)) : undefined, - endTime ? lt(messageRequest.createdAt, new Date(endTime)) : undefined - ) - ) - .groupBy(messageRequest.model) - .orderBy(sql`sum(${messageRequest.costUsd}) DESC`); + const userId = session.user.id; + const keyString = session.key.key; - // Get model breakdown for user (all keys) - const userBreakdown = await db + // Key 维度是 User 维度的子集:用一条聚合 SQL 扫描 userId 范围即可同时算出两套 breakdown。 + const modelBreakdown = await db .select({ model: messageRequest.model, - requests: sql`count(*)::int`, - cost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, - inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::double precision`, - outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::double precision`, - cacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::double precision`, - cacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::double precision`, + // User breakdown(跨所有 Key) + userRequests: sql`count(*)::int`, + userCost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, + userInputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::double precision`, + userOutputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::double precision`, + userCacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::double precision`, + userCacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::double precision`, + userCacheCreation5mTokens: sql`COALESCE(sum(${messageRequest.cacheCreation5mInputTokens}), 0)::double precision`, + userCacheCreation1hTokens: sql`COALESCE(sum(${messageRequest.cacheCreation1hInputTokens}), 0)::double precision`, + // Key breakdown(FILTER 聚合) + keyRequests: sql`count(*) FILTER (WHERE ${messageRequest.key} = ${keyString})::int`, + keyCost: sql`COALESCE(sum(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.key} = ${keyString}), 0)`, + keyInputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}) FILTER (WHERE ${messageRequest.key} = ${keyString}), 0)::double precision`, + keyOutputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}) FILTER (WHERE ${messageRequest.key} = ${keyString}), 0)::double precision`, + keyCacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}) FILTER (WHERE ${messageRequest.key} = ${keyString}), 0)::double precision`, + keyCacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}) FILTER (WHERE ${messageRequest.key} = ${keyString}), 0)::double precision`, + keyCacheCreation5mTokens: sql`COALESCE(sum(${messageRequest.cacheCreation5mInputTokens}) FILTER (WHERE ${messageRequest.key} = ${keyString}), 0)::double precision`, + keyCacheCreation1hTokens: sql`COALESCE(sum(${messageRequest.cacheCreation1hInputTokens}) FILTER (WHERE ${messageRequest.key} = ${keyString}), 0)::double precision`, }) .from(messageRequest) .where( and( - eq(messageRequest.userId, session.user.id), + eq(messageRequest.userId, userId), isNull(messageRequest.deletedAt), EXCLUDE_WARMUP_CONDITION, - startTime ? gte(messageRequest.createdAt, new Date(startTime)) : undefined, - endTime ? lt(messageRequest.createdAt, new Date(endTime)) : undefined + startDate ? gte(messageRequest.createdAt, startDate) : undefined, + endDate ? lt(messageRequest.createdAt, endDate) : undefined ) ) .groupBy(messageRequest.model) .orderBy(sql`sum(${messageRequest.costUsd}) DESC`); + const keyOnlyBreakdown = modelBreakdown.filter((row) => (row.keyRequests ?? 0) > 0); + + const summaryAcc = keyOnlyBreakdown.reduce( + (acc, row) => { + const cost = Number(row.keyCost ?? 0); + acc.totalRequests += row.keyRequests ?? 0; + acc.totalCost += Number.isFinite(cost) ? cost : 0; + acc.totalInputTokens += row.keyInputTokens ?? 0; + acc.totalOutputTokens += row.keyOutputTokens ?? 0; + acc.totalCacheCreationTokens += row.keyCacheCreationTokens ?? 0; + acc.totalCacheReadTokens += row.keyCacheReadTokens ?? 0; + acc.totalCacheCreation5mTokens += row.keyCacheCreation5mTokens ?? 0; + acc.totalCacheCreation1hTokens += row.keyCacheCreation1hTokens ?? 0; + return acc; + }, + { + totalRequests: 0, + totalCost: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCacheCreation5mTokens: 0, + totalCacheCreation1hTokens: 0, + } + ); + + const totalTokens = + summaryAcc.totalInputTokens + + summaryAcc.totalOutputTokens + + summaryAcc.totalCacheCreationTokens + + summaryAcc.totalCacheReadTokens; + + const stats: UsageLogSummary = { + totalRequests: summaryAcc.totalRequests, + totalCost: summaryAcc.totalCost, + totalTokens, + totalInputTokens: summaryAcc.totalInputTokens, + totalOutputTokens: summaryAcc.totalOutputTokens, + totalCacheCreationTokens: summaryAcc.totalCacheCreationTokens, + totalCacheReadTokens: summaryAcc.totalCacheReadTokens, + totalCacheCreation5mTokens: summaryAcc.totalCacheCreation5mTokens, + totalCacheCreation1hTokens: summaryAcc.totalCacheCreation1hTokens, + }; + const result: MyStatsSummary = { ...stats, - keyModelBreakdown: keyBreakdown.map((row) => ({ + keyModelBreakdown: keyOnlyBreakdown + .map((row) => ({ + model: row.model, + requests: row.keyRequests, + cost: Number(row.keyCost ?? 0), + inputTokens: row.keyInputTokens, + outputTokens: row.keyOutputTokens, + cacheCreationTokens: row.keyCacheCreationTokens, + cacheReadTokens: row.keyCacheReadTokens, + cacheCreation5mTokens: row.keyCacheCreation5mTokens, + cacheCreation1hTokens: row.keyCacheCreation1hTokens, + })) + .sort((a, b) => b.cost - a.cost), + userModelBreakdown: modelBreakdown.map((row) => ({ model: row.model, - requests: row.requests, - cost: Number(row.cost ?? 0), - inputTokens: row.inputTokens, - outputTokens: row.outputTokens, - cacheCreationTokens: row.cacheCreationTokens, - cacheReadTokens: row.cacheReadTokens, - })), - userModelBreakdown: userBreakdown.map((row) => ({ - model: row.model, - requests: row.requests, - cost: Number(row.cost ?? 0), - inputTokens: row.inputTokens, - outputTokens: row.outputTokens, - cacheCreationTokens: row.cacheCreationTokens, - cacheReadTokens: row.cacheReadTokens, + requests: row.userRequests, + cost: Number(row.userCost ?? 0), + inputTokens: row.userInputTokens, + outputTokens: row.userOutputTokens, + cacheCreationTokens: row.userCacheCreationTokens, + cacheReadTokens: row.userCacheReadTokens, + cacheCreation5mTokens: row.userCacheCreation5mTokens, + cacheCreation1hTokens: row.userCacheCreation1hTokens, })), currencyCode, }; diff --git a/src/actions/provider-endpoints.ts b/src/actions/provider-endpoints.ts index 2c46951ef..6639ba76e 100644 --- a/src/actions/provider-endpoints.ts +++ b/src/actions/provider-endpoints.ts @@ -4,12 +4,13 @@ import { z } from "zod"; import { getSession } from "@/lib/auth"; import { publishProviderCacheInvalidation } from "@/lib/cache/provider-cache"; import { + getAllEndpointHealthStatusAsync, getEndpointHealthInfo, resetEndpointCircuit as resetEndpointCircuitState, } from "@/lib/endpoint-circuit-breaker"; import { logger } from "@/lib/logger"; import { PROVIDER_ENDPOINT_CONFLICT_CODE } from "@/lib/provider-endpoint-error-codes"; -import { probeProviderEndpointAndRecord } from "@/lib/provider-endpoints/probe"; +import { probeProviderEndpointAndRecordByEndpoint } from "@/lib/provider-endpoints/probe"; import { ERROR_CODES } from "@/lib/utils/error-messages"; import { extractZodErrorCode, formatZodError } from "@/lib/utils/zod-i18n"; import { @@ -26,11 +27,21 @@ import { findProviderEndpointsByVendorAndType, findProviderVendorById, findProviderVendors, + findProviderVendorsByIds, softDeleteProviderEndpoint, tryDeleteProviderVendorIfEmpty, updateProviderEndpoint, updateProviderVendor, } from "@/repository"; +import { + findDashboardProviderEndpointsByVendorAndType, + findEnabledProviderVendorTypePairs, + hasEnabledProviderReferenceForVendorTypeUrl, +} from "@/repository/provider-endpoints"; +import { + findProviderEndpointProbeLogsBatch, + findVendorTypeEndpointStatsBatch, +} from "@/repository/provider-endpoints-batch"; import type { ProviderEndpoint, ProviderEndpointProbeLog, @@ -127,6 +138,16 @@ const BatchGetEndpointCircuitInfoSchema = z.object({ endpointIds: z.array(EndpointIdSchema).max(500), }); +const BatchGetVendorTypeEndpointStatsSchema = z.object({ + vendorIds: z.array(VendorIdSchema).max(500), + providerType: ProviderTypeSchema, +}); + +const BatchGetProviderEndpointProbeLogsBatchSchema = z.object({ + endpointIds: z.array(EndpointIdSchema).max(500), + limit: z.number().int().min(1).max(200).optional(), +}); + async function getAdminSession() { const session = await getSession(); if (!session || session.user.role !== "admin") { @@ -170,6 +191,34 @@ function isDirectEndpointEditConflictError(error: unknown): boolean { ); } +function isForeignKeyViolationError(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + + const candidate = error as { + code?: string; + message?: string; + cause?: { code?: string; message?: string }; + }; + + if (candidate.code === "23503" || candidate.cause?.code === "23503") { + return true; + } + + if ( + typeof candidate.message === "string" && + candidate.message.includes("foreign key constraint") + ) { + return true; + } + + return ( + typeof candidate.cause?.message === "string" && + candidate.cause.message.includes("foreign key constraint") + ); +} + export async function getProviderVendors(): Promise { try { const session = await getAdminSession(); @@ -184,6 +233,48 @@ export async function getProviderVendors(): Promise { } } +export type DashboardProviderVendor = ProviderVendor & { providerTypes: ProviderType[] }; + +export async function getDashboardProviderVendors(): Promise { + try { + const session = await getAdminSession(); + if (!session) { + return []; + } + + const typeOrder = new Map( + ProviderTypeSchema.options.map((value, index) => [value, index]) + ); + + const typesByVendorId = new Map>(); + const enabledVendorTypePairs = await findEnabledProviderVendorTypePairs(); + for (const { vendorId, providerType } of enabledVendorTypePairs) { + const existing = typesByVendorId.get(vendorId) ?? new Set(); + existing.add(providerType); + typesByVendorId.set(vendorId, existing); + } + + const vendorIds = [...typesByVendorId.keys()]; + if (vendorIds.length === 0) { + return []; + } + + const vendors = await findProviderVendorsByIds(vendorIds); + + return vendors + .map((vendor) => { + const types = Array.from(typesByVendorId.get(vendor.id) ?? []).sort( + (left, right) => (typeOrder.get(left) ?? 999) - (typeOrder.get(right) ?? 999) + ); + return { ...vendor, providerTypes: types }; + }) + .filter((vendor) => vendor.providerTypes.length > 0); + } catch (error) { + logger.error("getDashboardProviderVendors:error", error); + return []; + } +} + export async function getProviderVendorById(vendorId: number): Promise { try { const session = await getAdminSession(); @@ -226,6 +317,34 @@ export async function getProviderEndpoints(input: { } } +export async function getDashboardProviderEndpoints(input: { + vendorId: number; + providerType: ProviderType; +}): Promise { + try { + const session = await getAdminSession(); + if (!session) { + return []; + } + + const parsed = GetProviderEndpointsSchema.safeParse(input); + if (!parsed.success) { + logger.debug("getDashboardProviderEndpoints:invalid_input", { + error: parsed.error, + }); + return []; + } + + return await findDashboardProviderEndpointsByVendorAndType( + parsed.data.vendorId, + parsed.data.providerType as ProviderTypeInput + ); + } catch (error) { + logger.error("getDashboardProviderEndpoints:error", error); + return []; + } +} + export async function getProviderEndpointsByVendor(input: { vendorId: number; }): Promise { @@ -281,9 +400,35 @@ export async function addProviderEndpoint( isEnabled: parsed.data.isEnabled ?? true, }); + try { + await publishProviderCacheInvalidation(); + } catch (error) { + logger.warn("addProviderEndpoint:cache_invalidation_failed", { + vendorId: parsed.data.vendorId, + error: error instanceof Error ? error.message : String(error), + }); + } + return { ok: true, data: { endpoint } }; } catch (error) { logger.error("addProviderEndpoint:error", error); + + if (isDirectEndpointEditConflictError(error)) { + return { + ok: false, + error: "端点 URL 与同供应商类型下的其他端点冲突", + errorCode: ERROR_CODES.CONFLICT, + }; + } + + if (isForeignKeyViolationError(error)) { + return { + ok: false, + error: "供应商不存在", + errorCode: ERROR_CODES.NOT_FOUND, + }; + } + const message = error instanceof Error ? error.message : "创建端点失败"; return { ok: false, error: message, errorCode: ERROR_CODES.CREATE_FAILED }; } @@ -311,6 +456,15 @@ export async function editProviderEndpoint( }; } + const previous = await findProviderEndpointById(parsed.data.endpointId); + if (!previous || previous.deletedAt !== null) { + return { + ok: false, + error: "端点不存在", + errorCode: ERROR_CODES.NOT_FOUND, + }; + } + const endpoint = await updateProviderEndpoint(parsed.data.endpointId, { url: parsed.data.url, label: parsed.data.label, @@ -326,6 +480,32 @@ export async function editProviderEndpoint( }; } + const shouldResetCircuit = + (parsed.data.url !== undefined && parsed.data.url !== previous.url) || + (parsed.data.isEnabled === true && previous.isEnabled === false); + + if (shouldResetCircuit) { + try { + await resetEndpointCircuitState(endpoint.id); + } catch (error) { + logger.warn("editProviderEndpoint:reset_circuit_failed", { + endpointId: endpoint.id, + vendorId: endpoint.vendorId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + try { + await publishProviderCacheInvalidation(); + } catch (error) { + logger.warn("editProviderEndpoint:cache_invalidation_failed", { + endpointId: parsed.data.endpointId, + vendorId: endpoint.vendorId, + error: error instanceof Error ? error.message : String(error), + }); + } + return { ok: true, data: { endpoint } }; } catch (error) { logger.error("editProviderEndpoint:error", error); @@ -371,6 +551,21 @@ export async function removeProviderEndpoint(input: unknown): Promise>> { + try { + const session = await getAdminSession(); + if (!session) { + return { + ok: false, + error: "无权限执行此操作", + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const parsed = BatchGetProviderEndpointProbeLogsBatchSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + error: formatZodError(parsed.error), + errorCode: extractZodErrorCode(parsed.error), + }; + } + + const endpointIds = Array.from(new Set(parsed.data.endpointIds)); + if (endpointIds.length === 0) { + return { ok: true, data: [] }; + } + + const limitPerEndpoint = parsed.data.limit ?? 12; + const logsByEndpointId = await findProviderEndpointProbeLogsBatch({ + endpointIds, + limitPerEndpoint, + }); + + return { + ok: true, + data: endpointIds.map((endpointId) => ({ + endpointId, + logs: logsByEndpointId.get(endpointId) ?? [], + })), + }; + } catch (error) { + logger.error("batchGetProviderEndpointProbeLogs:error", error); + return { + ok: false, + error: "批量获取端点测活历史失败", + errorCode: ERROR_CODES.OPERATION_FAILED, + }; + } +} + +export async function batchGetVendorTypeEndpointStats(input: unknown): Promise< + ActionResult< + Array<{ + vendorId: number; + providerType: ProviderType; + total: number; + enabled: number; + healthy: number; + unhealthy: number; + unknown: number; + }> + > +> { + try { + const session = await getAdminSession(); + if (!session) { + return { + ok: false, + error: "无权限执行此操作", + errorCode: ERROR_CODES.PERMISSION_DENIED, + }; + } + + const parsed = BatchGetVendorTypeEndpointStatsSchema.safeParse(input); + if (!parsed.success) { + return { + ok: false, + error: formatZodError(parsed.error), + errorCode: extractZodErrorCode(parsed.error), + }; + } + + const vendorIds = Array.from(new Set(parsed.data.vendorIds)); + if (vendorIds.length === 0) { + return { ok: true, data: [] }; + } + + const rows = await findVendorTypeEndpointStatsBatch({ + vendorIds, + providerType: parsed.data.providerType as ProviderTypeInput, + }); + + const statsByVendorId = new Map(rows.map((row) => [row.vendorId, row])); + + return { + ok: true, + data: vendorIds.map((vendorId) => { + const stats = statsByVendorId.get(vendorId); + return { + vendorId, + providerType: parsed.data.providerType, + total: stats?.total ?? 0, + enabled: stats?.enabled ?? 0, + healthy: stats?.healthy ?? 0, + unhealthy: stats?.unhealthy ?? 0, + unknown: stats?.unknown ?? 0, + }; + }), + }; + } catch (error) { + logger.error("batchGetVendorTypeEndpointStats:error", error); + return { + ok: false, + error: "批量获取供应商端点统计失败", + errorCode: ERROR_CODES.OPERATION_FAILED, + }; + } +} + export async function getEndpointCircuitInfo(input: unknown): Promise< ActionResult<{ endpointId: number; @@ -584,17 +910,20 @@ export async function batchGetEndpointCircuitInfo(input: unknown): Promise< return { ok: true, data: [] }; } - const results = await Promise.all( - parsed.data.endpointIds.map(async (endpointId) => { - const { health } = await getEndpointHealthInfo(endpointId); - return { - endpointId, - circuitState: health.circuitState, - failureCount: health.failureCount, - circuitOpenUntil: health.circuitOpenUntil, - }; - }) - ); + const endpointIds = parsed.data.endpointIds; + const uniqueEndpointIds = Array.from(new Set(endpointIds)); + const healthStatus = await getAllEndpointHealthStatusAsync(uniqueEndpointIds); + + const results = endpointIds.map((endpointId) => { + const info = healthStatus[endpointId]; + + return { + endpointId, + circuitState: info?.circuitState ?? "closed", + failureCount: info?.failureCount ?? 0, + circuitOpenUntil: info?.circuitOpenUntil ?? null, + }; + }); return { ok: true, data: results }; } catch (error) { @@ -795,6 +1124,15 @@ export async function editProviderVendor( }; } + try { + await publishProviderCacheInvalidation(); + } catch (error) { + logger.warn("editProviderVendor:cache_invalidation_failed", { + vendorId: parsed.data.vendorId, + error: error instanceof Error ? error.message : String(error), + }); + } + return { ok: true, data: { vendor } }; } catch (error) { logger.error("editProviderVendor:error", error); diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 2323f0526..e056df2a0 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -50,7 +50,7 @@ import { import { backfillProviderEndpointsFromProviders, computeVendorKey, - findProviderVendorById, + findProviderVendorsByIds, getOrCreateProviderVendorIdFromUrls, tryDeleteProviderVendorIfEmpty, } from "@/repository/provider-endpoints"; @@ -3663,10 +3663,8 @@ export async function reclusterProviderVendors(args: { .filter((id): id is number => id !== null && id !== undefined && id > 0) ), ]; - const vendors = await Promise.all(uniqueVendorIds.map((id) => findProviderVendorById(id))); - const vendorMap = new Map( - vendors.filter((v): v is NonNullable => v !== null).map((v) => [v.id, v]) - ); + const vendors = await findProviderVendorsByIds(uniqueVendorIds); + const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor])); // Build provider map for quick lookup in transaction const providerMap = new Map(allProviders.map((p) => [p.id, p])); diff --git a/src/app/[locale]/dashboard/availability/_components/availability-dashboard.tsx b/src/app/[locale]/dashboard/availability/_components/availability-dashboard.tsx index 99833956b..8df86a2c0 100644 --- a/src/app/[locale]/dashboard/availability/_components/availability-dashboard.tsx +++ b/src/app/[locale]/dashboard/availability/_components/availability-dashboard.tsx @@ -1,7 +1,7 @@ "use client"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { AvailabilityQueryResult } from "@/lib/availability"; import { cn } from "@/lib/utils"; @@ -37,79 +37,166 @@ export function AvailabilityDashboard() { const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); - const fetchData = useCallback(async () => { - try { - setRefreshing(true); - const now = new Date(); - const timeRangeMs = TIME_RANGE_MAP[timeRange]; - const startTime = new Date(now.getTime() - timeRangeMs); - const bucketSizeMinutes = calculateBucketSize(timeRangeMs); - - const params = new URLSearchParams({ - startTime: startTime.toISOString(), - endTime: now.toISOString(), - bucketSizeMinutes: bucketSizeMinutes.toString(), - maxBuckets: TARGET_BUCKETS.toString(), - }); - - const res = await fetch(`/api/availability?${params}`); - if (!res.ok) { - throw new Error(t("states.fetchFailed")); + const requestIdRef = useRef(0); + const inFlightRef = useRef(false); + const abortControllerRef = useRef(null); + const lastFocusRefreshAtRef = useRef(0); + + const fetchData = useCallback( + async (options?: { force?: boolean }) => { + const force = options?.force ?? false; + + if (inFlightRef.current && !force) { + return; } - const result: AvailabilityQueryResult = await res.json(); - setData(result); - setError(null); - } catch (err) { - console.error("Failed to fetch availability data:", err); - setError(err instanceof Error ? err.message : t("states.fetchFailed")); - } finally { - setLoading(false); - setRefreshing(false); - } - }, [timeRange, t]); + if (force) { + abortControllerRef.current?.abort(); + } + + const requestId = ++requestIdRef.current; + const controller = new AbortController(); + abortControllerRef.current = controller; + inFlightRef.current = true; + + try { + setRefreshing(true); + const now = new Date(); + const timeRangeMs = TIME_RANGE_MAP[timeRange]; + const startTime = new Date(now.getTime() - timeRangeMs); + const bucketSizeMinutes = calculateBucketSize(timeRangeMs); + + const params = new URLSearchParams({ + startTime: startTime.toISOString(), + endTime: now.toISOString(), + bucketSizeMinutes: bucketSizeMinutes.toString(), + maxBuckets: TARGET_BUCKETS.toString(), + }); + + const res = await fetch(`/api/availability?${params}`, { + signal: controller.signal, + cache: "no-store", + }); + if (!res.ok) { + throw new Error(t("states.fetchFailed")); + } + + const result: AvailabilityQueryResult = await res.json(); + if (requestId !== requestIdRef.current) { + return; + } + setData(result); + setError(null); + } catch (err) { + if (controller.signal.aborted) { + return; + } + if (requestId !== requestIdRef.current) { + return; + } + console.error("Failed to fetch availability data:", err); + setError(err instanceof Error ? err.message : t("states.fetchFailed")); + } finally { + if (requestId === requestIdRef.current) { + setLoading(false); + setRefreshing(false); + inFlightRef.current = false; + } + } + }, + [timeRange, t] + ); useEffect(() => { - fetchData(); + void fetchData({ force: true }); }, [fetchData]); // Auto-refresh: 30s for provider tab, 10s for endpoint tab useEffect(() => { const interval = activeTab === "provider" ? 30000 : 10000; - const timer = setInterval(fetchData, interval); + const timer = setInterval(() => { + if (document.visibilityState === "hidden") return; + void fetchData(); + }, interval); return () => clearInterval(timer); }, [activeTab, fetchData]); - // Calculate overview metrics - const providers = data?.providers ?? []; - const overviewMetrics = { - systemAvailability: data?.systemAvailability ?? 0, - avgLatency: - providers.length > 0 - ? providers.reduce((sum, p) => { + // 当页面从后台回到前台时,做一次节流刷新,避免看到陈旧数据;同时配合 visibility 判断减少后台请求。 + useEffect(() => { + const refresh = () => { + const now = Date.now(); + if (now - lastFocusRefreshAtRef.current < 2000) return; + lastFocusRefreshAtRef.current = now; + void fetchData({ force: true }); + }; + + const onFocus = () => refresh(); + const onVisibilityChange = () => { + if (document.visibilityState === "visible") { + refresh(); + } + }; + + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onVisibilityChange); + + return () => { + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVisibilityChange); + }; + }, [fetchData]); + + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + const overviewMetrics = useMemo(() => { + const providers = data?.providers ?? []; + const providersWithLatency = providers.filter((p) => + p.timeBuckets.some((b) => b.avgLatencyMs > 0) + ); + + let activeProbes = 0; + let healthyCount = 0; + let unhealthyCount = 0; + for (const provider of providers) { + if (provider.currentStatus !== "unknown") activeProbes += 1; + if (provider.currentStatus === "green") healthyCount += 1; + if (provider.currentStatus === "red") unhealthyCount += 1; + } + + const avgLatency = + providersWithLatency.length > 0 + ? providersWithLatency.reduce((sum, p) => { const latencies = p.timeBuckets .filter((b) => b.avgLatencyMs > 0) .map((b) => b.avgLatencyMs); - return ( - sum + - (latencies.length > 0 ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0) - ); - }, 0) / - Math.max(1, providers.filter((p) => p.timeBuckets.some((b) => b.avgLatencyMs > 0)).length) - : 0, - errorRate: + if (latencies.length === 0) return sum; + return sum + latencies.reduce((a, b) => a + b, 0) / latencies.length; + }, 0) / providersWithLatency.length + : 0; + + const errorRate = providers.length > 0 ? providers.reduce((sum, p) => { const total = p.totalRequests; const errors = p.timeBuckets.reduce((s, b) => s + b.redCount, 0); return sum + (total > 0 ? errors / total : 0); }, 0) / providers.length - : 0, - activeProbes: providers.filter((p) => p.currentStatus !== "unknown").length, - totalProbes: providers.length, - healthyCount: providers.filter((p) => p.currentStatus === "green").length, - unhealthyCount: providers.filter((p) => p.currentStatus === "red").length, - }; + : 0; + + return { + systemAvailability: data?.systemAvailability ?? 0, + avgLatency, + errorRate, + activeProbes, + totalProbes: providers.length, + healthyCount, + unhealthyCount, + }; + }, [data]); return (
@@ -157,7 +244,9 @@ export function AvailabilityDashboard() { error={error} timeRange={timeRange} onTimeRangeChange={setTimeRange} - onRefresh={fetchData} + onRefresh={() => { + void fetchData({ force: true }); + }} /> diff --git a/src/app/[locale]/dashboard/availability/_components/endpoint-probe-history.tsx b/src/app/[locale]/dashboard/availability/_components/endpoint-probe-history.tsx index 3f48d9a03..a695b7d01 100644 --- a/src/app/[locale]/dashboard/availability/_components/endpoint-probe-history.tsx +++ b/src/app/[locale]/dashboard/availability/_components/endpoint-probe-history.tsx @@ -5,7 +5,12 @@ import { Activity, CheckCircle2, Play, RefreshCw, XCircle } from "lucide-react"; import { useTimeZone, useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; -import { getProviderVendors, probeProviderEndpoint } from "@/actions/provider-endpoints"; +import { + type DashboardProviderVendor, + getDashboardProviderEndpoints, + getDashboardProviderVendors, + probeProviderEndpoint, +} from "@/actions/provider-endpoints"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -26,33 +31,19 @@ import { } from "@/components/ui/table"; import { cn } from "@/lib/utils"; import { getErrorMessage } from "@/lib/utils/error-messages"; -import type { - ProviderEndpoint, - ProviderEndpointProbeLog, - ProviderType, - ProviderVendor, -} from "@/types/provider"; - -const PROVIDER_TYPES: ProviderType[] = [ - "claude", - "claude-auth", - "codex", - "gemini-cli", - "gemini", - "openai-compatible", -]; +import type { ProviderEndpoint, ProviderEndpointProbeLog, ProviderType } from "@/types/provider"; export function EndpointProbeHistory() { const t = useTranslations("dashboard.availability"); const tErrors = useTranslations("errors"); const timeZone = useTimeZone() ?? "UTC"; - const [vendors, setVendors] = useState([]); - const [selectedVendorId, setSelectedVendorId] = useState(""); - const [selectedType, setSelectedType] = useState(""); + const [vendors, setVendors] = useState([]); + const [selectedVendorId, setSelectedVendorId] = useState(null); + const [selectedType, setSelectedType] = useState(null); const [endpoints, setEndpoints] = useState([]); - const [selectedEndpointId, setSelectedEndpointId] = useState(""); + const [selectedEndpointId, setSelectedEndpointId] = useState(null); const [loadingEndpoints, setLoadingEndpoints] = useState(false); const [logs, setLogs] = useState([]); @@ -61,33 +52,35 @@ export function EndpointProbeHistory() { const [probing, setProbing] = useState(false); useEffect(() => { - getProviderVendors().then(setVendors).catch(console.error); + getDashboardProviderVendors() + .then((vendors) => { + setVendors(vendors); + if (vendors.length > 0) { + setSelectedVendorId(vendors[0].id); + setSelectedType(vendors[0].providerTypes[0] ?? null); + } + }) + .catch(console.error); }, []); useEffect(() => { if (!selectedVendorId || !selectedType) { setEndpoints([]); - setSelectedEndpointId(""); + setSelectedEndpointId(null); return; } setLoadingEndpoints(true); - const params = new URLSearchParams({ - vendorId: selectedVendorId, - providerType: selectedType, - }); - fetch(`/api/availability/endpoints?${params.toString()}`) - .then((res) => res.json()) + getDashboardProviderEndpoints({ vendorId: selectedVendorId, providerType: selectedType }) .then((data) => { - if (data.endpoints) { - setEndpoints(data.endpoints); - setSelectedEndpointId((prev) => - prev && !data.endpoints.some((e: ProviderEndpoint) => e.id.toString() === prev) - ? "" - : prev - ); - } + setEndpoints(data); + setSelectedEndpointId((prev) => { + if (!prev) { + return data[0]?.id ?? null; + } + return data.some((endpoint) => endpoint.id === prev) ? prev : (data[0]?.id ?? null); + }); }) .catch(console.error) .finally(() => setLoadingEndpoints(false)); @@ -102,7 +95,7 @@ export function EndpointProbeHistory() { setLoadingLogs(true); try { const params = new URLSearchParams({ - endpointId: selectedEndpointId, + endpointId: selectedEndpointId.toString(), limit: "50", }); const res = await fetch(`/api/availability/endpoints/probe-logs?${params.toString()}`); @@ -127,7 +120,7 @@ export function EndpointProbeHistory() { setProbing(true); try { const result = await probeProviderEndpoint({ - endpointId: Number.parseInt(selectedEndpointId, 10), + endpointId: selectedEndpointId, timeoutMs: 10000, }); @@ -163,7 +156,19 @@ export function EndpointProbeHistory() { - { + const vendorId = Number.parseInt(value, 10); + if (!Number.isFinite(vendorId)) { + return; + } + setSelectedVendorId(vendorId); + const vendor = vendors.find((v) => v.id === vendorId); + setSelectedType(vendor?.providerTypes[0] ?? null); + setSelectedEndpointId(null); + }} + > @@ -181,16 +186,25 @@ export function EndpointProbeHistory() { - { + setSelectedType(v as ProviderType); + setSelectedEndpointId(null); + }} + disabled={!selectedVendorId} + > - {PROVIDER_TYPES.map((type) => ( - - {type} - - ))} + {(vendors.find((v) => v.id === selectedVendorId)?.providerTypes ?? []).map( + (type) => ( + + {type} + + ) + )}
@@ -200,8 +214,14 @@ export function EndpointProbeHistory() { {t("probeHistory.selectEndpoint")} { - setSelectedVendorId(Number(v)); - setSelectedType(null); + const vendorId = Number(v); + setSelectedVendorId(vendorId); + const nextVendor = vendors.find((vendor) => vendor.id === vendorId); + setSelectedType(nextVendor?.providerTypes[0] ?? null); setSelectedEndpoint(null); }} > @@ -229,7 +406,7 @@ export function EndpointTab() { - {PROVIDER_TYPES.map((type) => ( + {providerTypes.map((type) => ( {type} diff --git a/src/app/[locale]/dashboard/availability/_components/endpoint/probe-grid.tsx b/src/app/[locale]/dashboard/availability/_components/endpoint/probe-grid.tsx index 03d05bf91..f05113d85 100644 --- a/src/app/[locale]/dashboard/availability/_components/endpoint/probe-grid.tsx +++ b/src/app/[locale]/dashboard/availability/_components/endpoint/probe-grid.tsx @@ -51,6 +51,9 @@ function formatLatency(ms: number | null): string { function formatTime(date: Date | string | null, timeZone?: string): string { if (!date) return "-"; const d = typeof date === "string" ? new Date(date) : date; + if (!Number.isFinite(d.getTime())) { + return "-"; + } if (timeZone) { return formatInTimeZone(d, timeZone, "HH:mm:ss"); } @@ -61,6 +64,22 @@ function formatTime(date: Date | string | null, timeZone?: string): string { }); } +function safeHostnameFromUrl(input: string): string | null { + const url = input.trim(); + if (!url) return null; + + try { + return new URL(url).hostname || null; + } catch { + // 兼容历史/手工录入:允许 host:port 或无 scheme 的写法。 + try { + return new URL(`https://${url}`).hostname || null; + } catch { + return null; + } + } +} + export function ProbeGrid({ endpoints, selectedEndpointId, @@ -85,6 +104,8 @@ export function ProbeGrid({ const status = getStatusConfig(endpoint); const StatusIcon = status.icon; const isSelected = selectedEndpointId === endpoint.id; + const hostname = endpoint.label ? null : safeHostnameFromUrl(endpoint.url); + const displayName = endpoint.label || hostname || endpoint.url; return ( @@ -105,9 +126,7 @@ export function ProbeGrid({
- - {endpoint.label || new URL(endpoint.url).hostname} - + {displayName}

{endpoint.url}

diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx index d57980e91..1d64e0e61 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx @@ -1,6 +1,6 @@ "use client"; -import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Expand, Filter, ListOrdered, Minimize2, Pause, Play, RefreshCw } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useLocale, useTranslations } from "next-intl"; @@ -26,14 +26,8 @@ import { UsageLogsFilters } from "./usage-logs-filters"; import { UsageLogsStatsPanel } from "./usage-logs-stats-panel"; import { VirtualizedLogsTable, type VirtualizedLogsTableFilters } from "./virtualized-logs-table"; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - staleTime: 30000, - }, - }, -}); +const EMPTY_PROVIDERS: ProviderDisplay[] = []; +const EMPTY_KEYS: Key[] = []; interface UsageLogsViewVirtualizedProps { isAdmin: boolean; @@ -141,11 +135,13 @@ function UsageLogsViewContent({ const resolvedBillingModelSource = billingModelSource ?? systemSettings?.billingModelSource ?? "original"; - const { data: providersData = [], isLoading: isProvidersLoading } = useQuery({ + const { data: providersData = EMPTY_PROVIDERS, isLoading: isProvidersLoading } = useQuery< + ProviderDisplay[] + >({ queryKey: ["usage-log-providers"], queryFn: getProviders, enabled: isAdmin && providers === undefined, - placeholderData: [], + placeholderData: EMPTY_PROVIDERS, }); const { data: keysResult, isLoading: isKeysLoading } = useQuery({ @@ -155,12 +151,13 @@ function UsageLogsViewContent({ }); const resolvedProviders = providers ?? providersData; - const resolvedKeys = initialKeys ?? (keysResult?.ok && keysResult.data ? keysResult.data : []); + const resolvedKeys = + initialKeys ?? (keysResult?.ok && keysResult.data ? keysResult.data : EMPTY_KEYS); // Use useSearchParams hook for client-side URL reactivity // Note: searchParams props from server don't update on client-side navigation - const filters = useMemo(() => { - return parseLogsUrlFilters({ + const filters = useMemo(() => { + const { page: _page, ...parsed } = parseLogsUrlFilters({ userId: _params.get("userId") ?? undefined, keyId: _params.get("keyId") ?? undefined, providerId: _params.get("providerId") ?? undefined, @@ -172,7 +169,9 @@ function UsageLogsViewContent({ endpoint: _params.get("endpoint") ?? undefined, minRetry: _params.get("minRetry") ?? undefined, page: _params.get("page") ?? undefined, - }) as VirtualizedLogsTableFilters & { page?: number }; + }); + + return parsed; }, [_params]); const { data: overviewData } = useQuery({ @@ -487,9 +486,5 @@ function UsageLogsViewContent({ } export function UsageLogsViewVirtualized(props: UsageLogsViewVirtualizedProps) { - return ( - - - - ); + return ; } diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index 48e5ccfee..af4a980ec 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -3,7 +3,7 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { ArrowUp, Loader2 } from "lucide-react"; import { useTranslations } from "next-intl"; -import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react"; +import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { getUsageLogsBatch } from "@/actions/usage-logs"; import { Badge } from "@/components/ui/badge"; @@ -72,6 +72,7 @@ export function VirtualizedLogsTable({ const tChain = useTranslations("provider-chain"); const parentRef = useRef(null); const [showScrollToTop, setShowScrollToTop] = useState(false); + const shouldPoll = autoRefreshEnabled && !showScrollToTop; const hideProviderColumn = hiddenColumns?.includes("provider") ?? false; const hideUserColumn = hiddenColumns?.includes("user") ?? false; @@ -121,11 +122,12 @@ export function VirtualizedLogsTable({ initialPageParam: undefined as { createdAt: string; id: number } | undefined, staleTime: 30000, // 30 seconds refetchOnWindowFocus: false, - refetchInterval: autoRefreshEnabled ? autoRefreshIntervalMs : false, + refetchInterval: shouldPoll ? autoRefreshIntervalMs : false, }); // Flatten all pages into a single array - const allLogs = data?.pages.flatMap((page) => page.logs) ?? []; + const pages = data?.pages; + const allLogs = useMemo(() => pages?.flatMap((page) => page.logs) ?? [], [pages]); // Virtual list setup const rowVirtualizer = useVirtualizer({ diff --git a/src/app/[locale]/dashboard/users/users-page-client.tsx b/src/app/[locale]/dashboard/users/users-page-client.tsx index 66153998a..56a7f6eeb 100644 --- a/src/app/[locale]/dashboard/users/users-page-client.tsx +++ b/src/app/[locale]/dashboard/users/users-page-client.tsx @@ -1,12 +1,6 @@ "use client"; -import { - QueryClient, - QueryClientProvider, - useInfiniteQuery, - useQuery, - useQueryClient, -} from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { Layers, Loader2, Plus, Search, ShieldCheck } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -31,15 +25,6 @@ import { CreateUserDialog } from "../_components/user/create-user-dialog"; import { clearUsageCache } from "../_components/user/user-limit-badge"; import { UserManagementTable } from "../_components/user/user-management-table"; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - staleTime: 30000, - }, - }, -}); - /** * Split comma-separated tags into an array of trimmed, non-empty strings. * This matches the server-side providerGroup handling in provider-selector.ts @@ -57,11 +42,7 @@ interface UsersPageClientProps { } export function UsersPageClient(props: UsersPageClientProps) { - return ( - - - - ); + return ; } function UsersPageContent({ currentUser }: UsersPageClientProps) { @@ -96,11 +77,16 @@ function UsersPageContent({ currentUser }: UsersPageClientProps) { // Debounce search term to avoid frequent API requests const debouncedSearchTerm = useDebounce(searchTerm, 300); - const debouncedPendingTagsKey = useDebounce(pendingTagFilters.slice().sort().join("|"), 300); - const debouncedPendingKeyGroupsKey = useDebounce( - pendingKeyGroupFilters.slice().sort().join("|"), - 300 + const pendingTagFiltersKey = useMemo( + () => pendingTagFilters.slice().sort().join("|"), + [pendingTagFilters] + ); + const pendingKeyGroupFiltersKey = useMemo( + () => pendingKeyGroupFilters.slice().sort().join("|"), + [pendingKeyGroupFilters] ); + const debouncedPendingTagsKey = useDebounce(pendingTagFiltersKey, 300); + const debouncedPendingKeyGroupsKey = useDebounce(pendingKeyGroupFiltersKey, 300); // Use debounced value for API queries, raw value for UI highlighting const resolvedSearchTerm = debouncedSearchTerm.trim() ? debouncedSearchTerm.trim() : undefined; diff --git a/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx b/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx index 4bd56eb55..07fb2b7e6 100644 --- a/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx +++ b/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx @@ -1,7 +1,5 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; import { ServerCog } from "lucide-react"; -import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { FormErrorBoundary } from "@/components/form-error-boundary"; @@ -14,8 +12,6 @@ interface AddProviderDialogProps { } export function AddProviderDialog({ enableMultiProviderTypes }: AddProviderDialogProps) { - const router = useRouter(); - const queryClient = useQueryClient(); const t = useTranslations("settings.providers"); const [open, setOpen] = useState(false); return ( @@ -32,10 +28,6 @@ export function AddProviderDialog({ enableMultiProviderTypes }: AddProviderDialo enableMultiProviderTypes={enableMultiProviderTypes} onSuccess={() => { setOpen(false); - queryClient.invalidateQueries({ queryKey: ["providers"] }); - queryClient.invalidateQueries({ queryKey: ["providers-health"] }); - // 刷新页面数据以显示新添加的服务商 - router.refresh(); }} /> diff --git a/src/app/[locale]/settings/providers/_components/endpoint-latency-sparkline.tsx b/src/app/[locale]/settings/providers/_components/endpoint-latency-sparkline.tsx index e5b1dc324..d18271960 100644 --- a/src/app/[locale]/settings/providers/_components/endpoint-latency-sparkline.tsx +++ b/src/app/[locale]/settings/providers/_components/endpoint-latency-sparkline.tsx @@ -3,7 +3,8 @@ import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { Line, LineChart, ResponsiveContainer, Tooltip, YAxis } from "recharts"; -import { getProviderEndpointProbeLogs } from "@/actions/provider-endpoints"; +import { useInViewOnce } from "@/lib/hooks/use-in-view-once"; +import { type ProbeLog, probeLogsBatcher } from "@/lib/provider-endpoints/probe-logs-batcher"; import { cn } from "@/lib/utils"; type SparkPoint = { @@ -38,51 +39,81 @@ function CustomTooltip({ ); } +function probeLogsToSparkPoints(logs: ProbeLog[]): SparkPoint[] { + const points: SparkPoint[] = new Array(logs.length); + for (let i = logs.length - 1, idx = 0; i >= 0; i -= 1, idx += 1) { + const log = logs[i]; + const rawTimestamp = + log.createdAt === undefined || log.createdAt === null + ? undefined + : new Date(log.createdAt).getTime(); + const timestamp = + rawTimestamp !== undefined && Number.isFinite(rawTimestamp) ? rawTimestamp : undefined; + + points[idx] = { + index: idx, + latencyMs: log.latencyMs ?? null, + ok: log.ok, + timestamp, + }; + } + return points; +} + export function EndpointLatencySparkline(props: { endpointId: number; limit?: number }) { - const { data: points = [] } = useQuery({ - queryKey: ["endpoint-probe-logs", props.endpointId, props.limit ?? 12], - queryFn: async (): Promise => { - const res = await getProviderEndpointProbeLogs({ - endpointId: props.endpointId, - limit: props.limit ?? 12, - }); - - if (!res.ok || !res.data) { - return []; - } - - return res.data.logs - .slice() - .reverse() - .map((log, idx) => ({ - index: idx, - latencyMs: log.latencyMs ?? null, - ok: log.ok, - timestamp: log.createdAt ? new Date(log.createdAt).getTime() : undefined, - })); + const limit = props.limit ?? 12; + const { ref, isInView } = useInViewOnce(); + + const { data: points = [], isLoading } = useQuery({ + queryKey: ["endpoint-probe-logs", props.endpointId, limit], + queryFn: async ({ signal }): Promise => { + const logs = await probeLogsBatcher.load(props.endpointId, limit, { signal }); + return probeLogsToSparkPoints(logs); }, staleTime: 30_000, + refetchOnWindowFocus: false, + enabled: isInView, }); const avgLatency = useMemo(() => { const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; - const recentPoints = points.filter( - (p) => p.latencyMs !== null && p.timestamp && p.timestamp >= fiveMinutesAgo - ); - if (recentPoints.length === 0) return null; - const sum = recentPoints.reduce((acc, p) => acc + (p.latencyMs ?? 0), 0); - return sum / recentPoints.length; + let sum = 0; + let count = 0; + + for (const point of points) { + if (point.latencyMs === null) continue; + const timestamp = point.timestamp; + if (timestamp === undefined || timestamp < fiveMinutesAgo) continue; + sum += point.latencyMs; + count += 1; + } + + return count > 0 ? sum / count : null; }, [points]); + const showSkeleton = !isInView || isLoading; + + if (showSkeleton) { + return ( +
+
+
+ ); + } + if (points.length === 0) { - return
; + return ( +
+
+
+ ); } const lastPoint = points[points.length - 1]; const stroke = lastPoint?.ok ? "#16a34a" : "#dc2626"; return ( -
+
diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx index 197a113ab..cc6eaa87b 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx @@ -35,6 +35,8 @@ import { NetworkSection } from "./sections/network-section"; import { RoutingSection } from "./sections/routing-section"; import { TestingSection } from "./sections/testing-section"; +const TAB_ORDER: TabId[] = ["basic", "routing", "limits", "network", "testing"]; + function normalizeWebsiteDomainFromUrl(rawUrl: string): string | null { const trimmed = rawUrl.trim(); if (!trimmed) return null; @@ -95,6 +97,8 @@ function ProviderFormContent({ const { data: vendors = [] } = useQuery({ queryKey: ["provider-vendors"], queryFn: getProviderVendors, + staleTime: 60_000, + refetchOnWindowFocus: false, }); const websiteDomain = useMemo( @@ -134,6 +138,8 @@ function ProviderFormContent({ providerType: state.routing.providerType, }); }, + staleTime: 30_000, + refetchOnWindowFocus: false, }); const enabledEndpointPoolEndpoints = useMemo( @@ -183,9 +189,6 @@ function ProviderFormContent({ }); const isScrollingToSection = useRef(false); - // Tab order for navigation - const tabOrder: TabId[] = ["basic", "routing", "limits", "network", "testing"]; - // Scroll to section when tab is clicked const scrollToSection = useCallback((tab: TabId) => { const section = sectionRefs.current[tab]; @@ -213,7 +216,7 @@ function ProviderFormContent({ let activeSection: TabId = "basic"; let minDistance = Infinity; - for (const tab of tabOrder) { + for (const tab of TAB_ORDER) { const section = sectionRefs.current[tab]; if (!section) continue; @@ -360,6 +363,11 @@ function ProviderFormContent({ return; } toast.success(t("success.updated")); + + void queryClient.invalidateQueries({ queryKey: ["providers"] }); + void queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + void queryClient.invalidateQueries({ queryKey: ["providers-statistics"] }); + void queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); } else { // For create: key is required const createFormData = { ...baseFormData, key: trimmedKey }; @@ -369,8 +377,10 @@ function ProviderFormContent({ return; } + void queryClient.invalidateQueries({ queryKey: ["providers"] }); + void queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + void queryClient.invalidateQueries({ queryKey: ["providers-statistics"] }); void queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); - void queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] }); toast.success(t("success.created")); dispatch({ type: "RESET_FORM" }); diff --git a/src/app/[locale]/settings/providers/_components/provider-endpoint-hover.tsx b/src/app/[locale]/settings/providers/_components/provider-endpoint-hover.tsx index dec3649b6..0503cbcee 100644 --- a/src/app/[locale]/settings/providers/_components/provider-endpoint-hover.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-endpoint-hover.tsx @@ -1,29 +1,330 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; import { Server } from "lucide-react"; import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; -import { getEndpointCircuitInfo, getProviderEndpointsByVendor } from "@/actions/provider-endpoints"; +import { + batchGetEndpointCircuitInfo, + batchGetVendorTypeEndpointStats, + getProviderEndpointsByVendor, +} from "@/actions/provider-endpoints"; import { Badge } from "@/components/ui/badge"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { createAbortError } from "@/lib/abort-utils"; import { cn } from "@/lib/utils"; import type { ProviderEndpoint, ProviderType } from "@/types/provider"; -import { getEndpointStatusModel } from "./endpoint-status"; +import { type EndpointCircuitState, getEndpointStatusModel } from "./endpoint-status"; interface ProviderEndpointHoverProps { vendorId: number; providerType: ProviderType; } +const MAX_VENDOR_IDS_PER_BATCH = 500; + +type VendorTypeEndpointStats = { + vendorId: number; + total: number; + enabled: number; + healthy: number; + unhealthy: number; + unknown: number; +}; + +type VendorStatsDeferred = { + resolve: (value: VendorTypeEndpointStats) => void; + reject: (reason: unknown) => void; +}; + +class VendorTypeEndpointStatsBatcher { + private readonly pendingVendorIdsByProviderType = new Map>(); + private readonly deferredByProviderTypeVendorId = new Map< + ProviderType, + Map + >(); + private flushTimer: ReturnType | null = null; + + load( + vendorId: number, + providerType: ProviderType, + options?: { signal?: AbortSignal } + ): Promise { + if (!Number.isFinite(vendorId) || vendorId <= 0) { + return Promise.resolve({ + vendorId, + total: 0, + enabled: 0, + healthy: 0, + unhealthy: 0, + unknown: 0, + }); + } + + return new Promise((resolve, reject) => { + const signal = options?.signal; + if (signal?.aborted) { + reject(createAbortError(signal)); + return; + } + + let settled = false; + let deferred: VendorStatsDeferred; + + const onAbort = () => { + if (settled) return; + settled = true; + this.removePendingRequest(providerType, vendorId, deferred); + this.maybeCancelFlushTimer(); + reject(createAbortError(signal)); + }; + + deferred = { + resolve: (value) => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", onAbort); + resolve(value); + }, + reject: (reason) => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", onAbort); + reject(reason); + }, + }; + + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + const pending = this.pendingVendorIdsByProviderType.get(providerType) ?? new Set(); + pending.add(vendorId); + this.pendingVendorIdsByProviderType.set(providerType, pending); + + const deferredByVendorId = + this.deferredByProviderTypeVendorId.get(providerType) ?? + new Map(); + const list = deferredByVendorId.get(vendorId) ?? []; + list.push(deferred); + deferredByVendorId.set(vendorId, list); + this.deferredByProviderTypeVendorId.set(providerType, deferredByVendorId); + + if (this.flushTimer) return; + + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + void this.flush().catch((error) => { + if (process.env.NODE_ENV !== "test") { + console.error("[VendorTypeEndpointStatsBatcher] flush failed", error); + } + }); + }, 0); + }); + } + + private maybeCancelFlushTimer() { + if (!this.flushTimer) return; + if (this.pendingVendorIdsByProviderType.size > 0) return; + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + + private removePendingRequest( + providerType: ProviderType, + vendorId: number, + deferred: VendorStatsDeferred + ) { + const deferredByVendorId = this.deferredByProviderTypeVendorId.get(providerType); + if (!deferredByVendorId) return; + const list = deferredByVendorId.get(vendorId); + if (!list) return; + + const next = list.filter((item) => item !== deferred); + if (next.length > 0) { + deferredByVendorId.set(vendorId, next); + return; + } + + deferredByVendorId.delete(vendorId); + if (deferredByVendorId.size === 0) { + this.deferredByProviderTypeVendorId.delete(providerType); + } + + const pending = this.pendingVendorIdsByProviderType.get(providerType); + if (!pending) return; + pending.delete(vendorId); + if (pending.size === 0) { + this.pendingVendorIdsByProviderType.delete(providerType); + } + } + + private async flush() { + const entries = Array.from(this.pendingVendorIdsByProviderType.entries()); + this.pendingVendorIdsByProviderType.clear(); + + if (entries.length === 0) { + return; + } + + await Promise.all( + entries.map(async ([providerType, vendorIdSet]) => { + const vendorIds = Array.from(vendorIdSet); + vendorIds.sort((a, b) => a - b); + + for (let index = 0; index < vendorIds.length; index += MAX_VENDOR_IDS_PER_BATCH) { + const chunk = vendorIds.slice(index, index + MAX_VENDOR_IDS_PER_BATCH); + const deferredMap = this.deferredByProviderTypeVendorId.get(providerType); + if (!deferredMap) continue; + + const deferredEntries = chunk + .map((vendorId) => ({ + vendorId, + deferred: deferredMap.get(vendorId) ?? [], + })) + .filter(({ deferred }) => deferred.length > 0); + + const vendorIdsToFetch = deferredEntries.map(({ vendorId }) => vendorId); + vendorIdsToFetch.forEach((vendorId) => deferredMap.delete(vendorId)); + if (deferredMap.size === 0) { + this.deferredByProviderTypeVendorId.delete(providerType); + } + + if (vendorIdsToFetch.length === 0) continue; + + try { + const res = await batchGetVendorTypeEndpointStats({ + vendorIds: vendorIdsToFetch, + providerType, + }); + const items = res.ok && res.data ? res.data : []; + + const statsByVendorId = new Map(); + vendorIdsToFetch.forEach((vendorId) => + statsByVendorId.set(vendorId, { + vendorId, + total: 0, + enabled: 0, + healthy: 0, + unhealthy: 0, + unknown: 0, + }) + ); + + items.forEach((item) => { + statsByVendorId.set(item.vendorId, { + vendorId: item.vendorId, + total: item.total, + enabled: item.enabled, + healthy: item.healthy, + unhealthy: item.unhealthy, + unknown: item.unknown, + }); + }); + + deferredEntries.forEach(({ vendorId, deferred }) => { + const value = statsByVendorId.get(vendorId); + if (value) { + deferred.forEach((d) => d.resolve(value)); + } else { + deferred.forEach((d) => + d.resolve({ + vendorId, + total: 0, + enabled: 0, + healthy: 0, + unhealthy: 0, + unknown: 0, + }) + ); + } + }); + } catch { + // 降级路径:batch action 异常时按 vendorId 逐个查询。为避免 chunk 较大时触发请求风暴,这里限制并发。 + const concurrency = 8; + let idx = 0; + + const workers = Array.from( + { length: Math.min(concurrency, deferredEntries.length) }, + async () => { + for (;;) { + const currentIndex = idx++; + if (currentIndex >= deferredEntries.length) return; + + const { vendorId, deferred } = deferredEntries[currentIndex]; + + try { + const endpoints = await getProviderEndpointsByVendor({ vendorId }); + const filtered = endpoints.filter( + (ep) => + ep.providerType === providerType && + ep.isEnabled === true && + ep.deletedAt === null + ); + + const healthy = filtered.filter((ep) => ep.lastProbeOk === true).length; + const unhealthy = filtered.filter((ep) => ep.lastProbeOk === false).length; + const unknown = filtered.filter((ep) => ep.lastProbeOk == null).length; + + const value: VendorTypeEndpointStats = { + vendorId, + total: endpoints.filter( + (ep) => ep.providerType === providerType && ep.deletedAt === null + ).length, + enabled: filtered.length, + healthy, + unhealthy, + unknown, + }; + + deferred.forEach((d) => d.resolve(value)); + } catch (innerError) { + deferred.forEach((d) => d.reject(innerError)); + } + } + } + ); + + await Promise.all(workers); + } + } + }) + ); + } +} + +const vendorStatsBatcherByQueryClient = new WeakMap(); + +function getVendorStatsBatcher(queryClient: QueryClient): VendorTypeEndpointStatsBatcher { + const existing = vendorStatsBatcherByQueryClient.get(queryClient); + if (existing) return existing; + + const batcher = new VendorTypeEndpointStatsBatcher(); + vendorStatsBatcherByQueryClient.set(queryClient, batcher); + return batcher; +} + export function ProviderEndpointHover({ vendorId, providerType }: ProviderEndpointHoverProps) { const t = useTranslations("settings.providers"); const [isOpen, setIsOpen] = useState(false); + const queryClient = useQueryClient(); + const statsBatcher = useMemo(() => getVendorStatsBatcher(queryClient), [queryClient]); + + const { data: stats } = useQuery({ + queryKey: ["provider-endpoints", vendorId, providerType, "hover-stats"], + queryFn: ({ signal }) => statsBatcher.load(vendorId, providerType, { signal }), + staleTime: 1000 * 30, + refetchOnWindowFocus: false, + }); - const { data: allEndpoints = [] } = useQuery({ + const count = stats?.enabled ?? 0; + + const { data: allEndpoints = [], isLoading: endpointsLoading } = useQuery({ queryKey: ["provider-endpoints", vendorId], queryFn: async () => getProviderEndpointsByVendor({ vendorId }), + enabled: isOpen || process.env.NODE_ENV === "test", staleTime: 1000 * 30, + refetchOnWindowFocus: false, }); const endpoints = useMemo(() => { @@ -53,7 +354,55 @@ export function ProviderEndpointHover({ vendorId, providerType }: ProviderEndpoi }); }, [allEndpoints, providerType]); - const count = endpoints.length; + const endpointIds = useMemo(() => endpoints.map((ep) => ep.id), [endpoints]); + const endpointIdsKey = useMemo(() => { + if (endpointIds.length === 0) return ""; + return endpointIds + .slice() + .sort((a, b) => a - b) + .join(","); + }, [endpointIds]); + + const { data: circuitInfoMap = {} } = useQuery({ + queryKey: ["endpoint-circuit-info", endpointIdsKey], + queryFn: async () => { + if (endpointIds.length === 0) return {}; + + const map: Record = {}; + const sortedEndpointIds = endpointIds.slice().sort((a, b) => a - b); + const MAX_ENDPOINT_IDS_PER_BATCH = 500; + const chunks: number[][] = []; + for (let index = 0; index < sortedEndpointIds.length; index += MAX_ENDPOINT_IDS_PER_BATCH) { + chunks.push(sortedEndpointIds.slice(index, index + MAX_ENDPOINT_IDS_PER_BATCH)); + } + + const results = await Promise.all( + chunks.map(async (chunk) => { + const res = await batchGetEndpointCircuitInfo({ endpointIds: chunk }); + return res.ok && res.data ? res.data : []; + }) + ); + + for (const item of results.flat()) { + map[item.endpointId] = item.circuitState as EndpointCircuitState; + } + + return map; + }, + enabled: isOpen && endpointIds.length > 0, + staleTime: 1000 * 10, + refetchOnWindowFocus: false, + }); + + const circuitStateByEndpointId = useMemo(() => { + const map = new Map(); + for (const [rawId, circuitState] of Object.entries(circuitInfoMap)) { + const endpointId = Number(rawId); + if (!Number.isFinite(endpointId)) continue; + map.set(endpointId, circuitState); + } + return map; + }, [circuitInfoMap]); return ( @@ -80,14 +429,22 @@ export function ProviderEndpointHover({ vendorId, providerType }: ProviderEndpoi
- {count === 0 ? ( + {endpointsLoading ? ( +
+ {t("keyLoading")} +
+ ) : count === 0 ? (
{t("endpointStatus.noEndpoints")}
) : (
{endpoints.map((endpoint) => ( - + ))}
)} @@ -98,19 +455,15 @@ export function ProviderEndpointHover({ vendorId, providerType }: ProviderEndpoi ); } -function EndpointRow({ endpoint, isOpen }: { endpoint: ProviderEndpoint; isOpen: boolean }) { +function EndpointRow({ + endpoint, + circuitState, +}: { + endpoint: ProviderEndpoint; + circuitState?: "closed" | "open" | "half-open"; +}) { const t = useTranslations("settings.providers"); - const { data: circuitResult } = useQuery({ - queryKey: ["endpoint-circuit", endpoint.id], - queryFn: async () => getEndpointCircuitInfo({ endpointId: endpoint.id }), - enabled: isOpen, - staleTime: 1000 * 10, - }); - - const circuitState = - circuitResult?.ok && circuitResult.data ? circuitResult.data.health.circuitState : undefined; - const statusModel = getEndpointStatusModel(endpoint, circuitState); const Icon = statusModel.icon; diff --git a/src/app/[locale]/settings/providers/_components/provider-endpoints-table.tsx b/src/app/[locale]/settings/providers/_components/provider-endpoints-table.tsx index 56cfde40b..0967cc0a9 100644 --- a/src/app/[locale]/settings/providers/_components/provider-endpoints-table.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-endpoints-table.tsx @@ -52,6 +52,7 @@ import { TableRow, } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useInViewOnce } from "@/lib/hooks/use-in-view-once"; import { getAllProviderTypes, getProviderTypeConfig, @@ -113,6 +114,8 @@ export function ProviderEndpointsTable({ } return await getProviderEndpointsByVendor({ vendorId }); }, + staleTime: 30_000, + refetchOnWindowFocus: false, }); // Sort endpoints by type order (from getAllProviderTypes) then by sortOrder @@ -132,20 +135,42 @@ export function ProviderEndpointsTable({ // Fetch circuit breaker states for all endpoints in batch const endpointIds = useMemo(() => endpoints.map((ep) => ep.id), [endpoints]); + const endpointIdsQueryKey = useMemo( + () => + endpointIds + .slice() + .sort((a, b) => a - b) + .join(","), + [endpointIds] + ); const { data: circuitInfoMap = {} } = useQuery({ - queryKey: ["endpoint-circuit-info", endpointIds.toSorted((a, b) => a - b).join(",")], + queryKey: ["endpoint-circuit-info", endpointIdsQueryKey], queryFn: async () => { if (endpointIds.length === 0) return {}; - const res = await batchGetEndpointCircuitInfo({ endpointIds }); - if (!res.ok || !res.data) return {}; const map: Record = {}; - for (const item of res.data) { + + const MAX_ENDPOINT_IDS_PER_BATCH = 500; + const chunks: number[][] = []; + for (let index = 0; index < endpointIds.length; index += MAX_ENDPOINT_IDS_PER_BATCH) { + chunks.push(endpointIds.slice(index, index + MAX_ENDPOINT_IDS_PER_BATCH)); + } + + const results = await Promise.all( + chunks.map(async (chunk) => { + const res = await batchGetEndpointCircuitInfo({ endpointIds: chunk }); + return res.ok && res.data ? res.data : []; + }) + ); + + for (const item of results.flat()) { map[item.endpointId] = item.circuitState as EndpointCircuitState; } + return map; }, enabled: endpointIds.length > 0, staleTime: 15_000, + refetchOnWindowFocus: false, }); if (isLoading) { @@ -228,7 +253,8 @@ function EndpointRow({ onMutate: () => setIsProbing(true), onSettled: () => setIsProbing(false), onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] }); + queryClient.invalidateQueries({ queryKey: ["provider-endpoints", endpoint.vendorId] }); + queryClient.invalidateQueries({ queryKey: ["endpoint-probe-logs", endpoint.id] }); if (data?.result.ok) { toast.success(t("probeSuccess")); } else { @@ -251,7 +277,7 @@ function EndpointRow({ return res.data; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] }); + queryClient.invalidateQueries({ queryKey: ["provider-endpoints", endpoint.vendorId] }); queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); toast.success(t("endpointDeleteSuccess")); }, @@ -272,7 +298,7 @@ function EndpointRow({ onMutate: () => setIsToggling(true), onSettled: () => setIsToggling(false), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] }); + queryClient.invalidateQueries({ queryKey: ["provider-endpoints", endpoint.vendorId] }); toast.success(t("endpointUpdateSuccess")); }, onError: () => { @@ -287,18 +313,19 @@ function EndpointRow({ return res; }, onMutate: () => { - // Optimistic update: immediately set circuit state to closed + // 乐观更新:仅更新包含该 endpointId 的 circuit-info 查询,避免污染其它 cache; + // 同时避免在成功后做过宽 invalidation 引发“刷新放大/请求风暴”(#779/#781 相关)。 queryClient.setQueriesData>( { queryKey: ["endpoint-circuit-info"] }, (old) => { if (!old) return old; + if (!Object.hasOwn(old, endpoint.id)) return old; + if (old[endpoint.id] === "closed") return old; return { ...old, [endpoint.id]: "closed" as EndpointCircuitState }; } ); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["endpoint-circuit-info"] }); - queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] }); toast.success(tStatus("resetCircuitSuccess")); }, onError: () => { @@ -450,14 +477,11 @@ export interface AddEndpointButtonProps { vendorId: number; /** If provided, locks the type selector to this value */ providerType?: ProviderType; - /** Custom query key suffix for cache invalidation */ - queryKeySuffix?: string; } export function AddEndpointButton({ vendorId, providerType: fixedProviderType, - queryKeySuffix, }: AddEndpointButtonProps) { const t = useTranslations("settings.providers"); const tErrors = useTranslations("errors"); @@ -507,20 +531,11 @@ export function AddEndpointButton({ if (res.ok) { toast.success(t("endpointAddSuccess")); setOpen(false); - // Invalidate both specific and general queries - // Explicitly suppress rejections to avoid double toast + // 仅失效 vendor 维度即可:前缀匹配会覆盖 providerType 等变体,避免重复 invalidation。 + // 显式吞掉 rejections,避免错误状态下二次 toast。 queryClient .invalidateQueries({ queryKey: ["provider-endpoints", vendorId] }) .catch(() => undefined); - if (fixedProviderType) { - queryClient - .invalidateQueries({ - queryKey: ["provider-endpoints", vendorId, fixedProviderType, queryKeySuffix].filter( - (value) => value != null - ), - }) - .catch(() => undefined); - } return; } @@ -684,7 +699,9 @@ function EditEndpointDialog({ endpoint }: { endpoint: ProviderEndpoint }) { if (res.ok) { toast.success(t("endpointUpdateSuccess")); setOpen(false); - queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] }).catch(() => undefined); + queryClient + .invalidateQueries({ queryKey: ["provider-endpoints", endpoint.vendorId] }) + .catch(() => undefined); return; } @@ -768,6 +785,7 @@ export interface ProviderEndpointsSectionProps { readOnly?: boolean; hideTypeColumn?: boolean; queryKeySuffix?: string; + deferUntilInView?: boolean; } /** @@ -780,31 +798,32 @@ export function ProviderEndpointsSection({ readOnly = false, hideTypeColumn = false, queryKeySuffix, + deferUntilInView = false, }: ProviderEndpointsSectionProps) { const t = useTranslations("settings.providers"); + const { ref, isInView } = useInViewOnce(); + const shouldLoad = !deferUntilInView || isInView; return ( -
+
{t("endpoints")} - {!readOnly && ( - } +
+ +
+ {shouldLoad ? ( + + ) : ( +
)}
- -
- -
); } diff --git a/src/app/[locale]/settings/providers/_components/provider-list.tsx b/src/app/[locale]/settings/providers/_components/provider-list.tsx index b70899b7e..2b209b39a 100644 --- a/src/app/[locale]/settings/providers/_components/provider-list.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-list.tsx @@ -1,6 +1,9 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { Globe } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useMemo } from "react"; +import { getProviderVendors } from "@/actions/provider-endpoints"; import type { CurrencyCode } from "@/lib/utils/currency"; import type { ProviderDisplay, ProviderStatisticsMap } from "@/types/provider"; import type { User } from "@/types/user"; @@ -54,6 +57,17 @@ export function ProviderList({ }: ProviderListProps) { const t = useTranslations("settings.providers"); + const { data: vendors = [] } = useQuery({ + queryKey: ["provider-vendors"], + queryFn: getProviderVendors, + staleTime: 60_000, + refetchOnWindowFocus: false, + }); + + const vendorById = useMemo(() => { + return new Map(vendors.map((vendor) => [vendor.id, vendor])); + }, [vendors]); + if (providers.length === 0) { return (
@@ -72,6 +86,7 @@ export function ProviderList({ await getProviderVendors(), - staleTime: 60000, - }); const [openEdit, setOpenEdit] = useState(false); const [openClone, setOpenClone] = useState(false); @@ -182,10 +176,6 @@ export function ProviderRichListItem({ const typeLabel = tTypes(`${typeKey}.label`); const typeDescription = tTypes(`${typeKey}.description`); - const vendor = provider.providerVendorId - ? vendors.find((v) => v.id === provider.providerVendorId) - : undefined; - useEffect(() => { setClipboardAvailable(isClipboardSupported()); }, []); @@ -223,7 +213,6 @@ export function ProviderRichListItem({ queryClient.invalidateQueries({ queryKey: ["providers"] }); queryClient.invalidateQueries({ queryKey: ["providers-health"] }); queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); - router.refresh(); } else { toast.error(tList("deleteFailed"), { description: res.error || tList("unknownError"), @@ -294,7 +283,6 @@ export function ProviderRichListItem({ }); queryClient.invalidateQueries({ queryKey: ["providers"] }); queryClient.invalidateQueries({ queryKey: ["providers-health"] }); - router.refresh(); } else { toast.error(tList("resetCircuitFailed"), { description: res.error || tList("unknownError"), @@ -320,7 +308,6 @@ export function ProviderRichListItem({ }); queryClient.invalidateQueries({ queryKey: ["providers"] }); queryClient.invalidateQueries({ queryKey: ["providers-health"] }); - router.refresh(); } else { toast.error(tList("resetUsageFailed"), { description: res.error || tList("unknownError"), @@ -349,7 +336,6 @@ export function ProviderRichListItem({ }); queryClient.invalidateQueries({ queryKey: ["providers"] }); queryClient.invalidateQueries({ queryKey: ["providers-health"] }); - router.refresh(); } else { toast.error(tList("toggleFailed"), { description: res.error || tList("unknownError"), @@ -373,7 +359,6 @@ export function ProviderRichListItem({ if (res.ok) { toast.success(tInline("saveSuccess")); queryClient.invalidateQueries({ queryKey: ["providers"] }); - router.refresh(); return true; } toast.error(tInline("saveFailed"), { description: res.error || tList("unknownError") }); @@ -403,7 +388,6 @@ export function ProviderRichListItem({ if (res.ok) { toast.success(tInline("saveSuccess")); queryClient.invalidateQueries({ queryKey: ["providers"] }); - router.refresh(); return true; } toast.error(tInline("groupSaveError"), { @@ -429,7 +413,6 @@ export function ProviderRichListItem({ if (res.ok) { toast.success(tInline("saveSuccess")); queryClient.invalidateQueries({ queryKey: ["providers"] }); - router.refresh(); return true; } toast.error(tInline("saveFailed"), { description: res.error || tList("unknownError") }); @@ -971,9 +954,6 @@ export function ProviderRichListItem({ provider={provider} onSuccess={() => { setOpenEdit(false); - queryClient.invalidateQueries({ queryKey: ["providers"] }); - queryClient.invalidateQueries({ queryKey: ["providers-health"] }); - router.refresh(); }} enableMultiProviderTypes={enableMultiProviderTypes} /> @@ -990,9 +970,6 @@ export function ProviderRichListItem({ cloneProvider={provider} onSuccess={() => { setOpenClone(false); - queryClient.invalidateQueries({ queryKey: ["providers"] }); - queryClient.invalidateQueries({ queryKey: ["providers-health"] }); - router.refresh(); }} enableMultiProviderTypes={enableMultiProviderTypes} /> diff --git a/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx b/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx index a4566315b..8ba4bc1cc 100644 --- a/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx @@ -51,10 +51,15 @@ export function ProviderVendorView(props: ProviderVendorViewProps) { const { data: vendors = [], isLoading: isVendorsLoading } = useQuery({ queryKey: ["provider-vendors"], - queryFn: async () => await getProviderVendors(), - staleTime: 60000, + queryFn: getProviderVendors, + staleTime: 60_000, + refetchOnWindowFocus: false, }); + const vendorById = useMemo(() => { + return new Map(vendors.map((vendor) => [vendor.id, vendor])); + }, [vendors]); + const providersByVendor = useMemo(() => { const grouped: Record = {}; const orphaned: ProviderDisplay[] = []; @@ -95,7 +100,7 @@ export function ProviderVendorView(props: ProviderVendorViewProps) { return (
{allVendorIds.map((vendorId) => { - const vendor = vendors.find((v) => v.id === vendorId); + const vendor = vendorId > 0 ? vendorById.get(vendorId) : undefined; const vendorProviders = providersByVendor[vendorId] || []; if (vendorProviders.length === 0) return null; @@ -209,7 +214,7 @@ function VendorCard({ /> {enableMultiProviderTypes && vendorId > 0 && ( - + )} diff --git a/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx index ca15a4ae5..da1441efb 100644 --- a/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx +++ b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx @@ -2,7 +2,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { CheckCircle, Copy, Edit2, Loader2, Plus, Trash2 } from "lucide-react"; -import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -42,7 +41,12 @@ import { PROVIDER_LIMITS } from "@/lib/constants/provider.constants"; import { getProviderTypeConfig, getProviderTypeTranslationKey } from "@/lib/provider-type-utils"; import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; import { type CurrencyCode, formatCurrency } from "@/lib/utils/currency"; -import type { ProviderDisplay, ProviderStatisticsMap, ProviderType } from "@/types/provider"; +import type { + ProviderDisplay, + ProviderEndpoint, + ProviderStatisticsMap, + ProviderType, +} from "@/types/provider"; import type { User } from "@/types/user"; import { ProviderForm } from "./forms/provider-form"; import { InlineEditPopover } from "./inline-edit-popover"; @@ -112,17 +116,26 @@ export function VendorKeysCompactList(props: { }} urlResolver={async (type) => { if (props.vendorId <= 0) return null; - const endpoints = await getProviderEndpoints({ - vendorId: props.vendorId, - providerType: type, - }); + + const queryKey = ["provider-endpoints", props.vendorId, type] as const; + const cached = queryClient.getQueryData(queryKey); + const endpoints = + cached ?? + (await queryClient.fetchQuery({ + queryKey, + queryFn: async () => + await getProviderEndpoints({ + vendorId: props.vendorId, + providerType: type, + }), + staleTime: 30_000, + })) ?? + []; const enabled = endpoints.find((e) => e.isEnabled); return (enabled ?? endpoints[0])?.url ?? null; }} onSuccess={() => { setCreateOpen(false); - queryClient.invalidateQueries({ queryKey: ["providers"] }); - queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); }} /> @@ -201,7 +214,6 @@ function VendorKeyRow(props: { const tTypes = useTranslations("settings.providers.types"); const queryClient = useQueryClient(); - const router = useRouter(); const validatePriority = (raw: string) => { if (raw.length === 0) return tInline("priorityInvalid"); @@ -239,7 +251,6 @@ function VendorKeyRow(props: { toast.success(tInline("saveSuccess")); queryClient.invalidateQueries({ queryKey: ["providers"] }); queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); - router.refresh(); return true; } toast.error(tInline("saveFailed"), { description: res.error || tList("unknownError") }); @@ -278,8 +289,8 @@ function VendorKeyRow(props: { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["providers-health"] }); queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); - router.refresh(); }, onError: () => { toast.error(t("toggleFailed")); @@ -293,12 +304,13 @@ function VendorKeyRow(props: { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + queryClient.invalidateQueries({ queryKey: ["providers-statistics"] }); queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); setDeleteDialogOpen(false); toast.success(tList("deleteSuccess"), { description: tList("deleteSuccessDesc", { name: props.provider.name }), }); - router.refresh(); }, onError: () => { toast.error(tList("deleteFailed")); @@ -469,9 +481,6 @@ function VendorKeyRow(props: { } onSuccess={() => { setEditOpen(false); - queryClient.invalidateQueries({ queryKey: ["providers"] }); - queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); - router.refresh(); }} /> diff --git a/src/app/api/actions/[...route]/route.ts b/src/app/api/actions/[...route]/route.ts index 08a1f52ca..76545893a 100644 --- a/src/app/api/actions/[...route]/route.ts +++ b/src/app/api/actions/[...route]/route.ts @@ -515,6 +515,46 @@ const { route: getProviderEndpointProbeLogsRoute, handler: getProviderEndpointPr ); app.openapi(getProviderEndpointProbeLogsRoute, getProviderEndpointProbeLogsHandler); +const { + route: batchGetProviderEndpointProbeLogsRoute, + handler: batchGetProviderEndpointProbeLogsHandler, +} = createActionRoute( + "providers", + "batchGetProviderEndpointProbeLogs", + providerEndpointActions.batchGetProviderEndpointProbeLogs, + { + requestSchema: z.object({ + endpointIds: z.array(z.number().int().positive()).max(500), + limit: z.number().int().min(1).max(200).optional(), + }), + description: "批量读取多个端点的测活历史(每端点取最近 N 条) (管理员)", + summary: "批量读取测活历史", + tags: ["供应商管理"], + requiredRole: "admin", + } +); +app.openapi(batchGetProviderEndpointProbeLogsRoute, batchGetProviderEndpointProbeLogsHandler); + +const { + route: batchGetVendorTypeEndpointStatsRoute, + handler: batchGetVendorTypeEndpointStatsHandler, +} = createActionRoute( + "providers", + "batchGetVendorTypeEndpointStats", + providerEndpointActions.batchGetVendorTypeEndpointStats, + { + requestSchema: z.object({ + vendorIds: z.array(z.number().int().positive()).max(500), + providerType: ProviderTypeSchema, + }), + description: "批量统计 vendor+type 维度端点数量与健康分布 (管理员)", + summary: "批量端点统计", + tags: ["供应商管理"], + requiredRole: "admin", + } +); +app.openapi(batchGetVendorTypeEndpointStatsRoute, batchGetVendorTypeEndpointStatsHandler); + const { route: getEndpointCircuitInfoRoute, handler: getEndpointCircuitInfoHandler } = createActionRoute( "providers", diff --git a/src/app/api/availability/endpoints/route.ts b/src/app/api/availability/endpoints/route.ts index 47ae8db0d..b0c831ec2 100644 --- a/src/app/api/availability/endpoints/route.ts +++ b/src/app/api/availability/endpoints/route.ts @@ -1,6 +1,6 @@ import { type NextRequest, NextResponse } from "next/server"; import { getSession } from "@/lib/auth"; -import { findProviderEndpointsByVendorAndType } from "@/repository"; +import { findDashboardProviderEndpointsByVendorAndType } from "@/repository/provider-endpoints"; import type { ProviderType } from "@/types/provider"; const PROVIDER_TYPES: ProviderType[] = [ @@ -36,7 +36,10 @@ export async function GET(request: NextRequest) { } try { - const endpoints = await findProviderEndpointsByVendorAndType(vendorId, providerTypeRaw); + const endpoints = await findDashboardProviderEndpointsByVendorAndType( + vendorId, + providerTypeRaw + ); return NextResponse.json({ vendorId, providerType: providerTypeRaw, endpoints }); } catch (error) { console.error("Endpoint availability API error:", error); diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index a11632bc9..ce9165886 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -82,6 +82,10 @@ export const users = pgTable('users', { usersEnabledExpiresAtIdx: index('idx_users_enabled_expires_at') .on(table.isEnabled, table.expiresAt) .where(sql`${table.deletedAt} IS NULL`), + // Tag 筛选(@>)的 GIN 索引:加速用户管理列表页的标签过滤 + usersTagsGinIdx: index('idx_users_tags_gin') + .using('gin', table.tags) + .where(sql`${table.deletedAt} IS NULL`), // 基础索引 usersCreatedAtIdx: index('idx_users_created_at').on(table.createdAt), usersDeletedAtIdx: index('idx_users_deleted_at').on(table.deletedAt), @@ -125,6 +129,7 @@ export const keys = pgTable('keys', { }, (table) => ({ // 基础索引(详细的复合索引通过迁移脚本管理) keysUserIdIdx: index('idx_keys_user_id').on(table.userId), + keysKeyIdx: index('idx_keys_key').on(table.key), keysCreatedAtIdx: index('idx_keys_created_at').on(table.createdAt), keysDeletedAtIdx: index('idx_keys_deleted_at').on(table.deletedAt), })); @@ -309,10 +314,19 @@ export const providers = pgTable('providers', { providersEnabledPriorityIdx: index('idx_providers_enabled_priority').on(table.isEnabled, table.priority, table.weight).where(sql`${table.deletedAt} IS NULL`), // 分组查询优化 providersGroupIdx: index('idx_providers_group').on(table.groupTag).where(sql`${table.deletedAt} IS NULL`), + // #779:加速“旧 URL 是否仍被引用”的判断(vendor/type/url 精确匹配) + providersVendorTypeUrlActiveIdx: index('idx_providers_vendor_type_url_active').on(table.providerVendorId, table.providerType, table.url).where(sql`${table.deletedAt} IS NULL`), // 基础索引 providersCreatedAtIdx: index('idx_providers_created_at').on(table.createdAt), providersDeletedAtIdx: index('idx_providers_deleted_at').on(table.deletedAt), providersVendorTypeIdx: index('idx_providers_vendor_type').on(table.providerVendorId, table.providerType).where(sql`${table.deletedAt} IS NULL`), + // #779/#781:Dashboard/Probe scheduler 的 enabled vendor/type 去重热路径 + providersEnabledVendorTypeIdx: index('idx_providers_enabled_vendor_type').on( + table.providerVendorId, + table.providerType + ).where( + sql`${table.deletedAt} IS NULL AND ${table.isEnabled} = true AND ${table.providerVendorId} IS NOT NULL AND ${table.providerVendorId} > 0` + ), })); // Provider Endpoints table - 供应商(官网域名) + 类型 维度的端点池 @@ -356,6 +370,14 @@ export const providerEndpoints = pgTable('provider_endpoints', { table.vendorId, table.providerType ).where(sql`${table.deletedAt} IS NULL`), + // #779:运行时端点选择热路径(vendor/type/enabled 定位 + sort_order 有序扫描) + providerEndpointsPickEnabledIdx: index('idx_provider_endpoints_pick_enabled').on( + table.vendorId, + table.providerType, + table.isEnabled, + table.sortOrder, + table.id + ).where(sql`${table.deletedAt} IS NULL`), providerEndpointsCreatedAtIdx: index('idx_provider_endpoints_created_at').on(table.createdAt), providerEndpointsDeletedAtIdx: index('idx_provider_endpoints_deleted_at').on(table.deletedAt), })); @@ -471,6 +493,33 @@ export const messageRequest = pgTable('message_request', { messageRequestProviderIdIdx: index('idx_message_request_provider_id').on(table.providerId), messageRequestUserIdIdx: index('idx_message_request_user_id').on(table.userId), messageRequestKeyIdx: index('idx_message_request_key').on(table.key), + // #779:Key 维度分页/时间范围查询热路径(my-usage / usage logs) + messageRequestKeyCreatedAtIdIdx: index('idx_message_request_key_created_at_id').on( + table.key, + table.createdAt.desc(), + table.id.desc() + ).where(sql`${table.deletedAt} IS NULL`), + // #779:my-usage 下拉筛选 DISTINCT model / endpoint(Key 维度热路径) + messageRequestKeyModelActiveIdx: index('idx_message_request_key_model_active').on( + table.key, + table.model + ).where( + sql`${table.deletedAt} IS NULL AND ${table.model} IS NOT NULL AND (${table.blockedBy} IS NULL OR ${table.blockedBy} <> 'warmup')` + ), + messageRequestKeyEndpointActiveIdx: index('idx_message_request_key_endpoint_active').on( + table.key, + table.endpoint + ).where( + sql`${table.deletedAt} IS NULL AND ${table.endpoint} IS NOT NULL AND (${table.blockedBy} IS NULL OR ${table.blockedBy} <> 'warmup')` + ), + // #779:全局 usage logs keyset 分页热路径(按 created_at + id 倒序) + messageRequestCreatedAtIdActiveIdx: index('idx_message_request_created_at_id_active').on( + table.createdAt.desc(), + table.id.desc() + ).where(sql`${table.deletedAt} IS NULL`), + // #779:筛选器 DISTINCT model / status_code 加速(admin usage logs) + messageRequestModelActiveIdx: index('idx_message_request_model_active').on(table.model).where(sql`${table.deletedAt} IS NULL AND ${table.model} IS NOT NULL`), + messageRequestStatusCodeActiveIdx: index('idx_message_request_status_code_active').on(table.statusCode).where(sql`${table.deletedAt} IS NULL AND ${table.statusCode} IS NOT NULL`), messageRequestCreatedAtIdx: index('idx_message_request_created_at').on(table.createdAt), messageRequestDeletedAtIdx: index('idx_message_request_deleted_at').on(table.deletedAt), })); diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 85ddfd3ba..fa606651d 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -271,7 +271,9 @@ export async function register() { // 生产环境: 执行完整初始化(迁移 + 价格表 + 清理任务 + 通知任务) if (process.env.NODE_ENV === "production") { - const { checkDatabaseConnection, runMigrations } = await import("@/lib/migrate"); + const { checkDatabaseConnection, runMigrations, withAdvisoryLock } = await import( + "@/lib/migrate" + ); logger.info("Initializing Claude Code Hub"); @@ -283,42 +285,66 @@ export async function register() { } // 执行迁移(可通过 AUTO_MIGRATE=false 跳过) - if (process.env.AUTO_MIGRATE !== "false") { + const autoMigrateRaw = process.env.AUTO_MIGRATE?.trim().toLowerCase(); + const autoMigrateDisabled = + autoMigrateRaw === "false" || + autoMigrateRaw === "0" || + autoMigrateRaw === "no" || + autoMigrateRaw === "off"; + + if (!autoMigrateDisabled) { await runMigrations(); } else { - logger.info("[Instrumentation] AUTO_MIGRATE=false: skipping migrations"); + logger.info("[Instrumentation] AUTO_MIGRATE disabled: skipping migrations", { + value: process.env.AUTO_MIGRATE, + }); } warmupApiKeyVacuumFilter(); - // 回填 provider_vendors(按域名自动聚合旧 providers) - try { - const { backfillProviderVendorsFromProviders } = await import( - "@/repository/provider-endpoints" - ); - const vendorResult = await backfillProviderVendorsFromProviders(); - logger.info("[Instrumentation] Provider vendors backfill completed", { - processed: vendorResult.processed, - providersUpdated: vendorResult.providersUpdated, - vendorsCreatedCount: vendorResult.vendorsCreated.size, - skippedInvalidUrl: vendorResult.skippedInvalidUrl, - }); - } catch (error) { - logger.warn("[Instrumentation] Failed to backfill provider vendors", { - error: error instanceof Error ? error.message : String(error), - }); - } + // 回填 provider_vendors/provider_endpoints(幂等) + // 多实例启动时仅允许一个实例执行,避免重复扫描/写入导致的启动抖动(#779/#781)。 + const backfillLockName = "claude-code-hub:backfill:providers"; + const backfill = await withAdvisoryLock( + backfillLockName, + async () => { + // 回填 provider_vendors(按域名自动聚合旧 providers) + try { + const { backfillProviderVendorsFromProviders } = await import( + "@/repository/provider-endpoints" + ); + const vendorResult = await backfillProviderVendorsFromProviders(); + logger.info("[Instrumentation] Provider vendors backfill completed", { + processed: vendorResult.processed, + providersUpdated: vendorResult.providersUpdated, + vendorsCreatedCount: vendorResult.vendorsCreated.size, + skippedInvalidUrl: vendorResult.skippedInvalidUrl, + }); + } catch (error) { + logger.warn("[Instrumentation] Failed to backfill provider vendors", { + error: error instanceof Error ? error.message : String(error), + }); + } - // 回填 provider_endpoints(从 providers.url/类型 生成端点池,幂等) - try { - const { backfillProviderEndpointsFromProviders } = await import( - "@/repository/provider-endpoints" - ); - const result = await backfillProviderEndpointsFromProviders(); - logger.info("[Instrumentation] Provider endpoints backfill completed", result); - } catch (error) { - logger.warn("[Instrumentation] Failed to backfill provider endpoints", { - error: error instanceof Error ? error.message : String(error), + // 回填 provider_endpoints(从 providers.url/类型 生成端点池,幂等) + try { + const { backfillProviderEndpointsFromProviders } = await import( + "@/repository/provider-endpoints" + ); + const result = await backfillProviderEndpointsFromProviders(); + logger.info("[Instrumentation] Provider endpoints backfill completed", result); + } catch (error) { + logger.warn("[Instrumentation] Failed to backfill provider endpoints", { + error: error instanceof Error ? error.message : String(error), + }); + } + }, + { skipIfLocked: true } + ); + + if (!backfill.ran) { + logger.info("[Instrumentation] Provider backfill skipped (lock not acquired)", { + lockName: backfillLockName, }); } diff --git a/src/lib/abort-utils.ts b/src/lib/abort-utils.ts new file mode 100644 index 000000000..ae84cdb49 --- /dev/null +++ b/src/lib/abort-utils.ts @@ -0,0 +1,10 @@ +export function createAbortError(signal?: AbortSignal): unknown { + if (!signal) return new Error("Aborted"); + if (signal.reason) return signal.reason; + + try { + return new DOMException("Aborted", "AbortError"); + } catch { + return new Error("Aborted"); + } +} diff --git a/src/lib/cache/ttl-map.ts b/src/lib/cache/ttl-map.ts new file mode 100644 index 000000000..80535ea86 --- /dev/null +++ b/src/lib/cache/ttl-map.ts @@ -0,0 +1,79 @@ +export class TTLMap { + private readonly ttlMs: number; + private readonly maxSize: number; + private readonly store = new Map(); + + constructor(opts: { ttlMs: number; maxSize: number }) { + this.ttlMs = opts.ttlMs; + this.maxSize = opts.maxSize; + } + + get(key: K): V | undefined { + const entry = this.store.get(key); + if (!entry) return undefined; + + if (entry.expiresAt <= Date.now()) { + this.store.delete(key); + return undefined; + } + + // LRU bump: delete and re-insert to move to end of iteration order + this.store.delete(key); + this.store.set(key, entry); + return entry.value; + } + + set(key: K, value: V): void { + // Delete first so re-insert goes to end (LRU order) + this.store.delete(key); + + if (this.store.size >= this.maxSize) { + this.evict(); + } + + this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs }); + } + + delete(key: K): boolean { + return this.store.delete(key); + } + + has(key: K): boolean { + const entry = this.store.get(key); + if (!entry) return false; + + if (entry.expiresAt <= Date.now()) { + this.store.delete(key); + return false; + } + + return true; + } + + get size(): number { + return this.store.size; + } + + private evict(): void { + const now = Date.now(); + + // First pass: remove expired entries + for (const [k, v] of this.store) { + if (v.expiresAt <= now) { + this.store.delete(k); + } + } + + // If still at capacity, evict oldest 10% + if (this.store.size >= this.maxSize) { + const evictCount = Math.max(1, Math.ceil(this.maxSize * 0.1)); + let remaining = evictCount; + + for (const k of this.store.keys()) { + this.store.delete(k); + remaining -= 1; + if (remaining <= 0) break; + } + } + } +} diff --git a/src/lib/endpoint-circuit-breaker.ts b/src/lib/endpoint-circuit-breaker.ts index b3560421b..0e31253e9 100644 --- a/src/lib/endpoint-circuit-breaker.ts +++ b/src/lib/endpoint-circuit-breaker.ts @@ -6,6 +6,7 @@ import { type EndpointCircuitBreakerState, type EndpointCircuitState, loadEndpointCircuitState, + loadEndpointCircuitStates, saveEndpointCircuitState, } from "@/lib/redis/endpoint-circuit-breaker-state"; @@ -30,7 +31,36 @@ export interface EndpointHealth { } const healthMap = new Map(); -const loadedFromRedis = new Set(); +const loadedFromRedisAt = new Map(); +const redisSyncInFlight = new Map>(); +const REDIS_SYNC_TTL_MS = 1_000; + +const ENDPOINT_HEALTH_CACHE_MAX_SIZE = 10_000; + +function bumpLRU(map: Map, key: K): void { + if (!map.has(key)) return; + const value = map.get(key) as V; + map.delete(key); + map.set(key, value); +} + +function enforceEndpointHealthCacheMaxSize(): void { + if (ENDPOINT_HEALTH_CACHE_MAX_SIZE <= 0) return; + + while (healthMap.size > ENDPOINT_HEALTH_CACHE_MAX_SIZE) { + const oldest = healthMap.keys().next().value as number | undefined; + if (oldest === undefined) break; + healthMap.delete(oldest); + loadedFromRedisAt.delete(oldest); + } + + while (loadedFromRedisAt.size > ENDPOINT_HEALTH_CACHE_MAX_SIZE) { + const oldest = loadedFromRedisAt.keys().next().value as number | undefined; + if (oldest === undefined) break; + loadedFromRedisAt.delete(oldest); + healthMap.delete(oldest); + } +} function getOrCreateHealthSync(endpointId: number): EndpointHealth { let health = healthMap.get(endpointId); @@ -44,30 +74,50 @@ function getOrCreateHealthSync(endpointId: number): EndpointHealth { }; healthMap.set(endpointId, health); } + + bumpLRU(healthMap, endpointId); + bumpLRU(loadedFromRedisAt, endpointId); + return health; } async function getOrCreateHealth(endpointId: number): Promise { + const inFlight = redisSyncInFlight.get(endpointId); + if (inFlight) { + await inFlight; + } + let health = healthMap.get(endpointId); + const loadedAt = loadedFromRedisAt.get(endpointId); + const now = Date.now(); const needsRedisCheck = - (!health && !loadedFromRedis.has(endpointId)) || (health && health.circuitState !== "closed"); + loadedAt === undefined || (loadedAt !== undefined && now - loadedAt > REDIS_SYNC_TTL_MS); if (needsRedisCheck) { - loadedFromRedis.add(endpointId); + loadedFromRedisAt.set(endpointId, now); try { const redisState = await loadEndpointCircuitState(endpointId); if (redisState) { - if (!health || redisState.circuitState !== health.circuitState) { - health = { - failureCount: redisState.failureCount, - lastFailureTime: redisState.lastFailureTime, - circuitState: redisState.circuitState, - circuitOpenUntil: redisState.circuitOpenUntil, - halfOpenSuccessCount: redisState.halfOpenSuccessCount, - }; - healthMap.set(endpointId, health); + // 从 Redis 同步到内存时,不能只在 circuitState 变化时才更新: + // failureCount / halfOpenSuccessCount 等字段也可能在其它实例中发生变化。 + if (health) { + health.failureCount = redisState.failureCount; + health.lastFailureTime = redisState.lastFailureTime; + health.circuitState = redisState.circuitState; + health.circuitOpenUntil = redisState.circuitOpenUntil; + health.halfOpenSuccessCount = redisState.halfOpenSuccessCount; + return health; } + + health = { + failureCount: redisState.failureCount, + lastFailureTime: redisState.lastFailureTime, + circuitState: redisState.circuitState, + circuitOpenUntil: redisState.circuitOpenUntil, + halfOpenSuccessCount: redisState.halfOpenSuccessCount, + }; + healthMap.set(endpointId, health); return health; } @@ -86,7 +136,9 @@ async function getOrCreateHealth(endpointId: number): Promise { } } - return getOrCreateHealthSync(endpointId); + const result = getOrCreateHealthSync(endpointId); + enforceEndpointHealthCacheMaxSize(); + return result; } function persistStateToRedis(endpointId: number, health: EndpointHealth): void { @@ -98,6 +150,23 @@ function persistStateToRedis(endpointId: number, health: EndpointHealth): void { halfOpenSuccessCount: health.halfOpenSuccessCount, }; + const isDefaultClosedState = + state.circuitState === "closed" && + state.failureCount <= 0 && + state.halfOpenSuccessCount <= 0 && + state.lastFailureTime == null && + state.circuitOpenUntil == null; + + if (isDefaultClosedState) { + deleteEndpointCircuitState(endpointId).catch((error) => { + logger.warn("[EndpointCircuitBreaker] Failed to delete default state from Redis", { + endpointId, + error: error instanceof Error ? error.message : String(error), + }); + }); + return; + } + saveEndpointCircuitState(endpointId, state).catch((error) => { logger.warn("[EndpointCircuitBreaker] Failed to persist state to Redis", { endpointId, @@ -113,6 +182,129 @@ export async function getEndpointHealthInfo( return { health, config: DEFAULT_ENDPOINT_CIRCUIT_BREAKER_CONFIG }; } +async function syncHealthFromRedisBatch(endpointIds: readonly number[], refreshNow: number) { + const uniqueEndpointIds = Array.from(new Set(endpointIds)); + const toLoad: number[] = []; + const waitPromises: Promise[] = []; + + for (const endpointId of uniqueEndpointIds) { + const inFlight = redisSyncInFlight.get(endpointId); + if (inFlight) { + waitPromises.push(inFlight); + continue; + } + + toLoad.push(endpointId); + } + + if (toLoad.length > 0) { + const promise = (async () => { + try { + const redisStates = await loadEndpointCircuitStates(toLoad); + + for (const endpointId of toLoad) { + const redisState = redisStates.get(endpointId) ?? null; + loadedFromRedisAt.set(endpointId, refreshNow); + + const health = getOrCreateHealthSync(endpointId); + if (redisState) { + // 从 Redis 同步到内存时,不能只在 circuitState 变化时才更新: + // failureCount / halfOpenSuccessCount 等字段在 forceRefresh 下也应保持一致。 + health.failureCount = redisState.failureCount; + health.lastFailureTime = redisState.lastFailureTime; + health.circuitState = redisState.circuitState; + health.circuitOpenUntil = redisState.circuitOpenUntil; + health.halfOpenSuccessCount = redisState.halfOpenSuccessCount; + continue; + } + + if (health.circuitState !== "closed") { + health.circuitState = "closed"; + health.failureCount = 0; + health.lastFailureTime = null; + health.circuitOpenUntil = null; + health.halfOpenSuccessCount = 0; + } + } + } catch (error) { + logger.warn("[EndpointCircuitBreaker] Failed to batch sync state from Redis", { + count: toLoad.length, + error: error instanceof Error ? error.message : String(error), + }); + } + })().finally(() => { + for (const endpointId of toLoad) { + if (redisSyncInFlight.get(endpointId) === promise) { + redisSyncInFlight.delete(endpointId); + } + } + }); + + for (const endpointId of toLoad) { + redisSyncInFlight.set(endpointId, promise); + } + + waitPromises.push(promise); + } + + if (waitPromises.length > 0) { + await Promise.all(waitPromises); + } +} + +export async function getAllEndpointHealthStatusAsync( + endpointIds: number[], + options?: { forceRefresh?: boolean } +): Promise> { + const { forceRefresh = false } = options || {}; + + if (endpointIds.length === 0) { + return {}; + } + + const uniqueEndpointIds = Array.from(new Set(endpointIds)); + + if (forceRefresh) { + for (const endpointId of uniqueEndpointIds) { + loadedFromRedisAt.delete(endpointId); + } + } + + const refreshNow = Date.now(); + const needsRefresh = uniqueEndpointIds.filter((endpointId) => { + const memoryState = healthMap.get(endpointId); + if (!memoryState) return true; + + const loadedAt = loadedFromRedisAt.get(endpointId); + if (loadedAt === undefined) return true; + return refreshNow - loadedAt > REDIS_SYNC_TTL_MS; + }); + + if (needsRefresh.length > 0) { + await syncHealthFromRedisBatch(needsRefresh, refreshNow); + } + + const now = Date.now(); + const status: Record = {}; + + for (const endpointId of uniqueEndpointIds) { + const health = getOrCreateHealthSync(endpointId); + + if (health.circuitState === "open") { + if (health.circuitOpenUntil && now > health.circuitOpenUntil) { + health.circuitState = "half-open"; + health.halfOpenSuccessCount = 0; + persistStateToRedis(endpointId, health); + } + } + + status[endpointId] = { ...health }; + } + + enforceEndpointHealthCacheMaxSize(); + return status; +} + export async function isEndpointCircuitOpen(endpointId: number): Promise { const { getEnvConfig } = await import("@/lib/config/env.schema"); if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) { @@ -232,6 +424,7 @@ export async function resetEndpointCircuit(endpointId: number): Promise { health.halfOpenSuccessCount = 0; await deleteEndpointCircuitState(endpointId); + enforceEndpointHealthCacheMaxSize(); } /** @@ -315,7 +508,7 @@ export async function initEndpointCircuitBreaker(): Promise { } healthMap.clear(); - loadedFromRedis.clear(); + loadedFromRedisAt.clear(); try { const { getRedisClient } = await import("@/lib/redis/client"); diff --git a/src/lib/hooks/use-in-view-once.ts b/src/lib/hooks/use-in-view-once.ts new file mode 100644 index 000000000..73e66b7f8 --- /dev/null +++ b/src/lib/hooks/use-in-view-once.ts @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useState } from "react"; + +type ObserverTargetCallback = (entry: IntersectionObserverEntry) => void; + +const DEFAULT_OPTIONS: IntersectionObserverInit = { + rootMargin: "200px", + threshold: 0, +}; + +const sharedObservers = new Map(); + +function getObserverOptionsKey(options: IntersectionObserverInit): string { + const rootMargin = options.rootMargin ?? "0px"; + const threshold = options.threshold; + const thresholdKey = + threshold === undefined + ? "0" + : Array.isArray(threshold) + ? threshold.join(",") + : String(threshold); + return `${rootMargin}|${thresholdKey}`; +} + +class SharedIntersectionObserver { + private readonly callbacksByTarget = new Map>(); + private readonly observer: IntersectionObserver; + private readonly optionsKey: string; + private disposed = false; + + constructor(optionsKey: string, options: IntersectionObserverInit) { + this.optionsKey = optionsKey; + this.observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + const callbacks = this.callbacksByTarget.get(entry.target); + if (!callbacks) continue; + + for (const callback of Array.from(callbacks)) { + callback(entry); + } + } + }, options); + } + + observe(target: Element, callback: ObserverTargetCallback): () => void { + if (this.disposed) { + return () => {}; + } + + const callbacks = this.callbacksByTarget.get(target); + if (!callbacks) { + const next = new Set(); + next.add(callback); + this.callbacksByTarget.set(target, next); + this.observer.observe(target); + } else { + callbacks.add(callback); + } + + return () => { + this.unobserve(target, callback); + }; + } + + private unobserve(target: Element, callback: ObserverTargetCallback) { + const callbacks = this.callbacksByTarget.get(target); + if (!callbacks) return; + + callbacks.delete(callback); + if (callbacks.size > 0) return; + + this.callbacksByTarget.delete(target); + + try { + this.observer.unobserve(target); + } catch { + // Ignore: target might already be gone/unobserved + } + + if (this.callbacksByTarget.size === 0) { + this.dispose(); + } + } + + private dispose() { + if (this.disposed) return; + this.disposed = true; + this.observer.disconnect(); + sharedObservers.delete(this.optionsKey); + } +} + +function getSharedObserver(options: IntersectionObserverInit): SharedIntersectionObserver { + const key = getObserverOptionsKey(options); + const existing = sharedObservers.get(key); + if (existing) return existing; + + const observer = new SharedIntersectionObserver(key, options); + sharedObservers.set(key, observer); + return observer; +} + +/** + * Returns true once an element enters the viewport (with 200px pre-fetch margin). + * + * Delays per-row/per-card requests until elements are near-visible, avoiding + * request storms on mount. In test environments or without IntersectionObserver, + * elements are treated as immediately visible. + */ +export function useInViewOnce() { + const [element, setElement] = useState(null); + const [isInView, setIsInView] = useState(false); + + const ref = useCallback((node: T | null) => { + setElement(node); + }, []); + + useEffect(() => { + if (isInView) return; + + if (process.env.NODE_ENV === "test" || typeof IntersectionObserver === "undefined") { + setIsInView(true); + return; + } + + if (!element) return; + + let disposed = false; + const sharedObserver = getSharedObserver(DEFAULT_OPTIONS); + let unsubscribe: (() => void) | null = null; + + const onEntry = (entry: IntersectionObserverEntry) => { + if (disposed) return; + if (!entry.isIntersecting) return; + + setIsInView(true); + unsubscribe?.(); + unsubscribe = null; + }; + + unsubscribe = sharedObserver.observe(element, onEntry); + return () => { + disposed = true; + unsubscribe?.(); + unsubscribe = null; + }; + }, [element, isInView]); + + return { ref, isInView }; +} diff --git a/src/lib/migrate.ts b/src/lib/migrate.ts index cca558f2b..ac25b3eee 100644 --- a/src/lib/migrate.ts +++ b/src/lib/migrate.ts @@ -1,38 +1,185 @@ -"use server"; +import "server-only"; import path from "node:path"; +import { readMigrationFiles } from "drizzle-orm/migrator"; import { drizzle } from "drizzle-orm/postgres-js"; import { migrate } from "drizzle-orm/postgres-js/migrator"; import postgres from "postgres"; import { logger } from "@/lib/logger"; +const MIGRATION_ADVISORY_LOCK_NAME = "claude-code-hub:migrations"; + +export async function withAdvisoryLock( + lockName: string, + fn: () => Promise, + options?: { skipIfLocked?: boolean } +): Promise<{ ran: boolean; result?: T }> { + if (!process.env.DSN) { + logger.error("DSN environment variable is not set"); + process.exit(1); + } + + const client = postgres(process.env.DSN, { max: 1 }); + let acquired = false; + + try { + if (options?.skipIfLocked) { + const [row] = await client`SELECT pg_try_advisory_lock(hashtext(${lockName})) as locked`; + acquired = row?.locked === true; + if (!acquired) { + return { ran: false }; + } + } else { + await client`SELECT pg_advisory_lock(hashtext(${lockName}))`; + acquired = true; + } + + const result = await fn(); + return { ran: true, result }; + } finally { + if (acquired) { + try { + await client`SELECT pg_advisory_unlock(hashtext(${lockName}))`; + } catch (unlockError) { + logger.error("Failed to release advisory lock", { + lockName, + error: unlockError instanceof Error ? unlockError.message : String(unlockError), + }); + } + } + + try { + await client.end(); + } catch (endError) { + logger.error("Failed to close advisory lock client", { + lockName, + error: endError instanceof Error ? endError.message : String(endError), + }); + } + } +} + +async function ensureDrizzleMigrationsTableExists( + client: ReturnType +): Promise { + await client`CREATE SCHEMA IF NOT EXISTS "drizzle"`; + await client` + CREATE TABLE IF NOT EXISTS "drizzle"."__drizzle_migrations" ( + id SERIAL PRIMARY KEY, + hash text NOT NULL, + created_at numeric + ) + `; +} + +async function repairDrizzleMigrationsCreatedAt(input: { + client: ReturnType; + migrationsFolder: string; +}): Promise { + const { client, migrationsFolder } = input; + + // drizzle-orm migrator 仅比较 `created_at(folderMillis)` 来决定是否执行迁移。 + // 若历史 journal 的 `when` 被修正(或曾出现非单调),旧实例可能会因为 `created_at` 偏大而永久跳过后续迁移。 + // 这里用 hash 对齐并修复 created_at,让升级对用户无感(Docker 拉新镜像重启即可)。 + const migrations = readMigrationFiles({ migrationsFolder }); + + const expectedCreatedAtByHash = new Map(); + for (const migration of migrations) { + expectedCreatedAtByHash.set(migration.hash, migration.folderMillis); + } + + const rows = (await client` + SELECT id, hash, created_at + FROM "drizzle"."__drizzle_migrations" + `) as Array<{ + id: number; + hash: string; + created_at: string | number | null; + }>; + + const pendingFixes: Array<{ id: number; hash: string; from: number | null; to: number }> = []; + + for (const row of rows) { + const expected = expectedCreatedAtByHash.get(row.hash); + if (expected == null) { + continue; + } + + const currentRaw = row.created_at; + const current = + typeof currentRaw === "number" + ? currentRaw + : typeof currentRaw === "string" + ? Number(currentRaw) + : null; + + if (current == null || !Number.isFinite(current) || current !== expected) { + pendingFixes.push({ + id: row.id, + hash: row.hash, + from: current, + to: expected, + }); + } + } + + if (pendingFixes.length === 0) { + return; + } + + for (const fix of pendingFixes) { + await client` + UPDATE "drizzle"."__drizzle_migrations" + SET created_at = ${fix.to} + WHERE id = ${fix.id} + `; + } + + logger.info("Repaired drizzle.__drizzle_migrations created_at", { + repaired: pendingFixes.length, + }); +} + /** * 自动执行数据库迁移 * 在生产环境启动时自动运行 */ export async function runMigrations() { if (!process.env.DSN) { - logger.error("❌ DSN environment variable is not set"); + logger.error("DSN environment variable is not set"); process.exit(1); } - logger.info("🔄 Starting database migrations..."); + logger.info("Starting database migrations..."); const migrationClient = postgres(process.env.DSN, { max: 1 }); const db = drizzle(migrationClient); try { + logger.info("Waiting for database migration lock..."); + await migrationClient`SELECT pg_advisory_lock(hashtext(${MIGRATION_ADVISORY_LOCK_NAME}))`; + logger.info("Database migration lock acquired"); + // 获取迁移文件路径 const migrationsFolder = path.join(process.cwd(), "drizzle"); + await ensureDrizzleMigrationsTableExists(migrationClient); + await repairDrizzleMigrationsCreatedAt({ client: migrationClient, migrationsFolder }); + // 执行迁移 await migrate(db, { migrationsFolder }); - logger.info("✅ Database migrations completed successfully!"); + logger.info("Database migrations completed successfully"); } catch (error) { - logger.error("❌ Migration failed:", error); + logger.error("Migration failed", error); process.exit(1); } finally { + try { + await migrationClient`SELECT pg_advisory_unlock(hashtext(${MIGRATION_ADVISORY_LOCK_NAME}))`; + } catch (unlockError) { + logger.error("Failed to release database migration lock", unlockError); + } + // 关闭连接 await migrationClient.end(); } @@ -43,7 +190,7 @@ export async function runMigrations() { */ export async function checkDatabaseConnection(retries = 30, delay = 2000): Promise { if (!process.env.DSN) { - logger.error("❌ DSN environment variable is not set"); + logger.error("DSN environment variable is not set"); return false; } @@ -55,7 +202,7 @@ export async function checkDatabaseConnection(retries = 30, delay = 2000): Promi logger.info("Database connection established"); return true; } catch (error) { - logger.error("⏳ Waiting for database... (${i + 1}/${retries})", error); + logger.error(`Waiting for database... (${i + 1}/${retries})`, error); if (i < retries - 1) { await new Promise((resolve) => setTimeout(resolve, delay)); } diff --git a/src/lib/provider-endpoints/endpoint-selector.ts b/src/lib/provider-endpoints/endpoint-selector.ts index dda76af15..98288be70 100644 --- a/src/lib/provider-endpoints/endpoint-selector.ts +++ b/src/lib/provider-endpoints/endpoint-selector.ts @@ -1,19 +1,23 @@ import "server-only"; -import { isEndpointCircuitOpen } from "@/lib/endpoint-circuit-breaker"; -import { findProviderEndpointsByVendorAndType } from "@/repository"; +import { getEnvConfig } from "@/lib/config/env.schema"; +import { getAllEndpointHealthStatusAsync } from "@/lib/endpoint-circuit-breaker"; +import { + findEnabledProviderEndpointsByVendorAndType, + findProviderEndpointsByVendorAndType, +} from "@/repository"; import type { ProviderEndpoint, ProviderType } from "@/types/provider"; -export function rankProviderEndpoints(endpoints: ProviderEndpoint[]): ProviderEndpoint[] { - const enabled = endpoints.filter((e) => e.isEnabled && !e.deletedAt); +function priorityRank(endpoint: ProviderEndpoint): number { + if (endpoint.lastProbeOk === true) return 0; + if (endpoint.lastProbeOk === null) return 1; + return 2; +} - const priorityRank = (endpoint: ProviderEndpoint): number => { - if (endpoint.lastProbeOk === true) return 0; - if (endpoint.lastProbeOk === null) return 1; - return 2; - }; +function rankActiveProviderEndpoints(endpoints: ProviderEndpoint[]): ProviderEndpoint[] { + if (endpoints.length <= 1) return endpoints; - return enabled.slice().sort((a, b) => { + endpoints.sort((a, b) => { const rankDiff = priorityRank(a) - priorityRank(b); if (rankDiff !== 0) return rankDiff; @@ -25,6 +29,13 @@ export function rankProviderEndpoints(endpoints: ProviderEndpoint[]): ProviderEn return a.id - b.id; }); + + return endpoints; +} + +export function rankProviderEndpoints(endpoints: ProviderEndpoint[]): ProviderEndpoint[] { + const enabled = endpoints.filter((e) => e.isEnabled && !e.deletedAt); + return rankActiveProviderEndpoints(enabled); } export async function getPreferredProviderEndpoints(input: { @@ -32,30 +43,30 @@ export async function getPreferredProviderEndpoints(input: { providerType: ProviderType; excludeEndpointIds?: number[]; }): Promise { - const excludeSet = new Set(input.excludeEndpointIds ?? []); + const excludeIds = input.excludeEndpointIds ?? []; + const excludeSet = excludeIds.length > 0 ? new Set(excludeIds) : null; - const endpoints = await findProviderEndpointsByVendorAndType(input.vendorId, input.providerType); - const filtered = endpoints.filter((e) => e.isEnabled && !e.deletedAt && !excludeSet.has(e.id)); + const endpoints = await findEnabledProviderEndpointsByVendorAndType( + input.vendorId, + input.providerType + ); + // `findEnabledProviderEndpointsByVendorAndType` 已保证 isEnabled=true 且 deletedAt IS NULL + const circuitCandidates = excludeSet ? endpoints.filter((e) => !excludeSet.has(e.id)) : endpoints; - if (filtered.length === 0) { + if (circuitCandidates.length === 0) { return []; } // When endpoint circuit breaker is disabled, skip circuit check entirely - const { getEnvConfig } = await import("@/lib/config/env.schema"); if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) { - return rankProviderEndpoints(filtered); + return rankProviderEndpoints(circuitCandidates); } - const circuitResults = await Promise.all( - filtered.map(async (endpoint) => ({ - endpoint, - isOpen: await isEndpointCircuitOpen(endpoint.id), - })) + const healthStatus = await getAllEndpointHealthStatusAsync(circuitCandidates.map((e) => e.id)); + const candidates = circuitCandidates.filter( + (endpoint) => healthStatus[endpoint.id]?.circuitState !== "open" ); - const candidates = circuitResults.filter(({ isOpen }) => !isOpen).map(({ endpoint }) => endpoint); - return rankProviderEndpoints(candidates); } @@ -78,20 +89,22 @@ export async function getEndpointFilterStats(input: { }): Promise { const endpoints = await findProviderEndpointsByVendorAndType(input.vendorId, input.providerType); const total = endpoints.length; - const enabled = endpoints.filter((e) => e.isEnabled && !e.deletedAt).length; + const enabledEndpoints = endpoints.filter((e) => e.isEnabled && !e.deletedAt); + const enabled = enabledEndpoints.length; // When endpoint circuit breaker is disabled, no endpoints can be circuit-open - const { getEnvConfig } = await import("@/lib/config/env.schema"); if (!getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) { return { total, enabled, circuitOpen: 0, available: enabled }; } - const circuitResults = await Promise.all( - endpoints - .filter((e) => e.isEnabled && !e.deletedAt) - .map(async (e) => isEndpointCircuitOpen(e.id)) - ); - const circuitOpen = circuitResults.filter(Boolean).length; + if (enabledEndpoints.length === 0) { + return { total, enabled: 0, circuitOpen: 0, available: 0 }; + } + + const healthStatus = await getAllEndpointHealthStatusAsync(enabledEndpoints.map((e) => e.id)); + const circuitOpen = enabledEndpoints.filter( + (e) => healthStatus[e.id]?.circuitState === "open" + ).length; const available = enabled - circuitOpen; return { total, enabled, circuitOpen, available }; diff --git a/src/lib/provider-endpoints/leader-lock.ts b/src/lib/provider-endpoints/leader-lock.ts index 8534b0b68..2bb1cbdbb 100644 --- a/src/lib/provider-endpoints/leader-lock.ts +++ b/src/lib/provider-endpoints/leader-lock.ts @@ -25,7 +25,7 @@ function cleanupExpiredMemoryLocks(now: number): void { export async function acquireLeaderLock(key: string, ttlMs: number): Promise { const lockId = generateLockId(); - const redis = getRedisClient(); + const redis = getRedisClient({ allowWhenRateLimitDisabled: true }); if (redis && redis.status === "ready") { try { @@ -60,7 +60,7 @@ export async function acquireLeaderLock(key: string, ttlMs: number): Promise { - const redis = getRedisClient(); + const redis = getRedisClient({ allowWhenRateLimitDisabled: true }); if (lock.lockType === "memory") { // If Redis becomes available, force callers to re-acquire a distributed lock. @@ -117,7 +117,7 @@ export async function releaseLeaderLock(lock: LeaderLock): Promise { return; } - const redis = getRedisClient(); + const redis = getRedisClient({ allowWhenRateLimitDisabled: true }); if (!redis || redis.status !== "ready") { return; } @@ -139,3 +139,58 @@ export async function releaseLeaderLock(lock: LeaderLock): Promise { }); } } + +export function startLeaderLockKeepAlive(opts: { + getLock: () => LeaderLock | undefined; + clearLock: () => void; + ttlMs: number; + logTag: string; + onLost: () => void; +}): { stop: () => void } { + let stopped = false; + let renewing = false; + let intervalId: ReturnType | undefined; + + const stop = () => { + if (stopped) return; + stopped = true; + if (intervalId) clearInterval(intervalId); + }; + + const tick = async () => { + if (stopped || renewing) return; + + const current = opts.getLock(); + if (!current) { + stop(); + opts.onLost(); + return; + } + + renewing = true; + try { + const ok = await renewLeaderLock(current, opts.ttlMs); + if (!ok) { + opts.clearLock(); + stop(); + opts.onLost(); + logger.warn(`[${opts.logTag}] Lost leader lock during operation`, { + key: current.key, + lockType: current.lockType, + }); + } + } finally { + renewing = false; + } + }; + + const intervalMs = Math.max(1000, Math.floor(opts.ttlMs / 2)); + intervalId = setInterval(() => { + void tick(); + }, intervalMs); + + const timer = intervalId as unknown as { unref?: () => void }; + timer.unref?.(); + + return { stop }; +} diff --git a/src/lib/provider-endpoints/probe-log-cleanup.ts b/src/lib/provider-endpoints/probe-log-cleanup.ts index a7a91f87f..9e6bd0108 100644 --- a/src/lib/provider-endpoints/probe-log-cleanup.ts +++ b/src/lib/provider-endpoints/probe-log-cleanup.ts @@ -3,6 +3,7 @@ import { acquireLeaderLock, type LeaderLock, releaseLeaderLock, + startLeaderLockKeepAlive, } from "@/lib/provider-endpoints/leader-lock"; import { deleteProviderEndpointProbeLogsBeforeDateBatch } from "@/repository"; @@ -39,6 +40,8 @@ async function runCleanupOnce(): Promise { cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_RUNNING__ = true; let lock: LeaderLock | null = null; + let leadershipLost = false; + let stopKeepAlive: (() => void) | undefined; try { lock = await acquireLeaderLock(LOCK_KEY, LOCK_TTL_MS); @@ -48,12 +51,28 @@ async function runCleanupOnce(): Promise { cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_LOCK__ = lock; + stopKeepAlive = startLeaderLockKeepAlive({ + getLock: () => cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_LOCK__, + clearLock: () => { + cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_LOCK__ = undefined; + }, + ttlMs: LOCK_TTL_MS, + logTag: "EndpointProbeLogCleanup", + onLost: () => { + leadershipLost = true; + }, + }).stop; + const now = Date.now(); const retentionMs = Math.max(0, RETENTION_DAYS) * 24 * 60 * 60 * 1000; const beforeDate = new Date(now - retentionMs); let totalDeleted = 0; while (true) { + if (leadershipLost) { + return; + } + const deleted = await deleteProviderEndpointProbeLogsBeforeDateBatch({ beforeDate, batchSize: CLEANUP_BATCH_SIZE, @@ -81,6 +100,7 @@ async function runCleanupOnce(): Promise { error: error instanceof Error ? error.message : String(error), }); } finally { + stopKeepAlive?.(); cleanupState.__CCH_ENDPOINT_PROBE_LOG_CLEANUP_RUNNING__ = false; if (lock) { diff --git a/src/lib/provider-endpoints/probe-logs-batcher.ts b/src/lib/provider-endpoints/probe-logs-batcher.ts new file mode 100644 index 000000000..97b0c1111 --- /dev/null +++ b/src/lib/provider-endpoints/probe-logs-batcher.ts @@ -0,0 +1,387 @@ +import { getProviderEndpointProbeLogs } from "@/actions/provider-endpoints"; +import { createAbortError } from "@/lib/abort-utils"; + +export type ProbeLog = { + ok: boolean; + latencyMs: number | null; + createdAt?: string | number | Date | null; +}; + +function normalizeProbeLog(value: unknown): ProbeLog | null { + if (!value || typeof value !== "object") return null; + + const rawOk = (value as { ok?: unknown }).ok; + if (typeof rawOk !== "boolean") return null; + + const rawLatencyMs = (value as { latencyMs?: unknown }).latencyMs; + const latencyMs = + typeof rawLatencyMs === "number" && Number.isFinite(rawLatencyMs) ? rawLatencyMs : null; + + const rawCreatedAt = (value as { createdAt?: unknown }).createdAt; + const createdAt = + rawCreatedAt === undefined || + rawCreatedAt === null || + typeof rawCreatedAt === "string" || + typeof rawCreatedAt === "number" || + rawCreatedAt instanceof Date + ? rawCreatedAt + : undefined; + + return { ok: rawOk, latencyMs, createdAt }; +} + +function normalizeProbeLogs(value: unknown): ProbeLog[] { + if (!Array.isArray(value)) return []; + + const logs: ProbeLog[] = []; + for (const item of value) { + const normalized = normalizeProbeLog(item); + if (normalized) logs.push(normalized); + } + return logs; +} + +function normalizeProbeLogsByEndpointId(data: unknown): Record | null { + if (!data || typeof data !== "object") return null; + + if (Array.isArray(data)) { + const map: Record = {}; + for (const item of data) { + if (!item || typeof item !== "object") continue; + const endpointId = (item as { endpointId?: unknown }).endpointId; + const logs = (item as { logs?: unknown }).logs; + if (typeof endpointId !== "number" || !Array.isArray(logs)) continue; + map[endpointId] = normalizeProbeLogs(logs); + } + return map; + } + + const obj = data as Record; + + const logsByEndpointId = obj.logsByEndpointId; + if (logsByEndpointId && typeof logsByEndpointId === "object") { + const raw = logsByEndpointId as Record; + const map: Record = {}; + for (const [k, v] of Object.entries(raw)) { + const endpointId = Number.parseInt(k, 10); + if (!Number.isFinite(endpointId) || !Array.isArray(v)) continue; + map[endpointId] = normalizeProbeLogs(v); + } + return map; + } + + const items = obj.items; + if (Array.isArray(items)) { + const map: Record = {}; + for (const item of items) { + if (!item || typeof item !== "object") continue; + const endpointId = (item as { endpointId?: unknown }).endpointId; + const logs = (item as { logs?: unknown }).logs; + if (typeof endpointId !== "number" || !Array.isArray(logs)) continue; + map[endpointId] = normalizeProbeLogs(logs); + } + return map; + } + + return null; +} + +let isBatchProbeLogsEndpointAvailable: boolean | undefined; +let batchProbeLogsEndpointDisabledAt: number | null = null; +const BATCH_PROBE_LOGS_RETRY_INTERVAL_MS = 5 * 60 * 1000; + +function isBatchProbeLogsDisabled(): boolean { + if (isBatchProbeLogsEndpointAvailable !== false) return false; + if (batchProbeLogsEndpointDisabledAt === null) { + isBatchProbeLogsEndpointAvailable = undefined; + return false; + } + if (Date.now() - batchProbeLogsEndpointDisabledAt > BATCH_PROBE_LOGS_RETRY_INTERVAL_MS) { + isBatchProbeLogsEndpointAvailable = undefined; + batchProbeLogsEndpointDisabledAt = null; + return false; + } + return true; +} + +async function tryFetchBatchProbeLogsByEndpointIds( + endpointIds: number[], + limit: number +): Promise | null> { + if (endpointIds.length <= 1) return null; + if (isBatchProbeLogsDisabled()) return null; + if (process.env.NODE_ENV === "test") return null; + + const MAX_ENDPOINT_IDS_PER_BATCH = 500; + const chunks: number[][] = []; + for (let index = 0; index < endpointIds.length; index += MAX_ENDPOINT_IDS_PER_BATCH) { + chunks.push(endpointIds.slice(index, index + MAX_ENDPOINT_IDS_PER_BATCH)); + } + + const merged: Record = {}; + const fallbackEndpointIds = new Set(); + const missingFromSuccessfulChunks = new Set(); + let didAnyChunkSucceed = false; + let didAnyChunkFail = false; + let didAnyChunk404 = false; + + for (const chunk of chunks) { + try { + const res = await fetch("/api/actions/providers/batchGetProviderEndpointProbeLogs", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ endpointIds: chunk, limit }), + }); + + if (res.status === 404) { + didAnyChunkFail = true; + didAnyChunk404 = true; + + for (const endpointId of chunk) fallbackEndpointIds.add(endpointId); + continue; + } + + if (!res.ok) { + didAnyChunkFail = true; + for (const endpointId of chunk) fallbackEndpointIds.add(endpointId); + continue; + } + + const json = (await res.json()) as { ok?: unknown; data?: unknown }; + if (json.ok !== true) { + didAnyChunkFail = true; + for (const endpointId of chunk) fallbackEndpointIds.add(endpointId); + continue; + } + + const normalized = normalizeProbeLogsByEndpointId(json.data); + if (!normalized) { + didAnyChunkFail = true; + for (const endpointId of chunk) fallbackEndpointIds.add(endpointId); + continue; + } + + didAnyChunkSucceed = true; + + const normalizedEndpointIds = new Set(); + for (const [endpointId, logs] of Object.entries(normalized)) { + const id = Number(endpointId); + normalizedEndpointIds.add(id); + merged[id] = logs; + } + + for (const endpointId of chunk) { + if (!normalizedEndpointIds.has(endpointId)) missingFromSuccessfulChunks.add(endpointId); + } + } catch { + didAnyChunkFail = true; + for (const endpointId of chunk) fallbackEndpointIds.add(endpointId); + } + } + + if (!didAnyChunkSucceed) { + if (didAnyChunk404) { + isBatchProbeLogsEndpointAvailable = false; + batchProbeLogsEndpointDisabledAt = Date.now(); + } + return null; + } + + isBatchProbeLogsEndpointAvailable = true; + batchProbeLogsEndpointDisabledAt = null; + + if (!didAnyChunkFail) { + return merged; + } + + const endpointIdsToFetchIndividually = new Set(); + for (const endpointId of fallbackEndpointIds) { + if (merged[endpointId] === undefined) endpointIdsToFetchIndividually.add(endpointId); + } + for (const endpointId of missingFromSuccessfulChunks) { + if (merged[endpointId] === undefined) endpointIdsToFetchIndividually.add(endpointId); + } + + if (endpointIdsToFetchIndividually.size === 0) return merged; + + const rest = await fetchProbeLogsByEndpointIdsIndividually( + Array.from(endpointIdsToFetchIndividually), + limit + ).catch(() => null); + + if (rest) { + for (const [endpointId, logs] of Object.entries(rest)) { + merged[Number(endpointId)] = logs; + } + } + + return merged; +} + +async function fetchProbeLogsByEndpointIdsIndividually( + endpointIds: number[], + limit: number +): Promise> { + const map: Record = {}; + const concurrency = 4; + let idx = 0; + + const workers = Array.from({ length: Math.min(concurrency, endpointIds.length) }, async () => { + for (;;) { + const currentIndex = idx++; + if (currentIndex >= endpointIds.length) return; + const endpointId = endpointIds[currentIndex]; + + try { + const res = await getProviderEndpointProbeLogs({ + endpointId, + limit, + }); + map[endpointId] = res.ok && res.data ? normalizeProbeLogs(res.data.logs) : []; + } catch { + map[endpointId] = []; + } + } + }); + + await Promise.all(workers); + return map; +} + +async function fetchProbeLogsByEndpointIds( + endpointIds: number[], + limit: number +): Promise> { + const batched = await tryFetchBatchProbeLogsByEndpointIds(endpointIds, limit); + if (batched) return batched; + return fetchProbeLogsByEndpointIdsIndividually(endpointIds, limit); +} + +type BatchRequest = { + endpointId: number; + resolve: (logs: ProbeLog[]) => void; + reject: (error: unknown) => void; +}; + +export class ProbeLogsBatcher { + private readonly pendingByLimit = new Map>(); + private flushTimer: ReturnType | null = null; + + load(endpointId: number, limit: number, options?: { signal?: AbortSignal }): Promise { + return new Promise((resolve, reject) => { + const signal = options?.signal; + if (signal?.aborted) { + reject(createAbortError(signal)); + return; + } + + let settled = false; + let request: BatchRequest; + + const onAbort = () => { + if (settled) return; + settled = true; + this.removePendingRequest(limit, endpointId, request); + this.maybeCancelFlushTimer(); + reject(createAbortError(signal)); + }; + + request = { + endpointId, + resolve: (logs) => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", onAbort); + resolve(logs); + }, + reject: (error) => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", onAbort); + reject(error); + }, + }; + + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + const group = this.pendingByLimit.get(limit) ?? new Map(); + const list = group.get(endpointId) ?? []; + list.push(request); + group.set(endpointId, list); + this.pendingByLimit.set(limit, group); + + if (this.flushTimer) return; + const delayMs = process.env.NODE_ENV === "test" ? 0 : 10; + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + void this.flush().catch((error) => { + if (process.env.NODE_ENV !== "test") { + console.error("[ProbeLogsBatcher] flush failed", error); + } + }); + }, delayMs); + }); + } + + private maybeCancelFlushTimer() { + if (!this.flushTimer) return; + if (this.pendingByLimit.size > 0) return; + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + + private removePendingRequest(limit: number, endpointId: number, request: BatchRequest) { + const group = this.pendingByLimit.get(limit); + if (!group) return; + const list = group.get(endpointId); + if (!list) return; + + const next = list.filter((item) => item !== request); + if (next.length > 0) { + group.set(endpointId, next); + return; + } + + group.delete(endpointId); + if (group.size === 0) { + this.pendingByLimit.delete(limit); + } + } + + private async flush() { + const snapshot = new Map(this.pendingByLimit); + this.pendingByLimit.clear(); + + try { + await Promise.all( + Array.from(snapshot.entries(), async ([limit, group]) => { + const endpointIds = Array.from(group.keys()); + if (endpointIds.length === 0) return; + + try { + const map = await fetchProbeLogsByEndpointIds(endpointIds, limit); + for (const [endpointId, requests] of group.entries()) { + const logs = map[endpointId] ?? []; + for (const req of requests) req.resolve(logs); + } + } catch (error) { + for (const requests of group.values()) { + for (const req of requests) req.reject(error); + } + } + }) + ); + } catch (error) { + for (const group of snapshot.values()) { + for (const requests of group.values()) { + for (const req of requests) req.reject(error); + } + } + } + } +} + +export const probeLogsBatcher = new ProbeLogsBatcher(); diff --git a/src/lib/provider-endpoints/probe-scheduler.ts b/src/lib/provider-endpoints/probe-scheduler.ts index 7d54cd20f..d5c8d9d3b 100644 --- a/src/lib/provider-endpoints/probe-scheduler.ts +++ b/src/lib/provider-endpoints/probe-scheduler.ts @@ -4,6 +4,7 @@ import { type LeaderLock, releaseLeaderLock, renewLeaderLock, + startLeaderLockKeepAlive, } from "@/lib/provider-endpoints/leader-lock"; import { probeProviderEndpointAndRecordByEndpoint } from "@/lib/provider-endpoints/probe"; import { @@ -29,6 +30,8 @@ const SINGLE_VENDOR_INTERVAL_MS = 600_000; const TIMEOUT_OVERRIDE_INTERVAL_MS = 10_000; // Scheduler tick interval - use shortest possible interval to support timeout override const TICK_INTERVAL_MS = Math.min(BASE_INTERVAL_MS, TIMEOUT_OVERRIDE_INTERVAL_MS); +// Max idle DB polling interval (bounded by base interval) +const IDLE_DB_POLL_INTERVAL_MS = Math.min(BASE_INTERVAL_MS, 30_000); const TIMEOUT_MS = Math.max(1, parseIntWithDefault(process.env.ENDPOINT_PROBE_TIMEOUT_MS, 5_000)); const CONCURRENCY = Math.max(1, parseIntWithDefault(process.env.ENDPOINT_PROBE_CONCURRENCY, 10)); const CYCLE_JITTER_MS = Math.max( @@ -46,6 +49,8 @@ const schedulerState = globalThis as unknown as { __CCH_ENDPOINT_PROBE_SCHEDULER_RUNNING__?: boolean; __CCH_ENDPOINT_PROBE_SCHEDULER_LOCK__?: LeaderLock; __CCH_ENDPOINT_PROBE_SCHEDULER_STOP_REQUESTED__?: boolean; + __CCH_ENDPOINT_PROBE_SCHEDULER_NEXT_DUE_AT_MS__?: number; + __CCH_ENDPOINT_PROBE_SCHEDULER_NEXT_DB_POLL_AT_MS__?: number; }; function sleep(ms: number): Promise { @@ -65,12 +70,13 @@ function shuffleInPlace(arr: T[]): void { } /** - * Count enabled endpoints per vendor + * Count enabled endpoints per vendor/type */ -function countEndpointsByVendor(endpoints: ProviderEndpointProbeTarget[]): Map { - const counts = new Map(); +function countEndpointsByVendorType(endpoints: ProviderEndpointProbeTarget[]): Map { + const counts = new Map(); for (const ep of endpoints) { - counts.set(ep.vendorId, (counts.get(ep.vendorId) ?? 0) + 1); + const key = `${ep.vendorId}:${ep.providerType}`; + counts.set(key, (counts.get(key) ?? 0) + 1); } return counts; } @@ -85,7 +91,7 @@ function countEndpointsByVendor(endpoints: ProviderEndpointProbeTarget[]): Map + vendorEndpointCounts: Map ): number { // Timeout override takes highest priority const hasTimeoutError = @@ -95,7 +101,8 @@ function getEffectiveIntervalMs( } // Single-vendor interval - const vendorCount = vendorEndpointCounts.get(endpoint.vendorId) ?? 0; + const vendorCount = + vendorEndpointCounts.get(`${endpoint.vendorId}:${endpoint.providerType}`) ?? 0; if (vendorCount === 1) { return SINGLE_VENDOR_INTERVAL_MS; } @@ -109,7 +116,7 @@ function getEffectiveIntervalMs( */ function filterDueEndpoints( endpoints: ProviderEndpointProbeTarget[], - vendorEndpointCounts: Map, + vendorEndpointCounts: Map, now: Date ): ProviderEndpointProbeTarget[] { const nowMs = now.getTime(); @@ -125,6 +132,38 @@ function filterDueEndpoints( }); } +function computeNextDueAtMs( + endpoints: ProviderEndpointProbeTarget[], + vendorEndpointCounts: Map, + nowMs: number +): number { + let nextDueAtMs = Number.POSITIVE_INFINITY; + for (const ep of endpoints) { + // Never probed - treat as immediately due (force refresh on next tick) + if (ep.lastProbedAt === null) { + return nowMs; + } + + const effectiveInterval = getEffectiveIntervalMs(ep, vendorEndpointCounts); + const dueAtMs = ep.lastProbedAt.getTime() + effectiveInterval; + if (dueAtMs < nextDueAtMs) { + nextDueAtMs = dueAtMs; + } + } + return nextDueAtMs; +} + +function clearNextWorkHints(): void { + schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_NEXT_DUE_AT_MS__ = undefined; + schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_NEXT_DB_POLL_AT_MS__ = undefined; +} + +function updateNextWorkHints(input: { nextDueAtMs: number; nowMs: number }): void { + schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_NEXT_DUE_AT_MS__ = input.nextDueAtMs; + schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_NEXT_DB_POLL_AT_MS__ = + input.nowMs + IDLE_DB_POLL_INTERVAL_MS; +} + async function ensureLeaderLock(): Promise { const current = schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_LOCK__; if (current) { @@ -146,55 +185,6 @@ async function ensureLeaderLock(): Promise { return true; } -function startLeaderLockKeepAlive(onLost: () => void): () => void { - let stopped = false; - let renewing = false; - let intervalId: ReturnType | undefined; - - const stop = () => { - if (stopped) return; - stopped = true; - if (intervalId) clearInterval(intervalId); - }; - - const tick = async () => { - if (stopped || renewing) return; - - const current = schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_LOCK__; - if (!current) { - stop(); - onLost(); - return; - } - - renewing = true; - try { - const ok = await renewLeaderLock(current, LOCK_TTL_MS); - if (!ok) { - schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_LOCK__ = undefined; - stop(); - onLost(); - logger.warn("[EndpointProbeScheduler] Lost leader lock during probe cycle", { - key: current.key, - lockType: current.lockType, - }); - } - } finally { - renewing = false; - } - }; - - const intervalMs = Math.max(1000, Math.floor(LOCK_TTL_MS / 2)); - intervalId = setInterval(() => { - void tick(); - }, intervalMs); - - const timer = intervalId as unknown as { unref?: () => void }; - timer.unref?.(); - - return stop; -} - async function runProbeCycle(): Promise { if (schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_RUNNING__) { return; @@ -212,12 +202,32 @@ async function runProbeCycle(): Promise { try { const isLeader = await ensureLeaderLock(); if (!isLeader) { + clearNextWorkHints(); return; } - stopKeepAlive = startLeaderLockKeepAlive(() => { - leadershipLost = true; - }); + const nowMsBeforeCycle = Date.now(); + const nextDueAtMs = schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_NEXT_DUE_AT_MS__; + const nextDbPollAtMs = schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_NEXT_DB_POLL_AT_MS__; + if (typeof nextDueAtMs === "number" && typeof nextDbPollAtMs === "number") { + const nextWorkAtMs = Math.min(nextDueAtMs, nextDbPollAtMs); + if (nowMsBeforeCycle < nextWorkAtMs) { + return; + } + } + + stopKeepAlive = startLeaderLockKeepAlive({ + getLock: () => schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_LOCK__, + clearLock: () => { + schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_LOCK__ = undefined; + }, + ttlMs: LOCK_TTL_MS, + logTag: "EndpointProbeScheduler", + onLost: () => { + leadershipLost = true; + clearNextWorkHints(); + }, + }).stop; const jitter = CYCLE_JITTER_MS > 0 ? Math.floor(Math.random() * CYCLE_JITTER_MS) : 0; await sleep(jitter); @@ -228,16 +238,22 @@ async function runProbeCycle(): Promise { const allEndpoints = await findEnabledProviderEndpointsForProbing(); if (allEndpoints.length === 0) { + updateNextWorkHints({ nextDueAtMs: Number.POSITIVE_INFINITY, nowMs: Date.now() }); return; } // Calculate vendor endpoint counts for interval decisions - const vendorEndpointCounts = countEndpointsByVendor(allEndpoints); + const vendorEndpointCounts = countEndpointsByVendorType(allEndpoints); // Filter to only endpoints that are due for probing const now = new Date(); const endpoints = filterDueEndpoints(allEndpoints, vendorEndpointCounts, now); if (endpoints.length === 0) { + const nowMs = now.getTime(); + updateNextWorkHints({ + nextDueAtMs: computeNextDueAtMs(allEndpoints, vendorEndpointCounts, nowMs), + nowMs, + }); return; } @@ -267,11 +283,15 @@ async function runProbeCycle(): Promise { } try { - await probeProviderEndpointAndRecordByEndpoint({ + const result = await probeProviderEndpointAndRecordByEndpoint({ endpoint, source: "scheduled", timeoutMs: TIMEOUT_MS, }); + + endpoint.lastProbedAt = new Date(); + endpoint.lastProbeOk = result.ok; + endpoint.lastProbeErrorType = result.ok ? null : result.errorType; } catch (error) { logger.warn("[EndpointProbeScheduler] Probe failed", { endpointId: endpoint.id, @@ -282,7 +302,19 @@ async function runProbeCycle(): Promise { }; await Promise.all(Array.from({ length: concurrency }, () => worker())); + + if (leadershipLost || schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_STOP_REQUESTED__) { + clearNextWorkHints(); + return; + } + + const cycleNowMs = Date.now(); + updateNextWorkHints({ + nextDueAtMs: computeNextDueAtMs(allEndpoints, vendorEndpointCounts, cycleNowMs), + nowMs: cycleNowMs, + }); } catch (error) { + clearNextWorkHints(); logger.warn("[EndpointProbeScheduler] Probe cycle error", { error: error instanceof Error ? error.message : String(error), }); @@ -299,6 +331,7 @@ export function startEndpointProbeScheduler(): void { schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_STOP_REQUESTED__ = false; schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_STARTED__ = true; + clearNextWorkHints(); void runProbeCycle(); @@ -311,6 +344,7 @@ export function startEndpointProbeScheduler(): void { singleVendorIntervalMs: SINGLE_VENDOR_INTERVAL_MS, timeoutOverrideIntervalMs: TIMEOUT_OVERRIDE_INTERVAL_MS, tickIntervalMs: TICK_INTERVAL_MS, + idleDbPollIntervalMs: IDLE_DB_POLL_INTERVAL_MS, timeoutMs: TIMEOUT_MS, concurrency: CONCURRENCY, jitterMs: CYCLE_JITTER_MS, @@ -320,6 +354,7 @@ export function startEndpointProbeScheduler(): void { export function stopEndpointProbeScheduler(): void { schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_STOP_REQUESTED__ = true; + clearNextWorkHints(); const intervalId = schedulerState.__CCH_ENDPOINT_PROBE_SCHEDULER_INTERVAL_ID__; if (intervalId) { @@ -343,6 +378,7 @@ export function getEndpointProbeSchedulerStatus(): { singleVendorIntervalMs: number; timeoutOverrideIntervalMs: number; tickIntervalMs: number; + idleDbPollIntervalMs: number; timeoutMs: number; concurrency: number; jitterMs: number; @@ -355,6 +391,7 @@ export function getEndpointProbeSchedulerStatus(): { singleVendorIntervalMs: SINGLE_VENDOR_INTERVAL_MS, timeoutOverrideIntervalMs: TIMEOUT_OVERRIDE_INTERVAL_MS, tickIntervalMs: TICK_INTERVAL_MS, + idleDbPollIntervalMs: IDLE_DB_POLL_INTERVAL_MS, timeoutMs: TIMEOUT_MS, concurrency: CONCURRENCY, jitterMs: CYCLE_JITTER_MS, diff --git a/src/lib/provider-endpoints/probe.ts b/src/lib/provider-endpoints/probe.ts index f28c8a2b3..902ef38b3 100644 --- a/src/lib/provider-endpoints/probe.ts +++ b/src/lib/provider-endpoints/probe.ts @@ -1,6 +1,7 @@ import "server-only"; import net from "node:net"; +import { getEnvConfig } from "@/lib/config/env.schema"; import { getEndpointCircuitStateSync, recordEndpointFailure, @@ -234,14 +235,24 @@ export async function probeProviderEndpointAndRecordByEndpoint(input: { : result.errorType || "probe_failed"; await recordEndpointFailure(input.endpoint.id, new Error(message)); } else { - // Probe success: reset circuit breaker if endpoint was open/half-open - const currentState = getEndpointCircuitStateSync(input.endpoint.id); - if (currentState !== "closed") { - await resetEndpointCircuit(input.endpoint.id); - logger.info("[EndpointProbe] Probe success, circuit reset", { - endpointId: input.endpoint.id, - previousState: currentState, - }); + // Probe success: best-effort reset circuit breaker state (cross-instance safe). + // Note: do not rely on in-memory state only; Redis may contain open/half-open state from another instance. + if (getEnvConfig().ENABLE_ENDPOINT_CIRCUIT_BREAKER) { + const previousState = getEndpointCircuitStateSync(input.endpoint.id); + try { + await resetEndpointCircuit(input.endpoint.id); + if (previousState !== "closed") { + logger.info("[EndpointProbe] Probe success, circuit reset", { + endpointId: input.endpoint.id, + previousState, + }); + } + } catch (error) { + logger.warn("[EndpointProbe] Probe success but failed to reset circuit", { + endpointId: input.endpoint.id, + error: error instanceof Error ? error.message : String(error), + }); + } } } diff --git a/src/lib/redis/client.ts b/src/lib/redis/client.ts index ea999e041..eb49fb70f 100644 --- a/src/lib/redis/client.ts +++ b/src/lib/redis/client.ts @@ -73,7 +73,7 @@ export function buildRedisOptionsForUrl(redisUrl: string) { return { isTLS, options: { ...baseOptions, ...tlsOptions } }; } -export function getRedisClient(): Redis | null { +export function getRedisClient(input?: { allowWhenRateLimitDisabled?: boolean }): Redis | null { // Skip Redis connection during CI/build phase (avoid connection attempts) if (process.env.CI === "true" || process.env.NEXT_PHASE === "phase-production-build") { return null; @@ -82,8 +82,9 @@ export function getRedisClient(): Redis | null { const redisUrl = process.env.REDIS_URL; const rateLimitRaw = process.env.ENABLE_RATE_LIMIT?.trim(); const isEnabled = rateLimitRaw !== "false" && rateLimitRaw !== "0"; + const allowWhenRateLimitDisabled = input?.allowWhenRateLimitDisabled === true; - if (!isEnabled || !redisUrl) { + if ((!isEnabled && !allowWhenRateLimitDisabled) || !redisUrl) { logger.warn("[Redis] Rate limiting disabled or REDIS_URL not configured"); return null; } diff --git a/src/lib/redis/endpoint-circuit-breaker-state.ts b/src/lib/redis/endpoint-circuit-breaker-state.ts index 1d7feae80..5a70b1ba4 100644 --- a/src/lib/redis/endpoint-circuit-breaker-state.ts +++ b/src/lib/redis/endpoint-circuit-breaker-state.ts @@ -47,6 +47,94 @@ function deserializeState(data: Record): EndpointCircuitBreakerS }; } +export async function loadEndpointCircuitStates( + endpointIds: readonly number[] +): Promise> { + const uniqueEndpointIds = Array.from(new Set(endpointIds)); + const stateMap = new Map(); + + for (const endpointId of uniqueEndpointIds) { + stateMap.set(endpointId, null); + } + + const redis = getRedisClient(); + + if (!redis || uniqueEndpointIds.length === 0) { + if (!redis && uniqueEndpointIds.length > 0) { + logger.debug("[EndpointCircuitState] Redis not available, returning null", { + count: uniqueEndpointIds.length, + }); + } + return stateMap; + } + + const PIPELINE_BATCH_SIZE = 200; + + try { + for (let i = 0; i < uniqueEndpointIds.length; i += PIPELINE_BATCH_SIZE) { + const batchIds = uniqueEndpointIds.slice(i, i + PIPELINE_BATCH_SIZE); + const pipeline = redis.pipeline(); + + for (const endpointId of batchIds) { + pipeline.hgetall(getStateKey(endpointId)); + } + + const results = await pipeline.exec(); + if (!results) { + logger.warn("[EndpointCircuitState] Pipeline exec returned null", { + count: batchIds.length, + }); + continue; + } + + let errorCount = 0; + const errorSamples: Array<{ endpointId: number; error: string }> = []; + + for (let index = 0; index < batchIds.length; index++) { + const endpointId = batchIds[index]; + const [error, data] = results[index] ?? []; + + if (error) { + errorCount += 1; + if (errorSamples.length < 3) { + errorSamples.push({ + endpointId, + error: error instanceof Error ? error.message : String(error), + }); + } + continue; + } + + if (!data || typeof data !== "object" || Array.isArray(data)) { + continue; + } + + const record = data as Record; + if (Object.keys(record).length === 0) { + continue; + } + + stateMap.set(endpointId, deserializeState(record)); + } + + if (errorCount > 0) { + logger.warn("[EndpointCircuitState] Partial batch load failures", { + errorCount, + sample: errorSamples, + }); + } + } + + return stateMap; + } catch (error) { + logger.warn("[EndpointCircuitState] Failed to batch load from Redis", { + count: uniqueEndpointIds.length, + error: error instanceof Error ? error.message : String(error), + }); + return stateMap; + } +} + export async function loadEndpointCircuitState( endpointId: number ): Promise { @@ -89,8 +177,10 @@ export async function saveEndpointCircuitState( try { const key = getStateKey(endpointId); const data = serializeState(state); - await redis.hset(key, data); - await redis.expire(key, STATE_TTL_SECONDS); + const pipeline = redis.pipeline(); + pipeline.hset(key, data); + pipeline.expire(key, STATE_TTL_SECONDS); + await pipeline.exec(); } catch (error) { logger.warn("[EndpointCircuitState] Failed to save to Redis", { endpointId, diff --git a/src/repository/_shared/usage-log-filters.ts b/src/repository/_shared/usage-log-filters.ts new file mode 100644 index 000000000..23e247209 --- /dev/null +++ b/src/repository/_shared/usage-log-filters.ts @@ -0,0 +1,57 @@ +import type { SQL } from "drizzle-orm"; +import { eq, gte, lt, sql } from "drizzle-orm"; +import { messageRequest } from "@/drizzle/schema"; + +export interface UsageLogFilterParams { + sessionId?: string; + startTime?: number; + endTime?: number; + statusCode?: number; + excludeStatusCode200?: boolean; + model?: string; + endpoint?: string; + minRetryCount?: number; +} + +export function buildUsageLogConditions(filters: UsageLogFilterParams): SQL[] { + const conditions: SQL[] = []; + + const trimmedSessionId = filters.sessionId?.trim(); + if (trimmedSessionId) { + conditions.push(eq(messageRequest.sessionId, trimmedSessionId)); + } + + if (filters.startTime !== undefined) { + const startDate = new Date(filters.startTime); + conditions.push(gte(messageRequest.createdAt, startDate)); + } + + if (filters.endTime !== undefined) { + const endDate = new Date(filters.endTime); + conditions.push(lt(messageRequest.createdAt, endDate)); + } + + if (filters.statusCode !== undefined) { + conditions.push(eq(messageRequest.statusCode, filters.statusCode)); + } else if (filters.excludeStatusCode200) { + conditions.push( + sql`(${messageRequest.statusCode} IS NULL OR ${messageRequest.statusCode} <> 200)` + ); + } + + if (filters.model) { + conditions.push(eq(messageRequest.model, filters.model)); + } + + if (filters.endpoint) { + conditions.push(eq(messageRequest.endpoint, filters.endpoint)); + } + + if (filters.minRetryCount !== undefined) { + conditions.push( + sql`GREATEST(COALESCE(jsonb_array_length(${messageRequest.providerChain}) - 1, 0), 0) >= ${filters.minRetryCount}` + ); + } + + return conditions; +} diff --git a/src/repository/index.ts b/src/repository/index.ts index 17258f18b..e03e1b7e0 100644 --- a/src/repository/index.ts +++ b/src/repository/index.ts @@ -49,6 +49,7 @@ export { createProviderEndpoint, deleteProviderEndpointProbeLogsBeforeDateBatch, deleteProviderVendor, + findEnabledProviderEndpointsByVendorAndType, findEnabledProviderEndpointsForProbing, findProviderEndpointById, findProviderEndpointProbeLogs, @@ -56,6 +57,7 @@ export { findProviderEndpointsByVendorAndType, findProviderVendorById, findProviderVendors, + findProviderVendorsByIds, recordProviderEndpointProbeResult, softDeleteProviderEndpoint, tryDeleteProviderVendorIfEmpty, diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index b9b578fa6..66e2981ed 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -158,23 +158,43 @@ function buildDateCondition( timezone: string, dateRange?: DateRangeParams ) { + const nowLocal = sql`CURRENT_TIMESTAMP AT TIME ZONE ${timezone}`; + if (period === "custom" && dateRange) { - // 自定义日期范围:startDate <= date <= endDate - return sql`(${messageRequest.createdAt} AT TIME ZONE ${timezone})::date >= ${dateRange.startDate}::date - AND (${messageRequest.createdAt} AT TIME ZONE ${timezone})::date <= ${dateRange.endDate}::date`; + // 自定义日期范围:startDate <= local_date <= endDate + const startLocal = sql`(${dateRange.startDate}::date)::timestamp`; + const endExclusiveLocal = sql`(${dateRange.endDate}::date + INTERVAL '1 day')`; + const start = sql`(${startLocal} AT TIME ZONE ${timezone})`; + const endExclusive = sql`(${endExclusiveLocal} AT TIME ZONE ${timezone})`; + return sql`${messageRequest.createdAt} >= ${start} AND ${messageRequest.createdAt} < ${endExclusive}`; } switch (period) { case "allTime": return sql`1=1`; - case "daily": - return sql`(${messageRequest.createdAt} AT TIME ZONE ${timezone})::date = (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date`; + case "daily": { + const startLocal = sql`DATE_TRUNC('day', ${nowLocal})`; + const endExclusiveLocal = sql`${startLocal} + INTERVAL '1 day'`; + const start = sql`(${startLocal} AT TIME ZONE ${timezone})`; + const endExclusive = sql`(${endExclusiveLocal} AT TIME ZONE ${timezone})`; + return sql`${messageRequest.createdAt} >= ${start} AND ${messageRequest.createdAt} < ${endExclusive}`; + } case "last24h": return sql`${messageRequest.createdAt} >= (CURRENT_TIMESTAMP - INTERVAL '24 hours')`; - case "weekly": - return sql`date_trunc('week', ${messageRequest.createdAt} AT TIME ZONE ${timezone}) = date_trunc('week', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})`; - case "monthly": - return sql`date_trunc('month', ${messageRequest.createdAt} AT TIME ZONE ${timezone}) = date_trunc('month', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})`; + case "weekly": { + const startLocal = sql`DATE_TRUNC('week', ${nowLocal})`; + const endExclusiveLocal = sql`${startLocal} + INTERVAL '1 week'`; + const start = sql`(${startLocal} AT TIME ZONE ${timezone})`; + const endExclusive = sql`(${endExclusiveLocal} AT TIME ZONE ${timezone})`; + return sql`${messageRequest.createdAt} >= ${start} AND ${messageRequest.createdAt} < ${endExclusive}`; + } + case "monthly": { + const startLocal = sql`DATE_TRUNC('month', ${nowLocal})`; + const endExclusiveLocal = sql`${startLocal} + INTERVAL '1 month'`; + const start = sql`(${startLocal} AT TIME ZONE ${timezone})`; + const endExclusive = sql`(${endExclusiveLocal} AT TIME ZONE ${timezone})`; + return sql`${messageRequest.createdAt} >= ${start} AND ${messageRequest.createdAt} < ${endExclusive}`; + } default: return sql`1=1`; } diff --git a/src/repository/overview.ts b/src/repository/overview.ts index d68ea8661..699e584b3 100644 --- a/src/repository/overview.ts +++ b/src/repository/overview.ts @@ -1,6 +1,6 @@ "use server"; -import { and, avg, count, eq, gte, isNull, sql, sum } from "drizzle-orm"; +import { and, avg, count, eq, gte, isNull, lt, sql, sum } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { messageRequest } from "@/drizzle/schema"; import { Decimal, toCostDecimal } from "@/lib/utils/currency"; @@ -42,6 +42,10 @@ export interface OverviewMetricsWithComparison extends OverviewMetrics { */ export async function getOverviewMetrics(): Promise { const timezone = await resolveSystemTimezone(); + const nowLocal = sql`CURRENT_TIMESTAMP AT TIME ZONE ${timezone}`; + const todayStartLocal = sql`DATE_TRUNC('day', ${nowLocal})`; + const todayStart = sql`(${todayStartLocal} AT TIME ZONE ${timezone})`; + const tomorrowStart = sql`((${todayStartLocal} + INTERVAL '1 day') AT TIME ZONE ${timezone})`; const [result] = await db .select({ @@ -55,7 +59,8 @@ export async function getOverviewMetrics(): Promise { and( isNull(messageRequest.deletedAt), EXCLUDE_WARMUP_CONDITION, - sql`(${messageRequest.createdAt} AT TIME ZONE ${timezone})::date = (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date` + gte(messageRequest.createdAt, todayStart), + lt(messageRequest.createdAt, tomorrowStart) ) ); @@ -89,6 +94,14 @@ export async function getOverviewMetricsWithComparison( userId?: number ): Promise { const timezone = await resolveSystemTimezone(); + const nowLocal = sql`CURRENT_TIMESTAMP AT TIME ZONE ${timezone}`; + const todayStartLocal = sql`DATE_TRUNC('day', ${nowLocal})`; + const todayStart = sql`(${todayStartLocal} AT TIME ZONE ${timezone})`; + const tomorrowStart = sql`((${todayStartLocal} + INTERVAL '1 day') AT TIME ZONE ${timezone})`; + const yesterdayStartLocal = sql`${todayStartLocal} - INTERVAL '1 day'`; + const yesterdayStart = sql`(${yesterdayStartLocal} AT TIME ZONE ${timezone})`; + const yesterdayEndLocal = sql`${yesterdayStartLocal} + (${nowLocal} - ${todayStartLocal})`; + const yesterdayEnd = sql`(${yesterdayEndLocal} AT TIME ZONE ${timezone})`; // 用户过滤条件 const userCondition = userId ? eq(messageRequest.userId, userId) : undefined; @@ -109,7 +122,8 @@ export async function getOverviewMetricsWithComparison( isNull(messageRequest.deletedAt), EXCLUDE_WARMUP_CONDITION, userCondition, - sql`(${messageRequest.createdAt} AT TIME ZONE ${timezone})::date = (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date` + gte(messageRequest.createdAt, todayStart), + lt(messageRequest.createdAt, tomorrowStart) ) ), @@ -126,10 +140,8 @@ export async function getOverviewMetricsWithComparison( isNull(messageRequest.deletedAt), EXCLUDE_WARMUP_CONDITION, userCondition, - // 昨日同一天 - sql`(${messageRequest.createdAt} AT TIME ZONE ${timezone})::date = ((CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '1 day')::date`, - // 且时间不超过昨日的当前时刻 - sql`(${messageRequest.createdAt} AT TIME ZONE ${timezone})::time <= (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::time` + gte(messageRequest.createdAt, yesterdayStart), + lt(messageRequest.createdAt, yesterdayEnd) ) ), diff --git a/src/repository/provider-endpoints-batch.ts b/src/repository/provider-endpoints-batch.ts new file mode 100644 index 000000000..a52e89dc4 --- /dev/null +++ b/src/repository/provider-endpoints-batch.ts @@ -0,0 +1,174 @@ +import "server-only"; + +import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { db } from "@/drizzle/db"; +import { providerEndpointProbeLogs, providerEndpoints } from "@/drizzle/schema"; +import { logger } from "@/lib/logger"; +import type { ProviderEndpointProbeLog, ProviderType } from "@/types/provider"; + +function toDate(value: unknown): Date { + if (value instanceof Date) return value; + if (typeof value === "string" || typeof value === "number") return new Date(value); + return new Date(); +} + +function toNullableNumber(value: unknown): number | null { + if (value === null || value === undefined) return null; + const n = typeof value === "number" ? value : Number(value); + return Number.isFinite(n) ? n : null; +} + +export type VendorTypeEndpointStats = { + vendorId: number; + total: number; + enabled: number; + healthy: number; + unhealthy: number; + unknown: number; +}; + +export async function findVendorTypeEndpointStatsBatch(input: { + vendorIds: number[]; + providerType: ProviderType; +}): Promise { + const vendorIds = Array.from(new Set(input.vendorIds)); + if (vendorIds.length === 0) { + return []; + } + + const rows = await db + .select({ + vendorId: providerEndpoints.vendorId, + total: sql`COUNT(*)::int`, + enabled: sql`(COUNT(*) FILTER (WHERE ${providerEndpoints.isEnabled} = true))::int`, + healthy: sql`(COUNT(*) FILTER (WHERE ${providerEndpoints.isEnabled} = true AND ${providerEndpoints.lastProbeOk} = true))::int`, + unhealthy: sql`(COUNT(*) FILTER (WHERE ${providerEndpoints.isEnabled} = true AND ${providerEndpoints.lastProbeOk} = false))::int`, + unknown: sql`(COUNT(*) FILTER (WHERE ${providerEndpoints.isEnabled} = true AND ${providerEndpoints.lastProbeOk} IS NULL))::int`, + }) + .from(providerEndpoints) + .where( + and( + inArray(providerEndpoints.vendorId, vendorIds), + eq(providerEndpoints.providerType, input.providerType), + isNull(providerEndpoints.deletedAt) + ) + ) + .groupBy(providerEndpoints.vendorId); + + return rows.map((row) => ({ + vendorId: row.vendorId, + total: Number(row.total), + enabled: Number(row.enabled), + healthy: Number(row.healthy), + unhealthy: Number(row.unhealthy), + unknown: Number(row.unknown), + })); +} + +export async function findProviderEndpointProbeLogsBatch(input: { + endpointIds: number[]; + limitPerEndpoint: number; +}): Promise> { + const endpointIds = Array.from(new Set(input.endpointIds)).filter((id) => + Number.isSafeInteger(id) + ); + if (endpointIds.length === 0) { + return new Map(); + } + + const rawLimit = Number(input.limitPerEndpoint); + const limitPerEndpoint = Number.isFinite(rawLimit) ? Math.max(1, Math.trunc(rawLimit)) : 1; + + // 性能:避免 `ROW_NUMBER() OVER (PARTITION BY ...)` 在单个端点 logs 很多时退化为更重的扫描/排序。 + // 改为 LATERAL + LIMIT:每个 endpoint_id 仅取最新 N 条,能更好利用 (endpoint_id, created_at desc) 索引。 + // 安全:VALUES 列表使用 drizzle sql 参数化占位符拼接(不会把 endpointId 作为 raw 字符串注入)。 + const endpointValues = sql.join( + endpointIds.map((id) => sql`(${id})`), + sql`, ` + ); + + const query = sql` + WITH endpoint_ids(endpoint_id) AS ( + VALUES ${endpointValues} + ) + SELECT + l.id, + l.endpoint_id as "endpointId", + l.source, + l.ok, + l.status_code as "statusCode", + l.latency_ms as "latencyMs", + l.error_type as "errorType", + l.error_message as "errorMessage", + l.created_at as "createdAt" + FROM endpoint_ids e + CROSS JOIN LATERAL ( + SELECT + id, + endpoint_id, + source, + ok, + status_code, + latency_ms, + error_type, + error_message, + created_at + FROM ${providerEndpointProbeLogs} + WHERE endpoint_id = e.endpoint_id + ORDER BY created_at DESC NULLS LAST, id DESC + LIMIT ${limitPerEndpoint} + ) l + ORDER BY l.endpoint_id ASC, l.created_at DESC NULLS LAST, l.id DESC + `; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (await db.execute(query)) as any; + const map = new Map(); + + for (const row of Array.from(result) as Array>) { + const endpointId = Number(row.endpointId); + if (!Number.isFinite(endpointId)) { + continue; + } + + const id = Number(row.id); + if (!Number.isFinite(id)) { + continue; + } + + const log: ProviderEndpointProbeLog = { + id, + endpointId, + source: row.source as ProviderEndpointProbeLog["source"], + ok: Boolean(row.ok), + statusCode: toNullableNumber(row.statusCode), + latencyMs: toNullableNumber(row.latencyMs), + errorType: (row.errorType as string | null) ?? null, + errorMessage: (row.errorMessage as string | null) ?? null, + createdAt: toDate(row.createdAt), + }; + + const existing = map.get(endpointId); + if (existing) { + existing.push(log); + } else { + map.set(endpointId, [log]); + } + } + + // Defensive: ensure per-endpoint limit, even if SQL changes or driver behavior differs. + for (const [endpointId, logs] of map) { + if (logs.length > limitPerEndpoint) { + map.set(endpointId, logs.slice(0, limitPerEndpoint)); + } + } + + if (map.size === 0) { + logger.debug("[ProviderEndpointProbeLogsBatch] No logs found", { + endpointCount: endpointIds.length, + limitPerEndpoint, + }); + } + + return map; +} diff --git a/src/repository/provider-endpoints.ts b/src/repository/provider-endpoints.ts index 4cd807655..956a2f4cc 100644 --- a/src/repository/provider-endpoints.ts +++ b/src/repository/provider-endpoints.ts @@ -1,4 +1,4 @@ -"use server"; +import "server-only"; import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; @@ -311,32 +311,53 @@ function toProviderEndpointProbeLog(row: any): ProviderEndpointProbeLog { export type ProviderEndpointProbeTarget = Pick< ProviderEndpoint, - "id" | "url" | "vendorId" | "lastProbedAt" | "lastProbeOk" | "lastProbeErrorType" + "id" | "url" | "vendorId" | "providerType" | "lastProbedAt" | "lastProbeOk" | "lastProbeErrorType" >; export async function findEnabledProviderEndpointsForProbing(): Promise< ProviderEndpointProbeTarget[] > { - const rows = await db - .select({ - id: providerEndpoints.id, - url: providerEndpoints.url, - vendorId: providerEndpoints.vendorId, - lastProbedAt: providerEndpoints.lastProbedAt, - lastProbeOk: providerEndpoints.lastProbeOk, - lastProbeErrorType: providerEndpoints.lastProbeErrorType, - }) - .from(providerEndpoints) - .where(and(eq(providerEndpoints.isEnabled, true), isNull(providerEndpoints.deletedAt))) - .orderBy(asc(providerEndpoints.id)); + // #779/#781:probe scheduler 热路径: + // - 仅探测仍存在「启用 provider」的 vendor/type 下的端点(避免全禁用/孤儿 vendor 仍被持续探测) + // - 仍需覆盖端点池内“手动添加”的端点(未必与 provider.url 一一对应),因此只按 vendor/type 维度 gating。 + const query = sql` + WITH enabled_vendor_types AS ( + SELECT DISTINCT p.provider_vendor_id AS vendor_id, p.provider_type + FROM ${providers} p + WHERE p.is_enabled = true + AND p.deleted_at IS NULL + AND p.provider_vendor_id IS NOT NULL + AND p.provider_vendor_id > 0 + ) + SELECT + e.id, + e.url, + e.vendor_id AS "vendorId", + e.provider_type AS "providerType", + e.last_probed_at AS "lastProbedAt", + e.last_probe_ok AS "lastProbeOk", + e.last_probe_error_type AS "lastProbeErrorType" + FROM ${providerEndpoints} e + INNER JOIN enabled_vendor_types vt + ON vt.vendor_id = e.vendor_id + AND vt.provider_type = e.provider_type + WHERE e.is_enabled = true + AND e.deleted_at IS NULL + ORDER BY e.id ASC + `; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = (await db.execute(query)) as any; + const rows = Array.from(result) as Array>; return rows.map((row) => ({ - id: row.id, - url: row.url, - vendorId: row.vendorId, + id: Number(row.id), + url: String(row.url), + vendorId: Number(row.vendorId), + providerType: row.providerType as ProviderType, lastProbedAt: toNullableDate(row.lastProbedAt), - lastProbeOk: row.lastProbeOk ?? null, - lastProbeErrorType: row.lastProbeErrorType ?? null, + lastProbeOk: (row.lastProbeOk as boolean | null) ?? null, + lastProbeErrorType: (row.lastProbeErrorType as string | null) ?? null, })); } @@ -412,11 +433,35 @@ export async function getOrCreateProviderVendorIdFromUrls( } const existing = await executor - .select({ id: providerVendors.id }) + .select({ + id: providerVendors.id, + displayName: providerVendors.displayName, + websiteUrl: providerVendors.websiteUrl, + faviconUrl: providerVendors.faviconUrl, + }) .from(providerVendors) .where(eq(providerVendors.websiteDomain, websiteDomain)) .limit(1); if (existing[0]) { + const updates: Partial = {}; + + const current = existing[0]; + const nextDisplayName = input.displayName?.trim() || null; + if ((current.displayName == null || current.displayName.trim() === "") && nextDisplayName) { + updates.displayName = nextDisplayName; + } + if (current.websiteUrl == null && input.websiteUrl) { + updates.websiteUrl = input.websiteUrl; + } + if (current.faviconUrl == null && input.faviconUrl) { + updates.faviconUrl = input.faviconUrl; + } + + if (Object.keys(updates).length > 0) { + updates.updatedAt = new Date(); + await executor.update(providerVendors).set(updates).where(eq(providerVendors.id, current.id)); + } + return existing[0].id; } @@ -600,6 +645,136 @@ export async function findProviderVendors( return rows.map(toProviderVendor); } +export async function findProviderVendorsByIds(vendorIds: number[]): Promise { + const ids = Array.from(new Set(vendorIds)).filter((id) => Number.isInteger(id) && id > 0); + if (ids.length === 0) { + return []; + } + + const rows = await db + .select({ + id: providerVendors.id, + websiteDomain: providerVendors.websiteDomain, + displayName: providerVendors.displayName, + websiteUrl: providerVendors.websiteUrl, + faviconUrl: providerVendors.faviconUrl, + createdAt: providerVendors.createdAt, + updatedAt: providerVendors.updatedAt, + }) + .from(providerVendors) + .where(inArray(providerVendors.id, ids)) + .orderBy(desc(providerVendors.createdAt)); + + return rows.map(toProviderVendor); +} + +/** + * Dashboard/Endpoint Health 用:推导 vendor/type 筛选项(仅基于启用的 provider)。 + * + * 相比 `findAllProvidersFresh` 读取全量 provider 字段,这里只取 vendorId/providerType,并做 DISTINCT 去重, + * 可显著减少数据传输与反序列化开销(#779/#781)。 + */ +export async function findEnabledProviderVendorTypePairs(): Promise< + Array<{ vendorId: number; providerType: ProviderType }> +> { + const rows = await db + .selectDistinct({ + vendorId: providers.providerVendorId, + providerType: providers.providerType, + }) + .from(providers) + .where( + and( + isNull(providers.deletedAt), + eq(providers.isEnabled, true), + isNotNull(providers.providerVendorId), + gt(providers.providerVendorId, 0) + ) + ) + .orderBy(asc(providers.providerVendorId), asc(providers.providerType)); + + return rows + .map((row) => ({ + vendorId: row.vendorId as number, + providerType: row.providerType as ProviderType, + })) + .filter( + (row) => Number.isFinite(row.vendorId) && row.vendorId > 0 && Boolean(row.providerType) + ); +} + +/** + * 判断某个 (vendor/type/url) 是否仍被启用的 provider 引用。 + * + * 用于:端点删除/同步时的“引用检查”,避免出现“删了端点但启动回填/启用 provider 又复活”的困惑(#781)。 + */ +export async function hasEnabledProviderReferenceForVendorTypeUrl(input: { + vendorId: number; + providerType: ProviderType; + url: string; + excludeProviderId?: number; +}): Promise { + const trimmedUrl = input.url.trim(); + if (!trimmedUrl) { + return false; + } + + const whereClauses = [ + eq(providers.providerVendorId, input.vendorId), + eq(providers.providerType, input.providerType), + eq(providers.url, trimmedUrl), + eq(providers.isEnabled, true), + isNull(providers.deletedAt), + ]; + + if (input.excludeProviderId != null) { + whereClauses.push(ne(providers.id, input.excludeProviderId)); + } + + const [row] = await db + .select({ id: providers.id }) + .from(providers) + .where(and(...whereClauses)) + .limit(1); + + return Boolean(row); +} + +/** + * Dashboard/Endpoint Health 用:仅在存在启用 provider 的前提下返回该 vendor/type 的端点池。 + * + * #781:避免“没有任何启用 provider 的 vendor/type”仍在 Dashboard 中展示与被探测。 + * + * 注意:端点池允许手动添加端点,未必与 `providers.url` 一一对应;这里按 vendor/type 维度 gating, + * 以尽量保持端点池的展示语义不变。 + */ +export async function findDashboardProviderEndpointsByVendorAndType( + vendorId: number, + providerType: ProviderType +): Promise { + const rows = await db + .select(providerEndpointSelectFields) + .from(providerEndpoints) + .where( + and( + eq(providerEndpoints.vendorId, vendorId), + eq(providerEndpoints.providerType, providerType), + isNull(providerEndpoints.deletedAt), + sql`EXISTS ( + SELECT 1 + FROM ${providers} p + WHERE p.provider_vendor_id = ${vendorId} + AND p.provider_type = ${providerType} + AND p.is_enabled = true + AND p.deleted_at IS NULL + )` + ) + ) + .orderBy(asc(providerEndpoints.sortOrder), asc(providerEndpoints.id)); + + return rows.map(toProviderEndpoint); +} + export async function findProviderVendorById(vendorId: number): Promise { const rows = await db .select({ @@ -790,6 +965,31 @@ export async function findProviderEndpointsByVendorAndType( return rows.map(toProviderEndpoint); } +/** + * 仅返回启用的端点(运行时热路径),用于减少不必要的数据传输与排序开销。 + * + * #779:配合索引 `idx_provider_endpoints_pick_enabled`,可按 vendor/type 有序扫描。 + */ +export async function findEnabledProviderEndpointsByVendorAndType( + vendorId: number, + providerType: ProviderType +): Promise { + const rows = await db + .select(providerEndpointSelectFields) + .from(providerEndpoints) + .where( + and( + eq(providerEndpoints.vendorId, vendorId), + eq(providerEndpoints.providerType, providerType), + eq(providerEndpoints.isEnabled, true), + isNull(providerEndpoints.deletedAt) + ) + ) + .orderBy(asc(providerEndpoints.sortOrder), asc(providerEndpoints.id)); + + return rows.map(toProviderEndpoint); +} + export async function findProviderEndpointsByVendor(vendorId: number): Promise { const rows = await db .select({ @@ -909,6 +1109,13 @@ export interface SyncProviderEndpointOnProviderEditInput { previousProviderType?: ProviderType | null; previousUrl: string; nextUrl: string; + /** + * 是否在旧 URL 仍被其它「启用 provider」引用时保留旧端点。 + * + * - `true`(默认):仅当旧 URL 仍被其它启用 provider 引用时保留旧端点;否则会尝试移动/软删除旧端点, + * 以避免 orphan endpoint 长期残留并造成“明明已改/删仍在被探测或展示”的困惑(#781)。 + * - `false`:忽略引用关系,尽量清理旧端点(谨慎使用:可能影响仍在使用旧 URL 的 provider)。 + */ keepPreviousWhenReferenced?: boolean; } @@ -972,6 +1179,9 @@ export async function syncProviderEndpointOnProviderEdit( eq(providerEndpoints.url, args.url) ) ) + // 兼容历史/并发导致的脏数据:同一 (vendor/type/url) 下可能同时存在 active 行与软删除历史行。 + // partial unique 只约束 deleted_at IS NULL,因此这里必须稳定地优先选择 active 行,避免误选历史行后 revive 触发 23505。 + .orderBy(desc(providerEndpoints.deletedAt), desc(providerEndpoints.id)) .limit(1); return row @@ -992,6 +1202,7 @@ export async function syncProviderEndpointOnProviderEdit( eq(providers.providerVendorId, previousVendorId), eq(providers.providerType, previousProviderType), eq(providers.url, previousUrl), + eq(providers.isEnabled, true), isNull(providers.deletedAt), ne(providers.id, input.providerId) ) @@ -1042,14 +1253,45 @@ export async function syncProviderEndpointOnProviderEdit( } if (concurrentEndpoint.deletedAt !== null) { - await tx - .update(providerEndpoints) - .set({ - deletedAt: null, - isEnabled: true, - updatedAt: now, - }) - .where(eq(providerEndpoints.id, concurrentEndpoint.id)); + try { + await tx + .update(providerEndpoints) + .set({ + deletedAt: null, + isEnabled: true, + updatedAt: now, + }) + .where(eq(providerEndpoints.id, concurrentEndpoint.id)); + } catch (error) { + if (!isUniqueViolationError(error)) { + throw error; + } + + const activeEndpoint = await loadEndpoint({ + vendorId: input.vendorId, + providerType: input.providerType, + url: nextUrl, + }); + + if (!activeEndpoint) { + throw new Error( + "[ProviderEndpointSync] failed to load next endpoint after revive conflict" + ); + } + + if (reactivateDisabled && !activeEndpoint.isEnabled) { + await tx + .update(providerEndpoints) + .set({ + isEnabled: true, + updatedAt: now, + }) + .where(eq(providerEndpoints.id, activeEndpoint.id)); + return "revived-next"; + } + + return "noop"; + } return "revived-next"; } @@ -1070,14 +1312,45 @@ export async function syncProviderEndpointOnProviderEdit( } if (nextEndpoint.deletedAt !== null) { - await tx - .update(providerEndpoints) - .set({ - deletedAt: null, - isEnabled: true, - updatedAt: now, - }) - .where(eq(providerEndpoints.id, nextEndpoint.id)); + try { + await tx + .update(providerEndpoints) + .set({ + deletedAt: null, + isEnabled: true, + updatedAt: now, + }) + .where(eq(providerEndpoints.id, nextEndpoint.id)); + } catch (error) { + if (!isUniqueViolationError(error)) { + throw error; + } + + const activeEndpoint = await loadEndpoint({ + vendorId: input.vendorId, + providerType: input.providerType, + url: nextUrl, + }); + + if (!activeEndpoint) { + throw new Error( + "[ProviderEndpointSync] failed to load next endpoint after revive conflict" + ); + } + + if (reactivateDisabled && !activeEndpoint.isEnabled) { + await tx + .update(providerEndpoints) + .set({ + isEnabled: true, + updatedAt: now, + }) + .where(eq(providerEndpoints.id, activeEndpoint.id)); + return "revived-next"; + } + + return "noop"; + } return "revived-next"; } @@ -1188,12 +1461,6 @@ export async function syncProviderEndpointOnProviderEdit( const ensureResult = await ensureNextEndpointActive(); - if (keepPreviousWhenReferenced) { - return { - action: mapEnsureResultToKeptAction(ensureResult), - }; - } - await tx .update(providerEndpoints) .set({ @@ -1231,12 +1498,6 @@ export async function syncProviderEndpointOnProviderEdit( keepPreviousWhenReferenced && (await hasActiveReferencesOnPreviousUrl()); if (!previousIsReferenced) { - if (keepPreviousWhenReferenced) { - return { - action: mapEnsureResultToKeptAction(ensureResult), - }; - } - await tx .update(providerEndpoints) .set({ @@ -1422,7 +1683,13 @@ export async function backfillProviderEndpointsFromProviders( const invalidSamples: BackfillProviderEndpointSample[] = []; while (true) { - const whereClauses = [isNull(providers.deletedAt), gt(providers.id, lastProviderId)]; + const whereClauses = [ + isNull(providers.deletedAt), + // 仅 backfill 启用中的 providers:disabled -> enabled 的场景由 updateProvider/updateProvidersBatch + // 在启用时 best-effort 确保 endpoint pool,不依赖 backfill 扫描。 + eq(providers.isEnabled, true), + gt(providers.id, lastProviderId), + ]; if (scopedVendorIds.length > 0) { whereClauses.push(inArray(providers.providerVendorId, scopedVendorIds)); } @@ -1628,6 +1895,7 @@ export async function backfillProviderEndpointsFromProviders( const deterministicSamples: BackfillProviderEndpointSample[] = []; const reportOnlyHistoricalSamples: BackfillProviderEndpointSample[] = []; const deterministicCandidates: BackfillProviderEndpointCandidate[] = []; + const historicalCandidates: BackfillProviderEndpointCandidate[] = []; let reportOnlyHistoricalCandidates = 0; for (const candidate of missingCandidates) { @@ -1646,6 +1914,9 @@ export async function backfillProviderEndpointsFromProviders( }, sampleLimit ); + // 兼容升级:即使存在历史 soft-deleted 行,也需要为当前活跃 provider 确保存在 active endpoint, + // 否则 strict endpoint pool 策略可能在端点池为空时阻断请求。 + historicalCandidates.push(candidate); continue; } @@ -1666,7 +1937,8 @@ export async function backfillProviderEndpointsFromProviders( } let repaired = 0; - if (mode === "apply" && deterministicCandidates.length > 0) { + const candidatesToInsert = [...deterministicCandidates, ...historicalCandidates]; + if (mode === "apply" && candidatesToInsert.length > 0) { const pending: Array<{ vendorId: number; providerType: ProviderType; url: string }> = []; const flush = async (): Promise => { if (pending.length === 0) { @@ -1686,7 +1958,7 @@ export async function backfillProviderEndpointsFromProviders( pending.length = 0; }; - for (const candidate of deterministicCandidates) { + for (const candidate of candidatesToInsert) { pending.push({ vendorId: candidate.vendorId, providerType: candidate.providerType, @@ -1832,18 +2104,7 @@ export async function recordProviderEndpointProbeResult(input: { const probedAt = input.probedAt ?? new Date(); await db.transaction(async (tx) => { - await tx.insert(providerEndpointProbeLogs).values({ - endpointId: input.endpointId, - source: input.source, - ok: input.ok, - statusCode: input.statusCode ?? null, - latencyMs: input.latencyMs ?? null, - errorType: input.errorType ?? null, - errorMessage: input.errorMessage ?? null, - createdAt: probedAt, - }); - - await tx + const updated = await tx .update(providerEndpoints) .set({ lastProbedAt: probedAt, @@ -1854,7 +2115,25 @@ export async function recordProviderEndpointProbeResult(input: { lastProbeErrorMessage: input.ok ? null : (input.errorMessage ?? null), updatedAt: new Date(), }) - .where(and(eq(providerEndpoints.id, input.endpointId), isNull(providerEndpoints.deletedAt))); + .where(and(eq(providerEndpoints.id, input.endpointId), isNull(providerEndpoints.deletedAt))) + .returning({ id: providerEndpoints.id }); + + // 端点可能在探测过程中被删除(vendor cascade / 管理后台操作)。 + // 这类情况属于“已不存在的端点”,应直接忽略,避免 probe scheduler 因 FK 失败而中断。 + if (updated.length === 0) { + return; + } + + await tx.insert(providerEndpointProbeLogs).values({ + endpointId: input.endpointId, + source: input.source, + ok: input.ok, + statusCode: input.statusCode ?? null, + latencyMs: input.latencyMs ?? null, + errorType: input.errorType ?? null, + errorMessage: input.errorMessage ?? null, + createdAt: probedAt, + }); }); } @@ -1877,7 +2156,10 @@ export async function findProviderEndpointProbeLogs( }) .from(providerEndpointProbeLogs) .where(eq(providerEndpointProbeLogs.endpointId, endpointId)) - .orderBy(desc(providerEndpointProbeLogs.createdAt)) + .orderBy( + sql`${providerEndpointProbeLogs.createdAt} DESC NULLS LAST`, + desc(providerEndpointProbeLogs.id) + ) .limit(limit) .offset(offset); diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 057dd4c46..be6c7f4b1 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -1,8 +1,8 @@ -"use server"; +import "server-only"; -import { and, desc, eq, isNotNull, isNull, ne, sql } from "drizzle-orm"; +import { and, desc, eq, inArray, isNotNull, isNull, ne, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; -import { providers } from "@/drizzle/schema"; +import { providerEndpoints, providers } from "@/drizzle/schema"; import { getCachedProviders } from "@/lib/cache/provider-cache"; import { resetEndpointCircuit } from "@/lib/endpoint-circuit-breaker"; import { logger } from "@/lib/logger"; @@ -499,12 +499,16 @@ export async function updateProvider( const shouldRefreshVendor = providerData.url !== undefined || providerData.website_url !== undefined; - const shouldSyncEndpoint = shouldRefreshVendor || providerData.provider_type !== undefined; + const shouldSyncEndpoint = + shouldRefreshVendor || + providerData.provider_type !== undefined || + providerData.is_enabled === true; const updateResult = await db.transaction(async (tx) => { let previousVendorId: number | null = null; let previousUrl: string | null = null; let previousProviderType: Provider["providerType"] | null = null; + let previousIsEnabled: boolean | null = null; let endpointCircuitResetId: number | null = null; if (shouldSyncEndpoint) { @@ -516,6 +520,7 @@ export async function updateProvider( name: providers.name, providerVendorId: providers.providerVendorId, providerType: providers.providerType, + isEnabled: providers.isEnabled, }) .from(providers) .where(and(eq(providers.id, id), isNull(providers.deletedAt))) @@ -525,6 +530,7 @@ export async function updateProvider( previousVendorId = current.providerVendorId; previousUrl = current.url; previousProviderType = current.providerType; + previousIsEnabled = current.isEnabled; if (shouldRefreshVendor) { const providerVendorId = await getOrCreateProviderVendorIdFromUrls( @@ -606,7 +612,16 @@ export async function updateProvider( const transformed = toProvider(provider); if (shouldSyncEndpoint && transformed.providerVendorId) { - if (previousUrl && previousProviderType) { + // 注意:即使 provider 当前处于禁用态,只要 vendor/type/url 发生变化也同步 endpoint pool: + // - 避免旧 URL 残留为 orphan endpoints(#781) + // - 保证后续启用/其它同 vendor/type 的 provider 能直接复用端点池 + if ( + previousUrl && + previousProviderType && + (previousUrl !== transformed.url || + previousProviderType !== transformed.providerType || + (previousVendorId != null && previousVendorId !== transformed.providerVendorId)) + ) { const syncResult = await syncProviderEndpointOnProviderEdit( { providerId: transformed.id, @@ -622,7 +637,7 @@ export async function updateProvider( ); endpointCircuitResetId = syncResult.resetCircuitEndpointId ?? null; - } else { + } else if (previousIsEnabled === false && transformed.isEnabled === true) { await ensureProviderEndpointExistsForUrl( { vendorId: transformed.providerVendorId, @@ -713,13 +728,71 @@ export async function updateProviderPrioritiesBatch( } export async function deleteProvider(id: number): Promise { - const result = await db - .update(providers) - .set({ deletedAt: new Date() }) - .where(and(eq(providers.id, id), isNull(providers.deletedAt))) - .returning({ id: providers.id }); + const now = new Date(); - return result.length > 0; + const deleted = await db.transaction(async (tx) => { + const [current] = await tx + .select({ + providerVendorId: providers.providerVendorId, + providerType: providers.providerType, + url: providers.url, + }) + .from(providers) + .where(and(eq(providers.id, id), isNull(providers.deletedAt))) + .limit(1); + + if (!current) { + return false; + } + + const result = await tx + .update(providers) + .set({ deletedAt: now, updatedAt: now }) + .where(and(eq(providers.id, id), isNull(providers.deletedAt))) + .returning({ id: providers.id }); + + if (result.length === 0) { + return false; + } + + if (current.providerVendorId != null && current.url) { + const [activeReference] = await tx + .select({ id: providers.id }) + .from(providers) + .where( + and( + eq(providers.providerVendorId, current.providerVendorId), + eq(providers.providerType, current.providerType), + eq(providers.url, current.url), + eq(providers.isEnabled, true), + isNull(providers.deletedAt) + ) + ) + .limit(1); + + if (!activeReference) { + await tx + .update(providerEndpoints) + .set({ + deletedAt: now, + isEnabled: false, + updatedAt: now, + }) + .where( + and( + eq(providerEndpoints.vendorId, current.providerVendorId), + eq(providerEndpoints.providerType, current.providerType), + eq(providerEndpoints.url, current.url), + isNull(providerEndpoints.deletedAt) + ) + ); + } + } + + return true; + }); + + return deleted; } export interface BatchProviderUpdates { @@ -739,7 +812,8 @@ export async function updateProvidersBatch( } const uniqueIds = [...new Set(ids)]; - const setClauses: Record = { updatedAt: new Date() }; + const now = new Date(); + const setClauses: Record = { updatedAt: now }; if (updates.isEnabled !== undefined) { setClauses.isEnabled = updates.isEnabled; @@ -761,16 +835,94 @@ export async function updateProvidersBatch( return 0; } - const idList = sql.join( - uniqueIds.map((id) => sql`${id}`), - sql`, ` - ); - const result = await db .update(providers) .set(setClauses) - .where(sql`id IN (${idList}) AND deleted_at IS NULL`) - .returning({ id: providers.id }); + .where(and(inArray(providers.id, uniqueIds), isNull(providers.deletedAt))) + .returning({ + id: providers.id, + providerVendorId: providers.providerVendorId, + providerType: providers.providerType, + url: providers.url, + }); + + // #779/#781:批量启用供应商时,best-effort 确保 endpoint pool 中存在对应 URL(避免历史/竞态导致启用后严格端点被阻断)。 + if (updates.isEnabled === true && result.length > 0) { + const endpointKeys = new Map< + string, + { vendorId: number; providerType: Provider["providerType"]; url: string } + >(); + + for (const row of result) { + if (row.providerVendorId == null || typeof row.url !== "string") { + continue; + } + + const trimmedUrl = row.url.trim(); + if (!trimmedUrl) { + continue; + } + + try { + // eslint-disable-next-line no-new + new URL(trimmedUrl); + } catch { + logger.warn("updateProvidersBatch:skip_invalid_url", { + providerId: row.id, + vendorId: row.providerVendorId, + providerType: row.providerType, + url: trimmedUrl, + }); + continue; + } + + const key = `${row.providerVendorId}::${row.providerType}::${trimmedUrl}`; + if (endpointKeys.has(key)) continue; + + endpointKeys.set(key, { + vendorId: row.providerVendorId, + providerType: row.providerType, + url: trimmedUrl, + }); + } + + if (endpointKeys.size > 0) { + try { + const inserted = await db + .insert(providerEndpoints) + .values( + Array.from(endpointKeys.values()).map((endpoint) => ({ + vendorId: endpoint.vendorId, + providerType: endpoint.providerType, + url: endpoint.url, + label: null, + updatedAt: now, + })) + ) + .onConflictDoNothing({ + target: [ + providerEndpoints.vendorId, + providerEndpoints.providerType, + providerEndpoints.url, + ], + where: sql`${providerEndpoints.deletedAt} IS NULL`, + }) + .returning({ id: providerEndpoints.id }); + + logger.debug("updateProvidersBatch:ensured_provider_endpoints", { + updatedProviders: result.length, + candidateEndpoints: endpointKeys.size, + insertedEndpoints: inserted.length, + }); + } catch (error) { + logger.warn("updateProvidersBatch:ensure_provider_endpoints_failed", { + updatedProviders: result.length, + candidateEndpoints: endpointKeys.size, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } logger.debug("updateProvidersBatch:completed", { requestedIds: uniqueIds.length, @@ -787,23 +939,95 @@ export async function deleteProvidersBatch(ids: number[]): Promise { } const uniqueIds = [...new Set(ids)]; - const idList = sql.join( - uniqueIds.map((id) => sql`${id}`), - sql`, ` - ); + const now = new Date(); - const result = await db - .update(providers) - .set({ deletedAt: new Date(), updatedAt: new Date() }) - .where(sql`id IN (${idList}) AND deleted_at IS NULL`) - .returning({ id: providers.id }); + const deletedCount = await db.transaction(async (tx) => { + const result = await tx + .update(providers) + .set({ deletedAt: now, updatedAt: now }) + .where(and(inArray(providers.id, uniqueIds), isNull(providers.deletedAt))) + .returning({ + id: providers.id, + providerVendorId: providers.providerVendorId, + providerType: providers.providerType, + url: providers.url, + }); + + if (result.length === 0) { + return 0; + } + + const endpointKeys = new Map< + string, + { vendorId: number; providerType: Provider["providerType"]; url: string } + >(); + + for (const candidate of result) { + if (candidate.providerVendorId == null || !candidate.url) { + continue; + } + + const key = `${candidate.providerVendorId}::${candidate.providerType}::${candidate.url}`; + if (endpointKeys.has(key)) { + continue; + } + + endpointKeys.set(key, { + vendorId: candidate.providerVendorId, + providerType: candidate.providerType, + url: candidate.url, + }); + } + + const endpoints = Array.from(endpointKeys.values()); + if (endpoints.length === 0) { + return result.length; + } + + const chunkSize = 200; + + for (let i = 0; i < endpoints.length; i += chunkSize) { + const chunk = endpoints.slice(i, i + chunkSize); + const tupleList = sql.join( + chunk.map( + (endpoint) => sql`(${endpoint.vendorId}, ${endpoint.providerType}, ${endpoint.url})` + ), + sql`, ` + ); + + await tx + .update(providerEndpoints) + .set({ + deletedAt: now, + isEnabled: false, + updatedAt: now, + }) + .where( + and( + isNull(providerEndpoints.deletedAt), + sql`(${providerEndpoints.vendorId}, ${providerEndpoints.providerType}, ${providerEndpoints.url}) IN (${tupleList})`, + sql`NOT EXISTS ( + SELECT 1 + FROM providers p + WHERE p.is_enabled = true + AND p.deleted_at IS NULL + AND p.provider_vendor_id = ${providerEndpoints.vendorId} + AND p.provider_type = ${providerEndpoints.providerType} + AND p.url = ${providerEndpoints.url} + )` + ) + ); + } + + return result.length; + }); logger.debug("deleteProvidersBatch:completed", { requestedIds: uniqueIds.length, - deletedCount: result.length, + deletedCount, }); - return result.length; + return deletedCount; } /** @@ -863,105 +1087,150 @@ export async function getDistinctProviderGroups(): Promise { * 包括:今天的总金额、今天的调用次数、最近一次调用时间和模型 * * 性能优化: - * - provider_stats CTE: LEFT JOIN 添加日期过滤,仅扫描今日数据(避免全表扫描) - * - latest_call CTE: 添加 7 天时间范围限制(避免扫描历史数据) + * - provider_stats: 先按最终供应商聚合,再与 providers 做 LEFT JOIN,避免 providers × message_request 的笛卡尔积 + * - bounds: 用“按时区计算的时间范围”过滤 created_at,便于命中 created_at 索引 + * - DST 兼容:对“本地日界/近 7 日”先在 timestamp 上做 +interval,再 AT TIME ZONE 回到 timestamptz,避免夏令时跨日偏移 + * - latest_call: 限制近 7 天范围,避免扫描历史数据 */ -export async function getProviderStatistics(): Promise< - Array<{ - id: number; - today_cost: string; - today_calls: number; - last_call_time: Date | null; - last_call_model: string | null; - }> -> { +export type ProviderStatisticsRow = { + id: number; + today_cost: string; + today_calls: number; + last_call_time: Date | null; + last_call_model: string | null; +}; + +// 轻量内存缓存:降低后台轮询/重复加载导致的重复扫描 +const PROVIDER_STATISTICS_CACHE_TTL_MS = 10 * 1000; // 10 秒 +let providerStatisticsCache: { + timezone: string; + expiresAt: number; + data: ProviderStatisticsRow[]; +} | null = null; + +// in-flight 去重:避免缓存过期瞬间并发触发多次相同查询(thundering herd) +let providerStatisticsInFlight: { + timezone: string; + promise: Promise; +} | null = null; + +export async function getProviderStatistics(): Promise { try { // 统一的时区处理:使用 PostgreSQL AT TIME ZONE + 系统时区配置 // 参考 getUserStatisticsFromDB 的实现,避免 Node.js Date 带来的时区偏移 const timezone = await resolveSystemTimezone(); + const now = Date.now(); + if ( + providerStatisticsCache && + providerStatisticsCache.expiresAt > now && + providerStatisticsCache.timezone === timezone + ) { + return providerStatisticsCache.data; + } + + if (providerStatisticsInFlight && providerStatisticsInFlight.timezone === timezone) { + return await providerStatisticsInFlight.promise; + } - // ⭐ 使用 providerChain 最后一项的 providerId 来确定最终供应商(兼容重试切换) - // 如果 provider_chain 为空(无重试),则使用 provider_id 字段 - const query = sql` - WITH provider_stats AS ( + const promise: Promise = (async () => { + // 使用 providerChain 最后一项的 providerId 来确定最终供应商(兼容重试切换) + // 如果 provider_chain 为空(无重试),则使用 provider_id 字段 + const query = sql` + WITH bounds AS ( + SELECT + (DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) AT TIME ZONE ${timezone}) AS today_start, + ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) AS tomorrow_start, + ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '7 days') AT TIME ZONE ${timezone}) AS last7_start + ), + provider_stats AS ( + -- 先按最终供应商聚合,再与 providers 做 LEFT JOIN,避免 providers × 今日请求 的笛卡尔积 + SELECT + mr.final_provider_id, + COALESCE(SUM(mr.cost_usd), 0) AS today_cost, + COUNT(*)::integer AS today_calls + FROM ( + SELECT + CASE + WHEN provider_chain IS NULL OR provider_chain = '[]'::jsonb THEN provider_id + WHEN (provider_chain->-1->>'id') ~ '^[0-9]+$' THEN (provider_chain->-1->>'id')::int + ELSE provider_id + END AS final_provider_id, + cost_usd + FROM message_request + WHERE deleted_at IS NULL + AND (blocked_by IS NULL OR blocked_by <> 'warmup') + AND created_at >= (SELECT today_start FROM bounds) + AND created_at < (SELECT tomorrow_start FROM bounds) + ) mr + GROUP BY mr.final_provider_id + ), + latest_call AS ( + SELECT DISTINCT ON (final_provider_id) + final_provider_id, + created_at AS last_call_time, + model AS last_call_model + FROM ( + SELECT + CASE + WHEN provider_chain IS NULL OR provider_chain = '[]'::jsonb THEN provider_id + WHEN (provider_chain->-1->>'id') ~ '^[0-9]+$' THEN (provider_chain->-1->>'id')::int + ELSE provider_id + END AS final_provider_id, + id, + created_at, + model + FROM message_request + WHERE deleted_at IS NULL + AND (blocked_by IS NULL OR blocked_by <> 'warmup') + AND created_at >= (SELECT last7_start FROM bounds) + ) mr + -- 性能优化:添加 7 天时间范围限制(避免扫描历史数据) + ORDER BY final_provider_id, created_at DESC, id DESC + ) SELECT p.id, - COALESCE( - SUM(CASE - WHEN (mr.created_at AT TIME ZONE ${timezone})::date = (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date - AND ( - -- 情况1:无重试(provider_chain 为 NULL 或空数组),使用 provider_id - (mr.provider_chain IS NULL OR jsonb_array_length(mr.provider_chain) = 0) AND mr.provider_id = p.id - OR - -- 情况2:有重试,使用 providerChain 最后一项的 id - (mr.provider_chain IS NOT NULL AND jsonb_array_length(mr.provider_chain) > 0 - AND (mr.provider_chain->-1->>'id')::int = p.id) - ) - THEN mr.cost_usd ELSE 0 END), - 0 - ) AS today_cost, - COUNT(CASE - WHEN (mr.created_at AT TIME ZONE ${timezone})::date = (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date - AND ( - (mr.provider_chain IS NULL OR jsonb_array_length(mr.provider_chain) = 0) AND mr.provider_id = p.id - OR - (mr.provider_chain IS NOT NULL AND jsonb_array_length(mr.provider_chain) > 0 - AND (mr.provider_chain->-1->>'id')::int = p.id) - ) - THEN 1 END)::integer AS today_calls + COALESCE(ps.today_cost, 0) AS today_cost, + COALESCE(ps.today_calls, 0) AS today_calls, + lc.last_call_time, + lc.last_call_model FROM providers p - -- 性能优化:添加日期过滤条件,仅扫描今日数据(避免全表扫描) - LEFT JOIN message_request mr ON mr.deleted_at IS NULL - AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - AND mr.created_at >= (CURRENT_DATE AT TIME ZONE ${timezone}) + LEFT JOIN provider_stats ps ON p.id = ps.final_provider_id + LEFT JOIN latest_call lc ON p.id = lc.final_provider_id WHERE p.deleted_at IS NULL - GROUP BY p.id - ), - latest_call AS ( - SELECT DISTINCT ON (final_provider_id) - -- 计算最终供应商ID:优先使用 providerChain 最后一项的 id - CASE - WHEN provider_chain IS NULL OR jsonb_array_length(provider_chain) = 0 THEN provider_id - ELSE (provider_chain->-1->>'id')::int - END AS final_provider_id, - created_at AS last_call_time, - model AS last_call_model - FROM message_request - -- 性能优化:添加 7 天时间范围限制(避免扫描历史数据) - WHERE deleted_at IS NULL - AND (blocked_by IS NULL OR blocked_by <> 'warmup') - AND created_at >= (CURRENT_DATE AT TIME ZONE ${timezone} - INTERVAL '7 days') - ORDER BY final_provider_id, created_at DESC - ) - SELECT - ps.id, - ps.today_cost, - ps.today_calls, - lc.last_call_time, - lc.last_call_model - FROM provider_stats ps - LEFT JOIN latest_call lc ON ps.id = lc.final_provider_id - ORDER BY ps.id ASC - `; - - logger.trace("getProviderStatistics:executing_query"); - - const result = await db.execute(query); - - logger.trace("getProviderStatistics:result", { - count: Array.isArray(result) ? result.length : 0, - }); + ORDER BY p.id ASC + `; + + logger.trace("getProviderStatistics:executing_query"); + + const result = await db.execute(query); + const data = Array.from(result) as ProviderStatisticsRow[]; + + logger.trace("getProviderStatistics:result", { + count: data.length, + }); - // 注意:返回结果中的 today_cost 为 numeric,使用字符串表示; - // last_call_time 由数据库返回为时间戳(UTC)。 - // 这里保持原样,交由上层进行展示格式化。 - return result as unknown as Array<{ - id: number; - today_cost: string; - today_calls: number; - last_call_time: Date | null; - last_call_model: string | null; - }>; + // 注意:返回结果中的 today_cost 为 numeric,使用字符串表示; + // last_call_time 由数据库返回为时间戳(UTC)。 + // 这里保持原样,交由上层进行展示格式化。 + providerStatisticsCache = { + timezone, + expiresAt: Date.now() + PROVIDER_STATISTICS_CACHE_TTL_MS, + data, + }; + + return data; + })(); + + // Set in-flight BEFORE awaiting to prevent concurrent callers from starting duplicate queries + providerStatisticsInFlight = { timezone, promise }; + + try { + return await promise; + } finally { + if (providerStatisticsInFlight?.promise === promise) { + providerStatisticsInFlight = null; + } + } } catch (error) { logger.trace("getProviderStatistics:error", { message: error instanceof Error ? error.message : String(error), diff --git a/src/repository/statistics.ts b/src/repository/statistics.ts index 93ce85dfa..4fecc985e 100644 --- a/src/repository/statistics.ts +++ b/src/repository/statistics.ts @@ -1,8 +1,9 @@ -"use server"; +import "server-only"; import { and, eq, gte, inArray, isNull, lt, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { keys, messageRequest } from "@/drizzle/schema"; +import { TTLMap } from "@/lib/cache/ttl-map"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; import type { DatabaseKey, @@ -16,6 +17,31 @@ import type { } from "@/types/statistics"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; +/** + * Key ID -> key string cache + * + * Short TTL allows slight staleness (keys rarely change). + * Size-bounded to avoid unbounded growth in multi-tenant scenarios. + */ +const keyStringByIdCache = new TTLMap({ ttlMs: 5 * 60 * 1000, maxSize: 1000 }); + +async function getKeyStringByIdCached(keyId: number): Promise { + const cached = keyStringByIdCache.get(keyId); + if (cached !== undefined) return cached; + + const keyRecord = await db + .select({ key: keys.key }) + .from(keys) + .where(eq(keys.id, keyId)) + .limit(1); + + const keyString = keyRecord?.[0]?.key ?? null; + if (!keyString) return null; + + keyStringByIdCache.set(keyId, keyString); + return keyString; +} + /** * 根据时间范围获取用户消费和API调用统计 * 注意:这个函数使用原生SQL,因为涉及到PostgreSQL特定的generate_series函数 @@ -45,6 +71,8 @@ export async function getUserStatisticsFromDB(timeRange: TimeRange): Promise= (DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) AT TIME ZONE ${timezone}) + AND mr.created_at < ((DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) + INTERVAL '1 day') AT TIME ZONE ${timezone}) AND DATE_TRUNC('hour', mr.created_at AT TIME ZONE ${timezone}) = hr.hour AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') WHERE u.deleted_at IS NULL @@ -81,6 +109,8 @@ export async function getUserStatisticsFromDB(timeRange: TimeRange): Promise= ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '6 days') AT TIME ZONE ${timezone}) + AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') WHERE u.deleted_at IS NULL @@ -117,6 +147,8 @@ export async function getUserStatisticsFromDB(timeRange: TimeRange): Promise= ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '29 days') AT TIME ZONE ${timezone}) + AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') WHERE u.deleted_at IS NULL @@ -153,6 +185,8 @@ export async function getUserStatisticsFromDB(timeRange: TimeRange): Promise= ((DATE_TRUNC('month', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})) AT TIME ZONE ${timezone}) + AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') WHERE u.deleted_at IS NULL @@ -229,6 +263,8 @@ export async function getKeyStatisticsFromDB( CROSS JOIN hour_range hr LEFT JOIN message_request mr ON mr.key = k.key AND mr.user_id = ${userId} + AND mr.created_at >= (DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) AT TIME ZONE ${timezone}) + AND mr.created_at < ((DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) + INTERVAL '1 day') AT TIME ZONE ${timezone}) AND DATE_TRUNC('hour', mr.created_at AT TIME ZONE ${timezone}) = hr.hour AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') GROUP BY k.id, k.name, hr.hour @@ -270,6 +306,8 @@ export async function getKeyStatisticsFromDB( CROSS JOIN date_range dr LEFT JOIN message_request mr ON mr.key = k.key AND mr.user_id = ${userId} + AND mr.created_at >= ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '6 days') AT TIME ZONE ${timezone}) + AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') GROUP BY k.id, k.name, dr.date @@ -311,6 +349,8 @@ export async function getKeyStatisticsFromDB( CROSS JOIN date_range dr LEFT JOIN message_request mr ON mr.key = k.key AND mr.user_id = ${userId} + AND mr.created_at >= ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '29 days') AT TIME ZONE ${timezone}) + AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') GROUP BY k.id, k.name, dr.date @@ -352,6 +392,8 @@ export async function getKeyStatisticsFromDB( CROSS JOIN date_range dr LEFT JOIN message_request mr ON mr.key = k.key AND mr.user_id = ${userId} + AND mr.created_at >= ((DATE_TRUNC('month', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})) AT TIME ZONE ${timezone}) + AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') GROUP BY k.id, k.name, dr.date @@ -423,21 +465,23 @@ export async function getMixedStatisticsFromDB( WHERE user_id = ${userId} AND deleted_at IS NULL ), - hourly_stats AS ( - SELECT - k.id AS key_id, - k.name AS key_name, - hr.hour, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM user_keys k - CROSS JOIN hour_range hr - LEFT JOIN message_request mr ON mr.key = k.key - AND mr.user_id = ${userId} - AND DATE_TRUNC('hour', mr.created_at AT TIME ZONE ${timezone}) = hr.hour - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY k.id, k.name, hr.hour - ) + hourly_stats AS ( + SELECT + k.id AS key_id, + k.name AS key_name, + hr.hour, + COUNT(mr.id) AS api_calls, + COALESCE(SUM(mr.cost_usd), 0) AS total_cost + FROM user_keys k + CROSS JOIN hour_range hr + LEFT JOIN message_request mr ON mr.key = k.key + AND mr.user_id = ${userId} + AND mr.created_at >= (DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) AT TIME ZONE ${timezone}) + AND mr.created_at < ((DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) + INTERVAL '1 day') AT TIME ZONE ${timezone}) + AND DATE_TRUNC('hour', mr.created_at AT TIME ZONE ${timezone}) = hr.hour + AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') + GROUP BY k.id, k.name, hr.hour + ) SELECT key_id, key_name, @@ -457,17 +501,19 @@ export async function getMixedStatisticsFromDB( '1 hour'::interval ) AS hour ), - hourly_stats AS ( - SELECT - hr.hour, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM hour_range hr - LEFT JOIN message_request mr ON DATE_TRUNC('hour', mr.created_at AT TIME ZONE ${timezone}) = hr.hour - AND mr.user_id != ${userId} - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY hr.hour - ) + hourly_stats AS ( + SELECT + hr.hour, + COUNT(mr.id) AS api_calls, + COALESCE(SUM(mr.cost_usd), 0) AS total_cost + FROM hour_range hr + LEFT JOIN message_request mr ON DATE_TRUNC('hour', mr.created_at AT TIME ZONE ${timezone}) = hr.hour + AND mr.created_at >= (DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) AT TIME ZONE ${timezone}) + AND mr.created_at < ((DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) + INTERVAL '1 day') AT TIME ZONE ${timezone}) + AND mr.user_id != ${userId} + AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') + GROUP BY hr.hour + ) SELECT -1 AS user_id, '其他用户' AS user_name, @@ -495,21 +541,23 @@ export async function getMixedStatisticsFromDB( WHERE user_id = ${userId} AND deleted_at IS NULL ), - daily_stats AS ( - SELECT - k.id AS key_id, - k.name AS key_name, - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM user_keys k - CROSS JOIN date_range dr - LEFT JOIN message_request mr ON mr.key = k.key - AND mr.user_id = ${userId} - AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY k.id, k.name, dr.date - ) + daily_stats AS ( + SELECT + k.id AS key_id, + k.name AS key_name, + dr.date, + COUNT(mr.id) AS api_calls, + COALESCE(SUM(mr.cost_usd), 0) AS total_cost + FROM user_keys k + CROSS JOIN date_range dr + LEFT JOIN message_request mr ON mr.key = k.key + AND mr.user_id = ${userId} + AND mr.created_at >= ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '6 days') AT TIME ZONE ${timezone}) + AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) + AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date + AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') + GROUP BY k.id, k.name, dr.date + ) SELECT key_id, key_name, @@ -529,17 +577,19 @@ export async function getMixedStatisticsFromDB( '1 day'::interval )::date AS date ), - daily_stats AS ( - SELECT - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM date_range dr - LEFT JOIN message_request mr ON (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.user_id != ${userId} - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY dr.date - ) + daily_stats AS ( + SELECT + dr.date, + COUNT(mr.id) AS api_calls, + COALESCE(SUM(mr.cost_usd), 0) AS total_cost + FROM date_range dr + LEFT JOIN message_request mr ON (mr.created_at AT TIME ZONE ${timezone})::date = dr.date + AND mr.created_at >= ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '6 days') AT TIME ZONE ${timezone}) + AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) + AND mr.user_id != ${userId} + AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') + GROUP BY dr.date + ) SELECT -1 AS user_id, '其他用户' AS user_name, @@ -567,21 +617,23 @@ export async function getMixedStatisticsFromDB( WHERE user_id = ${userId} AND deleted_at IS NULL ), - daily_stats AS ( - SELECT - k.id AS key_id, - k.name AS key_name, - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM user_keys k - CROSS JOIN date_range dr - LEFT JOIN message_request mr ON mr.key = k.key - AND mr.user_id = ${userId} - AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY k.id, k.name, dr.date - ) + daily_stats AS ( + SELECT + k.id AS key_id, + k.name AS key_name, + dr.date, + COUNT(mr.id) AS api_calls, + COALESCE(SUM(mr.cost_usd), 0) AS total_cost + FROM user_keys k + CROSS JOIN date_range dr + LEFT JOIN message_request mr ON mr.key = k.key + AND mr.user_id = ${userId} + AND mr.created_at >= ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '29 days') AT TIME ZONE ${timezone}) + AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) + AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date + AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') + GROUP BY k.id, k.name, dr.date + ) SELECT key_id, key_name, @@ -601,17 +653,19 @@ export async function getMixedStatisticsFromDB( '1 day'::interval )::date AS date ), - daily_stats AS ( - SELECT - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM date_range dr - LEFT JOIN message_request mr ON (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.user_id != ${userId} - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY dr.date - ) + daily_stats AS ( + SELECT + dr.date, + COUNT(mr.id) AS api_calls, + COALESCE(SUM(mr.cost_usd), 0) AS total_cost + FROM date_range dr + LEFT JOIN message_request mr ON (mr.created_at AT TIME ZONE ${timezone})::date = dr.date + AND mr.created_at >= ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '29 days') AT TIME ZONE ${timezone}) + AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) + AND mr.user_id != ${userId} + AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') + GROUP BY dr.date + ) SELECT -1 AS user_id, '其他用户' AS user_name, @@ -639,21 +693,23 @@ export async function getMixedStatisticsFromDB( WHERE user_id = ${userId} AND deleted_at IS NULL ), - daily_stats AS ( - SELECT - k.id AS key_id, - k.name AS key_name, - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM user_keys k - CROSS JOIN date_range dr - LEFT JOIN message_request mr ON mr.key = k.key - AND mr.user_id = ${userId} - AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY k.id, k.name, dr.date - ) + daily_stats AS ( + SELECT + k.id AS key_id, + k.name AS key_name, + dr.date, + COUNT(mr.id) AS api_calls, + COALESCE(SUM(mr.cost_usd), 0) AS total_cost + FROM user_keys k + CROSS JOIN date_range dr + LEFT JOIN message_request mr ON mr.key = k.key + AND mr.user_id = ${userId} + AND mr.created_at >= ((DATE_TRUNC('month', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})) AT TIME ZONE ${timezone}) + AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) + AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date + AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') + GROUP BY k.id, k.name, dr.date + ) SELECT key_id, key_name, @@ -673,17 +729,19 @@ export async function getMixedStatisticsFromDB( '1 day'::interval )::date AS date ), - daily_stats AS ( - SELECT - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM date_range dr - LEFT JOIN message_request mr ON (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.user_id != ${userId} - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY dr.date - ) + daily_stats AS ( + SELECT + dr.date, + COUNT(mr.id) AS api_calls, + COALESCE(SUM(mr.cost_usd), 0) AS total_cost + FROM date_range dr + LEFT JOIN message_request mr ON (mr.created_at AT TIME ZONE ${timezone})::date = dr.date + AND mr.created_at >= ((DATE_TRUNC('month', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})) AT TIME ZONE ${timezone}) + AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) + AND mr.user_id != ${userId} + AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') + GROUP BY dr.date + ) SELECT -1 AS user_id, '其他用户' AS user_name, @@ -737,28 +795,6 @@ export async function sumUserCostToday(userId: number): Promise { return Number(row?.total_cost || 0); } -/** - * 查询 Key 历史总消费(通过 Key ID) - * 用于显示 Key 的历史总消费统计 - * @param keyId - Key 的数据库 ID - * @param maxAgeDays - 最大查询天数,默认 365 天(避免全表扫描) - */ -export async function sumKeyTotalCostById( - keyId: number, - maxAgeDays: number = 365 -): Promise { - // 先查询 key 字符串 - const keyRecord = await db - .select({ key: keys.key }) - .from(keys) - .where(eq(keys.id, keyId)) - .limit(1); - - if (!keyRecord || keyRecord.length === 0) return 0; - - return sumKeyTotalCost(keyRecord[0].key, maxAgeDays); -} - /** * Query Key total cost (with optional time boundary) * @param keyHash - API Key hash @@ -945,16 +981,8 @@ export async function sumKeyCostInTimeRange( startTime: Date, endTime: Date ): Promise { - // 注意:message_request.key 存储的是 API key 字符串,需要先查询 keys 表获取 key 值 - const keyRecord = await db - .select({ key: keys.key }) - .from(keys) - .where(eq(keys.id, keyId)) - .limit(1); - - if (!keyRecord || keyRecord.length === 0) return 0; - - const keyString = keyRecord[0].key; + const keyString = await getKeyStringByIdCached(keyId); + if (!keyString) return 0; const result = await db .select({ total: sql`COALESCE(SUM(${messageRequest.costUsd}), 0)` }) @@ -972,6 +1000,163 @@ export async function sumKeyCostInTimeRange( return Number(result[0]?.total || 0); } +export interface QuotaCostRanges { + range5h: { startTime: Date; endTime: Date }; + rangeDaily: { startTime: Date; endTime: Date }; + rangeWeekly: { startTime: Date; endTime: Date }; + rangeMonthly: { startTime: Date; endTime: Date }; +} + +interface QuotaCostSummary { + cost5h: number; + costDaily: number; + costWeekly: number; + costMonthly: number; + costTotal: number; +} + +/** + * 合并查询:一次 SQL 返回用户各周期消费与总消费 + * + * 说明: + * - 通过 FILTER 子句避免多次往返/重复扫描 + * - scanStart/scanEnd 仅用于缩小扫描范围(不改变语义) + * - total 使用 maxAgeDays 做时间截断(与 sumUserTotalCost 语义一致) + */ +export async function sumUserQuotaCosts( + userId: number, + ranges: QuotaCostRanges, + maxAgeDays: number = 365 +): Promise { + const cutoffDate = + Number.isFinite(maxAgeDays) && maxAgeDays > 0 + ? new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000) + : null; + + const scanStart = cutoffDate + ? new Date( + Math.min( + ranges.range5h.startTime.getTime(), + ranges.rangeDaily.startTime.getTime(), + ranges.rangeWeekly.startTime.getTime(), + ranges.rangeMonthly.startTime.getTime(), + cutoffDate.getTime() + ) + ) + : null; + const scanEnd = new Date( + Math.max( + ranges.range5h.endTime.getTime(), + ranges.rangeDaily.endTime.getTime(), + ranges.rangeWeekly.endTime.getTime(), + ranges.rangeMonthly.endTime.getTime(), + Date.now() + ) + ); + + const costTotal = cutoffDate + ? sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${cutoffDate}), 0)` + : sql`COALESCE(SUM(${messageRequest.costUsd}), 0)`; + + const [row] = await db + .select({ + cost5h: sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${ranges.range5h.startTime} AND ${messageRequest.createdAt} < ${ranges.range5h.endTime}), 0)`, + costDaily: sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${ranges.rangeDaily.startTime} AND ${messageRequest.createdAt} < ${ranges.rangeDaily.endTime}), 0)`, + costWeekly: sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${ranges.rangeWeekly.startTime} AND ${messageRequest.createdAt} < ${ranges.rangeWeekly.endTime}), 0)`, + costMonthly: sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${ranges.rangeMonthly.startTime} AND ${messageRequest.createdAt} < ${ranges.rangeMonthly.endTime}), 0)`, + costTotal, + }) + .from(messageRequest) + .where( + and( + eq(messageRequest.userId, userId), + isNull(messageRequest.deletedAt), + EXCLUDE_WARMUP_CONDITION, + ...(scanStart ? [gte(messageRequest.createdAt, scanStart)] : []), + lt(messageRequest.createdAt, scanEnd) + ) + ); + + return { + cost5h: Number(row?.cost5h ?? 0), + costDaily: Number(row?.costDaily ?? 0), + costWeekly: Number(row?.costWeekly ?? 0), + costMonthly: Number(row?.costMonthly ?? 0), + costTotal: Number(row?.costTotal ?? 0), + }; +} + +/** + * 合并查询:一次 SQL 返回 Key 各周期消费与总消费(通过 keyId) + */ +export async function sumKeyQuotaCostsById( + keyId: number, + ranges: QuotaCostRanges, + maxAgeDays: number = 365 +): Promise { + const keyString = await getKeyStringByIdCached(keyId); + if (!keyString) { + return { cost5h: 0, costDaily: 0, costWeekly: 0, costMonthly: 0, costTotal: 0 }; + } + + const cutoffDate = + Number.isFinite(maxAgeDays) && maxAgeDays > 0 + ? new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000) + : null; + + const scanStart = cutoffDate + ? new Date( + Math.min( + ranges.range5h.startTime.getTime(), + ranges.rangeDaily.startTime.getTime(), + ranges.rangeWeekly.startTime.getTime(), + ranges.rangeMonthly.startTime.getTime(), + cutoffDate.getTime() + ) + ) + : null; + const scanEnd = new Date( + Math.max( + ranges.range5h.endTime.getTime(), + ranges.rangeDaily.endTime.getTime(), + ranges.rangeWeekly.endTime.getTime(), + ranges.rangeMonthly.endTime.getTime(), + Date.now() + ) + ); + + const costTotal = cutoffDate + ? sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${cutoffDate}), 0)` + : sql`COALESCE(SUM(${messageRequest.costUsd}), 0)`; + + const [row] = await db + .select({ + cost5h: sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${ranges.range5h.startTime} AND ${messageRequest.createdAt} < ${ranges.range5h.endTime}), 0)`, + costDaily: sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${ranges.rangeDaily.startTime} AND ${messageRequest.createdAt} < ${ranges.rangeDaily.endTime}), 0)`, + costWeekly: sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${ranges.rangeWeekly.startTime} AND ${messageRequest.createdAt} < ${ranges.rangeWeekly.endTime}), 0)`, + costMonthly: sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${ranges.rangeMonthly.startTime} AND ${messageRequest.createdAt} < ${ranges.rangeMonthly.endTime}), 0)`, + costTotal, + }) + .from(messageRequest) + .where( + and( + eq(messageRequest.key, keyString), + isNull(messageRequest.deletedAt), + EXCLUDE_WARMUP_CONDITION, + ...(scanStart ? [gte(messageRequest.createdAt, scanStart)] : []), + lt(messageRequest.createdAt, scanEnd) + ) + ); + + return { + cost5h: Number(row?.cost5h ?? 0), + costDaily: Number(row?.costDaily ?? 0), + costWeekly: Number(row?.costWeekly ?? 0), + costMonthly: Number(row?.costMonthly ?? 0), + costTotal: Number(row?.costTotal ?? 0), + }; +} + export interface CostEntryInTimeRange { id: number; createdAt: Date; @@ -1056,16 +1241,8 @@ export async function findKeyCostEntriesInTimeRange( startTime: Date, endTime: Date ): Promise { - // 注意:message_request.key 存储的是 API key 字符串,需要先查询 keys 表获取 key 值 - const keyRecord = await db - .select({ key: keys.key }) - .from(keys) - .where(eq(keys.id, keyId)) - .limit(1); - - if (!keyRecord || keyRecord.length === 0) return []; - - const keyString = keyRecord[0].key; + const keyString = await getKeyStringByIdCached(keyId); + if (!keyString) return []; const rows = await db .select({ @@ -1139,14 +1316,8 @@ export async function getRateLimitEventStats( // Key ID 过滤需要先查询 key 字符串 let keyString: string | null = null; if (key_id !== undefined) { - const keyRecord = await db - .select({ key: keys.key }) - .from(keys) - .where(eq(keys.id, key_id)) - .limit(1); - - if (keyRecord && keyRecord.length > 0) { - keyString = keyRecord[0].key; + keyString = await getKeyStringByIdCached(key_id); + if (keyString) { conditions.push(`${messageRequest.key.name} = $${paramIndex++}`); params.push(keyString); } else { diff --git a/src/repository/usage-logs.ts b/src/repository/usage-logs.ts index 7814a043e..d965b7d3e 100644 --- a/src/repository/usage-logs.ts +++ b/src/repository/usage-logs.ts @@ -1,13 +1,15 @@ -"use server"; +import "server-only"; import { and, desc, eq, isNull, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { keys as keysTable, messageRequest, providers, users } from "@/drizzle/schema"; +import { TTLMap } from "@/lib/cache/ttl-map"; import { buildUnifiedSpecialSettings } from "@/lib/utils/special-settings"; import type { ProviderChainItem } from "@/types/message"; import type { SpecialSetting } from "@/types/special-settings"; import { escapeLike } from "./_shared/like"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; +import { buildUsageLogConditions } from "./_shared/usage-log-filters"; export interface UsageLogFilters { userId?: number; @@ -114,21 +116,7 @@ export interface UsageLogBatchFilters extends Omit { - const { - userId, - keyId, - providerId, - sessionId, - startTime, - endTime, - statusCode, - excludeStatusCode200, - model, - endpoint, - minRetryCount, - cursor, - limit = 50, - } = filters; + const { userId, keyId, providerId, cursor, limit = 50 } = filters; // Build query conditions const conditions = [isNull(messageRequest.deletedAt)]; @@ -145,42 +133,7 @@ export async function findUsageLogsBatch( conditions.push(eq(messageRequest.providerId, providerId)); } - const trimmedSessionId = sessionId?.trim(); - if (trimmedSessionId) { - conditions.push(eq(messageRequest.sessionId, trimmedSessionId)); - } - - if (startTime !== undefined) { - const startDate = new Date(startTime); - conditions.push(sql`${messageRequest.createdAt} >= ${startDate.toISOString()}::timestamptz`); - } - - if (endTime !== undefined) { - const endDate = new Date(endTime); - conditions.push(sql`${messageRequest.createdAt} < ${endDate.toISOString()}::timestamptz`); - } - - if (statusCode !== undefined) { - conditions.push(eq(messageRequest.statusCode, statusCode)); - } else if (excludeStatusCode200) { - conditions.push( - sql`(${messageRequest.statusCode} IS NULL OR ${messageRequest.statusCode} <> 200)` - ); - } - - if (model) { - conditions.push(eq(messageRequest.model, model)); - } - - if (endpoint) { - conditions.push(eq(messageRequest.endpoint, endpoint)); - } - - if (minRetryCount !== undefined) { - conditions.push( - sql`GREATEST(COALESCE(jsonb_array_length(${messageRequest.providerChain}) - 1, 0), 0) >= ${minRetryCount}` - ); - } + conditions.push(...buildUsageLogConditions(filters)); // Cursor-based pagination: WHERE (created_at, id) < (cursor_created_at, cursor_id) // Using row value comparison for efficient keyset pagination @@ -281,6 +234,145 @@ export async function findUsageLogsBatch( return { logs, nextCursor, hasMore }; } +interface UsageLogSlimFilters { + keyString: string; + /** Session ID(精确匹配;空字符串/空白视为不筛选) */ + sessionId?: string; + /** 开始时间戳(毫秒),用于 >= 比较 */ + startTime?: number; + /** 结束时间戳(毫秒),用于 < 比较 */ + endTime?: number; + statusCode?: number; + /** 排除 200 状态码(筛选所有非 200 的请求,包括 NULL) */ + excludeStatusCode200?: boolean; + model?: string; + endpoint?: string; + /** 最低重试次数(provider_chain 长度 - 1) */ + minRetryCount?: number; + page?: number; + pageSize?: number; +} + +interface UsageLogSlimRow { + id: number; + createdAt: Date | null; + model: string | null; + originalModel: string | null; + endpoint: string | null; + statusCode: number | null; + inputTokens: number | null; + outputTokens: number | null; + costUsd: string | null; + durationMs: number | null; + cacheCreationInputTokens: number | null; + cacheReadInputTokens: number | null; + cacheCreation5mInputTokens: number | null; + cacheCreation1hInputTokens: number | null; + cacheTtlApplied: string | null; +} + +// my-usage logs: short TTL cache for total count to avoid repeated COUNT(*) on pagination/polling. +const usageLogSlimTotalCache = new TTLMap({ ttlMs: 10_000, maxSize: 1000 }); + +export async function findUsageLogsForKeySlim( + filters: UsageLogSlimFilters +): Promise<{ logs: UsageLogSlimRow[]; total: number }> { + const { keyString, page = 1, pageSize = 50 } = filters; + + const safePage = page > 0 ? page : 1; + const safePageSize = Math.min(100, Math.max(1, pageSize)); + + const conditions = [ + isNull(messageRequest.deletedAt), + eq(messageRequest.key, keyString), + EXCLUDE_WARMUP_CONDITION, + ]; + + const totalCacheKey = [ + keyString, + filters.sessionId?.trim() ?? "", + filters.startTime ?? "", + filters.endTime ?? "", + filters.statusCode ?? "", + filters.excludeStatusCode200 ? "1" : "0", + filters.model ?? "", + filters.endpoint ?? "", + filters.minRetryCount ?? "", + ].join("\u0001"); + + conditions.push(...buildUsageLogConditions(filters)); + + const offset = (safePage - 1) * safePageSize; + const results = await db + .select({ + id: messageRequest.id, + createdAt: messageRequest.createdAt, + model: messageRequest.model, + originalModel: messageRequest.originalModel, + endpoint: messageRequest.endpoint, + statusCode: messageRequest.statusCode, + inputTokens: messageRequest.inputTokens, + outputTokens: messageRequest.outputTokens, + costUsd: messageRequest.costUsd, + durationMs: messageRequest.durationMs, + cacheCreationInputTokens: messageRequest.cacheCreationInputTokens, + cacheReadInputTokens: messageRequest.cacheReadInputTokens, + cacheCreation5mInputTokens: messageRequest.cacheCreation5mInputTokens, + cacheCreation1hInputTokens: messageRequest.cacheCreation1hInputTokens, + cacheTtlApplied: messageRequest.cacheTtlApplied, + }) + .from(messageRequest) + .where(and(...conditions)) + .orderBy(desc(messageRequest.createdAt), desc(messageRequest.id)) + .limit(safePageSize + 1) + .offset(offset); + + const hasMore = results.length > safePageSize; + const pageRows = hasMore ? results.slice(0, safePageSize) : results; + + let total = offset + pageRows.length; + + const cachedTotal = usageLogSlimTotalCache.get(totalCacheKey); + if (cachedTotal !== undefined) { + total = Math.max(cachedTotal, total); + return { + logs: pageRows.map((row) => ({ ...row, costUsd: row.costUsd?.toString() ?? null })), + total, + }; + } + + if (pageRows.length === 0 && offset > 0) { + const countResults = await db + .select({ totalRows: sql`count(*)::double precision` }) + .from(messageRequest) + .where(and(...conditions)); + total = countResults[0]?.totalRows ?? 0; + } else if (hasMore) { + const countResults = await db + .select({ totalRows: sql`count(*)::double precision` }) + .from(messageRequest) + .where(and(...conditions)); + total = countResults[0]?.totalRows ?? 0; + } + + const logs: UsageLogSlimRow[] = pageRows.map((row) => ({ + ...row, + costUsd: row.costUsd?.toString() ?? null, + })); + + usageLogSlimTotalCache.set(totalCacheKey, total); + return { logs, total }; +} + +const distinctModelsByKeyCache = new TTLMap({ + ttlMs: 5 * 60 * 1000, + maxSize: 200, +}); +const distinctEndpointsByKeyCache = new TTLMap({ + ttlMs: 5 * 60 * 1000, + maxSize: 200, +}); + export async function getTotalUsageForKey(keyString: string): Promise { const [row] = await db .select({ total: sql`COALESCE(sum(${messageRequest.costUsd}), 0)` }) @@ -297,33 +389,47 @@ export async function getTotalUsageForKey(keyString: string): Promise { } export async function getDistinctModelsForKey(keyString: string): Promise { + const cached = distinctModelsByKeyCache.get(keyString); + if (cached !== undefined) return cached; + const result = await db.execute( sql`select distinct ${messageRequest.model} as model from ${messageRequest} where ${messageRequest.key} = ${keyString} and ${messageRequest.deletedAt} is null + and (${EXCLUDE_WARMUP_CONDITION}) and ${messageRequest.model} is not null order by model asc` ); - return Array.from(result) + const models = Array.from(result) .map((row) => (row as { model?: string }).model) .filter((model): model is string => !!model && model.trim().length > 0); + + distinctModelsByKeyCache.set(keyString, models); + return models; } export async function getDistinctEndpointsForKey(keyString: string): Promise { + const cached = distinctEndpointsByKeyCache.get(keyString); + if (cached !== undefined) return cached; + const result = await db.execute( sql`select distinct ${messageRequest.endpoint} as endpoint from ${messageRequest} where ${messageRequest.key} = ${keyString} and ${messageRequest.deletedAt} is null + and (${EXCLUDE_WARMUP_CONDITION}) and ${messageRequest.endpoint} is not null order by endpoint asc` ); - return Array.from(result) + const endpoints = Array.from(result) .map((row) => (row as { endpoint?: string }).endpoint) .filter((endpoint): endpoint is string => !!endpoint && endpoint.trim().length > 0); + + distinctEndpointsByKeyCache.set(keyString, endpoints); + return endpoints; } /** @@ -331,23 +437,11 @@ export async function getDistinctEndpointsForKey(keyString: string): Promise { - const { - userId, - keyId, - providerId, - sessionId, - startTime, - endTime, - statusCode, - excludeStatusCode200, - model, - endpoint, - minRetryCount, - page = 1, - pageSize = 50, - } = filters; - - // 构建查询条件 + const { userId, keyId, providerId, page = 1, pageSize = 50 } = filters; + + const safePage = page > 0 ? page : 1; + const safePageSize = Math.min(200, Math.max(1, pageSize)); + const conditions = [isNull(messageRequest.deletedAt)]; if (userId !== undefined) { @@ -362,79 +456,49 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis conditions.push(eq(messageRequest.providerId, providerId)); } - const trimmedSessionId = sessionId?.trim(); - if (trimmedSessionId) { - conditions.push(eq(messageRequest.sessionId, trimmedSessionId)); - } - - // 使用毫秒时间戳进行时间比较 - // 前端传递的是浏览器本地时区的毫秒时间戳,直接与数据库的 timestamptz 比较 - // PostgreSQL 会自动处理时区转换 - if (startTime !== undefined) { - const startDate = new Date(startTime); - conditions.push(sql`${messageRequest.createdAt} >= ${startDate.toISOString()}::timestamptz`); - } - - if (endTime !== undefined) { - const endDate = new Date(endTime); - conditions.push(sql`${messageRequest.createdAt} < ${endDate.toISOString()}::timestamptz`); - } - - if (statusCode !== undefined) { - conditions.push(eq(messageRequest.statusCode, statusCode)); - } else if (excludeStatusCode200) { - // 包含 status_code 为空或非 200 的请求 - conditions.push( - sql`(${messageRequest.statusCode} IS NULL OR ${messageRequest.statusCode} <> 200)` - ); - } - - if (model) { - conditions.push(eq(messageRequest.model, model)); - } - - if (endpoint) { - conditions.push(eq(messageRequest.endpoint, endpoint)); - } - - if (minRetryCount !== undefined) { - // 重试次数 = provider_chain 长度 - 1(最小为 0) - conditions.push( - sql`GREATEST(COALESCE(jsonb_array_length(${messageRequest.providerChain}) - 1, 0), 0) >= ${minRetryCount}` - ); - } - - // 查询总数和统计数据(添加 innerJoin keysTable 以支持 keyId 过滤) - const [summaryResult] = await db - .select({ - // total:用于分页/审计,必须包含 warmup - totalRows: sql`count(*)::double precision`, - // summary:所有统计字段必须排除 warmup(不计入任何统计) - totalRequests: sql`count(*) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision`, - totalCost: sql`COALESCE(sum(${messageRequest.costUsd}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION}), 0)`, - totalInputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, - totalOutputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, - totalCacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, - totalCacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, - totalCacheCreation5mTokens: sql`COALESCE(sum(${messageRequest.cacheCreation5mInputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, - totalCacheCreation1hTokens: sql`COALESCE(sum(${messageRequest.cacheCreation1hInputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, - }) - .from(messageRequest) - .innerJoin(keysTable, eq(messageRequest.key, keysTable.key)) - .where(and(...conditions)); - - const total = summaryResult?.totalRows ?? 0; - const totalRequests = summaryResult?.totalRequests ?? 0; - const totalCost = parseFloat(summaryResult?.totalCost ?? "0"); - const totalTokens = - (summaryResult?.totalInputTokens ?? 0) + - (summaryResult?.totalOutputTokens ?? 0) + - (summaryResult?.totalCacheCreationTokens ?? 0) + - (summaryResult?.totalCacheReadTokens ?? 0); + conditions.push(...buildUsageLogConditions(filters)); + + const offset = (safePage - 1) * safePageSize; + + // 查询总数和统计数据(仅在需要 keyId 过滤时才 join keysTable,避免无效 join) + const summaryQuery = + keyId === undefined + ? db + .select({ + // total:用于分页/审计,必须包含 warmup + totalRows: sql`count(*)::double precision`, + // summary:所有统计字段必须排除 warmup(不计入任何统计) + totalRequests: sql`count(*) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision`, + totalCost: sql`COALESCE(sum(${messageRequest.costUsd}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION}), 0)`, + totalInputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, + totalOutputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, + totalCacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, + totalCacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, + totalCacheCreation5mTokens: sql`COALESCE(sum(${messageRequest.cacheCreation5mInputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, + totalCacheCreation1hTokens: sql`COALESCE(sum(${messageRequest.cacheCreation1hInputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, + }) + .from(messageRequest) + .where(and(...conditions)) + : db + .select({ + // total:用于分页/审计,必须包含 warmup + totalRows: sql`count(*)::double precision`, + // summary:所有统计字段必须排除 warmup(不计入任何统计) + totalRequests: sql`count(*) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision`, + totalCost: sql`COALESCE(sum(${messageRequest.costUsd}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION}), 0)`, + totalInputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, + totalOutputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, + totalCacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, + totalCacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, + totalCacheCreation5mTokens: sql`COALESCE(sum(${messageRequest.cacheCreation5mInputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, + totalCacheCreation1hTokens: sql`COALESCE(sum(${messageRequest.cacheCreation1hInputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, + }) + .from(messageRequest) + .innerJoin(keysTable, eq(messageRequest.key, keysTable.key)) + .where(and(...conditions)); // 查询分页数据(使用 LEFT JOIN 以包含被拦截的请求) - const offset = (page - 1) * pageSize; - const results = await db + const logsQuery = db .select({ id: messageRequest.id, createdAt: messageRequest.createdAt, @@ -472,10 +536,22 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis .innerJoin(keysTable, eq(messageRequest.key, keysTable.key)) .leftJoin(providers, eq(messageRequest.providerId, providers.id)) // 改为 leftJoin .where(and(...conditions)) - .orderBy(desc(messageRequest.createdAt)) - .limit(pageSize) + .orderBy(desc(messageRequest.createdAt), desc(messageRequest.id)) + .limit(safePageSize) .offset(offset); + const [summaryRows, results] = await Promise.all([summaryQuery, logsQuery]); + const summaryResult = summaryRows[0]; + + const total = summaryResult?.totalRows ?? 0; + const totalRequests = summaryResult?.totalRequests ?? 0; + const totalCost = parseFloat(summaryResult?.totalCost ?? "0"); + const totalTokens = + (summaryResult?.totalInputTokens ?? 0) + + (summaryResult?.totalOutputTokens ?? 0) + + (summaryResult?.totalCacheCreationTokens ?? 0) + + (summaryResult?.totalCacheReadTokens ?? 0); + const logs: UsageLogRow[] = results.map((row) => { const totalRowTokens = (row.inputTokens ?? 0) + @@ -635,21 +711,8 @@ export async function findUsageLogSessionIdSuggestions( export async function findUsageLogsStats( filters: Omit ): Promise { - const { - userId, - keyId, - providerId, - sessionId, - startTime, - endTime, - statusCode, - excludeStatusCode200, - model, - endpoint, - minRetryCount, - } = filters; - - // 构建查询条件(与 findUsageLogsWithDetails 相同) + const { userId, keyId, providerId } = filters; + const conditions = [isNull(messageRequest.deletedAt)]; if (userId !== undefined) { @@ -664,47 +727,11 @@ export async function findUsageLogsStats( conditions.push(eq(messageRequest.providerId, providerId)); } - const trimmedSessionId = sessionId?.trim(); - if (trimmedSessionId) { - conditions.push(eq(messageRequest.sessionId, trimmedSessionId)); - } - - if (startTime !== undefined) { - const startDate = new Date(startTime); - conditions.push(sql`${messageRequest.createdAt} >= ${startDate.toISOString()}::timestamptz`); - } - - if (endTime !== undefined) { - const endDate = new Date(endTime); - conditions.push(sql`${messageRequest.createdAt} < ${endDate.toISOString()}::timestamptz`); - } - - if (statusCode !== undefined) { - conditions.push(eq(messageRequest.statusCode, statusCode)); - } else if (excludeStatusCode200) { - conditions.push( - sql`(${messageRequest.statusCode} IS NULL OR ${messageRequest.statusCode} <> 200)` - ); - } - - if (model) { - conditions.push(eq(messageRequest.model, model)); - } - - if (endpoint) { - conditions.push(eq(messageRequest.endpoint, endpoint)); - } - - if (minRetryCount !== undefined) { - conditions.push( - sql`GREATEST(COALESCE(jsonb_array_length(${messageRequest.providerChain}) - 1, 0), 0) >= ${minRetryCount}` - ); - } + conditions.push(...buildUsageLogConditions(filters)); const statsConditions = [...conditions, EXCLUDE_WARMUP_CONDITION]; - // 执行聚合查询(添加 innerJoin keysTable 以支持 keyId 过滤) - const [summaryResult] = await db + const baseQuery = db .select({ totalRequests: sql`count(*)::double precision`, totalCost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, @@ -715,9 +742,14 @@ export async function findUsageLogsStats( totalCacheCreation5mTokens: sql`COALESCE(sum(${messageRequest.cacheCreation5mInputTokens})::double precision, 0::double precision)`, totalCacheCreation1hTokens: sql`COALESCE(sum(${messageRequest.cacheCreation1hInputTokens})::double precision, 0::double precision)`, }) - .from(messageRequest) - .innerJoin(keysTable, eq(messageRequest.key, keysTable.key)) - .where(and(...statsConditions)); + .from(messageRequest); + + const query = + keyId !== undefined + ? baseQuery.innerJoin(keysTable, eq(messageRequest.key, keysTable.key)) + : baseQuery; + + const [summaryResult] = await query.where(and(...statsConditions)); const totalRequests = summaryResult?.totalRequests ?? 0; const totalCost = parseFloat(summaryResult?.totalCost ?? "0"); diff --git a/tests/integration/provider-endpoint-sync-race.test.ts b/tests/integration/provider-endpoint-sync-race.test.ts index 5bc4b1a55..12574236e 100644 --- a/tests/integration/provider-endpoint-sync-race.test.ts +++ b/tests/integration/provider-endpoint-sync-race.test.ts @@ -115,8 +115,8 @@ run("Provider endpoint sync on edit (integration race)", () => { expect(previousAfter).toBeDefined(); expect(previousAfter?.url).toBe(oldUrl); - expect(previousAfter?.deletedAt).toBeNull(); - expect(previousAfter?.isEnabled).toBe(true); + expect(previousAfter?.deletedAt).not.toBeNull(); + expect(previousAfter?.isEnabled).toBe(false); const activeEndpoints = await findProviderEndpointsByVendorAndType( vendorId!, @@ -127,8 +127,7 @@ run("Provider endpoint sync on edit (integration race)", () => { const previousActive = activeEndpoints.filter((endpoint) => endpoint.url === oldUrl); expect(nextActive).toHaveLength(1); expect(nextActive[0]?.isEnabled).toBe(true); - expect(previousActive).toHaveLength(1); - expect(previousActive[0]?.isEnabled).toBe(true); + expect(previousActive).toHaveLength(0); const providerAfter = await findProviderById(created.id); expect(providerAfter?.url).toBe(nextUrl); diff --git a/tests/unit/actions/my-usage-concurrent-inherit.test.ts b/tests/unit/actions/my-usage-concurrent-inherit.test.ts index 1f5146519..e1aabddc8 100644 --- a/tests/unit/actions/my-usage-concurrent-inherit.test.ts +++ b/tests/unit/actions/my-usage-concurrent-inherit.test.ts @@ -28,8 +28,21 @@ vi.mock("@/lib/rate-limit/time-utils", () => ({ const statisticsMock = { sumUserCostInTimeRange: vi.fn(async () => 0), sumUserTotalCost: vi.fn(async () => 0), + sumUserQuotaCosts: vi.fn(async () => ({ + cost5h: 0, + costDaily: 0, + costWeekly: 0, + costMonthly: 0, + costTotal: 0, + })), sumKeyCostInTimeRange: vi.fn(async () => 0), - sumKeyTotalCostById: vi.fn(async () => 0), + sumKeyQuotaCostsById: vi.fn(async () => ({ + cost5h: 0, + costDaily: 0, + costWeekly: 0, + costMonthly: 0, + costTotal: 0, + })), }; vi.mock("@/repository/statistics", () => statisticsMock); diff --git a/tests/unit/actions/my-usage-consistency.test.ts b/tests/unit/actions/my-usage-consistency.test.ts index 4c4876b5e..f844dcff4 100644 --- a/tests/unit/actions/my-usage-consistency.test.ts +++ b/tests/unit/actions/my-usage-consistency.test.ts @@ -93,24 +93,21 @@ describe("my-usage getMyQuota data source consistency", () => { // Mock the statistics module const sumKeyCostInTimeRangeMock = vi.fn(async () => 10.5); - const sumKeyTotalCostByIdMock = vi.fn(async () => 100.25); const sumUserCostInTimeRangeMock = vi.fn(async () => 10.5); const sumUserTotalCostMock = vi.fn(async () => 100.25); vi.doMock("@/repository/statistics", () => ({ sumKeyCostInTimeRange: sumKeyCostInTimeRangeMock, - sumKeyTotalCostById: sumKeyTotalCostByIdMock, sumUserCostInTimeRange: sumUserCostInTimeRangeMock, sumUserTotalCost: sumUserTotalCostMock, })); // Verify the function signatures match expect(typeof sumKeyCostInTimeRangeMock).toBe("function"); - expect(typeof sumKeyTotalCostByIdMock).toBe("function"); // The test validates that: // 1. Key 5h/daily/weekly/monthly uses sumKeyCostInTimeRange (DB direct) - // 2. Key total uses sumKeyTotalCostById (DB direct) + // 2. Key total uses sumKeyQuotaCostsById (DB direct) // 3. User 5h/weekly/monthly uses sumUserCost (which calls sumUserCostInTimeRange) // 4. User daily uses sumUserCostInTimeRange // 5. User total uses sumUserTotalCost @@ -125,7 +122,7 @@ describe("my-usage getMyQuota data source consistency", () => { // Result: Inconsistent values when Redis cache differs from DB // After fix: - // - Key: sumKeyCostInTimeRange / sumKeyTotalCostById (DB direct) + // - Key: sumKeyCostInTimeRange / sumKeyQuotaCostsById (DB direct) // - User: sumUserCost / sumUserCostInTimeRange (DB direct) // Result: Consistent values from same data source diff --git a/tests/unit/actions/my-usage-date-range-dst.test.ts b/tests/unit/actions/my-usage-date-range-dst.test.ts index bf3fa3766..ee1f8fea4 100644 --- a/tests/unit/actions/my-usage-date-range-dst.test.ts +++ b/tests/unit/actions/my-usage-date-range-dst.test.ts @@ -4,7 +4,7 @@ import { fromZonedTime } from "date-fns-tz"; const mocks = vi.hoisted(() => ({ getSession: vi.fn(), getSystemSettings: vi.fn(), - findUsageLogsWithDetails: vi.fn(), + findUsageLogsForKeySlim: vi.fn(), resolveSystemTimezone: vi.fn(), })); @@ -20,7 +20,7 @@ vi.mock("@/repository/usage-logs", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - findUsageLogsWithDetails: mocks.findUsageLogsWithDetails, + findUsageLogsForKeySlim: mocks.findUsageLogsForKeySlim, }; }); @@ -43,15 +43,15 @@ describe("my-usage date range parsing", () => { billingModelSource: "original", }); - mocks.findUsageLogsWithDetails.mockResolvedValue({ logs: [], total: 0 }); + mocks.findUsageLogsForKeySlim.mockResolvedValue({ logs: [], total: 0 }); const { getMyUsageLogs } = await import("@/actions/my-usage"); const res = await getMyUsageLogs({ startDate: "2024-03-10", endDate: "2024-03-10" }); expect(res.ok).toBe(true); - expect(mocks.findUsageLogsWithDetails).toHaveBeenCalledTimes(1); + expect(mocks.findUsageLogsForKeySlim).toHaveBeenCalledTimes(1); - const args = mocks.findUsageLogsWithDetails.mock.calls[0]?.[0]; + const args = mocks.findUsageLogsForKeySlim.mock.calls[0]?.[0]; expect(args.startTime).toBe(fromZonedTime("2024-03-10T00:00:00", tz).getTime()); expect(args.endTime).toBe(fromZonedTime("2024-03-11T00:00:00", tz).getTime()); @@ -72,15 +72,15 @@ describe("my-usage date range parsing", () => { billingModelSource: "original", }); - mocks.findUsageLogsWithDetails.mockResolvedValue({ logs: [], total: 0 }); + mocks.findUsageLogsForKeySlim.mockResolvedValue({ logs: [], total: 0 }); const { getMyUsageLogs } = await import("@/actions/my-usage"); const res = await getMyUsageLogs({ startDate: "2024-11-03", endDate: "2024-11-03" }); expect(res.ok).toBe(true); - expect(mocks.findUsageLogsWithDetails).toHaveBeenCalledTimes(1); + expect(mocks.findUsageLogsForKeySlim).toHaveBeenCalledTimes(1); - const args = mocks.findUsageLogsWithDetails.mock.calls[0]?.[0]; + const args = mocks.findUsageLogsForKeySlim.mock.calls[0]?.[0]; expect(args.startTime).toBe(fromZonedTime("2024-11-03T00:00:00", tz).getTime()); expect(args.endTime).toBe(fromZonedTime("2024-11-04T00:00:00", tz).getTime()); diff --git a/tests/unit/actions/my-usage-token-aggregation.test.ts b/tests/unit/actions/my-usage-token-aggregation.test.ts index 0a9a27ca6..6b0c750ea 100644 --- a/tests/unit/actions/my-usage-token-aggregation.test.ts +++ b/tests/unit/actions/my-usage-token-aggregation.test.ts @@ -148,11 +148,9 @@ describe("my-usage token aggregation", () => { const res = await getMyTodayStats(); expect(res.ok).toBe(true); - expect(capturedSelections.length).toBeGreaterThanOrEqual(2); + expect(capturedSelections.length).toBeGreaterThanOrEqual(1); expectNoIntTokenSum(capturedSelections[0], "inputTokens"); expectNoIntTokenSum(capturedSelections[0], "outputTokens"); - expectNoIntTokenSum(capturedSelections[1], "inputTokens"); - expectNoIntTokenSum(capturedSelections[1], "outputTokens"); }); test("getMyStatsSummary: token sum 不应使用 ::int", async () => { @@ -161,7 +159,6 @@ describe("my-usage token aggregation", () => { const capturedSelections: Array> = []; const selectQueue: any[] = []; selectQueue.push(createThenableQuery([])); - selectQueue.push(createThenableQuery([])); mocks.select.mockImplementation((selection: unknown) => { capturedSelections.push(selection as Record); @@ -196,13 +193,26 @@ describe("my-usage token aggregation", () => { const res = await getMyStatsSummary({ startDate: "2024-01-01", endDate: "2024-01-01" }); expect(res.ok).toBe(true); - expect(capturedSelections).toHaveLength(2); - - for (const selection of capturedSelections) { - expectNoIntTokenSum(selection, "inputTokens"); - expectNoIntTokenSum(selection, "outputTokens"); - expectNoIntTokenSum(selection, "cacheCreationTokens"); - expectNoIntTokenSum(selection, "cacheReadTokens"); + expect(capturedSelections).toHaveLength(1); + + const selection = capturedSelections[0]; + const tokenFields = [ + "userInputTokens", + "userOutputTokens", + "userCacheCreationTokens", + "userCacheReadTokens", + "userCacheCreation5mTokens", + "userCacheCreation1hTokens", + "keyInputTokens", + "keyOutputTokens", + "keyCacheCreationTokens", + "keyCacheReadTokens", + "keyCacheCreation5mTokens", + "keyCacheCreation1hTokens", + ]; + + for (const field of tokenFields) { + expectNoIntTokenSum(selection, field); } }); }); diff --git a/tests/unit/actions/provider-endpoints.test.ts b/tests/unit/actions/provider-endpoints.test.ts index 22f8c7369..3f1a2fe3d 100644 --- a/tests/unit/actions/provider-endpoints.test.ts +++ b/tests/unit/actions/provider-endpoints.test.ts @@ -10,6 +10,10 @@ const findProviderEndpointByIdMock = vi.fn(); const softDeleteProviderEndpointMock = vi.fn(); const tryDeleteProviderVendorIfEmptyMock = vi.fn(); const updateProviderEndpointMock = vi.fn(); +const findProviderEndpointProbeLogsBatchMock = vi.fn(); +const findVendorTypeEndpointStatsBatchMock = vi.fn(); +const hasEnabledProviderReferenceForVendorTypeUrlMock = vi.fn(); +const findDashboardProviderEndpointsByVendorAndTypeMock = vi.fn(); vi.mock("@/lib/auth", () => ({ getSession: getSessionMock, @@ -30,6 +34,7 @@ vi.mock("@/lib/logger", () => ({ })); vi.mock("@/lib/endpoint-circuit-breaker", () => ({ + getAllEndpointHealthStatusAsync: vi.fn(async () => ({})), getEndpointHealthInfo: vi.fn(async () => ({ health: {}, config: {} })), resetEndpointCircuit: vi.fn(async () => {}), })); @@ -48,7 +53,18 @@ vi.mock("@/lib/vendor-type-circuit-breaker", () => ({ })); vi.mock("@/lib/provider-endpoints/probe", () => ({ - probeProviderEndpointAndRecord: vi.fn(async () => null), + probeProviderEndpointAndRecordByEndpoint: vi.fn(async () => null), +})); + +vi.mock("@/repository/provider-endpoints-batch", () => ({ + findProviderEndpointProbeLogsBatch: findProviderEndpointProbeLogsBatchMock, + findVendorTypeEndpointStatsBatch: findVendorTypeEndpointStatsBatchMock, +})); + +vi.mock("@/repository/provider-endpoints", () => ({ + findDashboardProviderEndpointsByVendorAndType: findDashboardProviderEndpointsByVendorAndTypeMock, + findEnabledProviderVendorTypePairs: vi.fn(async () => []), + hasEnabledProviderReferenceForVendorTypeUrl: hasEnabledProviderReferenceForVendorTypeUrlMock, })); vi.mock("@/repository", () => ({ @@ -68,6 +84,8 @@ vi.mock("@/repository", () => ({ describe("provider-endpoints actions", () => { beforeEach(() => { vi.clearAllMocks(); + hasEnabledProviderReferenceForVendorTypeUrlMock.mockResolvedValue(false); + findDashboardProviderEndpointsByVendorAndTypeMock.mockResolvedValue([]); }); it("editProviderVendor: requires admin", async () => { @@ -80,6 +98,59 @@ describe("provider-endpoints actions", () => { expect(res.errorCode).toBe("PERMISSION_DENIED"); }); + it("getDashboardProviderEndpoints: requires admin", async () => { + getSessionMock.mockResolvedValue({ user: { role: "user" } }); + + const { getDashboardProviderEndpoints } = await import("@/actions/provider-endpoints"); + const res = await getDashboardProviderEndpoints({ vendorId: 1, providerType: "claude" }); + + expect(res).toEqual([]); + expect(findDashboardProviderEndpointsByVendorAndTypeMock).not.toHaveBeenCalled(); + }); + + it("getDashboardProviderEndpoints: invalid input returns empty list", async () => { + getSessionMock.mockResolvedValue({ user: { role: "admin" } }); + + const { getDashboardProviderEndpoints } = await import("@/actions/provider-endpoints"); + const res = await getDashboardProviderEndpoints({ vendorId: 0, providerType: "claude" }); + + expect(res).toEqual([]); + expect(findDashboardProviderEndpointsByVendorAndTypeMock).not.toHaveBeenCalled(); + }); + + it("getDashboardProviderEndpoints: returns endpoints in use for enabled providers", async () => { + getSessionMock.mockResolvedValue({ user: { role: "admin" } }); + + const endpoints = [ + { + id: 1, + vendorId: 10, + providerType: "claude", + url: "https://api.example.com", + label: null, + sortOrder: 0, + isEnabled: true, + lastProbedAt: null, + lastProbeOk: null, + lastProbeStatusCode: null, + lastProbeLatencyMs: null, + lastProbeErrorType: null, + lastProbeErrorMessage: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }, + ]; + + findDashboardProviderEndpointsByVendorAndTypeMock.mockResolvedValue(endpoints); + + const { getDashboardProviderEndpoints } = await import("@/actions/provider-endpoints"); + const res = await getDashboardProviderEndpoints({ vendorId: 10, providerType: "claude" }); + + expect(res).toEqual(endpoints); + expect(findDashboardProviderEndpointsByVendorAndTypeMock).toHaveBeenCalledWith(10, "claude"); + }); + it("editProviderVendor: computes favicon", async () => { getSessionMock.mockResolvedValue({ user: { role: "admin" } }); @@ -142,6 +213,26 @@ describe("provider-endpoints actions", () => { it("editProviderEndpoint: conflict maps to CONFLICT errorCode", async () => { getSessionMock.mockResolvedValue({ user: { role: "admin" } }); + + findProviderEndpointByIdMock.mockResolvedValue({ + id: 42, + vendorId: 123, + providerType: "claude", + url: "https://api.example.com", + label: null, + sortOrder: 0, + isEnabled: true, + lastProbedAt: null, + lastProbeOk: null, + lastProbeStatusCode: null, + lastProbeLatencyMs: null, + lastProbeErrorType: null, + lastProbeErrorMessage: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }); + updateProviderEndpointMock.mockRejectedValue( Object.assign(new Error("[ProviderEndpointEdit] endpoint conflict"), { code: "PROVIDER_ENDPOINT_CONFLICT", @@ -181,6 +272,7 @@ describe("provider-endpoints actions", () => { deletedAt: null, }; + findProviderEndpointByIdMock.mockResolvedValue(endpoint); updateProviderEndpointMock.mockResolvedValue(endpoint); const { editProviderEndpoint } = await import("@/actions/provider-endpoints"); @@ -252,55 +344,100 @@ describe("provider-endpoints actions", () => { }); softDeleteProviderEndpointMock.mockResolvedValue(true); tryDeleteProviderVendorIfEmptyMock.mockResolvedValue(true); + hasEnabledProviderReferenceForVendorTypeUrlMock.mockResolvedValue(false); const { removeProviderEndpoint } = await import("@/actions/provider-endpoints"); const res = await removeProviderEndpoint({ endpointId: 99 }); expect(res.ok).toBe(true); + + const { resetEndpointCircuit } = await import("@/lib/endpoint-circuit-breaker"); + expect(resetEndpointCircuit).toHaveBeenCalledWith(99); expect(tryDeleteProviderVendorIfEmptyMock).toHaveBeenCalledWith(123); }); + it("probeProviderEndpoint: calls probeProviderEndpointAndRecordByEndpoint and returns result", async () => { + getSessionMock.mockResolvedValue({ user: { role: "admin" } }); + + const endpoint = { + id: 7, + vendorId: 123, + providerType: "claude", + url: "https://api.example.com", + label: null, + sortOrder: 0, + isEnabled: true, + lastProbedAt: null, + lastProbeOk: null, + lastProbeStatusCode: null, + lastProbeLatencyMs: null, + lastProbeErrorType: null, + lastProbeErrorMessage: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }; + findProviderEndpointByIdMock.mockResolvedValue(endpoint); + + const { probeProviderEndpointAndRecordByEndpoint } = await import( + "@/lib/provider-endpoints/probe" + ); + const result = { + ok: true, + method: "HEAD", + statusCode: 200, + latencyMs: 10, + errorType: null, + errorMessage: null, + } as const; + vi.mocked(probeProviderEndpointAndRecordByEndpoint).mockResolvedValue(result); + + const { probeProviderEndpoint } = await import("@/actions/provider-endpoints"); + const res = await probeProviderEndpoint({ endpointId: 7, timeoutMs: 5000 }); + + expect(res.ok).toBe(true); + expect(probeProviderEndpointAndRecordByEndpoint).toHaveBeenCalledWith({ + endpoint, + source: "manual", + timeoutMs: 5000, + }); + expect(res.data?.result).toEqual(result); + }); + describe("batchGetEndpointCircuitInfo", () => { it("returns circuit info for multiple endpoints", async () => { getSessionMock.mockResolvedValue({ user: { role: "admin" } }); - const { getEndpointHealthInfo } = await import("@/lib/endpoint-circuit-breaker"); - vi.mocked(getEndpointHealthInfo) - .mockResolvedValueOnce({ - health: { - failureCount: 0, - lastFailureTime: null, - circuitState: "closed" as const, - circuitOpenUntil: null, - halfOpenSuccessCount: 0, - }, - config: { failureThreshold: 3, openDuration: 300000, halfOpenSuccessThreshold: 1 }, - }) - .mockResolvedValueOnce({ - health: { - failureCount: 5, - lastFailureTime: Date.now(), - circuitState: "open" as const, - circuitOpenUntil: Date.now() + 60000, - halfOpenSuccessCount: 0, - }, - config: { failureThreshold: 3, openDuration: 300000, halfOpenSuccessThreshold: 1 }, - }) - .mockResolvedValueOnce({ - health: { - failureCount: 1, - lastFailureTime: Date.now() - 1000, - circuitState: "half-open" as const, - circuitOpenUntil: null, - halfOpenSuccessCount: 0, - }, - config: { failureThreshold: 3, openDuration: 300000, halfOpenSuccessThreshold: 1 }, - }); + const { getAllEndpointHealthStatusAsync } = await import("@/lib/endpoint-circuit-breaker"); + vi.mocked(getAllEndpointHealthStatusAsync).mockResolvedValue({ + 1: { + failureCount: 0, + lastFailureTime: null, + circuitState: "closed", + circuitOpenUntil: null, + halfOpenSuccessCount: 0, + }, + 2: { + failureCount: 5, + lastFailureTime: Date.now(), + circuitState: "open", + circuitOpenUntil: Date.now() + 60000, + halfOpenSuccessCount: 0, + }, + 3: { + failureCount: 1, + lastFailureTime: Date.now() - 1000, + circuitState: "half-open", + circuitOpenUntil: null, + halfOpenSuccessCount: 0, + }, + }); const { batchGetEndpointCircuitInfo } = await import("@/actions/provider-endpoints"); const res = await batchGetEndpointCircuitInfo({ endpointIds: [1, 2, 3] }); expect(res.ok).toBe(true); + expect(getAllEndpointHealthStatusAsync).toHaveBeenCalledWith([1, 2, 3]); expect(res.data).toHaveLength(3); expect(res.data?.[0]).toEqual({ endpointId: 1, diff --git a/tests/unit/actions/providers-recluster.test.ts b/tests/unit/actions/providers-recluster.test.ts index 3af78aa16..d961b91c1 100644 --- a/tests/unit/actions/providers-recluster.test.ts +++ b/tests/unit/actions/providers-recluster.test.ts @@ -26,6 +26,10 @@ vi.mock("@/repository/provider", () => ({ vi.mock("@/repository/provider-endpoints", () => ({ computeVendorKey: computeVendorKeyMock, findProviderVendorById: findProviderVendorByIdMock, + findProviderVendorsByIds: vi.fn(async (vendorIds: number[]) => { + const vendors = await Promise.all(vendorIds.map((id) => findProviderVendorByIdMock(id))); + return vendors.filter((vendor): vendor is NonNullable => vendor !== null); + }), getOrCreateProviderVendorIdFromUrls: getOrCreateProviderVendorIdFromUrlsMock, backfillProviderEndpointsFromProviders: backfillProviderEndpointsFromProvidersMock, tryDeleteProviderVendorIfEmpty: tryDeleteProviderVendorIfEmptyMock, diff --git a/tests/unit/actions/total-usage-semantics.test.ts b/tests/unit/actions/total-usage-semantics.test.ts index 3811aa928..7908f4db6 100644 --- a/tests/unit/actions/total-usage-semantics.test.ts +++ b/tests/unit/actions/total-usage-semantics.test.ts @@ -4,9 +4,9 @@ * Verify that total usage reads in display paths use ALL_TIME_MAX_AGE_DAYS (Infinity) * to skip the date filter entirely, querying all-time data. * - * Key insight: The functions sumKeyTotalCostById and sumUserTotalCost have a default - * maxAgeDays of 365. For display purposes (showing "total" usage), we want all-time - * semantics, which means passing Infinity to skip the date filter. + * Key insight: The usage/quota aggregation functions default maxAgeDays to 365. + * For display purposes (showing "total" usage), we want all-time semantics, which + * means passing Infinity to skip the date filter. * * IMPORTANT: This test only covers DISPLAY paths. Enforcement paths (RateLimitService) * are intentionally NOT modified. @@ -19,13 +19,14 @@ const ALL_TIME_MAX_AGE_DAYS = Infinity; // Mock functions const getSessionMock = vi.fn(); -const sumKeyTotalCostByIdMock = vi.fn(); const sumUserTotalCostMock = vi.fn(); -const sumKeyCostInTimeRangeMock = vi.fn(); +const sumKeyQuotaCostsByIdMock = vi.fn(); +const sumUserQuotaCostsMock = vi.fn(); const sumUserCostInTimeRangeMock = vi.fn(); const getTimeRangeForPeriodMock = vi.fn(); const getTimeRangeForPeriodWithModeMock = vi.fn(); const getKeySessionCountMock = vi.fn(); +const getUserSessionCountMock = vi.fn(); const findUserByIdMock = vi.fn(); // Mock modules @@ -34,10 +35,10 @@ vi.mock("@/lib/auth", () => ({ })); vi.mock("@/repository/statistics", () => ({ - sumKeyTotalCostById: (...args: unknown[]) => sumKeyTotalCostByIdMock(...args), - sumUserTotalCost: (...args: unknown[]) => sumUserTotalCostMock(...args), - sumKeyCostInTimeRange: (...args: unknown[]) => sumKeyCostInTimeRangeMock(...args), sumUserCostInTimeRange: (...args: unknown[]) => sumUserCostInTimeRangeMock(...args), + sumUserTotalCost: (...args: unknown[]) => sumUserTotalCostMock(...args), + sumKeyQuotaCostsById: (...args: unknown[]) => sumKeyQuotaCostsByIdMock(...args), + sumUserQuotaCosts: (...args: unknown[]) => sumUserQuotaCostsMock(...args), })); vi.mock("@/lib/rate-limit/time-utils", () => ({ @@ -48,6 +49,7 @@ vi.mock("@/lib/rate-limit/time-utils", () => ({ vi.mock("@/lib/session-tracker", () => ({ SessionTracker: { getKeySessionCount: (...args: unknown[]) => getKeySessionCountMock(...args), + getUserSessionCount: (...args: unknown[]) => getUserSessionCountMock(...args), }, })); @@ -80,15 +82,24 @@ describe("total-usage-semantics", () => { getTimeRangeForPeriodWithModeMock.mockResolvedValue(defaultRange); // Default cost mocks - sumKeyCostInTimeRangeMock.mockResolvedValue(0); - sumUserCostInTimeRangeMock.mockResolvedValue(0); - sumKeyTotalCostByIdMock.mockResolvedValue(0); sumUserTotalCostMock.mockResolvedValue(0); + sumUserCostInTimeRangeMock.mockResolvedValue(0); getKeySessionCountMock.mockResolvedValue(0); + getUserSessionCountMock.mockResolvedValue(0); + + const emptyCosts = { + cost5h: 0, + costDaily: 0, + costWeekly: 0, + costMonthly: 0, + costTotal: 0, + }; + sumKeyQuotaCostsByIdMock.mockResolvedValue(emptyCosts); + sumUserQuotaCostsMock.mockResolvedValue(emptyCosts); }); describe("getMyQuota in my-usage.ts", () => { - it("should call sumKeyTotalCostById with ALL_TIME_MAX_AGE_DAYS for key total cost", async () => { + it("should call sumKeyQuotaCostsById with ALL_TIME_MAX_AGE_DAYS for key total cost", async () => { // Setup session mock getSessionMock.mockResolvedValue({ key: { @@ -131,15 +142,14 @@ describe("total-usage-semantics", () => { const { getMyQuota } = await import("@/actions/my-usage"); await getMyQuota(); - // Verify sumKeyTotalCostById was called with Infinity (all-time) - expect(sumKeyTotalCostByIdMock).toHaveBeenCalledWith(1, Infinity); + expect(sumKeyQuotaCostsByIdMock).toHaveBeenCalledWith( + 1, + expect.any(Object), + ALL_TIME_MAX_AGE_DAYS + ); }); - it.skip("should call sumUserTotalCost with ALL_TIME_MAX_AGE_DAYS for user total cost (via sumUserCost)", async () => { - // SKIPPED: Dynamic import in sumUserCost cannot be properly mocked with vi.mock() - // The source code verification test below proves the implementation is correct - // by checking the actual source code contains the correct function call pattern. - + it("should call sumUserQuotaCosts with ALL_TIME_MAX_AGE_DAYS for user total cost", async () => { // Setup session mock getSessionMock.mockResolvedValue({ key: { @@ -182,8 +192,11 @@ describe("total-usage-semantics", () => { const { getMyQuota } = await import("@/actions/my-usage"); await getMyQuota(); - // Verify sumUserTotalCost was called with Infinity (all-time) - expect(sumUserTotalCostMock).toHaveBeenCalledWith(1, Infinity); + expect(sumUserQuotaCostsMock).toHaveBeenCalledWith( + 1, + expect.any(Object), + ALL_TIME_MAX_AGE_DAYS + ); }); }); @@ -227,6 +240,8 @@ describe("total-usage-semantics", () => { describe("source code verification", () => { it("should verify sumUserCost passes ALL_TIME_MAX_AGE_DAYS when period is total", async () => { + // This test verifies the implementation by reading the source code pattern + // Ensure we call quota aggregation functions with ALL_TIME_MAX_AGE_DAYS for all-time usage. const fs = await import("node:fs/promises"); const path = await import("node:path"); @@ -236,11 +251,10 @@ describe("total-usage-semantics", () => { // Verify the constant is defined as Infinity expect(content).toContain("const ALL_TIME_MAX_AGE_DAYS = Infinity"); - // Verify sumUserTotalCost is called with the constant when period is total - expect(content).toContain("sumUserTotalCost(userId, ALL_TIME_MAX_AGE_DAYS)"); + // Verify quota aggregation uses the constant for all-time usage + expect(content).toMatch(/sumUserQuotaCosts\([^)]*ALL_TIME_MAX_AGE_DAYS\s*\)/); - // Verify sumKeyTotalCostById is called with the constant - expect(content).toContain("sumKeyTotalCostById(key.id, ALL_TIME_MAX_AGE_DAYS)"); + expect(content).toMatch(/sumKeyQuotaCostsById\([^)]*ALL_TIME_MAX_AGE_DAYS\s*\)/); }); it("should verify getUserAllLimitUsage passes ALL_TIME_MAX_AGE_DAYS", async () => { diff --git a/tests/unit/lib/endpoint-circuit-breaker.test.ts b/tests/unit/lib/endpoint-circuit-breaker.test.ts index 2232a59b5..573545867 100644 --- a/tests/unit/lib/endpoint-circuit-breaker.test.ts +++ b/tests/unit/lib/endpoint-circuit-breaker.test.ts @@ -27,6 +27,7 @@ async function flushPromises(rounds = 2): Promise { afterEach(() => { vi.useRealTimers(); + delete process.env.ENDPOINT_CIRCUIT_HEALTH_CACHE_MAX_SIZE; }); describe("endpoint-circuit-breaker", () => { @@ -61,6 +62,7 @@ describe("endpoint-circuit-breaker", () => { const { isEndpointCircuitOpen, + getEndpointHealthInfo, recordEndpointFailure, recordEndpointSuccess, resetEndpointCircuit, @@ -92,19 +94,20 @@ describe("endpoint-circuit-breaker", () => { expect(halfOpenState.circuitState).toBe("half-open"); await recordEndpointSuccess(1); - const closedState = saveMock.mock.calls[ - saveMock.mock.calls.length - 1 - ]?.[1] as SavedEndpointCircuitState; - expect(closedState.circuitState).toBe("closed"); - expect(closedState.failureCount).toBe(0); - expect(closedState.circuitOpenUntil).toBeNull(); - expect(closedState.lastFailureTime).toBeNull(); - expect(closedState.halfOpenSuccessCount).toBe(0); + expect(deleteMock).toHaveBeenCalledWith(1); + + const { health: afterSuccess } = await getEndpointHealthInfo(1); + expect(afterSuccess.circuitState).toBe("closed"); + expect(afterSuccess.failureCount).toBe(0); + expect(afterSuccess.circuitOpenUntil).toBeNull(); + expect(afterSuccess.lastFailureTime).toBeNull(); + expect(afterSuccess.halfOpenSuccessCount).toBe(0); expect(await isEndpointCircuitOpen(1)).toBe(false); + const deleteCallsAfterSuccess = deleteMock.mock.calls.length; await resetEndpointCircuit(1); - expect(deleteMock).toHaveBeenCalledWith(1); + expect(deleteMock.mock.calls.length).toBeGreaterThan(deleteCallsAfterSuccess); // 说明:recordEndpointFailure 在达到阈值后会触发异步告警(dynamic import + await)。 // 在 CI/bun 环境下,告警 Promise 可能在下一个测试开始后才完成,从而“借用”后续用例的 module mock, @@ -120,6 +123,7 @@ describe("endpoint-circuit-breaker", () => { vi.resetModules(); const saveMock = vi.fn(async () => {}); + const deleteMock = vi.fn(async () => {}); vi.doMock("@/lib/config/env.schema", () => ({ getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), @@ -131,7 +135,7 @@ describe("endpoint-circuit-breaker", () => { vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({ loadEndpointCircuitState: vi.fn(async () => null), saveEndpointCircuitState: saveMock, - deleteEndpointCircuitState: vi.fn(async () => {}), + deleteEndpointCircuitState: deleteMock, })); const { recordEndpointFailure, recordEndpointSuccess, getEndpointHealthInfo } = await import( @@ -145,10 +149,127 @@ describe("endpoint-circuit-breaker", () => { expect(health.failureCount).toBe(0); expect(health.circuitState).toBe("closed"); - const lastState = saveMock.mock.calls[ - saveMock.mock.calls.length - 1 - ]?.[1] as SavedEndpointCircuitState; - expect(lastState.failureCount).toBe(0); + expect(saveMock).toHaveBeenCalledTimes(1); + expect(deleteMock).toHaveBeenCalledWith(2); + }); + + test("getAllEndpointHealthStatusAsync: forceRefresh 时应同步 Redis 中的计数(即使 circuitState 未变化)", async () => { + vi.resetModules(); + + const endpointId = 42; + + const redisStates = new Map(); + const loadManyMock = vi.fn(async (endpointIds: number[]) => { + const result = new Map(); + for (const id of endpointIds) { + const state = redisStates.get(id); + if (state) { + result.set(id, state); + } + } + return result; + }); + + vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); + vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({ + loadEndpointCircuitState: vi.fn(async () => null), + loadEndpointCircuitStates: loadManyMock, + saveEndpointCircuitState: vi.fn(async () => {}), + deleteEndpointCircuitState: vi.fn(async () => {}), + })); + + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const t0 = Date.now(); + + redisStates.set(endpointId, { + failureCount: 1, + lastFailureTime: t0 - 1000, + circuitState: "closed", + circuitOpenUntil: null, + halfOpenSuccessCount: 0, + }); + + const { getAllEndpointHealthStatusAsync } = await import("@/lib/endpoint-circuit-breaker"); + + const first = await getAllEndpointHealthStatusAsync([endpointId], { forceRefresh: true }); + expect(first[endpointId]).toMatchObject({ + failureCount: 1, + lastFailureTime: t0 - 1000, + circuitState: "closed", + circuitOpenUntil: null, + halfOpenSuccessCount: 0, + }); + + redisStates.set(endpointId, { + failureCount: 2, + lastFailureTime: t0 + 123, + circuitState: "closed", + circuitOpenUntil: null, + halfOpenSuccessCount: 0, + }); + + const second = await getAllEndpointHealthStatusAsync([endpointId], { forceRefresh: true }); + expect(second[endpointId]).toMatchObject({ + failureCount: 2, + lastFailureTime: t0 + 123, + circuitState: "closed", + circuitOpenUntil: null, + halfOpenSuccessCount: 0, + }); + + expect(loadManyMock).toHaveBeenCalledTimes(2); + }); + + test("getAllEndpointHealthStatusAsync: 并发请求应复用 in-flight Redis 批量加载", async () => { + vi.resetModules(); + + const loadManyMock = vi.fn(async (_endpointIds: number[]) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + const result = new Map(); + result.set(1, { + failureCount: 0, + lastFailureTime: null, + circuitState: "closed", + circuitOpenUntil: null, + halfOpenSuccessCount: 0, + }); + result.set(2, { + failureCount: 0, + lastFailureTime: null, + circuitState: "closed", + circuitOpenUntil: null, + halfOpenSuccessCount: 0, + }); + result.set(3, { + failureCount: 0, + lastFailureTime: null, + circuitState: "closed", + circuitOpenUntil: null, + halfOpenSuccessCount: 0, + }); + return result; + }); + + vi.doMock("@/lib/logger", () => ({ logger: createLoggerMock() })); + vi.doMock("@/lib/redis/endpoint-circuit-breaker-state", () => ({ + loadEndpointCircuitState: vi.fn(async () => null), + loadEndpointCircuitStates: loadManyMock, + saveEndpointCircuitState: vi.fn(async () => {}), + deleteEndpointCircuitState: vi.fn(async () => {}), + })); + + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + + const { getAllEndpointHealthStatusAsync } = await import("@/lib/endpoint-circuit-breaker"); + + const p1 = getAllEndpointHealthStatusAsync([1, 2, 3], { forceRefresh: true }); + const p2 = getAllEndpointHealthStatusAsync([1, 2, 3], { forceRefresh: true }); + + vi.advanceTimersByTime(20); + await Promise.all([p1, p2]); + expect(loadManyMock).toHaveBeenCalledTimes(1); }); test("triggerEndpointCircuitBreakerAlert should call sendCircuitBreakerAlert", async () => { diff --git a/tests/unit/lib/provider-endpoints/endpoint-selector.test.ts b/tests/unit/lib/provider-endpoints/endpoint-selector.test.ts index 59ac0312d..b381a8ade 100644 --- a/tests/unit/lib/provider-endpoints/endpoint-selector.test.ts +++ b/tests/unit/lib/provider-endpoints/endpoint-selector.test.ts @@ -27,10 +27,14 @@ describe("provider-endpoints: endpoint-selector", () => { test("rankProviderEndpoints 应过滤 disabled/deleted,并按 lastProbeOk/sortOrder/latency/id 排序", async () => { vi.resetModules(); vi.doMock("@/repository", () => ({ + findEnabledProviderEndpointsByVendorAndType: vi.fn(), findProviderEndpointsByVendorAndType: vi.fn(), })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - isEndpointCircuitOpen: vi.fn(), + getAllEndpointHealthStatusAsync: vi.fn(), + })); + vi.doMock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), })); const { rankProviderEndpoints } = await import("@/lib/provider-endpoints/endpoint-selector"); @@ -91,23 +95,46 @@ describe("provider-endpoints: endpoint-selector", () => { test("getPreferredProviderEndpoints 应排除禁用/已删除/显式 exclude/熔断 open 的端点,并返回排序结果", async () => { vi.resetModules(); + // findEnabledProviderEndpointsByVendorAndType 语义:只返回 isEnabled=true 且 deletedAt=null 的端点 const endpoints: ProviderEndpoint[] = [ makeEndpoint({ id: 1, lastProbeOk: true, sortOrder: 1, lastProbeLatencyMs: 20 }), makeEndpoint({ id: 2, lastProbeOk: true, sortOrder: 0, lastProbeLatencyMs: 999 }), makeEndpoint({ id: 3, lastProbeOk: null, sortOrder: 0, lastProbeLatencyMs: 10 }), - makeEndpoint({ id: 4, isEnabled: false }), - makeEndpoint({ id: 5, deletedAt: new Date(1) }), makeEndpoint({ id: 6, lastProbeOk: false, sortOrder: 0, lastProbeLatencyMs: 1 }), ]; const findMock = vi.fn(async () => endpoints); - const isOpenMock = vi.fn(async (endpointId: number) => endpointId === 2); + const getAllStatusMock = vi.fn(async (endpointIds: number[]) => { + const status: Record< + number, + { + failureCount: number; + lastFailureTime: number | null; + circuitState: "closed" | "open" | "half-open"; + circuitOpenUntil: number | null; + halfOpenSuccessCount: number; + } + > = {}; + + for (const endpointId of endpointIds) { + status[endpointId] = { + failureCount: 0, + lastFailureTime: null, + circuitState: endpointId === 2 ? "open" : "closed", + circuitOpenUntil: endpointId === 2 ? Date.now() + 60_000 : null, + halfOpenSuccessCount: 0, + }; + } + + return status; + }); vi.doMock("@/repository", () => ({ - findProviderEndpointsByVendorAndType: findMock, + findEnabledProviderEndpointsByVendorAndType: findMock, + findProviderEndpointsByVendorAndType: vi.fn(async () => []), })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - isEndpointCircuitOpen: isOpenMock, + getAllEndpointHealthStatusAsync: getAllStatusMock, })); vi.doMock("@/lib/config/env.schema", () => ({ getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), @@ -124,7 +151,7 @@ describe("provider-endpoints: endpoint-selector", () => { }); expect(findMock).toHaveBeenCalledWith(123, "claude"); - expect(isOpenMock.mock.calls.map((c) => c[0])).toEqual([1, 2, 3]); + expect(getAllStatusMock).toHaveBeenCalledWith([1, 2, 3]); expect(result.map((e) => e.id)).toEqual([1, 3]); const best = await pickBestProviderEndpoint({ vendorId: 123, providerType: "claude" }); @@ -134,14 +161,15 @@ describe("provider-endpoints: endpoint-selector", () => { test("getPreferredProviderEndpoints 过滤后无候选时返回空数组", async () => { vi.resetModules(); - const findMock = vi.fn(async () => [makeEndpoint({ id: 1, isEnabled: false })]); - const isOpenMock = vi.fn(async () => false); + const findMock = vi.fn(async () => []); + const getAllStatusMock = vi.fn(async () => ({})); vi.doMock("@/repository", () => ({ - findProviderEndpointsByVendorAndType: findMock, + findEnabledProviderEndpointsByVendorAndType: findMock, + findProviderEndpointsByVendorAndType: vi.fn(async () => []), })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - isEndpointCircuitOpen: isOpenMock, + getAllEndpointHealthStatusAsync: getAllStatusMock, })); vi.doMock("@/lib/config/env.schema", () => ({ getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), @@ -157,7 +185,7 @@ describe("provider-endpoints: endpoint-selector", () => { const best = await pickBestProviderEndpoint({ vendorId: 1, providerType: "claude" }); expect(best).toBeNull(); - expect(isOpenMock).not.toHaveBeenCalled(); + expect(getAllStatusMock).not.toHaveBeenCalled(); }); }); @@ -174,14 +202,36 @@ describe("getEndpointFilterStats", () => { ]; const findMock = vi.fn(async () => endpoints); - // id=2 is circuit open - const isOpenMock = vi.fn(async (endpointId: number) => endpointId === 2); + const getAllStatusMock = vi.fn(async (endpointIds: number[]) => { + const status: Record< + number, + { + failureCount: number; + lastFailureTime: number | null; + circuitState: "closed" | "open" | "half-open"; + circuitOpenUntil: number | null; + halfOpenSuccessCount: number; + } + > = {}; + + for (const endpointId of endpointIds) { + status[endpointId] = { + failureCount: 0, + lastFailureTime: null, + circuitState: endpointId === 2 ? "open" : "closed", + circuitOpenUntil: endpointId === 2 ? Date.now() + 60_000 : null, + halfOpenSuccessCount: 0, + }; + } + + return status; + }); vi.doMock("@/repository", () => ({ findProviderEndpointsByVendorAndType: findMock, })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - isEndpointCircuitOpen: isOpenMock, + getAllEndpointHealthStatusAsync: getAllStatusMock, })); vi.doMock("@/lib/config/env.schema", () => ({ getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), @@ -191,6 +241,7 @@ describe("getEndpointFilterStats", () => { const stats = await getEndpointFilterStats({ vendorId: 10, providerType: "claude" }); expect(findMock).toHaveBeenCalledWith(10, "claude"); + expect(getAllStatusMock).toHaveBeenCalledWith([1, 2, 3]); expect(stats).toEqual({ total: 5, // all endpoints enabled: 3, // id=1,2,3 (isEnabled && !deletedAt) @@ -203,13 +254,13 @@ describe("getEndpointFilterStats", () => { vi.resetModules(); const findMock = vi.fn(async () => []); - const isOpenMock = vi.fn(async () => false); + const getAllStatusMock = vi.fn(async () => ({})); vi.doMock("@/repository", () => ({ findProviderEndpointsByVendorAndType: findMock, })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - isEndpointCircuitOpen: isOpenMock, + getAllEndpointHealthStatusAsync: getAllStatusMock, })); vi.doMock("@/lib/config/env.schema", () => ({ getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), @@ -224,7 +275,7 @@ describe("getEndpointFilterStats", () => { circuitOpen: 0, available: 0, }); - expect(isOpenMock).not.toHaveBeenCalled(); + expect(getAllStatusMock).not.toHaveBeenCalled(); }); test("should count all enabled endpoints as circuitOpen when all are open", async () => { @@ -236,13 +287,37 @@ describe("getEndpointFilterStats", () => { ]; const findMock = vi.fn(async () => endpoints); - const isOpenMock = vi.fn(async () => true); + const getAllStatusMock = vi.fn(async (endpointIds: number[]) => { + const status: Record< + number, + { + failureCount: number; + lastFailureTime: number | null; + circuitState: "closed" | "open" | "half-open"; + circuitOpenUntil: number | null; + halfOpenSuccessCount: number; + } + > = {}; + + for (const endpointId of endpointIds) { + status[endpointId] = { + failureCount: 0, + lastFailureTime: null, + circuitState: "open", + circuitOpenUntil: Date.now() + 60_000, + halfOpenSuccessCount: 0, + }; + } + + return status; + }); vi.doMock("@/repository", () => ({ + findEnabledProviderEndpointsByVendorAndType: vi.fn(async () => []), findProviderEndpointsByVendorAndType: findMock, })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - isEndpointCircuitOpen: isOpenMock, + getAllEndpointHealthStatusAsync: getAllStatusMock, })); vi.doMock("@/lib/config/env.schema", () => ({ getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }), @@ -251,6 +326,7 @@ describe("getEndpointFilterStats", () => { const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector"); const stats = await getEndpointFilterStats({ vendorId: 1, providerType: "openai-compatible" }); + expect(getAllStatusMock).toHaveBeenCalledWith([1, 2]); expect(stats).toEqual({ total: 2, enabled: 2, @@ -273,13 +349,14 @@ describe("ENABLE_ENDPOINT_CIRCUIT_BREAKER disabled", () => { ]; const findMock = vi.fn(async () => endpoints); - const isOpenMock = vi.fn(async () => true); + const getAllStatusMock = vi.fn(async () => ({})); vi.doMock("@/repository", () => ({ - findProviderEndpointsByVendorAndType: findMock, + findEnabledProviderEndpointsByVendorAndType: findMock, + findProviderEndpointsByVendorAndType: vi.fn(async () => []), })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - isEndpointCircuitOpen: isOpenMock, + getAllEndpointHealthStatusAsync: getAllStatusMock, })); vi.doMock("@/lib/config/env.schema", () => ({ getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }), @@ -294,7 +371,7 @@ describe("ENABLE_ENDPOINT_CIRCUIT_BREAKER disabled", () => { providerType: "claude", }); - expect(isOpenMock).not.toHaveBeenCalled(); + expect(getAllStatusMock).not.toHaveBeenCalled(); // All enabled, non-deleted endpoints returned (id=1,2,3), ranked by sortOrder/health expect(result.map((e) => e.id)).toEqual([1, 2, 3]); }); @@ -310,13 +387,13 @@ describe("ENABLE_ENDPOINT_CIRCUIT_BREAKER disabled", () => { ]; const findMock = vi.fn(async () => endpoints); - const isOpenMock = vi.fn(async () => true); + const getAllStatusMock = vi.fn(async () => ({})); vi.doMock("@/repository", () => ({ findProviderEndpointsByVendorAndType: findMock, })); vi.doMock("@/lib/endpoint-circuit-breaker", () => ({ - isEndpointCircuitOpen: isOpenMock, + getAllEndpointHealthStatusAsync: getAllStatusMock, })); vi.doMock("@/lib/config/env.schema", () => ({ getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }), @@ -325,7 +402,7 @@ describe("ENABLE_ENDPOINT_CIRCUIT_BREAKER disabled", () => { const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector"); const stats = await getEndpointFilterStats({ vendorId: 10, providerType: "claude" }); - expect(isOpenMock).not.toHaveBeenCalled(); + expect(getAllStatusMock).not.toHaveBeenCalled(); expect(stats).toEqual({ total: 4, enabled: 2, // id=1,2 (isEnabled && !deletedAt) diff --git a/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts b/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts index d1ba2de29..04ed13cf8 100644 --- a/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts +++ b/tests/unit/lib/provider-endpoints/probe-scheduler.test.ts @@ -54,6 +54,7 @@ vi.mock("@/lib/provider-endpoints/leader-lock", () => ({ acquireLeaderLock: (...args: unknown[]) => acquireLeaderLockMock(...args), renewLeaderLock: (...args: unknown[]) => renewLeaderLockMock(...args), releaseLeaderLock: (...args: unknown[]) => releaseLeaderLockMock(...args), + startLeaderLockKeepAlive: () => ({ stop: () => {} }), })); vi.mock("@/repository", () => ({ diff --git a/tests/unit/lib/ttl-map.test.ts b/tests/unit/lib/ttl-map.test.ts new file mode 100644 index 000000000..00c920bdc --- /dev/null +++ b/tests/unit/lib/ttl-map.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TTLMap } from "@/lib/cache/ttl-map"; + +describe("TTLMap", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should return undefined for missing key", () => { + const map = new TTLMap({ ttlMs: 1000, maxSize: 10 }); + expect(map.get("missing")).toBeUndefined(); + }); + + it("should store and retrieve a value", () => { + const map = new TTLMap({ ttlMs: 1000, maxSize: 10 }); + map.set("a", 42); + expect(map.get("a")).toBe(42); + expect(map.size).toBe(1); + }); + + it("should return undefined for expired key", () => { + const map = new TTLMap({ ttlMs: 1000, maxSize: 10 }); + map.set("a", 42); + vi.advanceTimersByTime(1001); + expect(map.get("a")).toBeUndefined(); + }); + + it("should not return expired key via has()", () => { + const map = new TTLMap({ ttlMs: 1000, maxSize: 10 }); + map.set("a", 42); + vi.advanceTimersByTime(1001); + expect(map.has("a")).toBe(false); + }); + + it("should bump LRU order on get", () => { + const map = new TTLMap({ ttlMs: 10000, maxSize: 3 }); + map.set("a", 1); + map.set("b", 2); + map.set("c", 3); + + // Access "a" to bump it (it was oldest) + map.get("a"); + + // Insert "d" - should evict "b" (oldest after bump), not "a" + map.set("d", 4); + expect(map.has("a")).toBe(true); + expect(map.has("b")).toBe(false); + expect(map.has("c")).toBe(true); + expect(map.has("d")).toBe(true); + }); + + it("should evict expired entries first when at capacity", () => { + const map = new TTLMap({ ttlMs: 1000, maxSize: 3 }); + map.set("a", 1); + map.set("b", 2); + map.set("c", 3); + + // Expire all entries + vi.advanceTimersByTime(1001); + + // Should evict expired entries, making room + map.set("d", 4); + expect(map.size).toBe(1); + expect(map.get("d")).toBe(4); + }); + + it("should evict oldest 10% when at capacity with no expired entries", () => { + const map = new TTLMap({ ttlMs: 100000, maxSize: 10 }); + for (let i = 0; i < 10; i++) { + map.set(`key-${i}`, i); + } + expect(map.size).toBe(10); + + // Insert one more - should evict at least 1 (10% of 10 = 1) + map.set("new", 99); + expect(map.size).toBeLessThanOrEqual(10); + expect(map.get("new")).toBe(99); + // Oldest key should be evicted + expect(map.has("key-0")).toBe(false); + }); + + it("should delete an existing key", () => { + const map = new TTLMap({ ttlMs: 1000, maxSize: 10 }); + map.set("a", 1); + expect(map.delete("a")).toBe(true); + expect(map.get("a")).toBeUndefined(); + expect(map.size).toBe(0); + }); + + it("should return false when deleting non-existent key", () => { + const map = new TTLMap({ ttlMs: 1000, maxSize: 10 }); + expect(map.delete("missing")).toBe(false); + }); + + it("should update existing key with new value and reset TTL", () => { + const map = new TTLMap({ ttlMs: 1000, maxSize: 10 }); + map.set("a", 1); + vi.advanceTimersByTime(800); + map.set("a", 2); + vi.advanceTimersByTime(800); + // Should still be alive (TTL was reset on second set) + expect(map.get("a")).toBe(2); + }); +}); diff --git a/tests/unit/lib/use-in-view-once.test.tsx b/tests/unit/lib/use-in-view-once.test.tsx new file mode 100644 index 000000000..9b028a833 --- /dev/null +++ b/tests/unit/lib/use-in-view-once.test.tsx @@ -0,0 +1,167 @@ +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +function render(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(node); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +describe("useInViewOnce", () => { + const originalNodeEnv = process.env.NODE_ENV; + const originalIntersectionObserver = globalThis.IntersectionObserver; + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + globalThis.IntersectionObserver = originalIntersectionObserver; + vi.resetModules(); + }); + + it("在 test 环境下应直接视为可见,并避免创建 IntersectionObserver", async () => { + process.env.NODE_ENV = "test"; + + const ioCtor = vi.fn(function (this: any) { + this.observe = vi.fn(); + this.unobserve = vi.fn(); + this.disconnect = vi.fn(); + }); + globalThis.IntersectionObserver = ioCtor as any; + + const { useEffect } = await import("react"); + const { useInViewOnce } = await import("@/lib/hooks/use-in-view-once"); + + let lastValue = false; + + function Probe() { + const { ref, isInView } = useInViewOnce(); + useEffect(() => { + lastValue = isInView; + }, [isInView]); + + return
; + } + + const { unmount } = render(); + await act(async () => {}); + + expect(lastValue).toBe(true); + expect(ioCtor).not.toHaveBeenCalled(); + + unmount(); + }); + + it("应复用共享 observer,并在最后一个 target 解绑后释放资源", async () => { + process.env.NODE_ENV = "development"; + + type MockEntry = { target: Element; isIntersecting: boolean }; + + class MockIntersectionObserver { + static instances: MockIntersectionObserver[] = []; + + private readonly callback: (entries: MockEntry[]) => void; + readonly observed = new Set(); + readonly observe = vi.fn((target: Element) => { + this.observed.add(target); + }); + readonly unobserve = vi.fn((target: Element) => { + this.observed.delete(target); + }); + readonly disconnect = vi.fn(() => { + this.observed.clear(); + }); + + constructor(callback: (entries: MockEntry[]) => void) { + this.callback = callback; + MockIntersectionObserver.instances.push(this); + } + + trigger(target: Element, isIntersecting: boolean) { + if (!this.observed.has(target)) return; + this.callback([{ target, isIntersecting }]); + } + } + + globalThis.IntersectionObserver = MockIntersectionObserver as any; + + const { useCallback, useEffect } = await import("react"); + const { useInViewOnce } = await import("@/lib/hooks/use-in-view-once"); + + let node1: Element | null = null; + let node2: Element | null = null; + let inView1 = false; + let inView2 = false; + + function Probe(props: { + onNode: (node: Element | null) => void; + onView: (value: boolean) => void; + }) { + const { ref, isInView } = useInViewOnce(); + const { onNode, onView } = props; + const mergedRef = useCallback( + (node: HTMLDivElement | null) => { + ref(node); + onNode(node); + }, + [onNode, ref] + ); + + useEffect(() => { + onView(isInView); + }, [isInView, onView]); + + return
; + } + + const { unmount } = render( +
+ (node1 = node)} onView={(value) => (inView1 = value)} /> + (node2 = node)} onView={(value) => (inView2 = value)} /> +
+ ); + await act(async () => {}); + + expect(MockIntersectionObserver.instances).toHaveLength(1); + const io = MockIntersectionObserver.instances[0]; + expect(io.observe).toHaveBeenCalledTimes(2); + + expect(node1).not.toBeNull(); + expect(node2).not.toBeNull(); + + act(() => { + io.trigger(node1 as Element, true); + }); + await act(async () => {}); + + expect(inView1).toBe(true); + expect(io.unobserve).toHaveBeenCalledWith(node1); + expect(io.disconnect).not.toHaveBeenCalled(); + expect(io.observed.has(node1 as Element)).toBe(false); + expect(io.observed.has(node2 as Element)).toBe(true); + + act(() => { + io.trigger(node2 as Element, true); + }); + await act(async () => {}); + + expect(inView2).toBe(true); + expect(io.unobserve).toHaveBeenCalledWith(node2); + expect(io.disconnect).toHaveBeenCalledTimes(1); + expect(io.observed.size).toBe(0); + + unmount(); + }); +}); diff --git a/tests/unit/repository/provider-endpoint-sync-helper.test.ts b/tests/unit/repository/provider-endpoint-sync-helper.test.ts index 55ab6803c..b7b5a8690 100644 --- a/tests/unit/repository/provider-endpoint-sync-helper.test.ts +++ b/tests/unit/repository/provider-endpoint-sync-helper.test.ts @@ -6,7 +6,8 @@ function createTxMock(selectResults: SelectRow[][]) { const queue = [...selectResults]; const selectLimitMock = vi.fn(async () => queue.shift() ?? []); - const selectWhereMock = vi.fn(() => ({ limit: selectLimitMock })); + const selectOrderByMock = vi.fn(() => ({ limit: selectLimitMock })); + const selectWhereMock = vi.fn(() => ({ orderBy: selectOrderByMock, limit: selectLimitMock })); const selectFromMock = vi.fn(() => ({ where: selectWhereMock })); const selectMock = vi.fn(() => ({ from: selectFromMock })); @@ -221,7 +222,7 @@ describe("syncProviderEndpointOnProviderEdit", () => { expect(resetEndpointCircuitMock).not.toHaveBeenCalled(); }); - test("in-place move unique conflict should fallback to conservative keep-previous behavior", async () => { + test("in-place move unique conflict should soft-delete previous endpoint when unreferenced", async () => { const oldUrl = "https://old.example.com/v1/messages"; const newUrl = "https://new.example.com/v1/messages"; const { syncProviderEndpointOnProviderEdit, updatePayloads, mocks, resetEndpointCircuitMock } = @@ -250,18 +251,24 @@ describe("syncProviderEndpointOnProviderEdit", () => { keepPreviousWhenReferenced: true, }); - expect(result).toEqual({ action: "kept-previous-and-kept-next" }); + expect(result).toEqual({ action: "soft-deleted-previous-and-kept-next" }); expect(mocks.insertMock).toHaveBeenCalledTimes(1); - expect(mocks.updateMock).toHaveBeenCalledTimes(1); + expect(mocks.updateMock).toHaveBeenCalledTimes(2); expect(updatePayloads[0]).toEqual( expect.objectContaining({ url: newUrl, }) ); + expect(updatePayloads[1]).toEqual( + expect.objectContaining({ + deletedAt: expect.any(Date), + isEnabled: false, + }) + ); expect(resetEndpointCircuitMock).not.toHaveBeenCalled(); }); - test("when next endpoint already exists, should keep previous endpoint under conservative policy", async () => { + test("when next endpoint already exists, should soft-delete previous endpoint when unreferenced", async () => { const oldUrl = "https://old.example.com/v1/messages"; const newUrl = "https://new.example.com/v1/messages"; const { syncProviderEndpointOnProviderEdit, mocks, resetEndpointCircuitMock } = @@ -283,8 +290,8 @@ describe("syncProviderEndpointOnProviderEdit", () => { keepPreviousWhenReferenced: true, }); - expect(result).toEqual({ action: "kept-previous-and-kept-next" }); - expect(mocks.updateMock).not.toHaveBeenCalled(); + expect(result).toEqual({ action: "soft-deleted-previous-and-kept-next" }); + expect(mocks.updateMock).toHaveBeenCalledTimes(1); expect(mocks.insertMock).not.toHaveBeenCalled(); expect(resetEndpointCircuitMock).not.toHaveBeenCalled(); }); diff --git a/tests/unit/repository/provider-endpoints-probe-result.test.ts b/tests/unit/repository/provider-endpoints-probe-result.test.ts new file mode 100644 index 000000000..31c8b3444 --- /dev/null +++ b/tests/unit/repository/provider-endpoints-probe-result.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test, vi } from "vitest"; + +describe("provider-endpoints repository - recordProviderEndpointProbeResult", () => { + test("endpoint 不存在/已删除时应静默忽略(不写 probe log)", async () => { + vi.resetModules(); + + const returningMock = vi.fn(async () => []); + const whereMock = vi.fn(() => ({ returning: returningMock })); + const setMock = vi.fn(() => ({ where: whereMock })); + const updateMock = vi.fn(() => ({ set: setMock })); + + const valuesMock = vi.fn(async () => {}); + const insertMock = vi.fn(() => ({ values: valuesMock })); + + const transactionMock = vi.fn(async (fn: (tx: any) => Promise) => { + return fn({ update: updateMock, insert: insertMock }); + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + transaction: transactionMock, + }, + })); + + const { recordProviderEndpointProbeResult } = await import("@/repository/provider-endpoints"); + + await expect( + recordProviderEndpointProbeResult({ + endpointId: 123, + source: "scheduled", + ok: true, + statusCode: 200, + latencyMs: 10, + errorType: null, + errorMessage: null, + probedAt: new Date("2026-01-01T00:00:00.000Z"), + }) + ).resolves.toBeUndefined(); + + expect(updateMock).toHaveBeenCalledTimes(1); + expect(insertMock).not.toHaveBeenCalled(); + expect(valuesMock).not.toHaveBeenCalled(); + }); + + test("endpoint 存在时应同时更新 snapshot + 写入 probe log", async () => { + vi.resetModules(); + + const returningMock = vi.fn(async () => [{ id: 123 }]); + const whereMock = vi.fn(() => ({ returning: returningMock })); + const setMock = vi.fn(() => ({ where: whereMock })); + const updateMock = vi.fn(() => ({ set: setMock })); + + const valuesMock = vi.fn(async () => {}); + const insertMock = vi.fn(() => ({ values: valuesMock })); + + const transactionMock = vi.fn(async (fn: (tx: any) => Promise) => { + return fn({ update: updateMock, insert: insertMock }); + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + transaction: transactionMock, + }, + })); + + const { recordProviderEndpointProbeResult } = await import("@/repository/provider-endpoints"); + + await recordProviderEndpointProbeResult({ + endpointId: 123, + source: "manual", + ok: false, + statusCode: 503, + latencyMs: 999, + errorType: "http_5xx", + errorMessage: "HTTP 503", + probedAt: new Date("2026-01-01T00:00:00.000Z"), + }); + + expect(updateMock).toHaveBeenCalledTimes(1); + expect(insertMock).toHaveBeenCalledTimes(1); + expect(valuesMock).toHaveBeenCalledTimes(1); + expect(valuesMock).toHaveBeenCalledWith( + expect.objectContaining({ + endpointId: 123, + source: "manual", + ok: false, + statusCode: 503, + latencyMs: 999, + errorType: "http_5xx", + errorMessage: "HTTP 503", + }) + ); + }); +}); diff --git a/tests/unit/repository/statistics-quota-costs-all-time.test.ts b/tests/unit/repository/statistics-quota-costs-all-time.test.ts new file mode 100644 index 000000000..9d2763a13 --- /dev/null +++ b/tests/unit/repository/statistics-quota-costs-all-time.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { QuotaCostRanges } from "@/repository/statistics"; + +function concatSqlStringChunks(sqlObject: unknown): string { + if (!sqlObject || typeof sqlObject !== "object") return ""; + + const maybeSql = sqlObject as { queryChunks?: unknown[] }; + if (!Array.isArray(maybeSql.queryChunks)) return ""; + + return maybeSql.queryChunks + .filter((chunk): chunk is string => typeof chunk === "string") + .join(""); +} + +function createRanges(): QuotaCostRanges { + const base = new Date("2026-01-01T00:00:00.000Z"); + const plus = (ms: number) => new Date(base.getTime() + ms); + + return { + range5h: { startTime: base, endTime: plus(5 * 60 * 60 * 1000) }, + rangeDaily: { startTime: base, endTime: plus(24 * 60 * 60 * 1000) }, + rangeWeekly: { startTime: base, endTime: plus(7 * 24 * 60 * 60 * 1000) }, + rangeMonthly: { startTime: base, endTime: plus(30 * 24 * 60 * 60 * 1000) }, + }; +} + +describe("sumUserQuotaCosts & sumKeyQuotaCostsById - all-time query support", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("sumUserQuotaCosts should treat Infinity as all-time (no created_at cutoff)", async () => { + const ranges = createRanges(); + let capturedAndArgs: unknown[] | undefined; + let capturedSelectFields: Record | undefined; + + vi.doMock("drizzle-orm", async () => { + const actual = await vi.importActual("drizzle-orm"); + return { + ...actual, + and: (...args: unknown[]) => { + capturedAndArgs = args; + return (actual as any).and(...args); + }, + }; + }); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: vi.fn().mockImplementation((fields: Record) => { + capturedSelectFields = fields; + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([ + { + cost5h: "0", + costDaily: "0", + costWeekly: "0", + costMonthly: "0", + costTotal: "0", + }, + ]), + }), + }; + }), + }, + })); + + const { sumUserQuotaCosts } = await import("@/repository/statistics"); + await sumUserQuotaCosts(1, ranges, Infinity); + + expect(capturedAndArgs).toBeDefined(); + expect(capturedAndArgs?.length).toBe(4); + + expect(capturedSelectFields).toBeDefined(); + expect(concatSqlStringChunks(capturedSelectFields?.costTotal)).not.toContain("FILTER"); + }); + + it("sumKeyQuotaCostsById should treat Infinity as all-time (no created_at cutoff)", async () => { + const ranges = createRanges(); + let capturedAndArgs: unknown[] | undefined; + let capturedSelectFields: Record | undefined; + + vi.doMock("drizzle-orm", async () => { + const actual = await vi.importActual("drizzle-orm"); + return { + ...actual, + and: (...args: unknown[]) => { + capturedAndArgs = args; + return (actual as any).and(...args); + }, + }; + }); + + let selectCallIndex = 0; + vi.doMock("@/drizzle/db", () => ({ + db: { + select: vi.fn().mockImplementation((fields: Record) => { + selectCallIndex += 1; + const currentCallIndex = selectCallIndex; + + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockImplementation(() => { + if (currentCallIndex === 1) { + return { + limit: vi.fn().mockResolvedValue([{ key: "test-key-string" }]), + }; + } + + capturedSelectFields = fields; + return Promise.resolve([ + { + cost5h: "0", + costDaily: "0", + costWeekly: "0", + costMonthly: "0", + costTotal: "0", + }, + ]); + }), + }), + }; + }), + }, + })); + + const { sumKeyQuotaCostsById } = await import("@/repository/statistics"); + await sumKeyQuotaCostsById(123, ranges, Infinity); + + expect(capturedAndArgs).toBeDefined(); + expect(capturedAndArgs?.length).toBe(4); + + expect(capturedSelectFields).toBeDefined(); + expect(concatSqlStringChunks(capturedSelectFields?.costTotal)).not.toContain("FILTER"); + }); +}); diff --git a/tests/unit/settings/providers/endpoint-latency-sparkline-ui.test.tsx b/tests/unit/settings/providers/endpoint-latency-sparkline-ui.test.tsx index 019b21ec4..3cf65a2e4 100644 --- a/tests/unit/settings/providers/endpoint-latency-sparkline-ui.test.tsx +++ b/tests/unit/settings/providers/endpoint-latency-sparkline-ui.test.tsx @@ -89,7 +89,10 @@ describe("EndpointLatencySparkline", () => { await flushTicks(5); expect(container.querySelector('[data-testid="recharts-line"]')).toBeNull(); - expect(container.querySelector('div[class*="bg-muted/20"]')).not.toBeNull(); + const placeholder = + container.querySelector('div[class*="bg-muted/20"]') ?? + container.querySelector('div[class*="bg-muted/10"]'); + expect(placeholder).not.toBeNull(); unmount(); }); diff --git a/tests/unit/settings/providers/provider-rich-list-item-endpoints.test.tsx b/tests/unit/settings/providers/provider-rich-list-item-endpoints.test.tsx index d7aaba153..65a2bb49c 100644 --- a/tests/unit/settings/providers/provider-rich-list-item-endpoints.test.tsx +++ b/tests/unit/settings/providers/provider-rich-list-item-endpoints.test.tsx @@ -217,6 +217,15 @@ describe("ProviderRichListItem Endpoint Display", () => { const { unmount } = renderWithProviders( From f1a5167b47cafb00576be85d13bb4506dd3780a7 Mon Sep 17 00:00:00 2001 From: ding113 Date: Mon, 16 Feb 2026 01:36:34 +0800 Subject: [PATCH 04/75] fix(repository): wrap SQL interval arithmetic in parentheses and cast endpoint id to integer Interval additions/subtractions in template literals lacked grouping parentheses, causing incorrect evaluation when composed with AT TIME ZONE. The VALUES list for endpoint IDs also needed an explicit integer cast to avoid type ambiguity in the LATERAL join. --- src/repository/leaderboard.ts | 6 +++--- src/repository/overview.ts | 4 ++-- src/repository/provider-endpoints-batch.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index 66e2981ed..ed63b959f 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -174,7 +174,7 @@ function buildDateCondition( return sql`1=1`; case "daily": { const startLocal = sql`DATE_TRUNC('day', ${nowLocal})`; - const endExclusiveLocal = sql`${startLocal} + INTERVAL '1 day'`; + const endExclusiveLocal = sql`(${startLocal} + INTERVAL '1 day')`; const start = sql`(${startLocal} AT TIME ZONE ${timezone})`; const endExclusive = sql`(${endExclusiveLocal} AT TIME ZONE ${timezone})`; return sql`${messageRequest.createdAt} >= ${start} AND ${messageRequest.createdAt} < ${endExclusive}`; @@ -183,14 +183,14 @@ function buildDateCondition( return sql`${messageRequest.createdAt} >= (CURRENT_TIMESTAMP - INTERVAL '24 hours')`; case "weekly": { const startLocal = sql`DATE_TRUNC('week', ${nowLocal})`; - const endExclusiveLocal = sql`${startLocal} + INTERVAL '1 week'`; + const endExclusiveLocal = sql`(${startLocal} + INTERVAL '1 week')`; const start = sql`(${startLocal} AT TIME ZONE ${timezone})`; const endExclusive = sql`(${endExclusiveLocal} AT TIME ZONE ${timezone})`; return sql`${messageRequest.createdAt} >= ${start} AND ${messageRequest.createdAt} < ${endExclusive}`; } case "monthly": { const startLocal = sql`DATE_TRUNC('month', ${nowLocal})`; - const endExclusiveLocal = sql`${startLocal} + INTERVAL '1 month'`; + const endExclusiveLocal = sql`(${startLocal} + INTERVAL '1 month')`; const start = sql`(${startLocal} AT TIME ZONE ${timezone})`; const endExclusive = sql`(${endExclusiveLocal} AT TIME ZONE ${timezone})`; return sql`${messageRequest.createdAt} >= ${start} AND ${messageRequest.createdAt} < ${endExclusive}`; diff --git a/src/repository/overview.ts b/src/repository/overview.ts index 699e584b3..c1cfaeb84 100644 --- a/src/repository/overview.ts +++ b/src/repository/overview.ts @@ -98,9 +98,9 @@ export async function getOverviewMetricsWithComparison( const todayStartLocal = sql`DATE_TRUNC('day', ${nowLocal})`; const todayStart = sql`(${todayStartLocal} AT TIME ZONE ${timezone})`; const tomorrowStart = sql`((${todayStartLocal} + INTERVAL '1 day') AT TIME ZONE ${timezone})`; - const yesterdayStartLocal = sql`${todayStartLocal} - INTERVAL '1 day'`; + const yesterdayStartLocal = sql`(${todayStartLocal} - INTERVAL '1 day')`; const yesterdayStart = sql`(${yesterdayStartLocal} AT TIME ZONE ${timezone})`; - const yesterdayEndLocal = sql`${yesterdayStartLocal} + (${nowLocal} - ${todayStartLocal})`; + const yesterdayEndLocal = sql`(${yesterdayStartLocal} + (${nowLocal} - ${todayStartLocal}))`; const yesterdayEnd = sql`(${yesterdayEndLocal} AT TIME ZONE ${timezone})`; // 用户过滤条件 diff --git a/src/repository/provider-endpoints-batch.ts b/src/repository/provider-endpoints-batch.ts index a52e89dc4..3088116cf 100644 --- a/src/repository/provider-endpoints-batch.ts +++ b/src/repository/provider-endpoints-batch.ts @@ -83,7 +83,7 @@ export async function findProviderEndpointProbeLogsBatch(input: { // 改为 LATERAL + LIMIT:每个 endpoint_id 仅取最新 N 条,能更好利用 (endpoint_id, created_at desc) 索引。 // 安全:VALUES 列表使用 drizzle sql 参数化占位符拼接(不会把 endpointId 作为 raw 字符串注入)。 const endpointValues = sql.join( - endpointIds.map((id) => sql`(${id})`), + endpointIds.map((id) => sql`(${id}::integer)`), sql`, ` ); From 2290bad1bae7315e0a0e540aca668e0975ca6daf Mon Sep 17 00:00:00 2001 From: ding113 Date: Mon, 16 Feb 2026 09:44:09 +0800 Subject: [PATCH 05/75] test(repository): add regression tests for SQL timezone parentheses and integer cast bugs Cover three PostgreSQL runtime errors caused by operator precedence and type inference issues in raw SQL expressions: - Leaderboard date conditions missing parentheses around INTERVAL arithmetic before AT TIME ZONE, triggering pg_catalog.timezone error - Overview comparison queries with the same parenthesization problem on yesterdayStartLocal / yesterdayEndLocal expressions - Provider endpoints batch CTE VALUES inferred as text, causing "integer = text" mismatch on LATERAL join; validated ::integer cast --- .../leaderboard-timezone-parentheses.test.ts | 178 ++++++++++++++++++ .../overview-timezone-parentheses.test.ts | 177 +++++++++++++++++ ...vider-endpoints-batch-integer-cast.test.ts | 128 +++++++++++++ 3 files changed, 483 insertions(+) create mode 100644 tests/unit/repository/leaderboard-timezone-parentheses.test.ts create mode 100644 tests/unit/repository/overview-timezone-parentheses.test.ts create mode 100644 tests/unit/repository/provider-endpoints-batch-integer-cast.test.ts diff --git a/tests/unit/repository/leaderboard-timezone-parentheses.test.ts b/tests/unit/repository/leaderboard-timezone-parentheses.test.ts new file mode 100644 index 000000000..401535051 --- /dev/null +++ b/tests/unit/repository/leaderboard-timezone-parentheses.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Regression test for: function pg_catalog.timezone(unknown, interval) does not exist + * + * PostgreSQL's `AT TIME ZONE` has higher precedence than `+` / `-`. + * Without parentheses, `expr + INTERVAL '1 day' AT TIME ZONE tz` is parsed as + * `expr + (INTERVAL '1 day' AT TIME ZONE tz)`, which applies AT TIME ZONE to + * an INTERVAL -- an invalid operation. + * + * The fix wraps arithmetic in parentheses: `(expr + INTERVAL '1 day') AT TIME ZONE tz`. + */ + +function sqlToString(sqlObj: unknown): string { + const visited = new Set(); + + const walk = (node: unknown): string => { + if (!node || visited.has(node)) return ""; + visited.add(node); + + if (typeof node === "string") return node; + if (typeof node === "number") return String(node); + + if (typeof node === "object") { + const anyNode = node as Record; + if (Array.isArray(anyNode)) { + return anyNode.map(walk).join(""); + } + + if (anyNode.value !== undefined) { + if (Array.isArray(anyNode.value)) { + return (anyNode.value as unknown[]).map(walk).join(""); + } + return walk(anyNode.value); + } + + if (anyNode.queryChunks) { + return walk(anyNode.queryChunks); + } + } + + return ""; + }; + + return walk(sqlObj); +} + +const mocks = vi.hoisted(() => ({ + resolveSystemTimezone: vi.fn(), +})); + +function createThenableQuery(result: T, whereArgs?: unknown[]) { + const query: any = Promise.resolve(result); + query.from = vi.fn(() => query); + query.innerJoin = vi.fn(() => query); + query.leftJoin = vi.fn(() => query); + query.orderBy = vi.fn(() => query); + query.limit = vi.fn(() => query); + query.offset = vi.fn(() => query); + query.groupBy = vi.fn(() => query); + query.where = vi.fn((arg: unknown) => { + whereArgs?.push(arg); + return query; + }); + return query; +} + +vi.mock("@/drizzle/db", () => { + const whereCapture: unknown[] = []; + return { + db: { + select: vi.fn(() => createThenableQuery([], whereCapture)), + }, + __whereCapture: whereCapture, + }; +}); + +vi.mock("@/drizzle/schema", () => ({ + messageRequest: { + deletedAt: "deletedAt", + providerId: "providerId", + userId: "userId", + costUsd: "costUsd", + inputTokens: "inputTokens", + outputTokens: "outputTokens", + cacheCreationInputTokens: "cacheCreationInputTokens", + cacheReadInputTokens: "cacheReadInputTokens", + errorMessage: "errorMessage", + blockedBy: "blockedBy", + createdAt: "createdAt", + ttfbMs: "ttfbMs", + durationMs: "durationMs", + statusCode: "statusCode", + model: "model", + originalModel: "originalModel", + }, + providers: { + id: "id", + name: "name", + deletedAt: "deletedAt", + providerType: "providerType", + }, + users: { + id: "id", + name: "name", + deletedAt: "deletedAt", + tags: "tags", + providerGroup: "providerGroup", + }, +})); + +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: mocks.resolveSystemTimezone, +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: vi.fn().mockResolvedValue({ billingModelSource: "redirected" }), +})); + +describe("buildDateCondition - timezone parentheses regression", () => { + let whereCapture: unknown[]; + + beforeEach(async () => { + vi.resetModules(); + mocks.resolveSystemTimezone.mockResolvedValue("Asia/Shanghai"); + + const dbModule = await import("@/drizzle/db"); + whereCapture = (dbModule as any).__whereCapture; + whereCapture.length = 0; + }); + + it("daily period: INTERVAL '1 day' must be parenthesized before AT TIME ZONE", async () => { + const { findDailyLeaderboard } = await import("@/repository/leaderboard"); + await findDailyLeaderboard(); + + expect(whereCapture.length).toBeGreaterThan(0); + const sqlStr = sqlToString(whereCapture[0]); + + // After fix: (... + INTERVAL '1 day') AT TIME ZONE + // Before fix (bug): ... + INTERVAL '1 day' AT TIME ZONE + expect(sqlStr).toContain("INTERVAL '1 day')"); + expect(sqlStr).not.toMatch(/INTERVAL '1 day' AT TIME ZONE/); + }); + + it("weekly period: INTERVAL '1 week' must be parenthesized before AT TIME ZONE", async () => { + const { findWeeklyLeaderboard } = await import("@/repository/leaderboard"); + await findWeeklyLeaderboard(); + + expect(whereCapture.length).toBeGreaterThan(0); + const sqlStr = sqlToString(whereCapture[0]); + + expect(sqlStr).toContain("INTERVAL '1 week')"); + expect(sqlStr).not.toMatch(/INTERVAL '1 week' AT TIME ZONE/); + }); + + it("monthly period: INTERVAL '1 month' must be parenthesized before AT TIME ZONE", async () => { + const { findMonthlyLeaderboard } = await import("@/repository/leaderboard"); + await findMonthlyLeaderboard(); + + expect(whereCapture.length).toBeGreaterThan(0); + const sqlStr = sqlToString(whereCapture[0]); + + expect(sqlStr).toContain("INTERVAL '1 month')"); + expect(sqlStr).not.toMatch(/INTERVAL '1 month' AT TIME ZONE/); + }); + + it("custom period: already has correct parentheses and should remain correct", async () => { + const { findCustomRangeLeaderboard } = await import("@/repository/leaderboard"); + await findCustomRangeLeaderboard({ startDate: "2026-01-01", endDate: "2026-01-31" }); + + expect(whereCapture.length).toBeGreaterThan(0); + const sqlStr = sqlToString(whereCapture[0]); + + // Custom period already had correct parentheses before the fix + expect(sqlStr).toContain("INTERVAL '1 day')"); + expect(sqlStr).not.toMatch(/INTERVAL '1 day' AT TIME ZONE/); + }); +}); diff --git a/tests/unit/repository/overview-timezone-parentheses.test.ts b/tests/unit/repository/overview-timezone-parentheses.test.ts new file mode 100644 index 000000000..4ab883b5f --- /dev/null +++ b/tests/unit/repository/overview-timezone-parentheses.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Regression test for: function pg_catalog.timezone(unknown, interval) does not exist + * + * In getOverviewMetricsWithComparison, `yesterdayStartLocal` and `yesterdayEndLocal` + * use arithmetic (`-` / `+`) with INTERVAL expressions that are later passed through + * `AT TIME ZONE`. Without parentheses, PG's operator precedence applies AT TIME ZONE + * to the INTERVAL sub-expression, which is invalid. + * + * The fix wraps the arithmetic: `(expr - INTERVAL '1 day')` and `(expr + (...))`. + */ + +function sqlToString(sqlObj: unknown): string { + const visited = new Set(); + + const walk = (node: unknown): string => { + if (!node || visited.has(node)) return ""; + visited.add(node); + + if (typeof node === "string") return node; + if (typeof node === "number") return String(node); + + if (typeof node === "object") { + const anyNode = node as Record; + if (Array.isArray(anyNode)) { + return anyNode.map(walk).join(""); + } + + if (anyNode.value !== undefined) { + if (Array.isArray(anyNode.value)) { + return (anyNode.value as unknown[]).map(walk).join(""); + } + return walk(anyNode.value); + } + + if (anyNode.queryChunks) { + return walk(anyNode.queryChunks); + } + } + + return ""; + }; + + return walk(sqlObj); +} + +const mocks = vi.hoisted(() => ({ + resolveSystemTimezone: vi.fn(), +})); + +function createThenableQuery(result: T, whereArgs?: unknown[]) { + const query: any = Promise.resolve(result); + query.from = vi.fn(() => query); + query.innerJoin = vi.fn(() => query); + query.leftJoin = vi.fn(() => query); + query.orderBy = vi.fn(() => query); + query.limit = vi.fn(() => query); + query.offset = vi.fn(() => query); + query.groupBy = vi.fn(() => query); + query.where = vi.fn((arg: unknown) => { + whereArgs?.push(arg); + return query; + }); + return query; +} + +const allWhereArgs: unknown[][] = []; + +vi.mock("@/drizzle/db", () => ({ + db: { + select: vi.fn(() => { + const whereArgs: unknown[] = []; + allWhereArgs.push(whereArgs); + return createThenableQuery( + [ + { + requestCount: 10, + totalCost: "1.5", + avgDuration: "200", + errorCount: 1, + }, + ], + whereArgs + ); + }), + }, +})); + +vi.mock("@/drizzle/schema", () => ({ + messageRequest: { + deletedAt: "deletedAt", + userId: "userId", + costUsd: "costUsd", + durationMs: "durationMs", + statusCode: "statusCode", + createdAt: "createdAt", + blockedBy: "blockedBy", + }, +})); + +vi.mock("@/lib/utils/timezone", () => ({ + resolveSystemTimezone: mocks.resolveSystemTimezone, +})); + +vi.mock("@/lib/utils/currency", () => ({ + Decimal: class FakeDecimal { + private v: number; + constructor(v: number | string) { + this.v = Number(v); + } + toDecimalPlaces() { + return this; + } + toNumber() { + return this.v; + } + }, + toCostDecimal: (v: unknown) => { + if (v === null || v === undefined) return null; + return { + toDecimalPlaces: () => ({ toNumber: () => Number(v) }), + }; + }, +})); + +describe("getOverviewMetricsWithComparison - timezone parentheses regression", () => { + beforeEach(() => { + vi.resetModules(); + allWhereArgs.length = 0; + mocks.resolveSystemTimezone.mockResolvedValue("Asia/Shanghai"); + }); + + it("yesterdayStartLocal arithmetic must be parenthesized to avoid timezone(unknown, interval)", async () => { + const { getOverviewMetricsWithComparison } = await import("@/repository/overview"); + await getOverviewMetricsWithComparison(); + + // getOverviewMetricsWithComparison fires 3 queries via Promise.all + // Query 2 (yesterday) uses yesterdayStart and yesterdayEnd + expect(allWhereArgs.length).toBe(3); + + const yesterdayWhereSql = sqlToString(allWhereArgs[1][0]); + + // yesterdayStartLocal = (todayStartLocal - INTERVAL '1 day') + // Must have closing paren after '1 day' BEFORE AT TIME ZONE + expect(yesterdayWhereSql).toContain("INTERVAL '1 day')"); + expect(yesterdayWhereSql).not.toMatch(/INTERVAL '1 day' AT TIME ZONE/); + }); + + it("yesterdayEndLocal arithmetic must be parenthesized", async () => { + const { getOverviewMetricsWithComparison } = await import("@/repository/overview"); + await getOverviewMetricsWithComparison(); + + expect(allWhereArgs.length).toBe(3); + + const yesterdayWhereSql = sqlToString(allWhereArgs[1][0]); + + // yesterdayEndLocal = (yesterdayStartLocal + (nowLocal - todayStartLocal)) + // The outer arithmetic must be wrapped in parens + // After fix the SQL should have nested parens: ((... - INTERVAL '1 day') + (...)) + // It should NOT have bare `)) AT TIME ZONE` without the outer arithmetic paren + expect(yesterdayWhereSql).toContain(") AT TIME ZONE"); + }); + + it("todayStart already has correct parentheses and should remain correct", async () => { + const { getOverviewMetricsWithComparison } = await import("@/repository/overview"); + await getOverviewMetricsWithComparison(); + + expect(allWhereArgs.length).toBe(3); + + const todayWhereSql = sqlToString(allWhereArgs[0][0]); + + // todayStartLocal uses DATE_TRUNC which doesn't need arithmetic parens + // tomorrowStart already had parens: ((todayStartLocal + INTERVAL '1 day') AT TIME ZONE tz) + expect(todayWhereSql).toContain("INTERVAL '1 day')"); + }); +}); diff --git a/tests/unit/repository/provider-endpoints-batch-integer-cast.test.ts b/tests/unit/repository/provider-endpoints-batch-integer-cast.test.ts new file mode 100644 index 000000000..fe7dbba96 --- /dev/null +++ b/tests/unit/repository/provider-endpoints-batch-integer-cast.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Regression test for: operator does not exist: integer = text + * + * The CTE `VALUES ($1), ($2)` generated by Drizzle infers columns as text. + * The LATERAL join then compares integer (table column) to text (CTE column), + * which PostgreSQL rejects. The fix adds an explicit `::integer` cast. + */ + +function sqlToString(sqlObj: unknown): string { + const visited = new Set(); + + const walk = (node: unknown): string => { + if (!node || visited.has(node)) return ""; + visited.add(node); + + if (typeof node === "string") return node; + if (typeof node === "number") return String(node); + + if (typeof node === "object") { + const anyNode = node as Record; + if (Array.isArray(anyNode)) { + return anyNode.map(walk).join(""); + } + + if (anyNode.value !== undefined) { + if (Array.isArray(anyNode.value)) { + return (anyNode.value as unknown[]).map(walk).join(""); + } + return walk(anyNode.value); + } + + if (anyNode.queryChunks) { + return walk(anyNode.queryChunks); + } + } + + return ""; + }; + + return walk(sqlObj); +} + +let capturedExecuteQuery: unknown = null; + +vi.mock("@/drizzle/db", () => ({ + db: { + execute: vi.fn((query: unknown) => { + capturedExecuteQuery = query; + return Promise.resolve([]); + }), + }, +})); + +vi.mock("@/drizzle/schema", () => ({ + providerEndpointProbeLogs: {}, + providerEndpoints: { + vendorId: "vendorId", + providerType: "providerType", + isEnabled: "isEnabled", + lastProbeOk: "lastProbeOk", + deletedAt: "deletedAt", + }, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe("findProviderEndpointProbeLogsBatch - integer cast regression", () => { + beforeEach(() => { + capturedExecuteQuery = null; + }); + + it("CTE VALUES must cast endpoint IDs to ::integer to avoid 'integer = text' error", async () => { + const { findProviderEndpointProbeLogsBatch } = await import( + "@/repository/provider-endpoints-batch" + ); + + await findProviderEndpointProbeLogsBatch({ + endpointIds: [10, 20, 30], + limitPerEndpoint: 5, + }); + + expect(capturedExecuteQuery).toBeTruthy(); + const sqlStr = sqlToString(capturedExecuteQuery); + + // The VALUES clause must contain ::integer casts to prevent PG type mismatch + expect(sqlStr).toContain("::integer"); + }); + + it("single endpoint ID also gets ::integer cast", async () => { + const { findProviderEndpointProbeLogsBatch } = await import( + "@/repository/provider-endpoints-batch" + ); + + await findProviderEndpointProbeLogsBatch({ + endpointIds: [42], + limitPerEndpoint: 1, + }); + + expect(capturedExecuteQuery).toBeTruthy(); + const sqlStr = sqlToString(capturedExecuteQuery); + + expect(sqlStr).toContain("::integer"); + }); + + it("empty endpointIds should not execute any query", async () => { + const { findProviderEndpointProbeLogsBatch } = await import( + "@/repository/provider-endpoints-batch" + ); + + const result = await findProviderEndpointProbeLogsBatch({ + endpointIds: [], + limitPerEndpoint: 5, + }); + + expect(capturedExecuteQuery).toBeNull(); + expect(result.size).toBe(0); + }); +}); From 1d164c51eaf4737afb863e768bc18840699c9827 Mon Sep 17 00:00:00 2001 From: ding113 Date: Mon, 16 Feb 2026 15:39:38 +0800 Subject: [PATCH 06/75] fix(proxy): avoid deadlock on reader.cancel() for large chunked responses On tee'd ReadableStreams backed by push-mode Node streams, awaiting reader.cancel() in readResponseTextUpTo can block indefinitely when the other branch has not started consuming yet. Switch to fire-and-forget cancel to unblock the main request path. --- src/app/v1/_lib/proxy/forwarder.ts | 14 +- ...y-forwarder-large-chunked-response.test.ts | 266 ++++++++++++++++++ 2 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 tests/unit/proxy/proxy-forwarder-large-chunked-response.test.ts diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 3f9be35a1..5ef27aa31 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -99,7 +99,8 @@ const NON_STREAM_BODY_INSPECTION_MAX_BYTES = 32 * 1024; // 32 KiB * * 注意: * - 该函数只用于启发式检测,不用于业务逻辑解析; - * - 超过上限时会 `cancel()` reader,避免继续占用资源; + * - 超过上限时会 fire-and-forget `cancel()` reader,避免继续占用资源; + * - cancel() 不使用 await,因为对 tee 分支的 cancel 在推模式大流下可能长时间阻塞; * - 调用方应使用 `response.clone()`,避免消费掉原始响应体,影响后续透传/解析。 */ async function readResponseTextUpTo( @@ -146,11 +147,14 @@ async function readResponseTextUpTo( if (flushed) chunks.push(flushed); } finally { if (truncated) { - try { - await reader.cancel(); - } catch (cancelErr) { + // Fire-and-forget: do NOT await reader.cancel() here. + // On tee'd ReadableStreams backed by push-mode Node streams (e.g. large chunked + // responses via nodeStreamToWebStreamSafe), await cancel() can block indefinitely + // because the tee controller waits for internal queue drainage while the other + // branch has not started consuming yet. This deadlocks the main request path. + reader.cancel().catch((cancelErr) => { logger.debug("readResponseTextUpTo: failed to cancel reader", { error: cancelErr }); - } + }); } try { diff --git a/tests/unit/proxy/proxy-forwarder-large-chunked-response.test.ts b/tests/unit/proxy/proxy-forwarder-large-chunked-response.test.ts new file mode 100644 index 000000000..2bdfc284b --- /dev/null +++ b/tests/unit/proxy/proxy-forwarder-large-chunked-response.test.ts @@ -0,0 +1,266 @@ +import { createServer } from "node:http"; +import type { Socket } from "node:net"; +import { describe, expect, test, vi } from "vitest"; +import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import type { Provider } from "@/types/provider"; + +const mocks = vi.hoisted(() => { + return { + isHttp2Enabled: vi.fn(async () => false), + }; +}); + +vi.mock("@/lib/config", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isHttp2Enabled: mocks.isHttp2Enabled, + }; +}); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + trace: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + }, +})); + +function createProvider(overrides: Partial = {}): Provider { + return { + id: 1, + name: "test-chunked", + url: "http://127.0.0.1:1", + key: "k", + providerVendorId: null, + isEnabled: true, + weight: 1, + priority: 0, + groupPriorities: null, + costMultiplier: 1, + groupTag: null, + providerType: "openai-compatible", + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + totalCostResetAt: null, + limitConcurrentSessions: 0, + maxRetryAttempts: null, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 1_800_000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 30_000, + streamingIdleTimeoutMs: 10_000, + requestTimeoutNonStreamingMs: 0, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: null, + tpm: 0, + rpm: 0, + rpd: 0, + cc: 0, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + }; +} + +function createSession(params?: { clientAbortSignal?: AbortSignal | null }): ProxySession { + const headers = new Headers(); + const session = Object.create(ProxySession.prototype); + + Object.assign(session, { + startTime: Date.now(), + method: "POST", + requestUrl: new URL("https://example.com/v1/chat/completions"), + headers, + originalHeaders: new Headers(headers), + headerLog: JSON.stringify(Object.fromEntries(headers.entries())), + request: { + model: "gpt-5.2", + log: "(test)", + message: { + model: "gpt-5.2", + messages: [{ role: "user", content: "hi" }], + }, + }, + userAgent: null, + context: null, + clientAbortSignal: params?.clientAbortSignal ?? null, + userName: "test-user", + authState: { success: true, user: null, key: null, apiKey: null }, + provider: null, + messageContext: null, + sessionId: null, + requestSequence: 1, + originalFormat: "claude", + providerType: null, + originalModelName: null, + originalUrlPathname: null, + providerChain: [], + cacheTtlResolved: null, + context1mApplied: false, + specialSettings: [], + cachedPriceData: undefined, + cachedBillingModelSource: undefined, + isHeaderModified: () => false, + }); + + return session as ProxySession; +} + +/** + * Start a local server that returns 200 + application/json + chunked body (no Content-Length). + * The body is larger than 32 KiB to trigger the truncated path in readResponseTextUpTo. + */ +async function startChunkedServer( + bodySize: number +): Promise<{ baseUrl: string; close: () => Promise }> { + const sockets = new Set(); + + const server = createServer((_req, res) => { + // Chunked transfer encoding: write headers without Content-Length, + // then write body in multiple chunks. + res.writeHead(200, { "content-type": "application/json" }); + + // Build a valid JSON body larger than bodySize. + // Use a simple structure: {"data":"AAAA..."} + const padding = "A".repeat(bodySize); + const body = JSON.stringify({ data: padding }); + + // Write in ~4KB chunks to simulate realistic chunked transfer + const chunkSize = 4096; + let offset = 0; + const writeNext = () => { + while (offset < body.length) { + const slice = body.slice(offset, offset + chunkSize); + offset += chunkSize; + if (!res.write(slice)) { + res.once("drain", writeNext); + return; + } + } + res.end(); + }; + writeNext(); + }); + + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + + const baseUrl = await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (!addr || typeof addr === "string") { + reject(new Error("Failed to get server address")); + return; + } + resolve(`http://127.0.0.1:${addr.port}`); + }); + }); + + const close = async () => { + for (const socket of sockets) { + try { + socket.destroy(); + } catch { + // ignore + } + } + sockets.clear(); + await new Promise((resolve) => server.close(() => resolve())); + }; + + return { baseUrl, close }; +} + +describe("ProxyForwarder - large chunked non-streaming response", () => { + test("200 + chunked + no Content-Length + >32KiB body must not hang on body inspection", async () => { + // 64 KiB body: well above the 32 KiB inspection limit to trigger truncated + cancel + const { baseUrl, close } = await startChunkedServer(64 * 1024); + const clientAbortController = new AbortController(); + + try { + const provider = createProvider({ + url: baseUrl, + // Disable response timeout so the only thing that can hang is readResponseTextUpTo + requestTimeoutNonStreamingMs: 0, + }); + + const session = createSession({ clientAbortSignal: clientAbortController.signal }); + session.setProvider(provider); + + const doForward = ( + ProxyForwarder as unknown as { + doForward: (this: typeof ProxyForwarder, ...args: unknown[]) => unknown; + } + ).doForward; + + const forwardPromise = doForward.call( + ProxyForwarder, + session, + provider, + baseUrl + ) as Promise; + + const result = await Promise.race([ + forwardPromise.then( + (response) => ({ type: "resolved" as const, response: response as Response }), + (error) => ({ type: "rejected" as const, error }) + ), + new Promise<{ type: "timeout" }>((resolve) => + setTimeout(() => resolve({ type: "timeout" as const }), 5_000) + ), + ]); + + if (result.type === "timeout") { + clientAbortController.abort(new Error("test_timeout")); + throw new Error( + "doForward timed out: readResponseTextUpTo likely blocking on reader.cancel() for large chunked response" + ); + } + + // doForward should resolve successfully (200 response) + expect(result.type).toBe("resolved"); + const response = (result as { type: "resolved"; response: Response }).response; + expect(response.status).toBe(200); + + // The response body must be fully readable by the client + const bodyText = await response.text(); + expect(bodyText.length).toBeGreaterThan(64 * 1024); + + const parsed = JSON.parse(bodyText); + expect(parsed.data).toBeDefined(); + } finally { + await close(); + } + }); +}); From 9462004596213b1dbf7b314719ccc4e8d1c82988 Mon Sep 17 00:00:00 2001 From: miraserver <20286838+miraserver@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:22:21 +0300 Subject: [PATCH 07/75] feat(provider): add swap cache TTL billing option (#798) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(provider): add swap cache TTL billing option to invert 1h/5min cost calculation When enabled on a provider, swaps the 5m and 1h token buckets for cost calculation only — the log badge remains unchanged. This addresses cases where a provider reports 1h cache but actually bills at the 5min rate. Co-Authored-By: Claude Opus 4.6 * fix(provider): swap cache TTL at data entry so badge/cost/metrics are consistent Move swap logic from post-resolution (billing5m/billing1h) to data entry point, inverting both bucket values and cache_ttl before downstream processing. Also swap session fallback TTL when usageMetrics.cache_ttl is absent. Update i18n descriptions and add tests for new behavior. Co-Authored-By: Claude Opus 4.6 * refactor(provider): extract applySwapCacheTtlBilling and fix Langfuse usage consistency Extract swap logic into reusable applySwapCacheTtlBilling() function, apply it in all response paths (non-streaming, SSE), and return finalized usage from finalizeRequestStats so Langfuse traces receive already-swapped metrics instead of re-parsing raw response text. Co-Authored-By: Claude Opus 4.6 * fix(provider): address code review findings for swap cache TTL billing - Record swapCacheTtlApplied on error/abort paths so NULL unambiguously means pre-migration - Add .default(false) to schema column and regenerate migration for query consistency - Remove redundant ?? false where provider is guaranteed non-null - Document in-place mutation in normalizeUsageWithSwap JSDoc - Include swapCacheTtlApplied in audit query for session detail page Co-Authored-By: Claude Opus 4.6 * fix(provider): clone usageMetrics in normalizeUsageWithSwap to prevent mutation side-effects The function was mutating the caller's object in-place via applySwapCacheTtlBilling, risking double-swap and inconsistent state. Now clones before swapping. Also adds swap_cache_ttl_billing to editProvider schema and a caller-isolation test. Co-Authored-By: Claude Opus 4.6 * feat(ui): add swap indicator on cacheTtlApplied badge in logs Thread swapCacheTtlApplied from DB through repository SELECT queries, error-details-dialog props, and all 3 badge render locations. When swap is active, badge turns amber with "~" suffix and i18n tooltip. Co-Authored-By: Claude Opus 4.6 * fix(ui): reorder swap toggle and add missing DialogTitle for a11y Move Swap Cache TTL Billing toggle directly after Forward client IP for logical grouping. Add hidden DialogTitle to all provider dialogs missing it (add, edit, clone, vendor key) to satisfy Radix a11y check. Co-Authored-By: Claude Opus 4.6 * fix(ui): prevent duplicate "default" key in provider group tabs When a provider has groupTag="default", the value already exists in the Set. Adding "default" again for the hasDefaultGroup case produced a duplicate React key warning. Deleting it from the Set before spreading eliminates the duplication. Co-Authored-By: Claude Opus 4.6 * chore: format code (feature-swap-cache-ttl-billing-00fa88c) * fix(ui): handle explicit "default" groupTag in provider tabs When a provider has an explicit "default" groupTag, treat it the same as having no tag rather than adding a literal "default" entry to the groups Set, which caused a duplicate tab. Co-Authored-By: Claude Opus 4.6 * refactor(provider): address code review nitpicks - Reorder VisuallyHidden import to third-party block in two files - Expand comment on session fallback cache_ttl inversion - Use ?? false for swapCacheTtlApplied to guarantee boolean type - Use destructuring swap instead of temp variable in applySwapCacheTtlBilling Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: John Doe Co-authored-by: Claude Opus 4.6 Co-authored-by: github-actions[bot] --- drizzle/0069_special_squirrel_girl.sql | 1 + drizzle/0070_stormy_exiles.sql | 1 + drizzle/meta/0069_snapshot.json | 3244 ++++++++++++++++ drizzle/meta/0070_snapshot.json | 3251 +++++++++++++++++ drizzle/meta/_journal.json | 14 + messages/en/dashboard.json | 2 + .../en/settings/providers/form/sections.json | 4 + messages/ja/dashboard.json | 2 + .../ja/settings/providers/form/sections.json | 4 + messages/ru/dashboard.json | 2 + .../ru/settings/providers/form/sections.json | 4 + messages/zh-CN/dashboard.json | 2 + .../settings/providers/form/sections.json | 4 + messages/zh-TW/dashboard.json | 2 + .../settings/providers/form/sections.json | 4 + src/actions/providers.ts | 2 + .../components/MetadataTab.tsx | 13 +- .../components/SummaryTab.tsx | 13 +- .../error-details-dialog/index.tsx | 3 + .../_components/error-details-dialog/types.ts | 2 + .../_components/usage-logs-table.test.tsx | 38 + .../logs/_components/usage-logs-table.tsx | 17 +- .../virtualized-logs-table.test.tsx | 34 + .../_components/virtualized-logs-table.tsx | 14 +- .../_components/add-provider-dialog.tsx | 6 +- .../_components/forms/provider-form/index.tsx | 1 + .../provider-form/provider-form-context.tsx | 3 + .../provider-form/provider-form-types.ts | 2 + .../sections/routing-section.tsx | 15 + .../_components/provider-manager.tsx | 8 +- .../_components/provider-rich-list-item.tsx | 8 + .../_components/vendor-keys-compact-list.tsx | 7 + src/app/v1/_lib/proxy/error-handler.ts | 1 + src/app/v1/_lib/proxy/response-handler.ts | 120 +- src/drizzle/schema.ts | 6 + src/lib/validation/schemas.ts | 2 + src/repository/_shared/transformers.ts | 2 + src/repository/message-write-buffer.ts | 2 + src/repository/message.ts | 7 + src/repository/provider.ts | 8 + src/repository/usage-logs.ts | 3 + src/types/message.ts | 5 +- src/types/provider.ts | 6 + .../cost-calculation-swap-cache-ttl.test.ts | 330 ++ 44 files changed, 7186 insertions(+), 33 deletions(-) create mode 100644 drizzle/0069_special_squirrel_girl.sql create mode 100644 drizzle/0070_stormy_exiles.sql create mode 100644 drizzle/meta/0069_snapshot.json create mode 100644 drizzle/meta/0070_snapshot.json create mode 100644 tests/unit/lib/cost-calculation-swap-cache-ttl.test.ts diff --git a/drizzle/0069_special_squirrel_girl.sql b/drizzle/0069_special_squirrel_girl.sql new file mode 100644 index 000000000..4e56dbb7c --- /dev/null +++ b/drizzle/0069_special_squirrel_girl.sql @@ -0,0 +1 @@ +ALTER TABLE "providers" ADD COLUMN "swap_cache_ttl_billing" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/0070_stormy_exiles.sql b/drizzle/0070_stormy_exiles.sql new file mode 100644 index 000000000..9ed323cc7 --- /dev/null +++ b/drizzle/0070_stormy_exiles.sql @@ -0,0 +1 @@ +ALTER TABLE "message_request" ADD COLUMN "swap_cache_ttl_applied" boolean DEFAULT false; \ No newline at end of file diff --git a/drizzle/meta/0069_snapshot.json b/drizzle/meta/0069_snapshot.json new file mode 100644 index 000000000..02001f725 --- /dev/null +++ b/drizzle/meta/0069_snapshot.json @@ -0,0 +1,3244 @@ +{ + "id": "b80dfcb9-be78-4c4f-a483-842cea7cefb4", + "prevId": "81847b3d-5ce4-4fb0-bad1-9e4570f3c5fb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0070_snapshot.json b/drizzle/meta/0070_snapshot.json new file mode 100644 index 000000000..a97618979 --- /dev/null +++ b/drizzle/meta/0070_snapshot.json @@ -0,0 +1,3251 @@ +{ + "id": "36940835-849c-47f5-9cbf-15a6c250499a", + "prevId": "b80dfcb9-be78-4c4f-a483-842cea7cefb4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6a74178de..9be36aa80 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -484,6 +484,20 @@ "when": 1771164248361, "tag": "0068_flaky_swarm", "breakpoints": true + }, + { + "idx": 69, + "version": "7", + "when": 1771188568762, + "tag": "0069_special_squirrel_girl", + "breakpoints": true + }, + { + "idx": 70, + "version": "7", + "when": 1771233193254, + "tag": "0070_stormy_exiles", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index ece1301d6..8b056aaf4 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -280,6 +280,7 @@ "cacheWrite1h": "Cache Write (1h)", "cacheRead": "Cache Read", "cacheTtl": "Cache TTL", + "cacheTtlSwapped": "Billing TTL (swapped)", "multiplier": "Provider Multiplier", "totalCost": "Total Cost", "context1m": "1M Context", @@ -365,6 +366,7 @@ "cacheWrite1h": "Cache Write (1h)", "cacheRead": "Cache Read", "cacheTtl": "Cache TTL", + "cacheTtlSwapped": "Billing TTL (swapped)", "multiplier": "Provider Multiplier", "totalCost": "Total Cost", "context1m": "1M Context", diff --git a/messages/en/settings/providers/form/sections.json b/messages/en/settings/providers/form/sections.json index becef1d2b..135d192bc 100644 --- a/messages/en/settings/providers/form/sections.json +++ b/messages/en/settings/providers/form/sections.json @@ -173,6 +173,10 @@ "inherit": "No override (follow client)" } }, + "swapCacheTtlBilling": { + "label": "Swap Cache TTL Billing", + "desc": "Invert cache TTL for incoming data: 1h tokens treated as 5min and vice versa. Affects badge, cost, and all stored metrics." + }, "codexOverrides": { "title": "Codex Parameter Overrides", "desc": "Override Codex (Responses API) request parameters at the provider level", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index a609b1c13..1a6fada76 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -280,6 +280,7 @@ "cacheWrite1h": "キャッシュ書き込み (1h)", "cacheRead": "キャッシュ読み取り", "cacheTtl": "キャッシュ TTL", + "cacheTtlSwapped": "課金 TTL (スワップ済み)", "multiplier": "プロバイダー倍率", "totalCost": "総コスト", "context1m": "1M コンテキスト", @@ -365,6 +366,7 @@ "cacheWrite1h": "キャッシュ書き込み (1h)", "cacheRead": "キャッシュ読み取り", "cacheTtl": "キャッシュ TTL", + "cacheTtlSwapped": "課金 TTL (スワップ済み)", "multiplier": "プロバイダー倍率", "totalCost": "合計費用", "context1m": "1M コンテキスト", diff --git a/messages/ja/settings/providers/form/sections.json b/messages/ja/settings/providers/form/sections.json index 6cf30359e..1356c87e4 100644 --- a/messages/ja/settings/providers/form/sections.json +++ b/messages/ja/settings/providers/form/sections.json @@ -174,6 +174,10 @@ "inherit": "オーバーライドしない(クライアントに従う)" } }, + "swapCacheTtlBilling": { + "label": "キャッシュTTL課金スワップ", + "desc": "受信データのキャッシュTTLを反転:1hトークンを5分として扱い、その逆も同様。バッジ、コスト、保存メトリクスすべてに影響します。" + }, "codexOverrides": { "title": "Codex パラメータオーバーライド", "desc": "プロバイダーレベルで Codex (Responses API) リクエストパラメータをオーバーライド", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 0204a1430..1ea2d9cc7 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -280,6 +280,7 @@ "cacheWrite1h": "Запись кэша (1h)", "cacheRead": "Чтение кэша", "cacheTtl": "TTL кэша", + "cacheTtlSwapped": "TTL биллинга (инверсия)", "multiplier": "Множитель поставщика", "totalCost": "Общая стоимость", "context1m": "1M контекст", @@ -365,6 +366,7 @@ "cacheWrite1h": "Запись кэша (1h)", "cacheRead": "Чтение кэша", "cacheTtl": "TTL кэша", + "cacheTtlSwapped": "TTL биллинга (инверсия)", "multiplier": "Множитель поставщика", "totalCost": "Общая стоимость", "context1m": "1M Контекст", diff --git a/messages/ru/settings/providers/form/sections.json b/messages/ru/settings/providers/form/sections.json index 4f651411f..b59ec1a4f 100644 --- a/messages/ru/settings/providers/form/sections.json +++ b/messages/ru/settings/providers/form/sections.json @@ -174,6 +174,10 @@ "inherit": "Не переопределять (следовать клиенту)" } }, + "swapCacheTtlBilling": { + "label": "Инверсия тарификации Cache TTL", + "desc": "Инвертировать TTL кэша на входе: токены 1h обрабатываются как 5 мин и наоборот. Влияет на бейдж, стоимость и все сохраняемые метрики." + }, "codexOverrides": { "title": "Переопределение параметров Codex", "desc": "Переопределение параметров запросов Codex (Responses API) на уровне провайдера", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 25354fe2d..924f895af 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -280,6 +280,7 @@ "cacheWrite1h": "缓存写入 (1h)", "cacheRead": "缓存读取", "cacheTtl": "缓存 TTL", + "cacheTtlSwapped": "计费 TTL (已互换)", "multiplier": "供应商倍率", "totalCost": "总费用", "context1m": "1M 上下文", @@ -365,6 +366,7 @@ "cacheWrite1h": "缓存写入 (1h)", "cacheRead": "缓存读取", "cacheTtl": "缓存 TTL", + "cacheTtlSwapped": "计费 TTL (已互换)", "multiplier": "供应商倍率", "totalCost": "总费用", "context1m": "1M 上下文", diff --git a/messages/zh-CN/settings/providers/form/sections.json b/messages/zh-CN/settings/providers/form/sections.json index b9694b837..3e54ef4ab 100644 --- a/messages/zh-CN/settings/providers/form/sections.json +++ b/messages/zh-CN/settings/providers/form/sections.json @@ -87,6 +87,10 @@ }, "desc": "强制设置 prompt cache TTL;仅影响包含 cache_control 的请求。" }, + "swapCacheTtlBilling": { + "label": "Cache TTL 计费互换", + "desc": "反转传入数据的缓存 TTL:1h 令牌视为 5 分钟,反之亦然。影响标记、成本及所有存储指标。" + }, "context1m": { "label": "1M 上下文窗口", "options": { diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 7f30dcc9b..abdafbccf 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -280,6 +280,7 @@ "cacheWrite1h": "快取寫入(1h)", "cacheRead": "快取讀取", "cacheTtl": "快取 TTL", + "cacheTtlSwapped": "計費 TTL (已互換)", "multiplier": "供應商倍率", "totalCost": "總費用", "context1m": "1M 上下文", @@ -365,6 +366,7 @@ "cacheWrite1h": "快取寫入(1h)", "cacheRead": "快取讀取", "cacheTtl": "快取 TTL", + "cacheTtlSwapped": "計費 TTL (已互換)", "multiplier": "供應商倍率", "totalCost": "總費用", "context1m": "1M 上下文長度", diff --git a/messages/zh-TW/settings/providers/form/sections.json b/messages/zh-TW/settings/providers/form/sections.json index 62e56b9a0..c007c3941 100644 --- a/messages/zh-TW/settings/providers/form/sections.json +++ b/messages/zh-TW/settings/providers/form/sections.json @@ -174,6 +174,10 @@ "inherit": "不覆寫(跟隨客戶端)" } }, + "swapCacheTtlBilling": { + "label": "Cache TTL 計費互換", + "desc": "反轉傳入資料的快取 TTL:1h 令牌視為 5 分鐘,反之亦然。影響標記、成本及所有儲存指標。" + }, "codexOverrides": { "title": "Codex 參數覆寫", "desc": "在供應商級別覆寫 Codex (Responses API) 請求參數", diff --git a/src/actions/providers.ts b/src/actions/providers.ts index e056df2a0..89cb72f06 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -277,6 +277,7 @@ export async function getProviders(): Promise { websiteUrl: provider.websiteUrl, faviconUrl: provider.faviconUrl, cacheTtlPreference: provider.cacheTtlPreference, + swapCacheTtlBilling: provider.swapCacheTtlBilling, context1mPreference: provider.context1mPreference, codexReasoningEffortPreference: provider.codexReasoningEffortPreference, codexReasoningSummaryPreference: provider.codexReasoningSummaryPreference, @@ -637,6 +638,7 @@ export async function editProvider( limit_total_usd?: number | null; limit_concurrent_sessions?: number | null; cache_ttl_preference?: "inherit" | "5m" | "1h"; + swap_cache_ttl_billing?: boolean; context_1m_preference?: Context1mPreference | null; codex_reasoning_effort_preference?: CodexReasoningEffortPreference | null; codex_reasoning_summary_preference?: CodexReasoningSummaryPreference | null; diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsx index 1d6d1364a..83178a60e 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsx @@ -35,6 +35,7 @@ export function MetadataTab({ cacheCreation1hInputTokens, cacheReadInputTokens, cacheTtlApplied, + swapCacheTtlApplied, costUsd, costMultiplier, context1mApplied, @@ -205,8 +206,18 @@ export function MetadataTab({ {cacheTtlApplied && (
{t("billingDetails.cacheTtl")}: - + {cacheTtlApplied} + {swapCacheTtlApplied ? " ~" : ""}
)} diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx index d502d9637..1873d23ce 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx @@ -42,6 +42,7 @@ export function SummaryTab({ cacheCreation1hInputTokens, cacheReadInputTokens, cacheTtlApplied, + swapCacheTtlApplied, costUsd, costMultiplier, context1mApplied, @@ -316,8 +317,18 @@ export function SummaryTab({ {cacheTtlApplied && (
{t("billingDetails.cacheTtl")}: - + {cacheTtlApplied} + {swapCacheTtlApplied ? " ~" : ""}
)} diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx index 70c75cbe7..3ecff9dfb 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx @@ -36,6 +36,7 @@ interface ErrorDetailsDialogProps { cacheCreation1hInputTokens?: number | null; cacheReadInputTokens?: number | null; cacheTtlApplied?: string | null; + swapCacheTtlApplied?: boolean | null; costUsd?: string | null; costMultiplier?: string | null; context1mApplied?: boolean | null; @@ -74,6 +75,7 @@ export function ErrorDetailsDialog({ cacheCreation1hInputTokens, cacheReadInputTokens, cacheTtlApplied, + swapCacheTtlApplied, costUsd, costMultiplier, context1mApplied, @@ -217,6 +219,7 @@ export function ErrorDetailsDialog({ cacheCreation1hInputTokens, cacheReadInputTokens, cacheTtlApplied, + swapCacheTtlApplied, costUsd, costMultiplier, context1mApplied, diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts index 7c1384d4a..26bd8eca2 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts @@ -48,6 +48,8 @@ export interface TabSharedProps { cacheReadInputTokens?: number | null; /** Cache TTL applied */ cacheTtlApplied?: string | null; + /** Whether swap cache TTL billing was applied */ + swapCacheTtlApplied?: boolean | null; /** Total cost in USD */ costUsd?: string | null; /** Cost multiplier */ diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx index 34fef1749..d45313e5c 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx @@ -82,6 +82,7 @@ function makeLog(overrides: Partial): UsageLogRow { userAgent: null, messagesCount: null, context1mApplied: null, + swapCacheTtlApplied: null, specialSettings: null, ...overrides, }; @@ -232,6 +233,43 @@ describe("usage-logs-table multiplier badge", () => { expect(html).toContain("TTFB"); }); + test("renders swap indicator on cacheTtl badge when swapCacheTtlApplied is true", () => { + const html = renderToStaticMarkup( + {}} + isPending={false} + /> + ); + + // Should contain the swap indicator "~" + expect(html).toContain("5m ~"); + // Should contain amber styling + expect(html).toContain("bg-amber-50"); + }); + + test("does not render swap indicator when swapCacheTtlApplied is false", () => { + const html = renderToStaticMarkup( + {}} + isPending={false} + /> + ); + + // Should contain the TTL value without swap indicator + expect(html).toContain("5m"); + expect(html).not.toContain("5m ~"); + // Should not contain amber styling + expect(html).not.toContain("bg-amber-50"); + }); + test("copies sessionId on click and shows toast", async () => { const writeText = vi.fn(async () => {}); Object.defineProperty(navigator, "clipboard", { diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx index 9b643ac85..06241c052 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx @@ -299,8 +299,22 @@ export function UsageLogsTable({
{log.cacheTtlApplied ? ( - + {log.cacheTtlApplied} + {log.swapCacheTtlApplied ? " ~" : ""} ) : null} @@ -509,6 +523,7 @@ export function UsageLogsTable({ cacheCreation1hInputTokens={log.cacheCreation1hInputTokens} cacheReadInputTokens={log.cacheReadInputTokens} cacheTtlApplied={log.cacheTtlApplied} + swapCacheTtlApplied={log.swapCacheTtlApplied} costUsd={log.costUsd} costMultiplier={log.costMultiplier} context1mApplied={log.context1mApplied} diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx index e39583be8..404501fa6 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx @@ -128,6 +128,7 @@ function makeLog(overrides: Partial): UsageLogRow { userAgent: null, messagesCount: null, context1mApplied: null, + swapCacheTtlApplied: null, specialSettings: null, ...overrides, }; @@ -340,4 +341,37 @@ describe("virtualized-logs-table multiplier badge", () => { // TTFB should also appear expect(html).toContain("TTFB"); }); + + test("renders swap indicator on cacheTtl badge when swapCacheTtlApplied is true", () => { + mockIsLoading = false; + mockIsError = false; + mockError = null; + mockHasNextPage = false; + mockIsFetchingNextPage = false; + + mockLogs = [makeLog({ id: 1, cacheTtlApplied: "5m", swapCacheTtlApplied: true })]; + const html = renderToStaticMarkup( + + ); + + expect(html).toContain("5m ~"); + expect(html).toContain("bg-amber-50"); + }); + + test("does not render swap indicator when swapCacheTtlApplied is false", () => { + mockIsLoading = false; + mockIsError = false; + mockError = null; + mockHasNextPage = false; + mockIsFetchingNextPage = false; + + mockLogs = [makeLog({ id: 1, cacheTtlApplied: "5m", swapCacheTtlApplied: false })]; + const html = renderToStaticMarkup( + + ); + + expect(html).toContain("5m"); + expect(html).not.toContain("5m ~"); + expect(html).not.toContain("bg-amber-50"); + }); }); diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index af4a980ec..7b24cc03b 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -562,9 +562,20 @@ export function VirtualizedLogsTable({ {log.cacheTtlApplied ? ( {log.cacheTtlApplied} + {log.swapCacheTtlApplied ? " ~" : ""} ) : null} @@ -739,6 +750,7 @@ export function VirtualizedLogsTable({ cacheCreation1hInputTokens={log.cacheCreation1hInputTokens} cacheReadInputTokens={log.cacheReadInputTokens} cacheTtlApplied={log.cacheTtlApplied} + swapCacheTtlApplied={log.swapCacheTtlApplied} costUsd={log.costUsd} costMultiplier={log.costMultiplier} context1mApplied={log.context1mApplied} diff --git a/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx b/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx index 07fb2b7e6..e8d944292 100644 --- a/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx +++ b/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx @@ -4,7 +4,8 @@ import { useTranslations } from "next-intl"; import { useState } from "react"; import { FormErrorBoundary } from "@/components/form-error-boundary"; import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { ProviderForm } from "./forms/provider-form"; interface AddProviderDialogProps { @@ -22,6 +23,9 @@ export function AddProviderDialog({ enableMultiProviderTypes }: AddProviderDialo + + {t("addProvider")} + 0 ? state.routing.groupTag.join(",") : null, cache_ttl_preference: state.routing.cacheTtlPreference, + swap_cache_ttl_billing: state.routing.swapCacheTtlBilling, context_1m_preference: state.routing.context1mPreference, codex_reasoning_effort_preference: state.routing.codexReasoningEffortPreference, codex_reasoning_summary_preference: state.routing.codexReasoningSummaryPreference, diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx index 9f9c890a2..facc525c9 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx @@ -52,6 +52,7 @@ export function createInitialState( weight: sourceProvider?.weight ?? 1, costMultiplier: sourceProvider?.costMultiplier ?? 1.0, cacheTtlPreference: sourceProvider?.cacheTtlPreference ?? "inherit", + swapCacheTtlBilling: sourceProvider?.swapCacheTtlBilling ?? false, context1mPreference: (sourceProvider?.context1mPreference as "inherit" | "force_enable" | "disabled") ?? "inherit", @@ -152,6 +153,8 @@ export function providerFormReducer( return { ...state, routing: { ...state.routing, costMultiplier: action.payload } }; case "SET_CACHE_TTL_PREFERENCE": return { ...state, routing: { ...state.routing, cacheTtlPreference: action.payload } }; + case "SET_SWAP_CACHE_TTL_BILLING": + return { ...state, routing: { ...state.routing, swapCacheTtlBilling: action.payload } }; case "SET_CONTEXT_1M_PREFERENCE": return { ...state, routing: { ...state.routing, context1mPreference: action.payload } }; case "SET_CODEX_REASONING_EFFORT": diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts index e4bf0eae6..60355dd9e 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts @@ -47,6 +47,7 @@ export interface RoutingState { weight: number; costMultiplier: number; cacheTtlPreference: "inherit" | "5m" | "1h"; + swapCacheTtlBilling: boolean; context1mPreference: "inherit" | "force_enable" | "disabled"; // Codex-specific codexReasoningEffortPreference: CodexReasoningEffortPreference; @@ -127,6 +128,7 @@ export type ProviderFormAction = | { type: "SET_WEIGHT"; payload: number } | { type: "SET_COST_MULTIPLIER"; payload: number } | { type: "SET_CACHE_TTL_PREFERENCE"; payload: "inherit" | "5m" | "1h" } + | { type: "SET_SWAP_CACHE_TTL_BILLING"; payload: boolean } | { type: "SET_CONTEXT_1M_PREFERENCE"; payload: "inherit" | "force_enable" | "disabled" } | { type: "SET_CODEX_REASONING_EFFORT"; payload: CodexReasoningEffortPreference } | { type: "SET_CODEX_REASONING_SUMMARY"; payload: CodexReasoningSummaryPreference } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx index e9c399c0f..d9949900e 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx @@ -342,6 +342,21 @@ export function RoutingSection() { /> + {/* Swap Cache TTL Billing */} + + + dispatch({ type: "SET_SWAP_CACHE_TTL_BILLING", payload: checked }) + } + disabled={state.ui.isPending} + /> + + {/* Cache TTL */} groups.add(g)); + tags.forEach((g) => { + if (g === "default") { + hasDefaultGroup = true; + } else { + groups.add(g); + } + }); } }); diff --git a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx index ff6d9a42b..6ff5e67e6 100644 --- a/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx @@ -15,6 +15,7 @@ import { import { useTranslations } from "next-intl"; import { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { editProvider, getUnmaskedProviderKey, @@ -136,6 +137,7 @@ export function ProviderRichListItem({ const [togglePending, startToggleTransition] = useTransition(); const canEdit = currentUser?.role === "admin"; + const t = useTranslations("settings.providers"); const tTypes = useTranslations("settings.providers.types"); const tList = useTranslations("settings.providers.list"); const tTimeout = useTranslations("settings.providers.form.sections.timeout"); @@ -948,6 +950,9 @@ export function ProviderRichListItem({ {/* 编辑 Dialog */} + + {t("editProvider")} + + + {t("clone")} + + + {t("addVendorKey")} + + + {t("editProvider")} + { +): Promise { const { messageContext, provider } = session; if (!provider || !messageContext) { - return; + return null; } const providerIdForPersistence = providerIdOverride ?? session.provider?.id; @@ -2816,28 +2889,19 @@ export async function finalizeRequestStats( model: session.getCurrentModel() ?? undefined, providerId: providerIdForPersistence, // 更新最终供应商ID(重试切换后) context1mApplied: session.getContext1mApplied(), + swapCacheTtlApplied: provider.swapCacheTtlBilling ?? false, }); - return; + return null; } // 4. 更新成本 - const resolvedCacheTtl = usageMetrics.cache_ttl ?? session.getCacheTtlResolved?.() ?? null; - const cache5m = - usageMetrics.cache_creation_5m_input_tokens ?? - (resolvedCacheTtl === "1h" ? undefined : usageMetrics.cache_creation_input_tokens); - const cache1h = - usageMetrics.cache_creation_1h_input_tokens ?? - (resolvedCacheTtl === "1h" ? usageMetrics.cache_creation_input_tokens : undefined); - const cacheTotal = - usageMetrics.cache_creation_input_tokens ?? ((cache5m ?? 0) + (cache1h ?? 0) || undefined); - - const normalizedUsage: UsageMetrics = { - ...usageMetrics, - cache_ttl: resolvedCacheTtl ?? usageMetrics.cache_ttl, - cache_creation_5m_input_tokens: cache5m, - cache_creation_1h_input_tokens: cache1h, - cache_creation_input_tokens: cacheTotal, - }; + // Invert cache TTL at data entry when provider option is enabled + // All downstream (badge, cost, DB, logs) will see inverted values + const normalizedUsage = normalizeUsageWithSwap( + usageMetrics, + session, + provider.swapCacheTtlBilling + ); await updateRequestCostFromUsage( messageContext.id, @@ -2905,7 +2969,10 @@ export async function finalizeRequestStats( model: session.getCurrentModel() ?? undefined, providerId: providerIdForPersistence, // 更新最终供应商ID(重试切换后) context1mApplied: session.getContext1mApplied(), + swapCacheTtlApplied: provider.swapCacheTtlBilling ?? false, }); + + return normalizedUsage; } /** @@ -3061,6 +3128,7 @@ async function persistRequestFailure(options: { model: session.getCurrentModel() ?? undefined, providerId: session.provider?.id, // 更新最终供应商ID(重试切换后) context1mApplied: session.getContext1mApplied(), + swapCacheTtlApplied: session.provider?.swapCacheTtlBilling ?? false, }); const isAsyncWrite = getEnvConfig().MESSAGE_REQUEST_WRITE_MODE !== "sync"; diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index ce9165886..3b4f31c75 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -268,6 +268,9 @@ export const providers = pgTable('providers', { // Cache TTL override(null = 不覆写,沿用客户端请求) cacheTtlPreference: varchar('cache_ttl_preference', { length: 10 }), + // Cache TTL billing swap: when true, invert 1h<->5m for cost calculation only + swapCacheTtlBilling: boolean('swap_cache_ttl_billing').notNull().default(false), + // 1M Context Window 偏好配置(仅对 Anthropic 类型供应商有效) // - 'inherit' (默认): 遵循客户端请求,客户端带 1M header 则启用 // - 'force_enable': 强制启用 1M 上下文(仅对支持的模型生效) @@ -453,6 +456,9 @@ export const messageRequest = pgTable('message_request', { // 1M Context Window 应用状态 context1mApplied: boolean('context_1m_applied').default(false), + // Swap Cache TTL Billing: whether cache TTL inversion was active for this request + swapCacheTtlApplied: boolean('swap_cache_ttl_applied').default(false), + // 特殊设置(用于记录各类“特殊行为/覆写”的命中与生效情况,便于审计与展示) specialSettings: jsonb('special_settings').$type(), diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index 18cd87244..ebfbf1321 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -490,6 +490,7 @@ export const CreateProviderSchema = z .optional() .default(0), cache_ttl_preference: CACHE_TTL_PREFERENCE.optional().default("inherit"), + swap_cache_ttl_billing: z.boolean().optional().default(false), context_1m_preference: CONTEXT_1M_PREFERENCE.nullable().optional(), codex_reasoning_effort_preference: CODEX_REASONING_EFFORT_PREFERENCE.optional().default("inherit"), @@ -693,6 +694,7 @@ export const UpdateProviderSchema = z .max(1000, "并发Session上限不能超过1000") .optional(), cache_ttl_preference: CACHE_TTL_PREFERENCE.optional(), + swap_cache_ttl_billing: z.boolean().optional(), context_1m_preference: CONTEXT_1M_PREFERENCE.nullable().optional(), codex_reasoning_effort_preference: CODEX_REASONING_EFFORT_PREFERENCE.optional(), codex_reasoning_summary_preference: CODEX_REASONING_SUMMARY_PREFERENCE.optional(), diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 7f9edb56b..d3773b713 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -119,6 +119,7 @@ export function toProvider(dbProvider: any): Provider { websiteUrl: dbProvider?.websiteUrl ?? null, faviconUrl: dbProvider?.faviconUrl ?? null, cacheTtlPreference: dbProvider?.cacheTtlPreference ?? null, + swapCacheTtlBilling: dbProvider?.swapCacheTtlBilling ?? false, context1mPreference: dbProvider?.context1mPreference ?? null, codexReasoningEffortPreference: dbProvider?.codexReasoningEffortPreference ?? null, codexReasoningSummaryPreference: dbProvider?.codexReasoningSummaryPreference ?? null, @@ -153,6 +154,7 @@ export function toMessageRequest(dbMessage: any): MessageRequest { cacheCreation1hInputTokens: dbMessage?.cacheCreation1hInputTokens ?? undefined, cacheTtlApplied: dbMessage?.cacheTtlApplied ?? null, context1mApplied: dbMessage?.context1mApplied ?? false, + swapCacheTtlApplied: dbMessage?.swapCacheTtlApplied ?? false, specialSettings: dbMessage?.specialSettings ?? null, }; } diff --git a/src/repository/message-write-buffer.ts b/src/repository/message-write-buffer.ts index e7f946bb1..d2f690189 100644 --- a/src/repository/message-write-buffer.ts +++ b/src/repository/message-write-buffer.ts @@ -26,6 +26,7 @@ export type MessageRequestUpdatePatch = { model?: string; providerId?: number; context1mApplied?: boolean; + swapCacheTtlApplied?: boolean; specialSettings?: CreateMessageRequestData["special_settings"]; }; @@ -59,6 +60,7 @@ const COLUMN_MAP: Record = { model: "model", providerId: "provider_id", context1mApplied: "context_1m_applied", + swapCacheTtlApplied: "swap_cache_ttl_applied", specialSettings: "special_settings", }; diff --git a/src/repository/message.ts b/src/repository/message.ts index a90d0e0ec..c22ace55a 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -135,6 +135,7 @@ export async function updateMessageRequestDetails( model?: string; // ⭐ 新增:支持更新重定向后的模型名称 providerId?: number; // ⭐ 新增:支持更新最终供应商ID(重试切换后) context1mApplied?: boolean; // 是否应用了1M上下文窗口 + swapCacheTtlApplied?: boolean; // Swap Cache TTL Billing active at request time specialSettings?: CreateMessageRequestData["special_settings"]; // 特殊设置(审计/展示) } ): Promise { @@ -195,6 +196,9 @@ export async function updateMessageRequestDetails( if (details.context1mApplied !== undefined) { updateData.context1mApplied = details.context1mApplied; } + if (details.swapCacheTtlApplied !== undefined) { + updateData.swapCacheTtlApplied = details.swapCacheTtlApplied; + } if (details.specialSettings !== undefined) { updateData.specialSettings = details.specialSettings; } @@ -285,6 +289,7 @@ export async function findMessageRequestAuditBySessionIdAndSequence( blockedReason: string | null; cacheTtlApplied: string | null; context1mApplied: boolean | null; + swapCacheTtlApplied: boolean | null; specialSettings: SpecialSetting[] | null; } | null> { const [row] = await db @@ -294,6 +299,7 @@ export async function findMessageRequestAuditBySessionIdAndSequence( blockedReason: messageRequest.blockedReason, cacheTtlApplied: messageRequest.cacheTtlApplied, context1mApplied: messageRequest.context1mApplied, + swapCacheTtlApplied: messageRequest.swapCacheTtlApplied, specialSettings: messageRequest.specialSettings, }) .from(messageRequest) @@ -313,6 +319,7 @@ export async function findMessageRequestAuditBySessionIdAndSequence( blockedReason: row.blockedReason, cacheTtlApplied: row.cacheTtlApplied, context1mApplied: row.context1mApplied, + swapCacheTtlApplied: row.swapCacheTtlApplied, specialSettings: Array.isArray(row.specialSettings) ? (row.specialSettings as SpecialSetting[]) : null, diff --git a/src/repository/provider.ts b/src/repository/provider.ts index be6c7f4b1..4d6b24fad 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -59,6 +59,7 @@ export async function createProvider(providerData: CreateProviderData): Promise< websiteUrl: providerData.website_url ?? null, faviconUrl: providerData.favicon_url ?? null, cacheTtlPreference: providerData.cache_ttl_preference ?? null, + swapCacheTtlBilling: providerData.swap_cache_ttl_billing ?? false, context1mPreference: providerData.context_1m_preference ?? null, codexReasoningEffortPreference: providerData.codex_reasoning_effort_preference ?? null, codexReasoningSummaryPreference: providerData.codex_reasoning_summary_preference ?? null, @@ -129,6 +130,7 @@ export async function createProvider(providerData: CreateProviderData): Promise< websiteUrl: providers.websiteUrl, faviconUrl: providers.faviconUrl, cacheTtlPreference: providers.cacheTtlPreference, + swapCacheTtlBilling: providers.swapCacheTtlBilling, context1mPreference: providers.context1mPreference, codexReasoningEffortPreference: providers.codexReasoningEffortPreference, codexReasoningSummaryPreference: providers.codexReasoningSummaryPreference, @@ -208,6 +210,7 @@ export async function findProviderList( websiteUrl: providers.websiteUrl, faviconUrl: providers.faviconUrl, cacheTtlPreference: providers.cacheTtlPreference, + swapCacheTtlBilling: providers.swapCacheTtlBilling, context1mPreference: providers.context1mPreference, codexReasoningEffortPreference: providers.codexReasoningEffortPreference, codexReasoningSummaryPreference: providers.codexReasoningSummaryPreference, @@ -287,6 +290,7 @@ export async function findAllProvidersFresh(): Promise { websiteUrl: providers.websiteUrl, faviconUrl: providers.faviconUrl, cacheTtlPreference: providers.cacheTtlPreference, + swapCacheTtlBilling: providers.swapCacheTtlBilling, context1mPreference: providers.context1mPreference, codexReasoningEffortPreference: providers.codexReasoningEffortPreference, codexReasoningSummaryPreference: providers.codexReasoningSummaryPreference, @@ -370,6 +374,7 @@ export async function findProviderById(id: number): Promise { websiteUrl: providers.websiteUrl, faviconUrl: providers.faviconUrl, cacheTtlPreference: providers.cacheTtlPreference, + swapCacheTtlBilling: providers.swapCacheTtlBilling, context1mPreference: providers.context1mPreference, codexReasoningEffortPreference: providers.codexReasoningEffortPreference, codexReasoningSummaryPreference: providers.codexReasoningSummaryPreference, @@ -471,6 +476,8 @@ export async function updateProvider( if (providerData.favicon_url !== undefined) dbData.faviconUrl = providerData.favicon_url; if (providerData.cache_ttl_preference !== undefined) dbData.cacheTtlPreference = providerData.cache_ttl_preference ?? null; + if (providerData.swap_cache_ttl_billing !== undefined) + dbData.swapCacheTtlBilling = providerData.swap_cache_ttl_billing; if (providerData.context_1m_preference !== undefined) dbData.context1mPreference = providerData.context_1m_preference ?? null; if (providerData.codex_reasoning_effort_preference !== undefined) @@ -590,6 +597,7 @@ export async function updateProvider( websiteUrl: providers.websiteUrl, faviconUrl: providers.faviconUrl, cacheTtlPreference: providers.cacheTtlPreference, + swapCacheTtlBilling: providers.swapCacheTtlBilling, context1mPreference: providers.context1mPreference, codexReasoningEffortPreference: providers.codexReasoningEffortPreference, codexReasoningSummaryPreference: providers.codexReasoningSummaryPreference, diff --git a/src/repository/usage-logs.ts b/src/repository/usage-logs.ts index d965b7d3e..410025958 100644 --- a/src/repository/usage-logs.ts +++ b/src/repository/usage-logs.ts @@ -63,6 +63,7 @@ export interface UsageLogRow { userAgent: string | null; // User-Agent(客户端信息) messagesCount: number | null; // Messages 数量 context1mApplied: boolean | null; // 是否应用了1M上下文窗口 + swapCacheTtlApplied: boolean | null; // 是否启用了swap cache TTL billing specialSettings: SpecialSetting[] | null; // 特殊设置(审计/展示) } @@ -178,6 +179,7 @@ export async function findUsageLogsBatch( userAgent: messageRequest.userAgent, messagesCount: messageRequest.messagesCount, context1mApplied: messageRequest.context1mApplied, + swapCacheTtlApplied: messageRequest.swapCacheTtlApplied, specialSettings: messageRequest.specialSettings, }) .from(messageRequest) @@ -529,6 +531,7 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis userAgent: messageRequest.userAgent, // User-Agent messagesCount: messageRequest.messagesCount, // Messages 数量 context1mApplied: messageRequest.context1mApplied, // 1M上下文窗口 + swapCacheTtlApplied: messageRequest.swapCacheTtlApplied, // swap cache TTL billing specialSettings: messageRequest.specialSettings, // 特殊设置(审计/展示) }) .from(messageRequest) diff --git a/src/types/message.ts b/src/types/message.ts index faa2e3f6f..c6833d290 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -256,7 +256,10 @@ export interface MessageRequest { // 1M 上下文窗口是否已应用 context1mApplied?: boolean; - // 特殊设置(用于记录各类“特殊行为/覆写”的命中与生效情况,便于审计与展示) + // Swap Cache TTL Billing: whether cache TTL inversion was active for this request + swapCacheTtlApplied?: boolean; + + // 特殊设置(用于记录各类"特殊行为/覆写"的命中与生效情况,便于审计与展示) specialSettings?: SpecialSetting[] | null; createdAt: Date; diff --git a/src/types/provider.ts b/src/types/provider.ts index 3ab55cacf..aed85a685 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -131,6 +131,9 @@ export interface Provider { // Cache TTL override(inherit 表示不强制覆写) cacheTtlPreference: CacheTtlPreference | null; + // Cache TTL billing swap: invert 1h<->5m for cost calculation + swapCacheTtlBilling: boolean; + // 1M Context Window 偏好配置(仅对 Anthropic 类型供应商有效) context1mPreference: Context1mPreference | null; @@ -214,6 +217,7 @@ export interface ProviderDisplay { websiteUrl: string | null; faviconUrl: string | null; cacheTtlPreference: CacheTtlPreference | null; + swapCacheTtlBilling: boolean; context1mPreference: Context1mPreference | null; codexReasoningEffortPreference: CodexReasoningEffortPreference | null; codexReasoningSummaryPreference: CodexReasoningSummaryPreference | null; @@ -305,6 +309,7 @@ export interface CreateProviderData { website_url?: string | null; favicon_url?: string | null; cache_ttl_preference?: CacheTtlPreference | null; + swap_cache_ttl_billing?: boolean; context_1m_preference?: Context1mPreference | null; codex_reasoning_effort_preference?: CodexReasoningEffortPreference | null; codex_reasoning_summary_preference?: CodexReasoningSummaryPreference | null; @@ -378,6 +383,7 @@ export interface UpdateProviderData { website_url?: string | null; favicon_url?: string | null; cache_ttl_preference?: CacheTtlPreference | null; + swap_cache_ttl_billing?: boolean; context_1m_preference?: Context1mPreference | null; codex_reasoning_effort_preference?: CodexReasoningEffortPreference | null; codex_reasoning_summary_preference?: CodexReasoningSummaryPreference | null; diff --git a/tests/unit/lib/cost-calculation-swap-cache-ttl.test.ts b/tests/unit/lib/cost-calculation-swap-cache-ttl.test.ts new file mode 100644 index 000000000..6fbda3f3b --- /dev/null +++ b/tests/unit/lib/cost-calculation-swap-cache-ttl.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, test } from "vitest"; +import { calculateRequestCostBreakdown } from "@/lib/utils/cost-calculation"; +import { applySwapCacheTtlBilling } from "@/app/v1/_lib/proxy/response-handler"; +import type { UsageMetrics } from "@/app/v1/_lib/proxy/response-handler"; +import type { ModelPriceData } from "@/types/model-price"; + +function makePriceData(overrides: Partial = {}): ModelPriceData { + return { + input_cost_per_token: 0.000003, // $3/MTok + output_cost_per_token: 0.000015, // $15/MTok + cache_creation_input_token_cost: 0.00000375, // 1.25x input (5m rate) + cache_read_input_token_cost: 0.0000003, // 0.1x input + cache_creation_input_token_cost_above_1hr: 0.000006, // 2x input (1h rate) + ...overrides, + }; +} + +/** + * Wrapper around the real applySwapCacheTtlBilling that returns a new object + * (the production function mutates in-place). + */ +function applySwap( + usage: { + cache_creation_5m_input_tokens?: number; + cache_creation_1h_input_tokens?: number; + cache_ttl?: "5m" | "1h"; + }, + swap: boolean +) { + const copy = { ...usage } as UsageMetrics; + applySwapCacheTtlBilling(copy, swap); + return { + cache_creation_5m_input_tokens: copy.cache_creation_5m_input_tokens, + cache_creation_1h_input_tokens: copy.cache_creation_1h_input_tokens, + cache_ttl: copy.cache_ttl, + }; +} + +describe("swap cache TTL billing", () => { + test("swap=false: normal billing (5m tokens at 5m rate, 1h tokens at 1h rate)", () => { + const tokens = { cache_creation_5m_input_tokens: 1000, cache_creation_1h_input_tokens: 0 }; + const swapped = applySwap(tokens, false); + + const result = calculateRequestCostBreakdown( + { input_tokens: 0, output_tokens: 0, ...swapped }, + makePriceData() + ); + + // 1000 * 0.00000375 (5m rate) + expect(result.cache_creation).toBeCloseTo(0.00375, 6); + }); + + test("swap=true: 1h tokens billed at 5m rate (cheaper)", () => { + // Provider reports 1h, but actually bills at 5m rate + const tokens = { cache_creation_5m_input_tokens: 0, cache_creation_1h_input_tokens: 1000 }; + const swapped = applySwap(tokens, true); + + const result = calculateRequestCostBreakdown( + { input_tokens: 0, output_tokens: 0, ...swapped }, + makePriceData() + ); + + // After swap: 1h tokens (1000) moved to 5m bucket -> 1000 * 0.00000375 + expect(result.cache_creation).toBeCloseTo(0.00375, 6); + }); + + test("swap=true: 5m tokens billed at 1h rate (more expensive)", () => { + // Provider reports 5m, but actually bills at 1h rate + const tokens = { cache_creation_5m_input_tokens: 1000, cache_creation_1h_input_tokens: 0 }; + const swapped = applySwap(tokens, true); + + const result = calculateRequestCostBreakdown( + { input_tokens: 0, output_tokens: 0, ...swapped }, + makePriceData() + ); + + // After swap: 5m tokens (1000) moved to 1h bucket -> 1000 * 0.000006 + expect(result.cache_creation).toBeCloseTo(0.006, 6); + }); + + test("swap inverts both buckets when both have tokens", () => { + const tokens = { cache_creation_5m_input_tokens: 200, cache_creation_1h_input_tokens: 800 }; + + const normalResult = calculateRequestCostBreakdown( + { input_tokens: 0, output_tokens: 0, ...applySwap(tokens, false) }, + makePriceData() + ); + + const swappedResult = calculateRequestCostBreakdown( + { input_tokens: 0, output_tokens: 0, ...applySwap(tokens, true) }, + makePriceData() + ); + + // Normal: 200 * 0.00000375 + 800 * 0.000006 = 0.00075 + 0.0048 = 0.00555 + expect(normalResult.cache_creation).toBeCloseTo(0.00555, 6); + + // Swapped: 800 * 0.00000375 + 200 * 0.000006 = 0.003 + 0.0012 = 0.0042 + expect(swappedResult.cache_creation).toBeCloseTo(0.0042, 6); + + // Swapped is cheaper because more tokens went to the cheaper 5m rate + expect(swappedResult.cache_creation).toBeLessThan(normalResult.cache_creation); + }); + + test("swap exchanges buckets when only one bucket has tokens", () => { + const tokens5mOnly = { cache_creation_5m_input_tokens: 500, cache_creation_1h_input_tokens: 0 }; + + const normal5m = applySwap(tokens5mOnly, false); + const swapped5m = applySwap(tokens5mOnly, true); + + // Normal: 500 at 5m rate + expect(normal5m.cache_creation_5m_input_tokens).toBe(500); + expect(normal5m.cache_creation_1h_input_tokens).toBe(0); + + // Swapped: 500 moved to 1h bucket, 0 moved to 5m bucket + expect(swapped5m.cache_creation_5m_input_tokens).toBe(0); + expect(swapped5m.cache_creation_1h_input_tokens).toBe(500); + }); + + test("swap with undefined tokens treats them as undefined (no crash)", () => { + const tokens = { + cache_creation_5m_input_tokens: undefined, + cache_creation_1h_input_tokens: 1000, + }; + const swapped = applySwap(tokens, true); + + expect(swapped.cache_creation_5m_input_tokens).toBe(1000); + expect(swapped.cache_creation_1h_input_tokens).toBeUndefined(); + + // Should not crash when passed to cost calculation + const result = calculateRequestCostBreakdown( + { input_tokens: 0, output_tokens: 0, ...swapped }, + makePriceData() + ); + // 1000 at 5m rate + expect(result.cache_creation).toBeCloseTo(0.00375, 6); + }); + + test("swap also inverts cache_ttl value", () => { + const usage5m = { + cache_creation_5m_input_tokens: 100, + cache_creation_1h_input_tokens: 0, + cache_ttl: "5m" as const, + }; + const usage1h = { + cache_creation_5m_input_tokens: 0, + cache_creation_1h_input_tokens: 100, + cache_ttl: "1h" as const, + }; + + const swapped5m = applySwap(usage5m, true); + const swapped1h = applySwap(usage1h, true); + + expect(swapped5m.cache_ttl).toBe("1h"); + expect(swapped1h.cache_ttl).toBe("5m"); + }); + + test("swap with only cache_creation_input_tokens (total) and cache_ttl=1h routes total to 5m bucket", () => { + // Upstream sends total without explicit buckets + cache_ttl: "1h" + // After swap: cache_ttl becomes "5m", so total should go to 5m bucket (not 1h) + const usage = { cache_ttl: "1h" as const }; + const swapped = applySwap(usage, true); + + // cache_ttl should be inverted + expect(swapped.cache_ttl).toBe("5m"); + + // Simulate how response-handler resolves buckets after swap: + // resolvedCacheTtl = "5m" (swapped), cache_creation_input_tokens = 1000 (total) + const resolvedCacheTtl = swapped.cache_ttl; + const totalTokens = 1000; + const cache5m = resolvedCacheTtl === "1h" ? undefined : totalTokens; + const cache1h = resolvedCacheTtl === "1h" ? totalTokens : undefined; + + // Total should land in 5m bucket (cheaper), not 1h + expect(cache5m).toBe(1000); + expect(cache1h).toBeUndefined(); + + const result = calculateRequestCostBreakdown( + { + input_tokens: 0, + output_tokens: 0, + cache_creation_5m_input_tokens: cache5m, + cache_creation_1h_input_tokens: cache1h, + }, + makePriceData() + ); + // 1000 * 0.00000375 (5m rate) + expect(result.cache_creation).toBeCloseTo(0.00375, 6); + }); +}); + +describe("applySwapCacheTtlBilling (direct)", () => { + test("swap=false is a no-op", () => { + const usage: UsageMetrics = { + input_tokens: 100, + output_tokens: 50, + cache_creation_5m_input_tokens: 200, + cache_creation_1h_input_tokens: 300, + cache_ttl: "5m", + }; + const before = { ...usage }; + applySwapCacheTtlBilling(usage, false); + expect(usage).toEqual(before); + }); + + test("swap=undefined is a no-op", () => { + const usage: UsageMetrics = { + cache_creation_5m_input_tokens: 200, + cache_creation_1h_input_tokens: 300, + cache_ttl: "1h", + }; + const before = { ...usage }; + applySwapCacheTtlBilling(usage, undefined); + expect(usage).toEqual(before); + }); + + test("swap=true swaps bucket values", () => { + const usage: UsageMetrics = { + cache_creation_5m_input_tokens: 200, + cache_creation_1h_input_tokens: 300, + }; + applySwapCacheTtlBilling(usage, true); + expect(usage.cache_creation_5m_input_tokens).toBe(300); + expect(usage.cache_creation_1h_input_tokens).toBe(200); + }); + + test("swap=true inverts cache_ttl 5m->1h", () => { + const usage: UsageMetrics = { cache_ttl: "5m" }; + applySwapCacheTtlBilling(usage, true); + expect(usage.cache_ttl).toBe("1h"); + }); + + test("swap=true inverts cache_ttl 1h->5m", () => { + const usage: UsageMetrics = { cache_ttl: "1h" }; + applySwapCacheTtlBilling(usage, true); + expect(usage.cache_ttl).toBe("5m"); + }); + + test("swap=true leaves undefined cache_ttl as undefined", () => { + const usage: UsageMetrics = { cache_creation_5m_input_tokens: 100 }; + applySwapCacheTtlBilling(usage, true); + expect(usage.cache_ttl).toBeUndefined(); + }); + + test("swap=true with undefined bucket values does not crash", () => { + const usage: UsageMetrics = {}; + applySwapCacheTtlBilling(usage, true); + expect(usage.cache_creation_5m_input_tokens).toBeUndefined(); + expect(usage.cache_creation_1h_input_tokens).toBeUndefined(); + }); + + test("swap=true preserves non-cache fields", () => { + const usage: UsageMetrics = { + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 75, + cache_creation_5m_input_tokens: 200, + cache_creation_1h_input_tokens: 300, + cache_ttl: "5m", + }; + applySwapCacheTtlBilling(usage, true); + expect(usage.input_tokens).toBe(100); + expect(usage.output_tokens).toBe(50); + expect(usage.cache_read_input_tokens).toBe(75); + }); + + test("swap=true does not touch mixed cache_ttl", () => { + const usage: UsageMetrics = { + cache_creation_5m_input_tokens: 100, + cache_creation_1h_input_tokens: 200, + cache_ttl: "mixed", + }; + applySwapCacheTtlBilling(usage, true); + // Buckets swap + expect(usage.cache_creation_5m_input_tokens).toBe(200); + expect(usage.cache_creation_1h_input_tokens).toBe(100); + // "mixed" is not "5m" or "1h", so stays unchanged + expect(usage.cache_ttl).toBe("mixed"); + }); + + test("applySwapCacheTtlBilling does not affect a pre-cloned copy (caller isolation)", () => { + const original: UsageMetrics = { + cache_creation_5m_input_tokens: 200, + cache_creation_1h_input_tokens: 800, + cache_ttl: "5m", + }; + const snapshot = { ...original }; + + // Clone then swap (mimics the fixed normalizeUsageWithSwap pattern) + const clone = { ...original }; + applySwapCacheTtlBilling(clone, true); + + // Original must be untouched + expect(original.cache_creation_5m_input_tokens).toBe(snapshot.cache_creation_5m_input_tokens); + expect(original.cache_creation_1h_input_tokens).toBe(snapshot.cache_creation_1h_input_tokens); + expect(original.cache_ttl).toBe(snapshot.cache_ttl); + + // Clone should have swapped values + expect(clone.cache_creation_5m_input_tokens).toBe(800); + expect(clone.cache_creation_1h_input_tokens).toBe(200); + expect(clone.cache_ttl).toBe("1h"); + }); + + test("double swap returns to original values (idempotency)", () => { + const usage: UsageMetrics = { + input_tokens: 500, + output_tokens: 250, + cache_creation_5m_input_tokens: 200, + cache_creation_1h_input_tokens: 800, + cache_read_input_tokens: 150, + cache_ttl: "5m", + }; + const original = { ...usage }; + + applySwapCacheTtlBilling(usage, true); + // After first swap, values are inverted + expect(usage.cache_creation_5m_input_tokens).toBe(800); + expect(usage.cache_creation_1h_input_tokens).toBe(200); + expect(usage.cache_ttl).toBe("1h"); + + applySwapCacheTtlBilling(usage, true); + // After second swap, values return to original + expect(usage.cache_creation_5m_input_tokens).toBe(original.cache_creation_5m_input_tokens); + expect(usage.cache_creation_1h_input_tokens).toBe(original.cache_creation_1h_input_tokens); + expect(usage.cache_ttl).toBe(original.cache_ttl); + // Non-cache fields unchanged throughout + expect(usage.input_tokens).toBe(original.input_tokens); + expect(usage.output_tokens).toBe(original.output_tokens); + expect(usage.cache_read_input_tokens).toBe(original.cache_read_input_tokens); + }); +}); From 8f50adce6f736e0458ee0f1240b7a85e42e8031a Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:14:35 +0800 Subject: [PATCH 08/75] refactor(proxy): introduce EndpointPolicy to replace hardcoded count_tokens checks (#801) * refactor(proxy): introduce EndpointPolicy to replace hardcoded count_tokens checks Replace scattered isCountTokensRequest() conditionals with a unified EndpointPolicy system resolved once at session construction time. This generalizes the "raw passthrough" behavior to cover both count_tokens and responses/compact endpoints via a single policy object. Key changes: - Add endpoint-paths.ts (path constants + normalization with case/slash/query handling) - Add endpoint-policy.ts (EndpointPolicy interface + resolution logic) - ProxySession holds immutable EndpointPolicy resolved at construction - GuardPipeline.fromSession() reads policy instead of RequestType enum - Forwarder, ResponseHandler, RequestFilter all gate on policy flags - proxy-handler uses trackConcurrentRequests from policy * fix(proxy): remove duplicate cache TTL call and cache endpoint policy in local var - Remove redundant first applyCacheTtlOverrideToMessage call (lines 1905-1917) that duplicated the post-Anthropic-overrides call (lines 2025-2036) - Cache session.getEndpointPolicy() in local variable in error handling path to avoid repeated accessor calls Addresses: gemini-code-assist and coderabbitai review comments --- src/app/v1/_lib/proxy-handler.ts | 11 +- src/app/v1/_lib/proxy/endpoint-paths.ts | 64 ++++ src/app/v1/_lib/proxy/endpoint-policy.ts | 68 ++++ src/app/v1/_lib/proxy/forwarder.ts | 242 ++++++------- src/app/v1/_lib/proxy/guard-pipeline.ts | 32 +- .../v1/_lib/proxy/provider-request-filter.ts | 4 + src/app/v1/_lib/proxy/request-filter.ts | 4 + src/app/v1/_lib/proxy/response-handler.ts | 100 ++--- src/app/v1/_lib/proxy/session.ts | 22 +- .../proxy/endpoint-path-normalization.test.ts | 56 +++ .../unit/proxy/endpoint-policy-parity.test.ts | 341 ++++++++++++++++++ tests/unit/proxy/endpoint-policy.test.ts | 61 ++++ .../unit/proxy/guard-pipeline-warmup.test.ts | 71 +++- .../proxy-forwarder-fake-200-html.test.ts | 2 + ...y-forwarder-large-chunked-response.test.ts | 2 + .../proxy-forwarder-nonok-body-hang.test.ts | 2 + .../proxy/proxy-forwarder-retry-limit.test.ts | 69 ++++ .../proxy-handler-session-id-error.test.ts | 44 +++ ...handler-endpoint-circuit-isolation.test.ts | 2 + ...gemini-stream-passthrough-timeouts.test.ts | 2 + .../response-handler-lease-decrement.test.ts | 2 + tests/unit/proxy/session.test.ts | 49 +++ 22 files changed, 1051 insertions(+), 199 deletions(-) create mode 100644 src/app/v1/_lib/proxy/endpoint-paths.ts create mode 100644 src/app/v1/_lib/proxy/endpoint-policy.ts create mode 100644 tests/unit/proxy/endpoint-path-normalization.test.ts create mode 100644 tests/unit/proxy/endpoint-policy-parity.test.ts create mode 100644 tests/unit/proxy/endpoint-policy.test.ts diff --git a/src/app/v1/_lib/proxy-handler.ts b/src/app/v1/_lib/proxy-handler.ts index 5f2b90b4e..744791aa9 100644 --- a/src/app/v1/_lib/proxy-handler.ts +++ b/src/app/v1/_lib/proxy-handler.ts @@ -7,7 +7,7 @@ import { attachSessionIdToErrorResponse } from "./proxy/error-session-id"; import { ProxyError } from "./proxy/errors"; import { detectClientFormat, detectFormatByEndpoint } from "./proxy/format-mapper"; import { ProxyForwarder } from "./proxy/forwarder"; -import { GuardPipelineBuilder, RequestType } from "./proxy/guard-pipeline"; +import { GuardPipelineBuilder } from "./proxy/guard-pipeline"; import { ProxyResponseHandler } from "./proxy/response-handler"; import { ProxyResponses } from "./proxy/responses"; import { ProxySession } from "./proxy/session"; @@ -49,9 +49,8 @@ export async function handleProxyRequest(c: Context): Promise { } } - // Decide request type and build configured guard pipeline - const type = session.isCountTokensRequest() ? RequestType.COUNT_TOKENS : RequestType.CHAT; - const pipeline = GuardPipelineBuilder.fromRequestType(type); + // Build guard pipeline from session endpoint policy + const pipeline = GuardPipelineBuilder.fromSession(session); // Run guard chain; may return early Response const early = await pipeline.run(session); @@ -60,7 +59,7 @@ export async function handleProxyRequest(c: Context): Promise { } // 9. 增加并发计数(在所有检查通过后,请求开始前)- 跳过 count_tokens - if (session.sessionId && !session.isCountTokensRequest()) { + if (session.sessionId && session.getEndpointPolicy().trackConcurrentRequests) { await SessionTracker.incrementConcurrentCount(session.sessionId); } @@ -97,7 +96,7 @@ export async function handleProxyRequest(c: Context): Promise { return ProxyResponses.buildError(500, "代理请求发生未知错误"); } finally { // 11. 减少并发计数(确保无论成功失败都执行)- 跳过 count_tokens - if (session?.sessionId && !session.isCountTokensRequest()) { + if (session?.sessionId && session.getEndpointPolicy().trackConcurrentRequests) { await SessionTracker.decrementConcurrentCount(session.sessionId); } } diff --git a/src/app/v1/_lib/proxy/endpoint-paths.ts b/src/app/v1/_lib/proxy/endpoint-paths.ts new file mode 100644 index 000000000..2c32d4411 --- /dev/null +++ b/src/app/v1/_lib/proxy/endpoint-paths.ts @@ -0,0 +1,64 @@ +const V1_PREFIX = "/v1"; + +export const V1_ENDPOINT_PATHS = { + MESSAGES: "/v1/messages", + MESSAGES_COUNT_TOKENS: "/v1/messages/count_tokens", + RESPONSES: "/v1/responses", + RESPONSES_COMPACT: "/v1/responses/compact", + CHAT_COMPLETIONS: "/v1/chat/completions", + MODELS: "/v1/models", +} as const; + +export const STANDARD_ENDPOINT_PATHS = [ + V1_ENDPOINT_PATHS.MESSAGES, + V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS, + V1_ENDPOINT_PATHS.RESPONSES, + V1_ENDPOINT_PATHS.RESPONSES_COMPACT, + V1_ENDPOINT_PATHS.CHAT_COMPLETIONS, + V1_ENDPOINT_PATHS.MODELS, +] as const; + +export const STRICT_STANDARD_ENDPOINT_PATHS = [ + V1_ENDPOINT_PATHS.MESSAGES, + V1_ENDPOINT_PATHS.RESPONSES, + V1_ENDPOINT_PATHS.RESPONSES_COMPACT, + V1_ENDPOINT_PATHS.CHAT_COMPLETIONS, +] as const; + +const standardEndpointPathSet = new Set(STANDARD_ENDPOINT_PATHS); +const strictStandardEndpointPathSet = new Set(STRICT_STANDARD_ENDPOINT_PATHS); + +export function normalizeEndpointPath(pathname: string): string { + const pathWithoutQuery = pathname.split("?")[0]; + const trimmedPath = + pathWithoutQuery.length > 1 && pathWithoutQuery.endsWith("/") + ? pathWithoutQuery.slice(0, -1) + : pathWithoutQuery; + + return trimmedPath.toLowerCase(); +} + +export function isStandardEndpointPath(pathname: string): boolean { + return standardEndpointPathSet.has(normalizeEndpointPath(pathname)); +} + +export function isStrictStandardEndpointPath(pathname: string): boolean { + return strictStandardEndpointPathSet.has(normalizeEndpointPath(pathname)); +} + +export function isCountTokensEndpointPath(pathname: string): boolean { + return normalizeEndpointPath(pathname) === V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS; +} + +export function isResponseCompactEndpointPath(pathname: string): boolean { + return normalizeEndpointPath(pathname) === V1_ENDPOINT_PATHS.RESPONSES_COMPACT; +} + +export function toV1RoutePath(pathname: string): string { + if (!pathname.startsWith(V1_PREFIX)) { + return pathname; + } + + const routePath = pathname.slice(V1_PREFIX.length); + return routePath.length > 0 ? routePath : "/"; +} diff --git a/src/app/v1/_lib/proxy/endpoint-policy.ts b/src/app/v1/_lib/proxy/endpoint-policy.ts new file mode 100644 index 000000000..1bd77d89f --- /dev/null +++ b/src/app/v1/_lib/proxy/endpoint-policy.ts @@ -0,0 +1,68 @@ +import { normalizeEndpointPath, V1_ENDPOINT_PATHS } from "./endpoint-paths"; + +export type EndpointGuardPreset = "chat" | "raw_passthrough"; + +export type EndpointPoolStrictness = "inherit" | "strict"; + +export interface EndpointPolicy { + readonly kind: "default" | "raw_passthrough"; + readonly guardPreset: EndpointGuardPreset; + readonly allowRetry: boolean; + readonly allowProviderSwitch: boolean; + readonly allowCircuitBreakerAccounting: boolean; + readonly trackConcurrentRequests: boolean; + readonly bypassRequestFilters: boolean; + readonly bypassForwarderPreprocessing: boolean; + readonly bypassSpecialSettings: boolean; + readonly bypassResponseRectifier: boolean; + readonly endpointPoolStrictness: EndpointPoolStrictness; +} + +const DEFAULT_ENDPOINT_POLICY: EndpointPolicy = Object.freeze({ + kind: "default", + guardPreset: "chat", + allowRetry: true, + allowProviderSwitch: true, + allowCircuitBreakerAccounting: true, + trackConcurrentRequests: true, + bypassRequestFilters: false, + bypassForwarderPreprocessing: false, + bypassSpecialSettings: false, + bypassResponseRectifier: false, + endpointPoolStrictness: "inherit", +}); + +const RAW_PASSTHROUGH_ENDPOINT_POLICY: EndpointPolicy = Object.freeze({ + kind: "raw_passthrough", + guardPreset: "raw_passthrough", + allowRetry: false, + allowProviderSwitch: false, + allowCircuitBreakerAccounting: false, + trackConcurrentRequests: false, + bypassRequestFilters: true, + bypassForwarderPreprocessing: true, + bypassSpecialSettings: true, + bypassResponseRectifier: true, + endpointPoolStrictness: "strict", +}); + +const rawPassthroughEndpointPathSet = new Set([ + V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS, + V1_ENDPOINT_PATHS.RESPONSES_COMPACT, +]); + +export function isRawPassthroughEndpointPath(pathname: string): boolean { + return rawPassthroughEndpointPathSet.has(normalizeEndpointPath(pathname)); +} + +export function isRawPassthroughEndpointPolicy(policy: EndpointPolicy): boolean { + return policy.kind === "raw_passthrough"; +} + +export function resolveEndpointPolicy(pathname: string): EndpointPolicy { + if (isRawPassthroughEndpointPath(pathname)) { + return RAW_PASSTHROUGH_ENDPOINT_POLICY; + } + + return DEFAULT_ENDPOINT_POLICY; +} diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 5ef27aa31..bc161dd0e 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -1621,18 +1621,20 @@ export class ProxyForwarder { break; } - // 🆕 count_tokens 请求特殊处理:不计入熔断,不触发供应商切换 - if (session.isCountTokensRequest()) { + // Raw passthrough endpoints: no circuit breaker, no provider switch, no retry + const endpointPolicy = session.getEndpointPolicy(); + if (!endpointPolicy.allowRetry) { logger.debug( - "ProxyForwarder: count_tokens request error, skipping circuit breaker and provider switch", + "ProxyForwarder: raw passthrough endpoint error, skipping circuit breaker and provider switch", { providerId: currentProvider.id, providerName: currentProvider.name, statusCode, error: proxyError.message, + policyKind: endpointPolicy.kind, } ); - // 直接抛出错误,不重试,不切换供应商 + // Throw immediately: no retry, no provider switch throw lastError; } @@ -1780,12 +1782,14 @@ export class ProxyForwarder { session.setContext1mApplied(context1mApplied); } - // 应用模型重定向(如果配置了) - const wasRedirected = ModelRedirector.apply(session, provider); - if (wasRedirected) { - logger.debug("ProxyForwarder: Model redirected", { - providerId: provider.id, - }); + // Apply model redirect (if configured) - skip for raw passthrough endpoints + if (!session.getEndpointPolicy().bypassForwarderPreprocessing) { + const wasRedirected = ModelRedirector.apply(session, provider); + if (wasRedirected) { + logger.debug("ProxyForwarder: Model redirected", { + providerId: provider.id, + }); + } } let processedHeaders: Headers; @@ -1898,138 +1902,126 @@ export class ProxyForwarder { }); } else { // --- STANDARD HANDLING --- - if ( - resolvedCacheTtl && - (provider.providerType === "claude" || provider.providerType === "claude-auth") - ) { - const applied = applyCacheTtlOverrideToMessage(session.request.message, resolvedCacheTtl); - if (applied) { - logger.info("ProxyForwarder: Applied cache TTL override to request", { - providerId: provider.id, - providerName: provider.name, - cacheTtl: resolvedCacheTtl, - }); - } - } - - // Codex 供应商级参数覆写(默认 inherit=遵循客户端) - if (provider.providerType === "codex") { - const { request: overridden, audit } = applyCodexProviderOverridesWithAudit( - provider, - session.request.message as Record - ); - session.request.message = overridden; - - if (audit) { - session.addSpecialSetting(audit); - const specialSettings = session.getSpecialSettings(); - - if (session.sessionId) { - // 这里用 await:避免后续响应侧写入(ResponseFixer 等)先完成后,被本次旧快照覆写 - await SessionManager.storeSessionSpecialSettings( - session.sessionId, - specialSettings, - session.requestSequence - ).catch((err) => { - logger.error("[ProxyForwarder] Failed to store special settings", { - error: err, - sessionId: session.sessionId, + if (!session.getEndpointPolicy().bypassForwarderPreprocessing) { + // Codex 供应商级参数覆写(默认 inherit=遵循客户端) + if (provider.providerType === "codex") { + const { request: overridden, audit } = applyCodexProviderOverridesWithAudit( + provider, + session.request.message as Record + ); + session.request.message = overridden; + + if (audit) { + session.addSpecialSetting(audit); + const specialSettings = session.getSpecialSettings(); + + if (session.sessionId) { + // 这里用 await:避免后续响应侧写入(ResponseFixer 等)先完成后,被本次旧快照覆写 + await SessionManager.storeSessionSpecialSettings( + session.sessionId, + specialSettings, + session.requestSequence + ).catch((err) => { + logger.error("[ProxyForwarder] Failed to store special settings", { + error: err, + sessionId: session.sessionId, + }); }); - }); - } + } - if (session.messageContext?.id) { - // 同上:确保 special_settings 的"旧值"不会在并发下覆盖"新值" - await updateMessageRequestDetails(session.messageContext.id, { - specialSettings, - }).catch((err) => { - logger.error("[ProxyForwarder] Failed to persist special settings", { - error: err, - messageRequestId: session.messageContext?.id, + if (session.messageContext?.id) { + // 同上:确保 special_settings 的"旧值"不会在并发下覆盖"新值" + await updateMessageRequestDetails(session.messageContext.id, { + specialSettings, + }).catch((err) => { + logger.error("[ProxyForwarder] Failed to persist special settings", { + error: err, + messageRequestId: session.messageContext?.id, + }); }); - }); + } } } - } - // Anthropic 供应商级参数覆写(默认 inherit=遵循客户端) - // 说明:允许管理员在供应商层面强制覆写 max_tokens 和 thinking.budget_tokens - if (provider.providerType === "claude" || provider.providerType === "claude-auth") { - // Billing header rectifier: proactively strip x-anthropic-billing-header from system prompt - { - const settings = await getCachedSystemSettings(); - const billingRectifierEnabled = settings.enableBillingHeaderRectifier ?? true; - if (billingRectifierEnabled) { - const billingResult = rectifyBillingHeader( + // Anthropic 供应商级参数覆写(默认 inherit=遵循客户端) + // 说明:允许管理员在供应商层面强制覆写 max_tokens 和 thinking.budget_tokens + if (provider.providerType === "claude" || provider.providerType === "claude-auth") { + // Billing header rectifier: proactively strip x-anthropic-billing-header from system prompt + { + const settings = await getCachedSystemSettings(); + const billingRectifierEnabled = settings.enableBillingHeaderRectifier ?? true; + if (billingRectifierEnabled) { + const billingResult = rectifyBillingHeader( + session.request.message as Record + ); + if (billingResult.applied) { + session.addSpecialSetting({ + type: "billing_header_rectifier", + scope: "request", + hit: true, + removedCount: billingResult.removedCount, + extractedValues: billingResult.extractedValues, + }); + logger.info("ProxyForwarder: Billing header rectifier applied", { + providerId: provider.id, + providerName: provider.name, + removedCount: billingResult.removedCount, + }); + await persistSpecialSettings(session); + } + } + } + + const { request: anthropicOverridden, audit: anthropicAudit } = + applyAnthropicProviderOverridesWithAudit( + provider, session.request.message as Record ); - if (billingResult.applied) { - session.addSpecialSetting({ - type: "billing_header_rectifier", - scope: "request", - hit: true, - removedCount: billingResult.removedCount, - extractedValues: billingResult.extractedValues, - }); - logger.info("ProxyForwarder: Billing header rectifier applied", { - providerId: provider.id, - providerName: provider.name, - removedCount: billingResult.removedCount, + session.request.message = anthropicOverridden; + + if (anthropicAudit) { + session.addSpecialSetting(anthropicAudit); + const specialSettings = session.getSpecialSettings(); + + if (session.sessionId) { + await SessionManager.storeSessionSpecialSettings( + session.sessionId, + specialSettings, + session.requestSequence + ).catch((err) => { + logger.error("[ProxyForwarder] Failed to store Anthropic special settings", { + error: err, + sessionId: session.sessionId, + }); }); - await persistSpecialSettings(session); } - } - } - - const { request: anthropicOverridden, audit: anthropicAudit } = - applyAnthropicProviderOverridesWithAudit( - provider, - session.request.message as Record - ); - session.request.message = anthropicOverridden; - - if (anthropicAudit) { - session.addSpecialSetting(anthropicAudit); - const specialSettings = session.getSpecialSettings(); - if (session.sessionId) { - await SessionManager.storeSessionSpecialSettings( - session.sessionId, - specialSettings, - session.requestSequence - ).catch((err) => { - logger.error("[ProxyForwarder] Failed to store Anthropic special settings", { - error: err, - sessionId: session.sessionId, + if (session.messageContext?.id) { + await updateMessageRequestDetails(session.messageContext.id, { + specialSettings, + }).catch((err) => { + logger.error("[ProxyForwarder] Failed to persist Anthropic special settings", { + error: err, + messageRequestId: session.messageContext?.id, + }); }); - }); + } } + } - if (session.messageContext?.id) { - await updateMessageRequestDetails(session.messageContext.id, { - specialSettings, - }).catch((err) => { - logger.error("[ProxyForwarder] Failed to persist Anthropic special settings", { - error: err, - messageRequestId: session.messageContext?.id, - }); + if ( + resolvedCacheTtl && + (provider.providerType === "claude" || provider.providerType === "claude-auth") + ) { + const applied = applyCacheTtlOverrideToMessage(session.request.message, resolvedCacheTtl); + if (applied) { + logger.debug("ProxyForwarder: Applied cache TTL override to request", { + providerId: provider.id, + ttl: resolvedCacheTtl, }); } } - } - - if ( - resolvedCacheTtl && - (provider.providerType === "claude" || provider.providerType === "claude-auth") - ) { - const applied = applyCacheTtlOverrideToMessage(session.request.message, resolvedCacheTtl); - if (applied) { - logger.debug("ProxyForwarder: Applied cache TTL override to request", { - providerId: provider.id, - ttl: resolvedCacheTtl, - }); - } - } + } // end bypassForwarderPreprocessing gate processedHeaders = ProxyForwarder.buildHeaders(session, provider); diff --git a/src/app/v1/_lib/proxy/guard-pipeline.ts b/src/app/v1/_lib/proxy/guard-pipeline.ts index 7070477d8..8f881ca25 100644 --- a/src/app/v1/_lib/proxy/guard-pipeline.ts +++ b/src/app/v1/_lib/proxy/guard-pipeline.ts @@ -1,5 +1,6 @@ import { ProxyAuthenticator } from "./auth-guard"; import { ProxyClientGuard } from "./client-guard"; +import type { EndpointPolicy } from "./endpoint-policy"; import { ProxyMessageService } from "./message-service"; import { ProxyModelGuard } from "./model-guard"; import { ProxyProviderRequestFilter } from "./provider-request-filter"; @@ -157,11 +158,24 @@ export class GuardPipelineBuilder { }; } + static fromSession(session: Pick): GuardPipeline { + return GuardPipelineBuilder.fromEndpointPolicy(session.getEndpointPolicy()); + } + + static fromEndpointPolicy(policy: Pick): GuardPipeline { + switch (policy.guardPreset) { + case "raw_passthrough": + return GuardPipelineBuilder.build(RAW_PASSTHROUGH_PIPELINE); + default: + return GuardPipelineBuilder.build(CHAT_PIPELINE); + } + } + // Convenience: build a pipeline from preset request type static fromRequestType(type: RequestType): GuardPipeline { switch (type) { case RequestType.COUNT_TOKENS: - return GuardPipelineBuilder.build(COUNT_TOKENS_PIPELINE); + return GuardPipelineBuilder.build(RAW_PASSTHROUGH_PIPELINE); default: return GuardPipelineBuilder.build(CHAT_PIPELINE); } @@ -188,16 +202,8 @@ export const CHAT_PIPELINE: GuardConfig = { ], }; -export const COUNT_TOKENS_PIPELINE: GuardConfig = { - // Minimal chain for count_tokens: no session, no sensitive, no rate limit, no message logging - steps: [ - "auth", - "client", - "model", - "version", - "probe", - "requestFilter", - "provider", - "providerRequestFilter", - ], +export const RAW_PASSTHROUGH_PIPELINE: GuardConfig = { + steps: ["auth", "client", "model", "version", "probe", "provider"], }; + +export const COUNT_TOKENS_PIPELINE: GuardConfig = RAW_PASSTHROUGH_PIPELINE; diff --git a/src/app/v1/_lib/proxy/provider-request-filter.ts b/src/app/v1/_lib/proxy/provider-request-filter.ts index 719bc1307..68242b029 100644 --- a/src/app/v1/_lib/proxy/provider-request-filter.ts +++ b/src/app/v1/_lib/proxy/provider-request-filter.ts @@ -9,6 +9,10 @@ import type { ProxySession } from "./session"; */ export class ProxyProviderRequestFilter { static async ensure(session: ProxySession): Promise { + if (session.getEndpointPolicy().bypassRequestFilters) { + return; + } + if (!session.provider) { logger.warn( "[ProxyProviderRequestFilter] No provider selected, skipping provider-specific filters" diff --git a/src/app/v1/_lib/proxy/request-filter.ts b/src/app/v1/_lib/proxy/request-filter.ts index 468863724..68e45aae7 100644 --- a/src/app/v1/_lib/proxy/request-filter.ts +++ b/src/app/v1/_lib/proxy/request-filter.ts @@ -12,6 +12,10 @@ import type { ProxySession } from "./session"; */ export class ProxyRequestFilter { static async ensure(session: ProxySession): Promise { + if (session.getEndpointPolicy().bypassRequestFilters) { + return; + } + try { await requestFilterEngine.applyGlobal(session); } catch (error) { diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index a50d4bb6b..f0eed673b 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -253,7 +253,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( // - 客户端主动中断:不计入熔断器(这通常不是供应商问题) // - 非客户端中断:计入 provider/endpoint 熔断失败(与 timeout 路径保持一致) if (!streamEndedNormally) { - if (!clientAborted) { + if (!clientAborted && session.getEndpointPolicy().allowCircuitBreakerAccounting) { try { // 动态导入:避免 proxy 模块与熔断器模块之间潜在的循环依赖。 const { recordFailure } = await import("@/lib/circuit-breaker"); @@ -301,7 +301,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( // 计入熔断器:让后续请求能正确触发故障转移/熔断。 // // 注意:404 语义在 forwarder 中属于 RESOURCE_NOT_FOUND,不计入熔断器(避免把“资源/模型不存在”当作供应商故障)。 - if (effectiveStatusCode !== 404) { + if (effectiveStatusCode !== 404 && session.getEndpointPolicy().allowCircuitBreakerAccounting) { try { // 动态导入:避免 proxy 模块与熔断器模块之间潜在的循环依赖。 const { recordFailure } = await import("@/lib/circuit-breaker"); @@ -350,7 +350,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( // 计入熔断器:让后续请求能正确触发故障转移/熔断。 // 注意:与 forwarder 口径保持一致:404 不计入熔断器(资源不存在不是供应商故障)。 - if (effectiveStatusCode !== 404) { + if (effectiveStatusCode !== 404 && session.getEndpointPolicy().allowCircuitBreakerAccounting) { try { const { recordFailure } = await import("@/lib/circuit-breaker"); await recordFailure(meta.providerId, new Error(errorMessage)); @@ -469,19 +469,21 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( export class ProxyResponseHandler { static async dispatch(session: ProxySession, response: Response): Promise { let fixedResponse = response; - try { - fixedResponse = await ResponseFixer.process(session, response); - } catch (error) { - logger.error( - "[ResponseHandler] ResponseFixer failed (getCachedSystemSettings/processNonStream)", - { - error: error instanceof Error ? error.message : String(error), - sessionId: session.sessionId ?? null, - messageRequestId: session.messageContext?.id ?? null, - requestSequence: session.requestSequence ?? null, - } - ); - fixedResponse = response; + if (!session.getEndpointPolicy().bypassResponseRectifier) { + try { + fixedResponse = await ResponseFixer.process(session, response); + } catch (error) { + logger.error( + "[ResponseHandler] ResponseFixer failed (getCachedSystemSettings/processNonStream)", + { + error: error instanceof Error ? error.message : String(error), + sessionId: session.sessionId ?? null, + messageRequestId: session.messageContext?.id ?? null, + requestSequence: session.requestSequence ?? null, + } + ); + fixedResponse = response; + } } const contentType = fixedResponse.headers.get("content-type") || ""; @@ -561,17 +563,19 @@ export class ProxyResponseHandler { errorMessageForFinalize = detected.isError ? detected.code : `HTTP ${statusCode}`; // 计入熔断器 - try { - const { recordFailure } = await import("@/lib/circuit-breaker"); - await recordFailure(provider.id, new Error(errorMessageForFinalize)); - } catch (cbError) { - logger.warn( - "ResponseHandler: Failed to record non-200 error in circuit breaker (passthrough)", - { - providerId: provider.id, - error: cbError, - } - ); + if (session.getEndpointPolicy().allowCircuitBreakerAccounting) { + try { + const { recordFailure } = await import("@/lib/circuit-breaker"); + await recordFailure(provider.id, new Error(errorMessageForFinalize)); + } catch (cbError) { + logger.warn( + "ResponseHandler: Failed to record non-200 error in circuit breaker (passthrough)", + { + providerId: provider.id, + error: cbError, + } + ); + } } // 记录到决策链 @@ -851,14 +855,16 @@ export class ProxyResponseHandler { const errorMessageForDb = detected.isError ? detected.code : `HTTP ${statusCode}`; // 计入熔断器 - try { - const { recordFailure } = await import("@/lib/circuit-breaker"); - await recordFailure(provider.id, new Error(errorMessageForDb)); - } catch (cbError) { - logger.warn("ResponseHandler: Failed to record non-200 error in circuit breaker", { - providerId: provider.id, - error: cbError, - }); + if (session.getEndpointPolicy().allowCircuitBreakerAccounting) { + try { + const { recordFailure } = await import("@/lib/circuit-breaker"); + await recordFailure(provider.id, new Error(errorMessageForDb)); + } catch (cbError) { + logger.warn("ResponseHandler: Failed to record non-200 error in circuit breaker", { + providerId: provider.id, + error: cbError, + }); + } } // 记录到决策链 @@ -938,17 +944,19 @@ export class ProxyResponseHandler { }); // 计入熔断器(动态导入避免循环依赖) - try { - const { recordFailure } = await import("@/lib/circuit-breaker"); - await recordFailure(provider.id, err); - logger.debug("ResponseHandler: Response timeout recorded in circuit breaker", { - providerId: provider.id, - }); - } catch (cbError) { - logger.warn("ResponseHandler: Failed to record timeout in circuit breaker", { - providerId: provider.id, - error: cbError, - }); + if (session.getEndpointPolicy().allowCircuitBreakerAccounting) { + try { + const { recordFailure } = await import("@/lib/circuit-breaker"); + await recordFailure(provider.id, err); + logger.debug("ResponseHandler: Response timeout recorded in circuit breaker", { + providerId: provider.id, + }); + } catch (cbError) { + logger.warn("ResponseHandler: Failed to record timeout in circuit breaker", { + providerId: provider.id, + error: cbError, + }); + } } // 注意:无法重试,因为客户端已收到 HTTP 200 diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index dda2c4a46..bebf1ba9e 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -11,6 +11,8 @@ import type { ModelPriceData } from "@/types/model-price"; import type { Provider, ProviderType } from "@/types/provider"; import type { SpecialSetting } from "@/types/special-settings"; import type { User } from "@/types/user"; +import { isCountTokensEndpointPath } from "./endpoint-paths"; +import { type EndpointPolicy, resolveEndpointPolicy } from "./endpoint-policy"; import { ProxyError } from "./errors"; import type { ClientFormat } from "./format-mapper"; @@ -83,6 +85,8 @@ export class ProxySession { originalFormat: ClientFormat = "claude"; providerType: ProviderType | null = null; + private readonly endpointPolicy: EndpointPolicy; + // 模型重定向追踪:保存原始模型名(重定向前) private originalModelName: string | null = null; @@ -154,6 +158,7 @@ export class ProxySession { this.messageContext = null; this.sessionId = null; this.providerChain = []; + this.endpointPolicy = resolveSessionEndpointPolicy(init.requestUrl); } static async fromContext(c: Context): Promise { @@ -528,6 +533,10 @@ export class ProxySession { return this.request.model; } + getEndpointPolicy(): EndpointPolicy { + return this.endpointPolicy; + } + /** * 获取请求的 API endpoint(来自 URL.pathname) * 处理边界:若 URL 不存在则返回 null @@ -548,7 +557,7 @@ export class ProxySession { */ isCountTokensRequest(): boolean { const endpoint = this.getEndpoint(); - return endpoint === "/v1/messages/count_tokens"; + return endpoint !== null && isCountTokensEndpointPath(endpoint); } /** @@ -793,6 +802,17 @@ function optimizeRequestMessage(message: Record): Record 0) { + return resolveEndpointPolicy(pathname); + } + } catch {} + + return resolveEndpointPolicy("/"); +} + export function extractModelFromPath(pathname: string): string | null { // 匹配 Vertex AI 路径:/v1/publishers/google/models/{model}: const publishersMatch = pathname.match(/\/publishers\/google\/models\/([^/:]+)(?::[^/]+)?/); diff --git a/tests/unit/proxy/endpoint-path-normalization.test.ts b/tests/unit/proxy/endpoint-path-normalization.test.ts new file mode 100644 index 000000000..8b4662e04 --- /dev/null +++ b/tests/unit/proxy/endpoint-path-normalization.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "vitest"; +import { isRawPassthroughEndpointPath } from "@/app/v1/_lib/proxy/endpoint-policy"; +import { + isCountTokensEndpointPath, + isResponseCompactEndpointPath, +} from "@/app/v1/_lib/proxy/endpoint-paths"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; + +const countTokensVariants = [ + "/v1/messages/count_tokens", + "/v1/messages/count_tokens/", + "/V1/MESSAGES/COUNT_TOKENS", +]; + +const compactVariants = [ + "/v1/responses/compact", + "/v1/responses/compact/", + "/V1/RESPONSES/COMPACT", +]; + +function isCountTokensRequestWithEndpoint(pathname: string | null): boolean { + const sessionLike = { + getEndpoint: () => pathname, + } as Pick; + + return ProxySession.prototype.isCountTokensRequest.call(sessionLike as ProxySession); +} + +describe("endpoint path normalization", () => { + test.each(countTokensVariants)("count_tokens stays classified for variant %s", (pathname) => { + expect(isCountTokensEndpointPath(pathname)).toBe(true); + expect(isRawPassthroughEndpointPath(pathname)).toBe(true); + expect(isCountTokensRequestWithEndpoint(pathname)).toBe(true); + }); + + test.each(compactVariants)("responses/compact stays classified for variant %s", (pathname) => { + expect(isResponseCompactEndpointPath(pathname)).toBe(true); + expect(isRawPassthroughEndpointPath(pathname)).toBe(true); + }); + + test.each([ + "/v1/messages", + "/v1/responses", + "/v1/messages/count", + "/v1/responses/mini", + ])("non-target path is not misclassified for %s", (pathname) => { + expect(isCountTokensEndpointPath(pathname)).toBe(false); + expect(isResponseCompactEndpointPath(pathname)).toBe(false); + expect(isRawPassthroughEndpointPath(pathname)).toBe(false); + expect(isCountTokensRequestWithEndpoint(pathname)).toBe(false); + }); + + test("session count_tokens detection handles null endpoint", () => { + expect(isCountTokensRequestWithEndpoint(null)).toBe(false); + }); +}); diff --git a/tests/unit/proxy/endpoint-policy-parity.test.ts b/tests/unit/proxy/endpoint-policy-parity.test.ts new file mode 100644 index 000000000..7145728fd --- /dev/null +++ b/tests/unit/proxy/endpoint-policy-parity.test.ts @@ -0,0 +1,341 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { + type EndpointPolicy, + isRawPassthroughEndpointPath, + isRawPassthroughEndpointPolicy, + resolveEndpointPolicy, +} from "@/app/v1/_lib/proxy/endpoint-policy"; +import { V1_ENDPOINT_PATHS } from "@/app/v1/_lib/proxy/endpoint-paths"; + +// --------------------------------------------------------------------------- +// Shared constants +// --------------------------------------------------------------------------- + +const RAW_PASSTHROUGH_ENDPOINTS = [ + V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS, + V1_ENDPOINT_PATHS.RESPONSES_COMPACT, +] as const; + +const DEFAULT_ENDPOINTS = [ + V1_ENDPOINT_PATHS.MESSAGES, + V1_ENDPOINT_PATHS.RESPONSES, + V1_ENDPOINT_PATHS.CHAT_COMPLETIONS, +] as const; + +// --------------------------------------------------------------------------- +// T11: Endpoint parity -- count_tokens and responses/compact produce +// identical EndpointPolicy objects and exhibit identical behaviour +// under provider errors. +// --------------------------------------------------------------------------- + +describe("T11: raw passthrough endpoint parity", () => { + test("count_tokens and responses/compact resolve to the exact same EndpointPolicy object", () => { + const countTokensPolicy = resolveEndpointPolicy(V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS); + const compactPolicy = resolveEndpointPolicy(V1_ENDPOINT_PATHS.RESPONSES_COMPACT); + + // Reference equality: same frozen singleton + expect(countTokensPolicy).toBe(compactPolicy); + + // Both recognized as raw_passthrough + expect(isRawPassthroughEndpointPolicy(countTokensPolicy)).toBe(true); + expect(isRawPassthroughEndpointPolicy(compactPolicy)).toBe(true); + }); + + test("both raw passthrough endpoints have identical strict policy fields", () => { + const countTokensPolicy = resolveEndpointPolicy(V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS); + const compactPolicy = resolveEndpointPolicy(V1_ENDPOINT_PATHS.RESPONSES_COMPACT); + + const expectedPolicy: EndpointPolicy = { + kind: "raw_passthrough", + guardPreset: "raw_passthrough", + allowRetry: false, + allowProviderSwitch: false, + allowCircuitBreakerAccounting: false, + trackConcurrentRequests: false, + bypassRequestFilters: true, + bypassForwarderPreprocessing: true, + bypassSpecialSettings: true, + bypassResponseRectifier: true, + endpointPoolStrictness: "strict", + }; + + expect(countTokensPolicy).toEqual(expectedPolicy); + expect(compactPolicy).toEqual(expectedPolicy); + }); + + test("under provider error, both endpoints result in no retry, no provider switch, no circuit breaker accounting", () => { + for (const pathname of RAW_PASSTHROUGH_ENDPOINTS) { + const policy = resolveEndpointPolicy(pathname); + + expect(policy.allowRetry).toBe(false); + expect(policy.allowProviderSwitch).toBe(false); + expect(policy.allowCircuitBreakerAccounting).toBe(false); + } + }); + + test("isRawPassthroughEndpointPath returns true for both raw passthrough canonical paths", () => { + for (const pathname of RAW_PASSTHROUGH_ENDPOINTS) { + expect(isRawPassthroughEndpointPath(pathname)).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// T12: Bypass completeness -- spy-based zero-call assertions to verify that +// request filter guards early-return without invoking the engine. +// --------------------------------------------------------------------------- + +const applyGlobalMock = vi.fn(async () => {}); +const applyForProviderMock = vi.fn(async () => {}); + +vi.mock("@/lib/request-filter-engine", () => ({ + requestFilterEngine: { + applyGlobal: applyGlobalMock, + applyForProvider: applyForProviderMock, + }, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + }, +})); + +describe("T12: bypass completeness (spy-based zero-call assertions)", () => { + beforeEach(() => { + applyGlobalMock.mockClear(); + applyForProviderMock.mockClear(); + }); + + test("ProxyRequestFilter.ensure early-returns without calling applyGlobal for raw passthrough", async () => { + const { ProxyRequestFilter } = await import("@/app/v1/_lib/proxy/request-filter"); + + for (const pathname of RAW_PASSTHROUGH_ENDPOINTS) { + applyGlobalMock.mockClear(); + + const session = { + getEndpointPolicy: () => resolveEndpointPolicy(pathname), + } as any; + + await ProxyRequestFilter.ensure(session); + expect(applyGlobalMock).not.toHaveBeenCalled(); + } + }); + + test("ProxyProviderRequestFilter.ensure early-returns without calling applyForProvider for raw passthrough", async () => { + const { ProxyProviderRequestFilter } = await import( + "@/app/v1/_lib/proxy/provider-request-filter" + ); + + for (const pathname of RAW_PASSTHROUGH_ENDPOINTS) { + applyForProviderMock.mockClear(); + + const session = { + getEndpointPolicy: () => resolveEndpointPolicy(pathname), + provider: { id: 1 }, + } as any; + + await ProxyProviderRequestFilter.ensure(session); + expect(applyForProviderMock).not.toHaveBeenCalled(); + } + }); + + test("ProxyRequestFilter.ensure calls applyGlobal for default policy endpoints", async () => { + const { ProxyRequestFilter } = await import("@/app/v1/_lib/proxy/request-filter"); + + for (const pathname of DEFAULT_ENDPOINTS) { + applyGlobalMock.mockClear(); + + const session = { + getEndpointPolicy: () => resolveEndpointPolicy(pathname), + } as any; + + await ProxyRequestFilter.ensure(session); + expect(applyGlobalMock).toHaveBeenCalledTimes(1); + } + }); + + test("ProxyProviderRequestFilter.ensure calls applyForProvider for default policy endpoints", async () => { + const { ProxyProviderRequestFilter } = await import( + "@/app/v1/_lib/proxy/provider-request-filter" + ); + + for (const pathname of DEFAULT_ENDPOINTS) { + applyForProviderMock.mockClear(); + + const session = { + getEndpointPolicy: () => resolveEndpointPolicy(pathname), + provider: { id: 1 }, + } as any; + + await ProxyProviderRequestFilter.ensure(session); + expect(applyForProviderMock).toHaveBeenCalledTimes(1); + } + }); +}); + +// --------------------------------------------------------------------------- +// T13: Non-target regression -- default endpoints retain full default policy. +// --------------------------------------------------------------------------- + +describe("T13: non-target regression (default policy preserved)", () => { + const expectedDefaultPolicy: EndpointPolicy = { + kind: "default", + guardPreset: "chat", + allowRetry: true, + allowProviderSwitch: true, + allowCircuitBreakerAccounting: true, + trackConcurrentRequests: true, + bypassRequestFilters: false, + bypassForwarderPreprocessing: false, + bypassSpecialSettings: false, + bypassResponseRectifier: false, + endpointPoolStrictness: "inherit", + }; + + test("/v1/messages retains full default policy", () => { + const policy = resolveEndpointPolicy(V1_ENDPOINT_PATHS.MESSAGES); + expect(policy).toEqual(expectedDefaultPolicy); + expect(isRawPassthroughEndpointPolicy(policy)).toBe(false); + }); + + test("/v1/responses retains full default policy", () => { + const policy = resolveEndpointPolicy(V1_ENDPOINT_PATHS.RESPONSES); + expect(policy).toEqual(expectedDefaultPolicy); + expect(isRawPassthroughEndpointPolicy(policy)).toBe(false); + }); + + test("/v1/chat/completions retains full default policy", () => { + const policy = resolveEndpointPolicy(V1_ENDPOINT_PATHS.CHAT_COMPLETIONS); + expect(policy).toEqual(expectedDefaultPolicy); + expect(isRawPassthroughEndpointPolicy(policy)).toBe(false); + }); + + test("all default endpoints resolve to the same singleton object", () => { + const policies = DEFAULT_ENDPOINTS.map((p) => resolveEndpointPolicy(p)); + // All should be the same reference + for (let i = 1; i < policies.length; i++) { + expect(policies[i]).toBe(policies[0]); + } + }); + + test("default policy has all bypass flags set to false", () => { + for (const pathname of DEFAULT_ENDPOINTS) { + const policy = resolveEndpointPolicy(pathname); + expect(policy.bypassRequestFilters).toBe(false); + expect(policy.bypassForwarderPreprocessing).toBe(false); + expect(policy.bypassSpecialSettings).toBe(false); + expect(policy.bypassResponseRectifier).toBe(false); + } + }); + + test("default policy has all allow flags set to true", () => { + for (const pathname of DEFAULT_ENDPOINTS) { + const policy = resolveEndpointPolicy(pathname); + expect(policy.allowRetry).toBe(true); + expect(policy.allowProviderSwitch).toBe(true); + expect(policy.allowCircuitBreakerAccounting).toBe(true); + expect(policy.trackConcurrentRequests).toBe(true); + } + }); +}); + +// --------------------------------------------------------------------------- +// T14: Path edge-case tests -- normalization handles trailing slashes, case +// variants, query strings, and non-matching paths correctly. +// --------------------------------------------------------------------------- + +describe("T14: path edge-case normalization", () => { + test("trailing slash: /v1/messages/count_tokens/ -> raw_passthrough", () => { + expect(isRawPassthroughEndpointPath("/v1/messages/count_tokens/")).toBe(true); + const policy = resolveEndpointPolicy("/v1/messages/count_tokens/"); + expect(policy.kind).toBe("raw_passthrough"); + }); + + test("trailing slash: /v1/responses/compact/ -> raw_passthrough", () => { + expect(isRawPassthroughEndpointPath("/v1/responses/compact/")).toBe(true); + const policy = resolveEndpointPolicy("/v1/responses/compact/"); + expect(policy.kind).toBe("raw_passthrough"); + }); + + test("uppercase: /V1/MESSAGES/COUNT_TOKENS -> raw_passthrough", () => { + expect(isRawPassthroughEndpointPath("/V1/MESSAGES/COUNT_TOKENS")).toBe(true); + const policy = resolveEndpointPolicy("/V1/MESSAGES/COUNT_TOKENS"); + expect(policy.kind).toBe("raw_passthrough"); + }); + + test("uppercase: /V1/RESPONSES/COMPACT -> raw_passthrough", () => { + expect(isRawPassthroughEndpointPath("/V1/RESPONSES/COMPACT")).toBe(true); + const policy = resolveEndpointPolicy("/V1/RESPONSES/COMPACT"); + expect(policy.kind).toBe("raw_passthrough"); + }); + + test("query string: /v1/messages/count_tokens?foo=bar -> raw_passthrough", () => { + expect(isRawPassthroughEndpointPath("/v1/messages/count_tokens?foo=bar")).toBe(true); + const policy = resolveEndpointPolicy("/v1/messages/count_tokens?foo=bar"); + expect(policy.kind).toBe("raw_passthrough"); + }); + + test("query string: /v1/responses/compact?foo=bar -> raw_passthrough", () => { + expect(isRawPassthroughEndpointPath("/v1/responses/compact?foo=bar")).toBe(true); + const policy = resolveEndpointPolicy("/v1/responses/compact?foo=bar"); + expect(policy.kind).toBe("raw_passthrough"); + }); + + test("combined edge case: uppercase + trailing slash + query string", () => { + expect(isRawPassthroughEndpointPath("/V1/MESSAGES/COUNT_TOKENS/?x=1")).toBe(true); + expect(isRawPassthroughEndpointPath("/V1/RESPONSES/COMPACT/?x=1")).toBe(true); + + const policy1 = resolveEndpointPolicy("/V1/MESSAGES/COUNT_TOKENS/?x=1"); + const policy2 = resolveEndpointPolicy("/V1/RESPONSES/COMPACT/?x=1"); + expect(policy1.kind).toBe("raw_passthrough"); + expect(policy2.kind).toBe("raw_passthrough"); + }); + + test("/v1/messages/ (with trailing slash) -> default, NOT raw_passthrough", () => { + expect(isRawPassthroughEndpointPath("/v1/messages/")).toBe(false); + const policy = resolveEndpointPolicy("/v1/messages/"); + expect(policy.kind).toBe("default"); + }); + + test("/v1/messages (no trailing slash) -> default", () => { + expect(isRawPassthroughEndpointPath("/v1/messages")).toBe(false); + const policy = resolveEndpointPolicy("/v1/messages"); + expect(policy.kind).toBe("default"); + }); + + test("/v1/responses (no sub-path) -> default", () => { + expect(isRawPassthroughEndpointPath("/v1/responses")).toBe(false); + const policy = resolveEndpointPolicy("/v1/responses"); + expect(policy.kind).toBe("default"); + }); + + test("/v1/chat/completions -> default", () => { + expect(isRawPassthroughEndpointPath("/v1/chat/completions")).toBe(false); + const policy = resolveEndpointPolicy("/v1/chat/completions"); + expect(policy.kind).toBe("default"); + }); + + test.each([ + "/v1/messages/count", + "/v1/messages/count_token", + "/v1/responses/mini", + "/v1/responses/compacted", + "/v2/messages/count_tokens", + "/v1/messages/count_tokens/extra", + ])("non-matching path %s -> default", (pathname) => { + expect(isRawPassthroughEndpointPath(pathname)).toBe(false); + const policy = resolveEndpointPolicy(pathname); + expect(policy.kind).toBe("default"); + }); + + test("empty and root paths -> default", () => { + expect(resolveEndpointPolicy("/").kind).toBe("default"); + expect(resolveEndpointPolicy("").kind).toBe("default"); + }); +}); diff --git a/tests/unit/proxy/endpoint-policy.test.ts b/tests/unit/proxy/endpoint-policy.test.ts new file mode 100644 index 000000000..af540acc6 --- /dev/null +++ b/tests/unit/proxy/endpoint-policy.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "vitest"; +import { + isRawPassthroughEndpointPath, + isRawPassthroughEndpointPolicy, + resolveEndpointPolicy, +} from "@/app/v1/_lib/proxy/endpoint-policy"; +import { V1_ENDPOINT_PATHS } from "@/app/v1/_lib/proxy/endpoint-paths"; + +describe("endpoint-policy", () => { + test("raw passthrough endpoints resolve to identical strict policy", () => { + const countTokensPolicy = resolveEndpointPolicy(V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS); + const compactPolicy = resolveEndpointPolicy(V1_ENDPOINT_PATHS.RESPONSES_COMPACT); + + expect(countTokensPolicy).toBe(compactPolicy); + expect(isRawPassthroughEndpointPolicy(countTokensPolicy)).toBe(true); + expect(countTokensPolicy).toEqual({ + kind: "raw_passthrough", + guardPreset: "raw_passthrough", + allowRetry: false, + allowProviderSwitch: false, + allowCircuitBreakerAccounting: false, + trackConcurrentRequests: false, + bypassRequestFilters: true, + bypassForwarderPreprocessing: true, + bypassSpecialSettings: true, + bypassResponseRectifier: true, + endpointPoolStrictness: "strict", + }); + }); + + test.each([ + "/v1/messages/count_tokens/", + "/V1/MESSAGES/COUNT_TOKENS", + "/v1/responses/compact/", + "/V1/RESPONSES/COMPACT", + ])("raw passthrough endpoints path helper matches variant %s", (pathname) => { + expect(isRawPassthroughEndpointPath(pathname)).toBe(true); + expect(isRawPassthroughEndpointPolicy(resolveEndpointPolicy(pathname))).toBe(true); + }); + + test("default policy stays on non-target endpoints", () => { + const messagesPolicy = resolveEndpointPolicy(V1_ENDPOINT_PATHS.MESSAGES); + const responsesPolicy = resolveEndpointPolicy(V1_ENDPOINT_PATHS.RESPONSES); + + expect(messagesPolicy).toBe(responsesPolicy); + expect(isRawPassthroughEndpointPolicy(messagesPolicy)).toBe(false); + expect(messagesPolicy).toEqual({ + kind: "default", + guardPreset: "chat", + allowRetry: true, + allowProviderSwitch: true, + allowCircuitBreakerAccounting: true, + trackConcurrentRequests: true, + bypassRequestFilters: false, + bypassForwarderPreprocessing: false, + bypassSpecialSettings: false, + bypassResponseRectifier: false, + endpointPoolStrictness: "inherit", + }); + }); +}); diff --git a/tests/unit/proxy/guard-pipeline-warmup.test.ts b/tests/unit/proxy/guard-pipeline-warmup.test.ts index 401565913..aa953f336 100644 --- a/tests/unit/proxy/guard-pipeline-warmup.test.ts +++ b/tests/unit/proxy/guard-pipeline-warmup.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test, vi } from "vitest"; +import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; +import { V1_ENDPOINT_PATHS } from "@/app/v1/_lib/proxy/endpoint-paths"; const callOrder: string[] = []; @@ -204,20 +206,73 @@ describe("GuardPipeline:warmup 拦截点", () => { const res = await pipeline.run(session); expect(res).toBeNull(); + expect(callOrder).toEqual(["auth", "client", "model", "version", "probe", "provider"]); + expect(callOrder).not.toContain("session"); + expect(callOrder).not.toContain("warmup"); + expect(callOrder).not.toContain("sensitive"); + expect(callOrder).not.toContain("rateLimit"); + expect(callOrder).not.toContain("requestFilter"); + expect(callOrder).not.toContain("providerRequestFilter"); + expect(callOrder).not.toContain("messageContext"); + }); + + test("count_tokens 和 responses/compact 应通过 endpoint policy 选择同一 raw preset", async () => { + const { GuardPipelineBuilder } = await import("@/app/v1/_lib/proxy/guard-pipeline"); + + const endpoints = [ + V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS, + V1_ENDPOINT_PATHS.RESPONSES_COMPACT, + ]; + const orders: string[][] = []; + + for (const endpoint of endpoints) { + callOrder.length = 0; + const session = { + getEndpointPolicy: () => resolveEndpointPolicy(endpoint), + isProbeRequest: () => { + callOrder.push("probe"); + return false; + }, + } as any; + + const pipeline = GuardPipelineBuilder.fromSession(session); + const res = await pipeline.run(session); + + expect(res).toBeNull(); + orders.push([...callOrder]); + } + + expect(orders[0]).toEqual(orders[1]); + expect(orders[0]).toEqual(["auth", "client", "model", "version", "probe", "provider"]); + }); + + test("/v1/messages 仍应通过 endpoint policy 选择现有 chat preset", async () => { + callOrder.length = 0; + + const { GuardPipelineBuilder } = await import("@/app/v1/_lib/proxy/guard-pipeline"); + + const session = { + getEndpointPolicy: () => resolveEndpointPolicy(V1_ENDPOINT_PATHS.MESSAGES), + isProbeRequest: () => { + callOrder.push("probe"); + return false; + }, + } as any; + + const pipeline = GuardPipelineBuilder.fromSession(session); + const res = await pipeline.run(session); + + expect(res).not.toBeNull(); + expect(res?.status).toBe(200); expect(callOrder).toEqual([ "auth", + "sensitive", "client", "model", "version", "probe", - "requestFilter", - "provider", - "providerRequestFilter", + "session", + "warmup", ]); - expect(callOrder).not.toContain("session"); - expect(callOrder).not.toContain("warmup"); - expect(callOrder).not.toContain("sensitive"); - expect(callOrder).not.toContain("rateLimit"); - expect(callOrder).not.toContain("messageContext"); }); }); diff --git a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts index 2aa054a10..8b57d3915 100644 --- a/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts +++ b/tests/unit/proxy/proxy-forwarder-fake-200-html.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; +import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; const mocks = vi.hoisted(() => { return { @@ -185,6 +186,7 @@ function createSession(): ProxySession { specialSettings: [], cachedPriceData: undefined, cachedBillingModelSource: undefined, + endpointPolicy: resolveEndpointPolicy("/v1/messages"), isHeaderModified: () => false, }); diff --git a/tests/unit/proxy/proxy-forwarder-large-chunked-response.test.ts b/tests/unit/proxy/proxy-forwarder-large-chunked-response.test.ts index 2bdfc284b..4e9cfd9c7 100644 --- a/tests/unit/proxy/proxy-forwarder-large-chunked-response.test.ts +++ b/tests/unit/proxy/proxy-forwarder-large-chunked-response.test.ts @@ -2,6 +2,7 @@ import { createServer } from "node:http"; import type { Socket } from "node:net"; import { describe, expect, test, vi } from "vitest"; import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; +import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; import { ProxySession } from "@/app/v1/_lib/proxy/session"; import type { Provider } from "@/types/provider"; @@ -128,6 +129,7 @@ function createSession(params?: { clientAbortSignal?: AbortSignal | null }): Pro specialSettings: [], cachedPriceData: undefined, cachedBillingModelSource: undefined, + endpointPolicy: resolveEndpointPolicy("/v1/chat/completions"), isHeaderModified: () => false, }); diff --git a/tests/unit/proxy/proxy-forwarder-nonok-body-hang.test.ts b/tests/unit/proxy/proxy-forwarder-nonok-body-hang.test.ts index d1a1e3cf4..95eee1076 100644 --- a/tests/unit/proxy/proxy-forwarder-nonok-body-hang.test.ts +++ b/tests/unit/proxy/proxy-forwarder-nonok-body-hang.test.ts @@ -2,6 +2,7 @@ import { createServer } from "node:http"; import type { Socket } from "node:net"; import { describe, expect, test, vi } from "vitest"; import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; +import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; import { ProxyError } from "@/app/v1/_lib/proxy/errors"; import { ProxySession } from "@/app/v1/_lib/proxy/session"; import type { Provider } from "@/types/provider"; @@ -128,6 +129,7 @@ function createSession(params?: { clientAbortSignal?: AbortSignal | null }): Pro specialSettings: [], cachedPriceData: undefined, cachedBillingModelSource: undefined, + endpointPolicy: resolveEndpointPolicy("/v1/chat/completions"), isHeaderModified: () => false, }); diff --git a/tests/unit/proxy/proxy-forwarder-retry-limit.test.ts b/tests/unit/proxy/proxy-forwarder-retry-limit.test.ts index 9e3f11c16..266fbb2bb 100644 --- a/tests/unit/proxy/proxy-forwarder-retry-limit.test.ts +++ b/tests/unit/proxy/proxy-forwarder-retry-limit.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; +import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; +import { V1_ENDPOINT_PATHS } from "@/app/v1/_lib/proxy/endpoint-paths"; const mocks = vi.hoisted(() => { return { @@ -188,6 +190,7 @@ function createSession(requestUrl: URL = new URL("https://example.com/v1/message originalModelName: null, originalUrlPathname: null, providerChain: [], + endpointPolicy: resolveEndpointPolicy(requestUrl.pathname), cacheTtlResolved: null, context1mApplied: false, specialSettings: [], @@ -200,6 +203,72 @@ function createSession(requestUrl: URL = new URL("https://example.com/v1/message return session as ProxySession; } +describe("ProxyForwarder - raw passthrough policy parity (T5 RED)", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(categorizeErrorAsync).mockResolvedValue(ErrorCategory.PROVIDER_ERROR); + }); + + test.each([ + V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS, + V1_ENDPOINT_PATHS.RESPONSES_COMPACT, + ])("RED: %s 失败时都应统一为 no-retry/no-switch/no-circuit(Wave2 未实现前应失败)", async (pathname) => { + vi.useFakeTimers(); + + try { + const session = createSession(new URL(`https://example.com${pathname}`)); + const provider = createProvider({ + providerType: "claude", + providerVendorId: 123, + maxRetryAttempts: 3, + }); + session.setProvider(provider); + + mocks.getPreferredProviderEndpoints.mockResolvedValue([ + makeEndpoint({ + id: 1, + vendorId: 123, + providerType: "claude", + url: "https://ep1.example.com", + }), + makeEndpoint({ + id: 2, + vendorId: 123, + providerType: "claude", + url: "https://ep2.example.com", + }), + ]); + + const doForward = vi.spyOn( + ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown }, + "doForward" + ); + const selectAlternative = vi.spyOn( + ProxyForwarder as unknown as { selectAlternative: (...args: unknown[]) => unknown }, + "selectAlternative" + ); + + doForward.mockImplementation(async () => { + throw new ProxyError("upstream failed", 500); + }); + + const sendPromise = ProxyForwarder.send(session); + let caughtError: Error | null = null; + sendPromise.catch((error) => { + caughtError = error as Error; + }); + await vi.runAllTimersAsync(); + + expect(caughtError).toBeInstanceOf(ProxyError); + expect(doForward).toHaveBeenCalledTimes(1); + expect(selectAlternative).not.toHaveBeenCalled(); + expect(mocks.recordFailure).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("ProxyForwarder - retry limit enforcement", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/tests/unit/proxy/proxy-handler-session-id-error.test.ts b/tests/unit/proxy/proxy-handler-session-id-error.test.ts index 18062cc79..a7132a7a7 100644 --- a/tests/unit/proxy/proxy-handler-session-id-error.test.ts +++ b/tests/unit/proxy/proxy-handler-session-id-error.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test, vi } from "vitest"; +import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; +import { V1_ENDPOINT_PATHS } from "@/app/v1/_lib/proxy/endpoint-paths"; import { ProxyResponses } from "@/app/v1/_lib/proxy/responses"; import { ProxyError } from "@/app/v1/_lib/proxy/errors"; @@ -11,6 +13,7 @@ const h = vi.hoisted(() => ({ model: "gpt", message: {}, }, + getEndpointPolicy: () => resolveEndpointPolicy(h.session.requestUrl.pathname), isCountTokensRequest: () => false, setOriginalFormat: () => {}, recordForwardStart: () => {}, @@ -40,6 +43,12 @@ vi.mock("@/app/v1/_lib/proxy/session", () => ({ vi.mock("@/app/v1/_lib/proxy/guard-pipeline", () => ({ RequestType: { CHAT: "CHAT", COUNT_TOKENS: "COUNT_TOKENS" }, GuardPipelineBuilder: { + fromSession: () => ({ + run: async () => { + if (h.pipelineError) throw h.pipelineError; + return h.earlyResponse; + }, + }), fromRequestType: () => ({ run: async () => { if (h.pipelineError) throw h.pipelineError; @@ -167,6 +176,41 @@ describe("handleProxyRequest - session id on errors", async () => { expect(h.trackerCalls).toEqual(["inc", "startRequest", "dec"]); }); + test.each([ + { + pathname: V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS, + isCountTokensRequest: true, + }, + { + pathname: V1_ENDPOINT_PATHS.RESPONSES_COMPACT, + isCountTokensRequest: false, + }, + ])("RED: raw endpoint $pathname 应统一跳过并发计数(Wave2 未实现前会失败)", async ({ + pathname, + isCountTokensRequest, + }) => { + h.fromContextError = null; + h.session.originalFormat = "claude"; + h.endpointFormat = "openai"; + h.trackerCalls.length = 0; + h.pipelineError = null; + h.earlyResponse = null; + h.forwardResponse = new Response("ok", { status: 200 }); + h.dispatchedResponse = null; + + h.session.requestUrl = new URL(`http://localhost${pathname}`); + h.session.getEndpointPolicy = () => resolveEndpointPolicy(h.session.requestUrl.pathname); + h.session.sessionId = "s_123"; + h.session.messageContext = { id: 1, user: { id: 1, name: "u" }, key: { name: "k" } }; + h.session.provider = { id: 1, name: "p" }; + h.session.isCountTokensRequest = () => isCountTokensRequest; + + const res = await handleProxyRequest({} as any); + + expect(res.status).toBe(200); + expect(h.trackerCalls).toEqual(["startRequest"]); + }); + test("session not created and ProxyError thrown: returns buildError without session header", async () => { h.fromContextError = new ProxyError("upstream", 401); h.endpointFormat = null; diff --git a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts index 533f8247f..e3e83fbd7 100644 --- a/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts +++ b/tests/unit/proxy/response-handler-endpoint-circuit-isolation.test.ts @@ -9,6 +9,7 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; import type { ModelPriceData } from "@/types/model-price"; // Track async tasks for draining @@ -173,6 +174,7 @@ function createSession(opts?: { sessionId?: string | null }): ProxySession { specialSettings: [], cachedPriceData: undefined, cachedBillingModelSource: undefined, + endpointPolicy: resolveEndpointPolicy("/v1/messages"), isHeaderModified: () => false, getContext1mApplied: () => false, getOriginalModel: () => "test-model", diff --git a/tests/unit/proxy/response-handler-gemini-stream-passthrough-timeouts.test.ts b/tests/unit/proxy/response-handler-gemini-stream-passthrough-timeouts.test.ts index bc26ea8af..afc42b326 100644 --- a/tests/unit/proxy/response-handler-gemini-stream-passthrough-timeouts.test.ts +++ b/tests/unit/proxy/response-handler-gemini-stream-passthrough-timeouts.test.ts @@ -2,6 +2,7 @@ import { createServer } from "node:http"; import type { Socket } from "node:net"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; +import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; import { ProxyResponseHandler } from "@/app/v1/_lib/proxy/response-handler"; import { ProxySession } from "@/app/v1/_lib/proxy/session"; import type { Provider } from "@/types/provider"; @@ -191,6 +192,7 @@ function createSession(params: { specialSettings: [], cachedPriceData: undefined, cachedBillingModelSource: undefined, + endpointPolicy: resolveEndpointPolicy("/v1/chat/completions"), isHeaderModified: () => false, }); diff --git a/tests/unit/proxy/response-handler-lease-decrement.test.ts b/tests/unit/proxy/response-handler-lease-decrement.test.ts index b6a0a4773..1100256ef 100644 --- a/tests/unit/proxy/response-handler-lease-decrement.test.ts +++ b/tests/unit/proxy/response-handler-lease-decrement.test.ts @@ -9,6 +9,7 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; import type { ModelPriceData } from "@/types/model-price"; // Track async tasks for draining @@ -157,6 +158,7 @@ function createSession(opts: { specialSettings: [], cachedPriceData: undefined, cachedBillingModelSource: undefined, + endpointPolicy: resolveEndpointPolicy("/v1/messages"), isHeaderModified: () => false, getContext1mApplied: () => false, getOriginalModel: () => originalModel, diff --git a/tests/unit/proxy/session.test.ts b/tests/unit/proxy/session.test.ts index 5afe30248..9771ea4df 100644 --- a/tests/unit/proxy/session.test.ts +++ b/tests/unit/proxy/session.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import { isRawPassthroughEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy"; +import { V1_ENDPOINT_PATHS } from "@/app/v1/_lib/proxy/endpoint-paths"; import type { ModelPrice, ModelPriceData } from "@/types/model-price"; import type { SystemSettings } from "@/types/system-config"; import type { Provider } from "@/types/provider"; @@ -101,6 +103,53 @@ function createSession({ return session; } +describe("ProxySession endpoint policy", () => { + it.each([ + V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS, + "/V1/RESPONSES/COMPACT/", + ])("应在创建时解析 raw passthrough policy: %s", (pathname) => { + const session = createSession({ + redirectedModel: null, + requestUrl: new URL(`http://localhost${pathname}`), + }); + + const policy = session.getEndpointPolicy(); + expect(isRawPassthroughEndpointPolicy(policy)).toBe(true); + expect(policy.trackConcurrentRequests).toBe(false); + }); + + it("应在请求路径后续变更后保持创建时 policy 不变", () => { + const session = createSession({ + redirectedModel: null, + requestUrl: new URL(`http://localhost${V1_ENDPOINT_PATHS.MESSAGES_COUNT_TOKENS}`), + }); + + const policyAtCreation = session.getEndpointPolicy(); + session.requestUrl = new URL(`http://localhost${V1_ENDPOINT_PATHS.MESSAGES}`); + + expect(session.getEndpointPolicy()).toBe(policyAtCreation); + expect(isRawPassthroughEndpointPolicy(session.getEndpointPolicy())).toBe(true); + }); + + it("应在 pathname 无法读取时回退到 default policy", () => { + const malformedUrl = { + get pathname() { + throw new Error("broken pathname"); + }, + } as unknown as URL; + + const session = createSession({ + redirectedModel: null, + requestUrl: malformedUrl, + }); + + const policy = session.getEndpointPolicy(); + expect(isRawPassthroughEndpointPolicy(policy)).toBe(false); + expect(policy.kind).toBe("default"); + expect(policy.trackConcurrentRequests).toBe(true); + }); +}); + describe("ProxySession.getCachedPriceDataByBillingSource", () => { it("配置 = original 时应优先使用原始模型", async () => { const originalPriceData: ModelPriceData = { input_cost_per_token: 1, output_cost_per_token: 2 }; From 61cce9b5f825261e960594a67d2c8b3ebea10949 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:28:25 +0800 Subject: [PATCH 09/75] refactor: security auth overhaul and provider batch operations (#806) --- messages/en/auth.json | 8 +- messages/en/settings/providers/batchEdit.json | 68 +- messages/ja/auth.json | 8 +- messages/ja/settings/providers/batchEdit.json | 68 +- messages/ru/auth.json | 8 +- messages/ru/settings/providers/batchEdit.json | 68 +- messages/zh-CN/auth.json | 8 +- .../zh-CN/settings/providers/batchEdit.json | 68 +- messages/zh-TW/auth.json | 8 +- .../zh-TW/settings/providers/batchEdit.json | 68 +- src/actions/providers.ts | 1116 ++++++++++++++++- src/app/[locale]/login/loading.tsx | 33 +- src/app/[locale]/login/page.tsx | 458 +++++-- src/app/[locale]/login/redirect-safety.ts | 37 + .../_components/adaptive-thinking-editor.tsx | 178 +++ .../_components/add-provider-dialog.tsx | 2 +- .../batch-edit/build-patch-draft.ts | 290 +++++ .../batch-edit/provider-batch-dialog.tsx | 656 ++++++---- .../provider-batch-preview-step.tsx | 179 +++ .../batch-edit/provider-batch-toolbar.tsx | 109 +- .../provider-form/components/form-tab-nav.tsx | 65 +- .../_components/forms/provider-form/index.tsx | 75 +- .../provider-form/provider-form-context.tsx | 187 ++- .../provider-form/provider-form-types.ts | 13 +- .../sections/basic-info-section.tsx | 89 +- .../sections/network-section.tsx | 33 +- .../sections/routing-section.tsx | 443 ++----- .../sections/testing-section.tsx | 75 +- .../_components/provider-manager.tsx | 39 + .../_components/provider-rich-list-item.tsx | 35 +- .../_components/thinking-budget-editor.tsx | 90 ++ .../_components/vendor-keys-compact-list.tsx | 43 +- .../_components/usage-doc-auth-context.tsx | 28 + src/app/[locale]/usage-doc/layout.tsx | 5 +- src/app/[locale]/usage-doc/page.tsx | 5 +- src/app/api/auth/login/route.ts | 252 +++- src/app/api/auth/logout/route.ts | 79 +- src/app/v1/_lib/cors.ts | 20 +- src/app/v1/_lib/proxy/auth-guard.ts | 59 + src/lib/api/action-adapter-openapi.ts | 13 +- src/lib/auth-session-store/index.ts | 20 + .../auth-session-store/redis-session-store.ts | 225 ++++ src/lib/auth.ts | 216 +++- src/lib/config/env.schema.ts | 1 + src/lib/provider-batch-patch-error-codes.ts | 11 + src/lib/provider-patch-contract.ts | 974 ++++++++++++++ src/lib/providers/undo-store.ts | 81 ++ src/lib/redis/redis-kv-store.ts | 142 +++ src/lib/security/auth-response-headers.ts | 22 + src/lib/security/constant-time-compare.ts | 27 + src/lib/security/csrf-origin-guard.ts | 66 + src/lib/security/login-abuse-policy.ts | 249 ++++ src/lib/security/security-headers.ts | 63 + src/proxy.ts | 37 +- src/repository/index.ts | 2 + src/repository/provider.ts | 344 ++++- src/types/provider.ts | 202 +++ .../action-adapter-auth-session.unit.test.ts | 5 +- .../auth-bruteforce-integration.test.ts | 172 +++ .../auth-csrf-route-integration.test.ts | 175 +++ tests/security/auth-dual-read.test.ts | 264 ++++ tests/security/constant-time-compare.test.ts | 43 + tests/security/csrf-origin-guard.test.ts | 133 ++ .../security/full-security-regression.test.ts | 283 +++++ tests/security/login-abuse-policy.test.ts | 234 ++++ tests/security/proxy-auth-rate-limit.test.ts | 160 +++ .../security-headers-integration.test.ts | 196 +++ tests/security/security-headers.test.ts | 111 ++ tests/security/session-contract.test.ts | 112 ++ .../security/session-cookie-hardening.test.ts | 205 +++ .../session-fixation-rotation.test.ts | 178 +++ .../session-login-integration.test.ts | 237 ++++ tests/security/session-store.test.ts | 262 ++++ .../unit/actions/provider-undo-delete.test.ts | 253 ++++ tests/unit/actions/provider-undo-edit.test.ts | 396 ++++++ .../actions/providers-apply-engine.test.ts | 425 +++++++ .../providers-batch-field-mapping.test.ts | 256 ++++ .../providers-patch-actions-contract.test.ts | 305 +++++ .../actions/providers-patch-contract.test.ts | 922 ++++++++++++++ .../actions/providers-preview-engine.test.ts | 563 +++++++++ .../actions/providers-undo-engine.test.ts | 391 ++++++ .../unit/actions/providers-undo-store.test.ts | 180 +++ tests/unit/actions/providers.test.ts | 8 +- .../api/auth-login-failure-taxonomy.test.ts | 163 +++ tests/unit/api/auth-login-route.test.ts | 316 +++++ .../auth/auth-cookie-constant-sync.test.ts | 23 + tests/unit/auth/login-redirect-safety.test.ts | 77 ++ tests/unit/auth/opaque-admin-session.test.ts | 137 ++ .../unit/auth/set-auth-cookie-options.test.ts | 110 ++ tests/unit/i18n/auth-login-keys.test.ts | 67 + tests/unit/lib/redis/redis-kv-store.test.ts | 259 ++++ .../login/login-footer-system-name.test.tsx | 151 +++ .../unit/login/login-footer-version.test.tsx | 101 ++ tests/unit/login/login-loading-state.test.tsx | 191 +++ tests/unit/login/login-overlay-a11y.test.tsx | 147 +++ .../login/login-regression-matrix.test.tsx | 230 ++++ tests/unit/login/login-ui-redesign.test.tsx | 147 +++ .../login/login-visual-regression.test.tsx | 98 ++ .../proxy-auth-cookie-passthrough.test.ts | 83 ++ ...vider-batch-update-advanced-fields.test.ts | 196 +++ .../unit/repository/provider-restore.test.ts | 300 +++++ .../adaptive-thinking-editor.test.tsx | 336 +++++ .../providers/build-patch-draft.test.ts | 647 ++++++++++ .../settings/providers/form-tab-nav.test.tsx | 213 ++++ .../provider-batch-dialog-step1.test.tsx | 482 +++++++ .../provider-batch-preview-step.test.tsx | 296 +++++ .../provider-batch-toolbar-selection.test.tsx | 246 ++++ .../providers/provider-batch-toolbar.test.tsx | 215 ++++ .../provider-form-batch-context.test.ts | 190 +++ .../providers/provider-undo-toast.test.tsx | 595 +++++++++ .../providers/thinking-budget-editor.test.tsx | 233 ++++ .../usage-doc/usage-doc-auth-state.test.tsx | 126 ++ tests/unit/usage-doc/usage-doc-page.test.tsx | 17 +- vitest.config.ts | 2 + 114 files changed, 19732 insertions(+), 899 deletions(-) create mode 100644 src/app/[locale]/login/redirect-safety.ts create mode 100644 src/app/[locale]/settings/providers/_components/adaptive-thinking-editor.tsx create mode 100644 src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts create mode 100644 src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-preview-step.tsx create mode 100644 src/app/[locale]/settings/providers/_components/thinking-budget-editor.tsx create mode 100644 src/app/[locale]/usage-doc/_components/usage-doc-auth-context.tsx create mode 100644 src/lib/auth-session-store/index.ts create mode 100644 src/lib/auth-session-store/redis-session-store.ts create mode 100644 src/lib/provider-batch-patch-error-codes.ts create mode 100644 src/lib/provider-patch-contract.ts create mode 100644 src/lib/providers/undo-store.ts create mode 100644 src/lib/redis/redis-kv-store.ts create mode 100644 src/lib/security/auth-response-headers.ts create mode 100644 src/lib/security/constant-time-compare.ts create mode 100644 src/lib/security/csrf-origin-guard.ts create mode 100644 src/lib/security/login-abuse-policy.ts create mode 100644 src/lib/security/security-headers.ts create mode 100644 tests/security/auth-bruteforce-integration.test.ts create mode 100644 tests/security/auth-csrf-route-integration.test.ts create mode 100644 tests/security/auth-dual-read.test.ts create mode 100644 tests/security/constant-time-compare.test.ts create mode 100644 tests/security/csrf-origin-guard.test.ts create mode 100644 tests/security/full-security-regression.test.ts create mode 100644 tests/security/login-abuse-policy.test.ts create mode 100644 tests/security/proxy-auth-rate-limit.test.ts create mode 100644 tests/security/security-headers-integration.test.ts create mode 100644 tests/security/security-headers.test.ts create mode 100644 tests/security/session-contract.test.ts create mode 100644 tests/security/session-cookie-hardening.test.ts create mode 100644 tests/security/session-fixation-rotation.test.ts create mode 100644 tests/security/session-login-integration.test.ts create mode 100644 tests/security/session-store.test.ts create mode 100644 tests/unit/actions/provider-undo-delete.test.ts create mode 100644 tests/unit/actions/provider-undo-edit.test.ts create mode 100644 tests/unit/actions/providers-apply-engine.test.ts create mode 100644 tests/unit/actions/providers-batch-field-mapping.test.ts create mode 100644 tests/unit/actions/providers-patch-actions-contract.test.ts create mode 100644 tests/unit/actions/providers-patch-contract.test.ts create mode 100644 tests/unit/actions/providers-preview-engine.test.ts create mode 100644 tests/unit/actions/providers-undo-engine.test.ts create mode 100644 tests/unit/actions/providers-undo-store.test.ts create mode 100644 tests/unit/api/auth-login-failure-taxonomy.test.ts create mode 100644 tests/unit/api/auth-login-route.test.ts create mode 100644 tests/unit/auth/auth-cookie-constant-sync.test.ts create mode 100644 tests/unit/auth/login-redirect-safety.test.ts create mode 100644 tests/unit/auth/opaque-admin-session.test.ts create mode 100644 tests/unit/auth/set-auth-cookie-options.test.ts create mode 100644 tests/unit/i18n/auth-login-keys.test.ts create mode 100644 tests/unit/lib/redis/redis-kv-store.test.ts create mode 100644 tests/unit/login/login-footer-system-name.test.tsx create mode 100644 tests/unit/login/login-footer-version.test.tsx create mode 100644 tests/unit/login/login-loading-state.test.tsx create mode 100644 tests/unit/login/login-overlay-a11y.test.tsx create mode 100644 tests/unit/login/login-regression-matrix.test.tsx create mode 100644 tests/unit/login/login-ui-redesign.test.tsx create mode 100644 tests/unit/login/login-visual-regression.test.tsx create mode 100644 tests/unit/proxy/proxy-auth-cookie-passthrough.test.ts create mode 100644 tests/unit/repository/provider-batch-update-advanced-fields.test.ts create mode 100644 tests/unit/repository/provider-restore.test.ts create mode 100644 tests/unit/settings/providers/adaptive-thinking-editor.test.tsx create mode 100644 tests/unit/settings/providers/build-patch-draft.test.ts create mode 100644 tests/unit/settings/providers/form-tab-nav.test.tsx create mode 100644 tests/unit/settings/providers/provider-batch-dialog-step1.test.tsx create mode 100644 tests/unit/settings/providers/provider-batch-preview-step.test.tsx create mode 100644 tests/unit/settings/providers/provider-batch-toolbar-selection.test.tsx create mode 100644 tests/unit/settings/providers/provider-batch-toolbar.test.tsx create mode 100644 tests/unit/settings/providers/provider-form-batch-context.test.ts create mode 100644 tests/unit/settings/providers/provider-undo-toast.test.tsx create mode 100644 tests/unit/settings/providers/thinking-budget-editor.test.tsx create mode 100644 tests/unit/usage-doc/usage-doc-auth-state.test.tsx diff --git a/messages/en/auth.json b/messages/en/auth.json index 460d311d0..4feeabdd7 100644 --- a/messages/en/auth.json +++ b/messages/en/auth.json @@ -1,7 +1,10 @@ { "form": { "title": "Login Panel", - "description": "Access the unified admin console with your API Key" + "description": "Access the unified admin console with your API Key", + "apiKeyLabel": "API Key", + "showPassword": "Show password", + "hidePassword": "Hide password" }, "login": { "title": "Login", @@ -20,6 +23,9 @@ "placeholders": { "apiKeyExample": "e.g. sk-xxxxxxxx" }, + "brand": { + "tagline": "Unified API management console" + }, "actions": { "enterConsole": "Enter Console", "viewUsageDoc": "View Usage Documentation" diff --git a/messages/en/settings/providers/batchEdit.json b/messages/en/settings/providers/batchEdit.json index 7abbd6045..c1bc48a12 100644 --- a/messages/en/settings/providers/batchEdit.json +++ b/messages/en/settings/providers/batchEdit.json @@ -5,6 +5,10 @@ "invertSelection": "Invert", "selectedCount": "{count} selected", "editSelected": "Edit Selected", + "selectByType": "Select by Type", + "selectByTypeItem": "{type} ({count})", + "selectByGroup": "Select by Group", + "selectByGroupItem": "{group} ({count})", "actions": { "edit": "Edit", "delete": "Delete", @@ -20,12 +24,33 @@ "next": "Next", "noFieldEnabled": "Please enable at least one field to update" }, + "sections": { + "basic": "Basic Settings", + "routing": "Group & Routing", + "anthropic": "Anthropic Settings" + }, "fields": { - "isEnabled": "Status", + "isEnabled": { + "label": "Status", + "noChange": "No Change", + "enable": "Enable", + "disable": "Disable" + }, "priority": "Priority", "weight": "Weight", "costMultiplier": "Cost Multiplier", - "groupTag": "Group Tag" + "groupTag": { + "label": "Group Tag", + "clear": "Clear" + }, + "modelRedirects": "Model Redirects", + "allowedModels": "Allowed Models", + "thinkingBudget": "Thinking Budget", + "adaptiveThinking": "Adaptive Thinking" + }, + "affectedProviders": { + "title": "Affected Providers", + "more": "+{count} more" }, "confirm": { "title": "Confirm Operation", @@ -34,10 +59,47 @@ "goBack": "Go Back", "processing": "Processing..." }, + "preview": { + "title": "Preview Changes", + "description": "Review changes before applying to {count} providers", + "providerHeader": "{name}", + "fieldChanged": "{field}: {before} -> {after}", + "fieldSkipped": "{field}: Skipped ({reason})", + "excludeProvider": "Exclude", + "summary": "{providerCount} providers, {fieldCount} changes, {skipCount} skipped", + "noChanges": "No changes to apply", + "apply": "Apply Changes", + "back": "Back to Edit", + "loading": "Generating preview..." + }, + "batchNotes": { + "codexOnly": "Codex only", + "claudeOnly": "Claude only", + "geminiOnly": "Gemini only" + }, + "selectionHint": "Select multiple providers for batch operations", + "undo": { + "button": "Undo", + "success": "Operation undone successfully", + "expired": "Undo expired", + "batchDeleteSuccess": "Deleted {count} providers", + "batchDeleteUndone": "Restored {count} providers", + "singleDeleteSuccess": "Provider deleted", + "singleDeleteUndone": "Provider restored", + "singleEditSuccess": "Provider updated", + "singleEditUndone": "Changes reverted", + "failed": "Undo failed" + }, "toast": { "updated": "Updated {count} providers", "deleted": "Deleted {count} providers", "circuitReset": "Reset {count} circuit breakers", - "failed": "Operation failed: {error}" + "failed": "Operation failed: {error}", + "undo": "Undo", + "undoSuccess": "Reverted {count} providers", + "undoFailed": "Undo failed: {error}", + "undoExpired": "Undo window expired", + "previewFailed": "Preview failed: {error}", + "unknownError": "Unknown error" } } diff --git a/messages/ja/auth.json b/messages/ja/auth.json index 113aa9193..68658e5ce 100644 --- a/messages/ja/auth.json +++ b/messages/ja/auth.json @@ -1,7 +1,10 @@ { "form": { "title": "ログインパネル", - "description": "API キーを使用して統一管理コンソールにアクセスします" + "description": "API キーを使用して統一管理コンソールにアクセスします", + "apiKeyLabel": "API Key", + "showPassword": "パスワードを表示", + "hidePassword": "パスワードを非表示" }, "login": { "title": "ログイン", @@ -20,6 +23,9 @@ "placeholders": { "apiKeyExample": "例: sk-xxxxxxxx" }, + "brand": { + "tagline": "統合API管理コンソール" + }, "actions": { "enterConsole": "コンソールに入る", "viewUsageDoc": "使用方法を見る" diff --git a/messages/ja/settings/providers/batchEdit.json b/messages/ja/settings/providers/batchEdit.json index 68f98a0a2..8feb4f198 100644 --- a/messages/ja/settings/providers/batchEdit.json +++ b/messages/ja/settings/providers/batchEdit.json @@ -5,6 +5,10 @@ "invertSelection": "反転", "selectedCount": "{count} 件選択中", "editSelected": "選択項目を編集", + "selectByType": "タイプで選択", + "selectByTypeItem": "{type} ({count})", + "selectByGroup": "グループで選択", + "selectByGroupItem": "{group} ({count})", "actions": { "edit": "編集", "delete": "削除", @@ -20,12 +24,33 @@ "next": "次へ", "noFieldEnabled": "更新するフィールドを少なくとも1つ有効にしてください" }, + "sections": { + "basic": "基本設定", + "routing": "グループとルーティング", + "anthropic": "Anthropic 設定" + }, "fields": { - "isEnabled": "ステータス", + "isEnabled": { + "label": "ステータス", + "noChange": "変更なし", + "enable": "有効", + "disable": "無効" + }, "priority": "優先度", "weight": "重み", "costMultiplier": "価格倍率", - "groupTag": "グループタグ" + "groupTag": { + "label": "グループタグ", + "clear": "クリア" + }, + "modelRedirects": "モデルリダイレクト", + "allowedModels": "許可モデル", + "thinkingBudget": "思考バジェット", + "adaptiveThinking": "アダプティブ思考" + }, + "affectedProviders": { + "title": "影響を受けるプロバイダー", + "more": "+{count} 件" }, "confirm": { "title": "操作の確認", @@ -34,10 +59,47 @@ "goBack": "戻る", "processing": "処理中..." }, + "preview": { + "title": "変更のプレビュー", + "description": "{count} 件のプロバイダーに適用する前に変更内容を確認してください", + "providerHeader": "{name}", + "fieldChanged": "{field}: {before} -> {after}", + "fieldSkipped": "{field}: スキップ ({reason})", + "excludeProvider": "除外", + "summary": "{providerCount} 件のプロバイダー, {fieldCount} 件の変更, {skipCount} 件スキップ", + "noChanges": "適用する変更はありません", + "apply": "変更を適用", + "back": "編集に戻る", + "loading": "プレビューを生成中..." + }, + "batchNotes": { + "codexOnly": "Codex のみ", + "claudeOnly": "Claude のみ", + "geminiOnly": "Gemini のみ" + }, + "selectionHint": "複数のプロバイダーを選択して一括操作を実行", + "undo": { + "button": "元に戻す", + "success": "操作が正常に元に戻されました", + "expired": "元に戻す期限が切れました", + "batchDeleteSuccess": "{count} 件のプロバイダーを削除しました", + "batchDeleteUndone": "{count} 件のプロバイダーを復元しました", + "singleDeleteSuccess": "プロバイダーを削除しました", + "singleDeleteUndone": "プロバイダーを復元しました", + "singleEditSuccess": "プロバイダーを更新しました", + "singleEditUndone": "変更を元に戻しました", + "failed": "元に戻すことに失敗しました" + }, "toast": { "updated": "{count} 件のプロバイダーを更新しました", "deleted": "{count} 件のプロバイダーを削除しました", "circuitReset": "{count} 件のサーキットブレーカーをリセットしました", - "failed": "操作に失敗しました: {error}" + "failed": "操作に失敗しました: {error}", + "undo": "元に戻す", + "undoSuccess": "{count} 件のプロバイダーを復元しました", + "undoFailed": "元に戻す操作に失敗しました: {error}", + "undoExpired": "元に戻す期限が切れました", + "previewFailed": "プレビューに失敗しました: {error}", + "unknownError": "不明なエラー" } } diff --git a/messages/ru/auth.json b/messages/ru/auth.json index 4e6f42542..de91560a7 100644 --- a/messages/ru/auth.json +++ b/messages/ru/auth.json @@ -1,7 +1,10 @@ { "form": { "title": "Панель входа", - "description": "Введите ваш API ключ для доступа к данным" + "description": "Введите ваш API ключ для доступа к данным", + "apiKeyLabel": "API Key", + "showPassword": "Показать пароль", + "hidePassword": "Скрыть пароль" }, "login": { "title": "Вход", @@ -20,6 +23,9 @@ "placeholders": { "apiKeyExample": "например sk-xxxxxxxx" }, + "brand": { + "tagline": "Единая консоль управления API" + }, "actions": { "enterConsole": "Перейти в консоль", "viewUsageDoc": "Просмотреть документацию" diff --git a/messages/ru/settings/providers/batchEdit.json b/messages/ru/settings/providers/batchEdit.json index 3d5c6c4f3..9a620bf1b 100644 --- a/messages/ru/settings/providers/batchEdit.json +++ b/messages/ru/settings/providers/batchEdit.json @@ -5,6 +5,10 @@ "invertSelection": "Инвертировать", "selectedCount": "Выбрано: {count}", "editSelected": "Редактировать выбранные", + "selectByType": "Выбрать по типу", + "selectByTypeItem": "{type} ({count})", + "selectByGroup": "Выбрать по группе", + "selectByGroupItem": "{group} ({count})", "actions": { "edit": "Редактировать", "delete": "Удалить", @@ -20,12 +24,33 @@ "next": "Далее", "noFieldEnabled": "Пожалуйста, включите хотя бы одно поле для обновления" }, + "sections": { + "basic": "Основные настройки", + "routing": "Группы и маршрутизация", + "anthropic": "Настройки Anthropic" + }, "fields": { - "isEnabled": "Статус", + "isEnabled": { + "label": "Статус", + "noChange": "Без изменений", + "enable": "Включить", + "disable": "Отключить" + }, "priority": "Приоритет", "weight": "Вес", "costMultiplier": "Множитель стоимости", - "groupTag": "Тег группы" + "groupTag": { + "label": "Тег группы", + "clear": "Очистить" + }, + "modelRedirects": "Перенаправление моделей", + "allowedModels": "Разрешённые модели", + "thinkingBudget": "Бюджет мышления", + "adaptiveThinking": "Адаптивное мышление" + }, + "affectedProviders": { + "title": "Затронутые поставщики", + "more": "+{count} ещё" }, "confirm": { "title": "Подтвердите операцию", @@ -34,10 +59,47 @@ "goBack": "Назад", "processing": "Обработка..." }, + "preview": { + "title": "Предпросмотр изменений", + "description": "Проверьте изменения перед применением к {count} поставщикам", + "providerHeader": "{name}", + "fieldChanged": "{field}: {before} -> {after}", + "fieldSkipped": "{field}: Пропущено ({reason})", + "excludeProvider": "Исключить", + "summary": "{providerCount} поставщиков, {fieldCount} изменений, {skipCount} пропущено", + "noChanges": "Нет изменений для применения", + "apply": "Применить изменения", + "back": "Вернуться к редактированию", + "loading": "Генерация предпросмотра..." + }, + "batchNotes": { + "codexOnly": "Только Codex", + "claudeOnly": "Только Claude", + "geminiOnly": "Только Gemini" + }, + "selectionHint": "Выберите нескольких поставщиков для массовых операций", + "undo": { + "button": "Отменить", + "success": "Операция успешно отменена", + "expired": "Время отмены истекло", + "batchDeleteSuccess": "Удалено поставщиков: {count}", + "batchDeleteUndone": "Восстановлено поставщиков: {count}", + "singleDeleteSuccess": "Поставщик удалён", + "singleDeleteUndone": "Поставщик восстановлен", + "singleEditSuccess": "Поставщик обновлён", + "singleEditUndone": "Изменения отменены", + "failed": "Ошибка отмены" + }, "toast": { "updated": "Обновлено поставщиков: {count}", "deleted": "Удалено поставщиков: {count}", "circuitReset": "Сброшено прерывателей: {count}", - "failed": "Операция не удалась: {error}" + "failed": "Операция не удалась: {error}", + "undo": "Отменить", + "undoSuccess": "Восстановлено поставщиков: {count}", + "undoFailed": "Отмена не удалась: {error}", + "undoExpired": "Время отмены истекло", + "previewFailed": "Предпросмотр не удался: {error}", + "unknownError": "Неизвестная ошибка" } } diff --git a/messages/zh-CN/auth.json b/messages/zh-CN/auth.json index 9ffb12e4f..9cb3f1934 100644 --- a/messages/zh-CN/auth.json +++ b/messages/zh-CN/auth.json @@ -27,6 +27,9 @@ "placeholders": { "apiKeyExample": "例如 sk-xxxxxxxx" }, + "brand": { + "tagline": "统一 API 管理控制台" + }, "actions": { "enterConsole": "进入控制台", "viewUsageDoc": "查看使用文档" @@ -41,6 +44,9 @@ }, "form": { "title": "登录面板", - "description": "使用您的 API Key 进入统一控制台" + "description": "使用您的 API Key 进入统一控制台", + "apiKeyLabel": "API Key", + "showPassword": "显示密码", + "hidePassword": "隐藏密码" } } diff --git a/messages/zh-CN/settings/providers/batchEdit.json b/messages/zh-CN/settings/providers/batchEdit.json index 87e6d842b..49d938805 100644 --- a/messages/zh-CN/settings/providers/batchEdit.json +++ b/messages/zh-CN/settings/providers/batchEdit.json @@ -5,6 +5,10 @@ "invertSelection": "反选", "selectedCount": "已选 {count} 项", "editSelected": "编辑选中项", + "selectByType": "按类型选择", + "selectByTypeItem": "{type} ({count})", + "selectByGroup": "按分组选择", + "selectByGroupItem": "{group} ({count})", "actions": { "edit": "编辑", "delete": "删除", @@ -20,12 +24,33 @@ "next": "下一步", "noFieldEnabled": "请至少启用一个要更新的字段" }, + "sections": { + "basic": "基本设置", + "routing": "分组与路由", + "anthropic": "Anthropic 设置" + }, "fields": { - "isEnabled": "状态", + "isEnabled": { + "label": "状态", + "noChange": "不修改", + "enable": "启用", + "disable": "禁用" + }, "priority": "优先级", "weight": "权重", "costMultiplier": "价格倍率", - "groupTag": "分组标签" + "groupTag": { + "label": "分组标签", + "clear": "清除" + }, + "modelRedirects": "模型重定向", + "allowedModels": "允许的模型", + "thinkingBudget": "思维预算", + "adaptiveThinking": "自适应思维" + }, + "affectedProviders": { + "title": "受影响的供应商", + "more": "+{count} 更多" }, "confirm": { "title": "确认操作", @@ -34,10 +59,47 @@ "goBack": "返回", "processing": "处理中..." }, + "preview": { + "title": "预览变更", + "description": "将变更应用到 {count} 个供应商前请先确认", + "providerHeader": "{name}", + "fieldChanged": "{field}: {before} -> {after}", + "fieldSkipped": "{field}: 已跳过 ({reason})", + "excludeProvider": "排除", + "summary": "{providerCount} 个供应商, {fieldCount} 项变更, {skipCount} 项跳过", + "noChanges": "没有可应用的变更", + "apply": "应用变更", + "back": "返回编辑", + "loading": "正在生成预览..." + }, + "batchNotes": { + "codexOnly": "仅 Codex", + "claudeOnly": "仅 Claude", + "geminiOnly": "仅 Gemini" + }, + "selectionHint": "选择多个服务商后可进行批量操作", + "undo": { + "button": "撤销", + "success": "操作已成功撤销", + "expired": "撤销窗口已过期", + "batchDeleteSuccess": "已删除 {count} 个供应商", + "batchDeleteUndone": "已恢复 {count} 个供应商", + "singleDeleteSuccess": "供应商已删除", + "singleDeleteUndone": "供应商已恢复", + "singleEditSuccess": "供应商已更新", + "singleEditUndone": "更改已回退", + "failed": "撤销失败" + }, "toast": { "updated": "已更新 {count} 个供应商", "deleted": "已删除 {count} 个供应商", "circuitReset": "已重置 {count} 个熔断器", - "failed": "操作失败: {error}" + "failed": "操作失败: {error}", + "undo": "撤销", + "undoSuccess": "已还原 {count} 个供应商", + "undoFailed": "撤销失败: {error}", + "undoExpired": "撤销窗口已过期", + "previewFailed": "预览失败: {error}", + "unknownError": "未知错误" } } diff --git a/messages/zh-TW/auth.json b/messages/zh-TW/auth.json index 58da807c1..439ca9dca 100644 --- a/messages/zh-TW/auth.json +++ b/messages/zh-TW/auth.json @@ -1,7 +1,10 @@ { "form": { "title": "登錄面板", - "description": "使用您的 API Key 進入統一控制台" + "description": "使用您的 API Key 進入統一控制台", + "apiKeyLabel": "API Key", + "showPassword": "顯示密碼", + "hidePassword": "隱藏密碼" }, "login": { "title": "登錄", @@ -20,6 +23,9 @@ "placeholders": { "apiKeyExample": "例如 sk-xxxxxxxx" }, + "brand": { + "tagline": "統一 API 管理控制台" + }, "actions": { "enterConsole": "進入控制台", "viewUsageDoc": "查看使用文檔" diff --git a/messages/zh-TW/settings/providers/batchEdit.json b/messages/zh-TW/settings/providers/batchEdit.json index 30ac0472a..b8541e6e1 100644 --- a/messages/zh-TW/settings/providers/batchEdit.json +++ b/messages/zh-TW/settings/providers/batchEdit.json @@ -5,6 +5,10 @@ "invertSelection": "反選", "selectedCount": "已選 {count} 項", "editSelected": "編輯選中項", + "selectByType": "按類型選擇", + "selectByTypeItem": "{type} ({count})", + "selectByGroup": "按分組選擇", + "selectByGroupItem": "{group} ({count})", "actions": { "edit": "編輯", "delete": "刪除", @@ -20,12 +24,33 @@ "next": "下一步", "noFieldEnabled": "請至少啟用一個要更新的欄位" }, + "sections": { + "basic": "基本設定", + "routing": "分組與路由", + "anthropic": "Anthropic 設定" + }, "fields": { - "isEnabled": "狀態", + "isEnabled": { + "label": "狀態", + "noChange": "不修改", + "enable": "啟用", + "disable": "停用" + }, "priority": "優先級", "weight": "權重", "costMultiplier": "價格倍率", - "groupTag": "分組標籤" + "groupTag": { + "label": "分組標籤", + "clear": "清除" + }, + "modelRedirects": "模型重新導向", + "allowedModels": "允許的模型", + "thinkingBudget": "思維預算", + "adaptiveThinking": "自適應思維" + }, + "affectedProviders": { + "title": "受影響的供應商", + "more": "+{count} 更多" }, "confirm": { "title": "確認操作", @@ -34,10 +59,47 @@ "goBack": "返回", "processing": "處理中..." }, + "preview": { + "title": "預覽變更", + "description": "將變更應用到 {count} 個供應商前請先確認", + "providerHeader": "{name}", + "fieldChanged": "{field}: {before} -> {after}", + "fieldSkipped": "{field}: 已跳過 ({reason})", + "excludeProvider": "排除", + "summary": "{providerCount} 個供應商, {fieldCount} 項變更, {skipCount} 項跳過", + "noChanges": "沒有可應用的變更", + "apply": "應用變更", + "back": "返回編輯", + "loading": "正在產生預覽..." + }, + "batchNotes": { + "codexOnly": "僅 Codex", + "claudeOnly": "僅 Claude", + "geminiOnly": "僅 Gemini" + }, + "selectionHint": "選擇多個供應商以進行批次操作", + "undo": { + "button": "復原", + "success": "操作已成功復原", + "expired": "復原時限已過期", + "batchDeleteSuccess": "已刪除 {count} 個供應商", + "batchDeleteUndone": "已還原 {count} 個供應商", + "singleDeleteSuccess": "供應商已刪除", + "singleDeleteUndone": "供應商已恢復", + "singleEditSuccess": "供應商已更新", + "singleEditUndone": "變更已還原", + "failed": "復原失敗" + }, "toast": { "updated": "已更新 {count} 個供應商", "deleted": "已刪除 {count} 個供應商", "circuitReset": "已重置 {count} 個熔斷器", - "failed": "操作失敗: {error}" + "failed": "操作失敗: {error}", + "undo": "復原", + "undoSuccess": "已還原 {count} 個供應商", + "undoFailed": "復原失敗: {error}", + "undoExpired": "復原時限已過期", + "previewFailed": "預覽失敗: {error}", + "unknownError": "未知錯誤" } } diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 89cb72f06..181093158 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -1,6 +1,7 @@ "use server"; import { eq } from "drizzle-orm"; +import { z } from "zod"; import { GeminiAuth } from "@/app/v1/_lib/gemini/auth"; import { isClientAbortError } from "@/app/v1/_lib/proxy/errors"; import { buildProxyUrl } from "@/app/v1/_lib/url"; @@ -16,6 +17,13 @@ import { } from "@/lib/circuit-breaker"; import { PROVIDER_GROUP, PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; +import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes"; +import { + buildProviderBatchApplyUpdates, + hasProviderBatchPatchChanges, + normalizeProviderBatchPatchDraft, + PROVIDER_PATCH_ERROR_CODES, +} from "@/lib/provider-patch-contract"; import { executeProviderTest, type ProviderTestConfig, @@ -32,11 +40,15 @@ import { deleteProviderCircuitConfig, saveProviderCircuitConfig, } from "@/lib/redis/circuit-breaker-config"; +import { RedisKVStore } from "@/lib/redis/redis-kv-store"; import type { Context1mPreference } from "@/lib/special-attributes"; import { maskKey } from "@/lib/utils/validation"; +import { extractZodErrorCode, formatZodError } from "@/lib/utils/zod-i18n"; import { validateProviderUrlForConnectivity } from "@/lib/validation/provider-url"; import { CreateProviderSchema, UpdateProviderSchema } from "@/lib/validation/schemas"; +import { restoreProvidersBatch } from "@/repository"; import { + type BatchProviderUpdates, createProvider, deleteProvider, findAllProviders, @@ -46,6 +58,7 @@ import { resetProviderTotalCostResetAt, updateProvider, updateProviderPrioritiesBatch, + updateProvidersBatch, } from "@/repository/provider"; import { backfillProviderEndpointsFromProviders, @@ -63,7 +76,12 @@ import type { CodexReasoningEffortPreference, CodexReasoningSummaryPreference, CodexTextVerbosityPreference, + Provider, + ProviderBatchApplyUpdates, + ProviderBatchPatch, + ProviderBatchPatchField, ProviderDisplay, + ProviderPatchOperation, ProviderStatisticsMap, ProviderType, } from "@/types/provider"; @@ -664,7 +682,7 @@ export async function editProvider( rpd?: number | null; cc?: number | null; } -): Promise { +): Promise> { try { const session = await getSession(); if (!session || session.user.role !== "admin") { @@ -710,6 +728,30 @@ export async function editProvider( ...(faviconUrl !== undefined && { favicon_url: faviconUrl }), }; + const currentProvider = await findProviderById(providerId); + if (!currentProvider) { + return { ok: false, error: "供应商不存在" }; + } + + const preimageFields: Record = {}; + for (const [field, nextValue] of Object.entries(payload)) { + if (field === "key") { + continue; + } + + const providerKey = SINGLE_EDIT_PREIMAGE_FIELD_TO_PROVIDER_KEY[field]; + if (!providerKey) { + continue; + } + + const currentValue = currentProvider[providerKey]; + if (!hasProviderFieldChangedForUndo(currentValue, nextValue)) { + continue; + } + + preimageFields[providerKey] = currentValue; + } + const provider = await updateProvider(providerId, payload); if (!provider) { @@ -743,7 +785,26 @@ export async function editProvider( // 广播缓存更新(跨实例即时生效) await broadcastProviderCacheInvalidation({ operation: "edit", providerId }); - return { ok: true }; + const undoToken = createProviderPatchUndoToken(); + const operationId = createProviderPatchOperationId(); + + await providerPatchUndoStore.set(undoToken, { + undoToken, + operationId, + providerIds: [providerId], + preimage: { + [providerId]: preimageFields, + }, + patch: EMPTY_PROVIDER_BATCH_PATCH, + }); + + return { + ok: true, + data: { + undoToken, + operationId, + }, + }; } catch (error) { logger.error("更新服务商失败:", error); const message = error instanceof Error ? error.message : "更新服务商失败"; @@ -752,7 +813,9 @@ export async function editProvider( } // 删除服务商 -export async function removeProvider(providerId: number): Promise { +export async function removeProvider( + providerId: number +): Promise> { try { const session = await getSession(); if (!session || session.user.role !== "admin") { @@ -762,6 +825,15 @@ export async function removeProvider(providerId: number): Promise const provider = await findProviderById(providerId); await deleteProvider(providerId); + const undoToken = createProviderPatchUndoToken(); + const operationId = createProviderPatchOperationId(); + + await providerDeleteUndoStore.set(undoToken, { + undoToken, + operationId, + providerIds: [providerId], + }); + // 清除内存缓存(无论 Redis 是否成功都要执行) clearConfigCache(providerId); await clearProviderState(providerId); @@ -793,7 +865,13 @@ export async function removeProvider(providerId: number): Promise // 广播缓存更新(跨实例即时生效) await broadcastProviderCacheInvalidation({ operation: "remove", providerId }); - return { ok: true }; + return { + ok: true, + data: { + undoToken, + operationId, + }, + }; } catch (error) { logger.error("删除服务商失败:", error); const message = error instanceof Error ? error.message : "删除服务商失败"; @@ -1023,6 +1101,925 @@ export async function resetProviderTotalUsage(providerId: number): Promise; +} + +interface ProviderPatchUndoSnapshot { + undoToken: string; + operationId: string; + providerIds: number[]; + preimage: Record>; + patch: ProviderBatchPatch; +} + +interface ProviderDeleteUndoSnapshot { + undoToken: string; + operationId: string; + providerIds: number[]; +} + +const providerBatchPatchPreviewStore = new RedisKVStore({ + prefix: "cch:prov:preview:", + defaultTtlSeconds: PROVIDER_BATCH_PREVIEW_TTL_SECONDS, +}); +const providerPatchUndoStore = new RedisKVStore({ + prefix: "cch:prov:undo-patch:", + defaultTtlSeconds: PROVIDER_PATCH_UNDO_TTL_SECONDS, +}); +const providerDeleteUndoStore = new RedisKVStore({ + prefix: "cch:prov:undo-del:", + defaultTtlSeconds: PROVIDER_DELETE_UNDO_TTL_SECONDS, +}); +type ProviderPatchActionError = Extract; + +const SINGLE_EDIT_PREIMAGE_FIELD_TO_PROVIDER_KEY: Record = { + name: "name", + url: "url", + is_enabled: "isEnabled", + weight: "weight", + priority: "priority", + cost_multiplier: "costMultiplier", + group_tag: "groupTag", + group_priorities: "groupPriorities", + provider_type: "providerType", + preserve_client_ip: "preserveClientIp", + model_redirects: "modelRedirects", + allowed_models: "allowedModels", + limit_5h_usd: "limit5hUsd", + limit_daily_usd: "limitDailyUsd", + daily_reset_mode: "dailyResetMode", + daily_reset_time: "dailyResetTime", + limit_weekly_usd: "limitWeeklyUsd", + limit_monthly_usd: "limitMonthlyUsd", + limit_total_usd: "limitTotalUsd", + limit_concurrent_sessions: "limitConcurrentSessions", + cache_ttl_preference: "cacheTtlPreference", + swap_cache_ttl_billing: "swapCacheTtlBilling", + context_1m_preference: "context1mPreference", + codex_reasoning_effort_preference: "codexReasoningEffortPreference", + codex_reasoning_summary_preference: "codexReasoningSummaryPreference", + codex_text_verbosity_preference: "codexTextVerbosityPreference", + codex_parallel_tool_calls_preference: "codexParallelToolCallsPreference", + anthropic_max_tokens_preference: "anthropicMaxTokensPreference", + anthropic_thinking_budget_preference: "anthropicThinkingBudgetPreference", + anthropic_adaptive_thinking: "anthropicAdaptiveThinking", + gemini_google_search_preference: "geminiGoogleSearchPreference", + max_retry_attempts: "maxRetryAttempts", + circuit_breaker_failure_threshold: "circuitBreakerFailureThreshold", + circuit_breaker_open_duration: "circuitBreakerOpenDuration", + circuit_breaker_half_open_success_threshold: "circuitBreakerHalfOpenSuccessThreshold", + proxy_url: "proxyUrl", + proxy_fallback_to_direct: "proxyFallbackToDirect", + first_byte_timeout_streaming_ms: "firstByteTimeoutStreamingMs", + streaming_idle_timeout_ms: "streamingIdleTimeoutMs", + request_timeout_non_streaming_ms: "requestTimeoutNonStreamingMs", + website_url: "websiteUrl", + favicon_url: "faviconUrl", + mcp_passthrough_type: "mcpPassthroughType", + mcp_passthrough_url: "mcpPassthroughUrl", + tpm: "tpm", + rpm: "rpm", + rpd: "rpd", + cc: "cc", +}; + +const EMPTY_PROVIDER_BATCH_PATCH: ProviderBatchPatch = (() => { + const normalized = normalizeProviderBatchPatchDraft({}); + if (!normalized.ok) { + throw new Error("Failed to initialize empty provider batch patch"); + } + return normalized.data; +})(); + +function hasProviderFieldChangedForUndo(before: unknown, after: unknown): boolean { + if (Object.is(before, after)) { + return false; + } + + if ( + before !== null && + after !== null && + typeof before === "object" && + typeof after === "object" + ) { + try { + return JSON.stringify(before) !== JSON.stringify(after); + } catch { + return true; + } + } + + return true; +} + +function dedupeProviderIds(providerIds: number[]): number[] { + return [...new Set(providerIds)].sort((a, b) => a - b); +} + +function getChangedPatchFields(patch: ProviderBatchPatch): ProviderBatchPatchField[] { + return (Object.keys(patch) as ProviderBatchPatchField[]).filter( + (field) => patch[field].mode !== "no_change" + ); +} + +function isSameProviderIdList(left: number[], right: number[]): boolean { + if (left.length !== right.length) { + return false; + } + + for (let i = 0; i < left.length; i++) { + if (left[i] !== right[i]) { + return false; + } + } + + return true; +} + +function createProviderBatchPreviewToken(): string { + return `provider_patch_preview_${crypto.randomUUID()}`; +} + +function createProviderPatchUndoToken(): string { + return `provider_patch_undo_${crypto.randomUUID()}`; +} + +function createProviderPatchOperationId(): string { + return `provider_patch_apply_${crypto.randomUUID()}`; +} + +function buildActionValidationError(error: z.ZodError): ProviderPatchActionError { + return { + ok: false, + error: formatZodError(error), + errorCode: extractZodErrorCode(error) || PROVIDER_BATCH_PATCH_ERROR_CODES.INVALID_INPUT, + }; +} + +function buildNoChangesError(): ProviderPatchActionError { + return { + ok: false, + error: "没有可应用的变更", + errorCode: PROVIDER_BATCH_PATCH_ERROR_CODES.NOTHING_TO_APPLY, + }; +} + +function mapApplyUpdatesToRepositoryFormat( + applyUpdates: ProviderBatchApplyUpdates +): BatchProviderUpdates { + const result: BatchProviderUpdates = {}; + if (applyUpdates.is_enabled !== undefined) { + result.isEnabled = applyUpdates.is_enabled; + } + if (applyUpdates.priority !== undefined) { + result.priority = applyUpdates.priority; + } + if (applyUpdates.weight !== undefined) { + result.weight = applyUpdates.weight; + } + if (applyUpdates.cost_multiplier !== undefined) { + result.costMultiplier = applyUpdates.cost_multiplier.toString(); + } + if (applyUpdates.group_tag !== undefined) { + result.groupTag = applyUpdates.group_tag; + } + if (applyUpdates.model_redirects !== undefined) { + result.modelRedirects = applyUpdates.model_redirects; + } + if (applyUpdates.allowed_models !== undefined) { + result.allowedModels = applyUpdates.allowed_models; + } + if (applyUpdates.anthropic_thinking_budget_preference !== undefined) { + result.anthropicThinkingBudgetPreference = applyUpdates.anthropic_thinking_budget_preference; + } + if (applyUpdates.anthropic_adaptive_thinking !== undefined) { + result.anthropicAdaptiveThinking = applyUpdates.anthropic_adaptive_thinking; + } + if (applyUpdates.preserve_client_ip !== undefined) { + result.preserveClientIp = applyUpdates.preserve_client_ip; + } + if (applyUpdates.group_priorities !== undefined) { + result.groupPriorities = applyUpdates.group_priorities; + } + if (applyUpdates.cache_ttl_preference !== undefined) { + result.cacheTtlPreference = applyUpdates.cache_ttl_preference; + } + if (applyUpdates.swap_cache_ttl_billing !== undefined) { + result.swapCacheTtlBilling = applyUpdates.swap_cache_ttl_billing; + } + if (applyUpdates.context_1m_preference !== undefined) { + result.context1mPreference = applyUpdates.context_1m_preference; + } + if (applyUpdates.codex_reasoning_effort_preference !== undefined) { + result.codexReasoningEffortPreference = applyUpdates.codex_reasoning_effort_preference; + } + if (applyUpdates.codex_reasoning_summary_preference !== undefined) { + result.codexReasoningSummaryPreference = applyUpdates.codex_reasoning_summary_preference; + } + if (applyUpdates.codex_text_verbosity_preference !== undefined) { + result.codexTextVerbosityPreference = applyUpdates.codex_text_verbosity_preference; + } + if (applyUpdates.codex_parallel_tool_calls_preference !== undefined) { + result.codexParallelToolCallsPreference = applyUpdates.codex_parallel_tool_calls_preference; + } + if (applyUpdates.anthropic_max_tokens_preference !== undefined) { + result.anthropicMaxTokensPreference = applyUpdates.anthropic_max_tokens_preference; + } + if (applyUpdates.gemini_google_search_preference !== undefined) { + result.geminiGoogleSearchPreference = applyUpdates.gemini_google_search_preference; + } + if (applyUpdates.limit_5h_usd !== undefined) { + result.limit5hUsd = + applyUpdates.limit_5h_usd != null ? applyUpdates.limit_5h_usd.toString() : null; + } + if (applyUpdates.limit_daily_usd !== undefined) { + result.limitDailyUsd = + applyUpdates.limit_daily_usd != null ? applyUpdates.limit_daily_usd.toString() : null; + } + if (applyUpdates.daily_reset_mode !== undefined) { + result.dailyResetMode = applyUpdates.daily_reset_mode; + } + if (applyUpdates.daily_reset_time !== undefined) { + result.dailyResetTime = applyUpdates.daily_reset_time; + } + if (applyUpdates.limit_weekly_usd !== undefined) { + result.limitWeeklyUsd = + applyUpdates.limit_weekly_usd != null ? applyUpdates.limit_weekly_usd.toString() : null; + } + if (applyUpdates.limit_monthly_usd !== undefined) { + result.limitMonthlyUsd = + applyUpdates.limit_monthly_usd != null ? applyUpdates.limit_monthly_usd.toString() : null; + } + if (applyUpdates.limit_total_usd !== undefined) { + result.limitTotalUsd = + applyUpdates.limit_total_usd != null ? applyUpdates.limit_total_usd.toString() : null; + } + if (applyUpdates.limit_concurrent_sessions !== undefined) { + result.limitConcurrentSessions = applyUpdates.limit_concurrent_sessions; + } + if (applyUpdates.circuit_breaker_failure_threshold !== undefined) { + result.circuitBreakerFailureThreshold = applyUpdates.circuit_breaker_failure_threshold; + } + if (applyUpdates.circuit_breaker_open_duration !== undefined) { + result.circuitBreakerOpenDuration = applyUpdates.circuit_breaker_open_duration; + } + if (applyUpdates.circuit_breaker_half_open_success_threshold !== undefined) { + result.circuitBreakerHalfOpenSuccessThreshold = + applyUpdates.circuit_breaker_half_open_success_threshold; + } + if (applyUpdates.max_retry_attempts !== undefined) { + result.maxRetryAttempts = applyUpdates.max_retry_attempts; + } + if (applyUpdates.proxy_url !== undefined) { + result.proxyUrl = applyUpdates.proxy_url; + } + if (applyUpdates.proxy_fallback_to_direct !== undefined) { + result.proxyFallbackToDirect = applyUpdates.proxy_fallback_to_direct; + } + if (applyUpdates.first_byte_timeout_streaming_ms !== undefined) { + result.firstByteTimeoutStreamingMs = applyUpdates.first_byte_timeout_streaming_ms; + } + if (applyUpdates.streaming_idle_timeout_ms !== undefined) { + result.streamingIdleTimeoutMs = applyUpdates.streaming_idle_timeout_ms; + } + if (applyUpdates.request_timeout_non_streaming_ms !== undefined) { + result.requestTimeoutNonStreamingMs = applyUpdates.request_timeout_non_streaming_ms; + } + if (applyUpdates.mcp_passthrough_type !== undefined) { + result.mcpPassthroughType = applyUpdates.mcp_passthrough_type; + } + if (applyUpdates.mcp_passthrough_url !== undefined) { + result.mcpPassthroughUrl = applyUpdates.mcp_passthrough_url; + } + return result; +} + +const PATCH_FIELD_TO_PROVIDER_KEY: Record = { + is_enabled: "isEnabled", + priority: "priority", + weight: "weight", + cost_multiplier: "costMultiplier", + group_tag: "groupTag", + model_redirects: "modelRedirects", + allowed_models: "allowedModels", + anthropic_thinking_budget_preference: "anthropicThinkingBudgetPreference", + anthropic_adaptive_thinking: "anthropicAdaptiveThinking", + preserve_client_ip: "preserveClientIp", + group_priorities: "groupPriorities", + cache_ttl_preference: "cacheTtlPreference", + swap_cache_ttl_billing: "swapCacheTtlBilling", + context_1m_preference: "context1mPreference", + codex_reasoning_effort_preference: "codexReasoningEffortPreference", + codex_reasoning_summary_preference: "codexReasoningSummaryPreference", + codex_text_verbosity_preference: "codexTextVerbosityPreference", + codex_parallel_tool_calls_preference: "codexParallelToolCallsPreference", + anthropic_max_tokens_preference: "anthropicMaxTokensPreference", + gemini_google_search_preference: "geminiGoogleSearchPreference", + limit_5h_usd: "limit5hUsd", + limit_daily_usd: "limitDailyUsd", + daily_reset_mode: "dailyResetMode", + daily_reset_time: "dailyResetTime", + limit_weekly_usd: "limitWeeklyUsd", + limit_monthly_usd: "limitMonthlyUsd", + limit_total_usd: "limitTotalUsd", + limit_concurrent_sessions: "limitConcurrentSessions", + circuit_breaker_failure_threshold: "circuitBreakerFailureThreshold", + circuit_breaker_open_duration: "circuitBreakerOpenDuration", + circuit_breaker_half_open_success_threshold: "circuitBreakerHalfOpenSuccessThreshold", + max_retry_attempts: "maxRetryAttempts", + proxy_url: "proxyUrl", + proxy_fallback_to_direct: "proxyFallbackToDirect", + first_byte_timeout_streaming_ms: "firstByteTimeoutStreamingMs", + streaming_idle_timeout_ms: "streamingIdleTimeoutMs", + request_timeout_non_streaming_ms: "requestTimeoutNonStreamingMs", + mcp_passthrough_type: "mcpPassthroughType", + mcp_passthrough_url: "mcpPassthroughUrl", +}; + +const PATCH_FIELD_CLEAR_VALUE: Partial> = { + anthropic_thinking_budget_preference: "inherit", + cache_ttl_preference: "inherit", + context_1m_preference: "inherit", + codex_reasoning_effort_preference: "inherit", + codex_reasoning_summary_preference: "inherit", + codex_text_verbosity_preference: "inherit", + codex_parallel_tool_calls_preference: "inherit", + anthropic_max_tokens_preference: "inherit", + gemini_google_search_preference: "inherit", + mcp_passthrough_type: "none", +}; + +const CLAUDE_ONLY_FIELDS: ReadonlySet = new Set([ + "anthropic_thinking_budget_preference", + "anthropic_adaptive_thinking", + "anthropic_max_tokens_preference", + "context_1m_preference", +]); + +const CODEX_ONLY_FIELDS: ReadonlySet = new Set([ + "codex_reasoning_effort_preference", + "codex_reasoning_summary_preference", + "codex_text_verbosity_preference", + "codex_parallel_tool_calls_preference", +]); + +const GEMINI_ONLY_FIELDS: ReadonlySet = new Set([ + "gemini_google_search_preference", +]); + +function isClaudeProviderType(providerType: ProviderType): boolean { + return providerType === "claude" || providerType === "claude-auth"; +} + +function isCodexProviderType(providerType: ProviderType): boolean { + return providerType === "codex"; +} + +function isGeminiProviderType(providerType: ProviderType): boolean { + return providerType === "gemini" || providerType === "gemini-cli"; +} + +const CLAUDE_ONLY_REPO_KEYS: ReadonlySet = new Set([ + "anthropicThinkingBudgetPreference", + "anthropicAdaptiveThinking", + "anthropicMaxTokensPreference", + "context1mPreference", +]); + +const CODEX_ONLY_REPO_KEYS: ReadonlySet = new Set([ + "codexReasoningEffortPreference", + "codexReasoningSummaryPreference", + "codexTextVerbosityPreference", + "codexParallelToolCallsPreference", +]); + +const GEMINI_ONLY_REPO_KEYS: ReadonlySet = new Set([ + "geminiGoogleSearchPreference", +]); + +function filterRepositoryUpdatesByProviderType( + updates: BatchProviderUpdates, + providerType: string +): BatchProviderUpdates { + const filtered = { ...updates }; + if (!isClaudeProviderType(providerType as ProviderType)) { + for (const key of CLAUDE_ONLY_REPO_KEYS) delete filtered[key]; + } + if (!isCodexProviderType(providerType as ProviderType)) { + for (const key of CODEX_ONLY_REPO_KEYS) delete filtered[key]; + } + if (!isGeminiProviderType(providerType as ProviderType)) { + for (const key of GEMINI_ONLY_REPO_KEYS) delete filtered[key]; + } + return filtered; +} + +function computePreviewAfterValue( + field: ProviderBatchPatchField, + operation: ProviderPatchOperation +): unknown { + if (operation.mode === "set") { + if ( + field === "allowed_models" && + Array.isArray(operation.value) && + operation.value.length === 0 + ) { + return null; + } + return operation.value; + } + if (operation.mode === "clear") { + return PATCH_FIELD_CLEAR_VALUE[field] ?? null; + } + return undefined; +} + +function generatePreviewRows( + providers: Provider[], + patch: ProviderBatchPatch, + changedFields: ProviderBatchPatchField[] +): ProviderBatchPreviewRow[] { + const rows: ProviderBatchPreviewRow[] = []; + + for (const provider of providers) { + for (const field of changedFields) { + const operation = patch[field] as ProviderPatchOperation; + const providerKey = PATCH_FIELD_TO_PROVIDER_KEY[field]; + const before = provider[providerKey]; + const after = computePreviewAfterValue(field, operation); + + const isClaudeOnly = CLAUDE_ONLY_FIELDS.has(field); + const isCodexOnly = CODEX_ONLY_FIELDS.has(field); + const isGeminiOnly = GEMINI_ONLY_FIELDS.has(field); + + let isCompatible = true; + let skipReason = ""; + if (isClaudeOnly && !isClaudeProviderType(provider.providerType)) { + isCompatible = false; + skipReason = `Field "${field}" is only applicable to claude/claude-auth providers`; + } else if (isCodexOnly && !isCodexProviderType(provider.providerType)) { + isCompatible = false; + skipReason = `Field "${field}" is only applicable to codex providers`; + } else if (isGeminiOnly && !isGeminiProviderType(provider.providerType)) { + isCompatible = false; + skipReason = `Field "${field}" is only applicable to gemini/gemini-cli providers`; + } + + if (isCompatible) { + rows.push({ + providerId: provider.id, + providerName: provider.name, + field, + status: "changed", + before, + after, + }); + } else { + rows.push({ + providerId: provider.id, + providerName: provider.name, + field, + status: "skipped", + before, + after, + skipReason, + }); + } + } + } + + return rows; +} + +export async function previewProviderBatchPatch( + input: unknown +): Promise> { + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "无权限执行此操作" }; + } + + const parsed = PreviewProviderBatchPatchSchema.safeParse(input); + if (!parsed.success) { + return buildActionValidationError(parsed.error); + } + + const normalizedPatch = normalizeProviderBatchPatchDraft(parsed.data.patch); + if (!normalizedPatch.ok) { + return { + ok: false, + error: normalizedPatch.error.message, + errorCode: PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE, + }; + } + + if (!hasProviderBatchPatchChanges(normalizedPatch.data)) { + return buildNoChangesError(); + } + + const providerIds = dedupeProviderIds(parsed.data.providerIds); + const changedFields = getChangedPatchFields(normalizedPatch.data); + const nowMs = Date.now(); + + const allProviders = await findAllProvidersFresh(); + const providerIdSet = new Set(providerIds); + const matchedProviders = allProviders.filter((p) => providerIdSet.has(p.id)); + const rows = generatePreviewRows(matchedProviders, normalizedPatch.data, changedFields); + const skipCount = rows.filter((r) => r.status === "skipped").length; + + const previewToken = createProviderBatchPreviewToken(); + const previewRevision = `${nowMs}:${providerIds.join(",")}:${changedFields.join(",")}`; + const previewExpiresAt = nowMs + PROVIDER_BATCH_PREVIEW_TTL_SECONDS * 1000; + + await providerBatchPatchPreviewStore.set(previewToken, { + previewToken, + previewRevision, + providerIds, + patch: normalizedPatch.data, + patchSerialized: JSON.stringify(normalizedPatch.data), + changedFields, + rows, + applied: false, + appliedResultByIdempotencyKey: {}, + }); + + return { + ok: true, + data: { + previewToken, + previewRevision, + previewExpiresAt: new Date(previewExpiresAt).toISOString(), + providerIds, + changedFields, + rows, + summary: { + providerCount: providerIds.length, + fieldCount: changedFields.length, + skipCount, + }, + }, + }; + } catch (error) { + logger.error("预览批量补丁失败:", error); + const message = error instanceof Error ? error.message : "预览批量补丁失败"; + return { ok: false, error: message }; + } +} + +export async function applyProviderBatchPatch( + input: unknown +): Promise> { + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "无权限执行此操作" }; + } + + const parsed = ApplyProviderBatchPatchSchema.safeParse(input); + if (!parsed.success) { + return buildActionValidationError(parsed.error); + } + + const nowMs = Date.now(); + + const snapshot = await providerBatchPatchPreviewStore.get(parsed.data.previewToken); + if (!snapshot) { + return { + ok: false, + error: "预览已过期,请重新预览", + errorCode: PROVIDER_BATCH_PATCH_ERROR_CODES.PREVIEW_EXPIRED, + }; + } + + const normalizedPatch = normalizeProviderBatchPatchDraft(parsed.data.patch); + if (!normalizedPatch.ok) { + return { + ok: false, + error: normalizedPatch.error.message, + errorCode: PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE, + }; + } + + if (!hasProviderBatchPatchChanges(normalizedPatch.data)) { + return buildNoChangesError(); + } + + const providerIds = dedupeProviderIds(parsed.data.providerIds); + const patchSerialized = JSON.stringify(normalizedPatch.data); + const isStale = + parsed.data.previewRevision !== snapshot.previewRevision || + !isSameProviderIdList(providerIds, snapshot.providerIds) || + patchSerialized !== snapshot.patchSerialized; + + if (parsed.data.idempotencyKey) { + const existingResult = snapshot.appliedResultByIdempotencyKey[parsed.data.idempotencyKey]; + if (existingResult) { + return { ok: true, data: existingResult }; + } + } + + if (isStale || snapshot.applied) { + return { + ok: false, + error: "预览内容已失效,请重新预览", + errorCode: PROVIDER_BATCH_PATCH_ERROR_CODES.PREVIEW_STALE, + }; + } + + const excludeSet = new Set(parsed.data.excludeProviderIds ?? []); + const effectiveProviderIds = providerIds.filter((id) => !excludeSet.has(id)); + if (effectiveProviderIds.length === 0) { + return { + ok: false, + error: "排除后无可应用的供应商", + errorCode: PROVIDER_BATCH_PATCH_ERROR_CODES.NOTHING_TO_APPLY, + }; + } + + const updatesResult = buildProviderBatchApplyUpdates(normalizedPatch.data); + if (!updatesResult.ok) { + return { + ok: false, + error: updatesResult.error.message, + errorCode: PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE, + }; + } + + const allProviders = await findAllProvidersFresh(); + const effectiveIdSet = new Set(effectiveProviderIds); + const matchedProviders = allProviders.filter((p) => effectiveIdSet.has(p.id)); + const changedFields = getChangedPatchFields(normalizedPatch.data); + const preimage: Record> = {}; + for (const provider of matchedProviders) { + const fieldValues: Record = {}; + for (const field of changedFields) { + const providerKey = PATCH_FIELD_TO_PROVIDER_KEY[field]; + fieldValues[providerKey] = provider[providerKey]; + } + preimage[provider.id] = fieldValues; + } + + const repositoryUpdates = mapApplyUpdatesToRepositoryFormat(updatesResult.data); + + const hasTypeSpecificFields = changedFields.some( + (f) => CLAUDE_ONLY_FIELDS.has(f) || CODEX_ONLY_FIELDS.has(f) || GEMINI_ONLY_FIELDS.has(f) + ); + + let dbUpdatedCount: number; + if (!hasTypeSpecificFields) { + dbUpdatedCount = await updateProvidersBatch(effectiveProviderIds, repositoryUpdates); + } else { + const providersByType = new Map(); + for (const provider of matchedProviders) { + const type = provider.providerType; + if (!providersByType.has(type)) providersByType.set(type, []); + providersByType.get(type)!.push(provider.id); + } + + dbUpdatedCount = 0; + for (const [type, ids] of providersByType) { + const filtered = filterRepositoryUpdatesByProviderType(repositoryUpdates, type); + if (Object.keys(filtered).length > 0) { + dbUpdatedCount += await updateProvidersBatch(ids, filtered); + } + } + } + + await publishProviderCacheInvalidation(); + + const appliedAt = new Date(nowMs).toISOString(); + const undoToken = createProviderPatchUndoToken(); + const undoExpiresAtMs = nowMs + PROVIDER_PATCH_UNDO_TTL_SECONDS * 1000; + + const applyResult: ApplyProviderBatchPatchResult = { + operationId: createProviderPatchOperationId(), + appliedAt, + updatedCount: dbUpdatedCount, + undoToken, + undoExpiresAt: new Date(undoExpiresAtMs).toISOString(), + }; + + snapshot.applied = true; + if (parsed.data.idempotencyKey) { + snapshot.appliedResultByIdempotencyKey[parsed.data.idempotencyKey] = applyResult; + } + await providerBatchPatchPreviewStore.set(parsed.data.previewToken, snapshot); + + await providerPatchUndoStore.set(undoToken, { + undoToken, + operationId: applyResult.operationId, + providerIds: effectiveProviderIds, + preimage, + patch: normalizedPatch.data, + }); + + return { ok: true, data: applyResult }; + } catch (error) { + logger.error("应用批量补丁失败:", error); + const message = error instanceof Error ? error.message : "应用批量补丁失败"; + return { ok: false, error: message }; + } +} + +export async function undoProviderPatch( + input: unknown +): Promise> { + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "无权限执行此操作" }; + } + + const parsed = UndoProviderPatchSchema.safeParse(input); + if (!parsed.success) { + return buildActionValidationError(parsed.error); + } + + const nowMs = Date.now(); + + const snapshot = await providerPatchUndoStore.get(parsed.data.undoToken); + if (!snapshot) { + return { + ok: false, + error: "撤销窗口已过期", + errorCode: PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED, + }; + } + + if (snapshot.operationId !== parsed.data.operationId) { + return { + ok: false, + error: "撤销参数与操作不匹配", + errorCode: PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_CONFLICT, + }; + } + + // Delete after validation passes so operationId mismatch doesn't destroy the token + await providerPatchUndoStore.delete(parsed.data.undoToken); + + // Group providers by identical preimage values to minimise DB round-trips + const preimageGroups = new Map(); + + for (const providerId of snapshot.providerIds) { + const providerPreimage = snapshot.preimage[providerId]; + if (!providerPreimage || Object.keys(providerPreimage).length === 0) { + continue; + } + + const updatesObj: Record = {}; + for (const [key, value] of Object.entries(providerPreimage)) { + if (key === "costMultiplier" && typeof value === "number") { + updatesObj[key] = value.toString(); + } else { + updatesObj[key] = value; + } + } + const updates = updatesObj as BatchProviderUpdates; + + const groupKey = JSON.stringify(updates); + const existing = preimageGroups.get(groupKey); + if (existing) { + existing.ids.push(providerId); + } else { + preimageGroups.set(groupKey, { ids: [providerId], updates }); + } + } + + let revertedCount = 0; + for (const { ids, updates } of preimageGroups.values()) { + const count = await updateProvidersBatch(ids, updates); + revertedCount += count; + } + + if (preimageGroups.size > 0) { + await publishProviderCacheInvalidation(); + } + + return { + ok: true, + data: { + operationId: snapshot.operationId, + revertedAt: new Date(nowMs).toISOString(), + revertedCount, + }, + }; + } catch (error) { + logger.error("撤销批量补丁失败:", error); + const message = error instanceof Error ? error.message : "撤销批量补丁失败"; + return { ok: false, error: message }; + } +} export interface BatchUpdateProvidersParams { providerIds: number[]; @@ -1032,6 +2029,10 @@ export interface BatchUpdateProvidersParams { weight?: number; cost_multiplier?: number; group_tag?: string | null; + model_redirects?: Record | null; + allowed_models?: string[] | null; + anthropic_thinking_budget_preference?: AnthropicThinkingBudgetPreference | null; + anthropic_adaptive_thinking?: AnthropicAdaptiveThinkingConfig | null; }; } @@ -1069,6 +2070,22 @@ export async function batchUpdateProviders( repositoryUpdates.costMultiplier = updates.cost_multiplier.toString(); } if (updates.group_tag !== undefined) repositoryUpdates.groupTag = updates.group_tag; + if (updates.model_redirects !== undefined) { + repositoryUpdates.modelRedirects = updates.model_redirects; + } + if (updates.allowed_models !== undefined) { + repositoryUpdates.allowedModels = + Array.isArray(updates.allowed_models) && updates.allowed_models.length === 0 + ? null + : updates.allowed_models; + } + if (updates.anthropic_thinking_budget_preference !== undefined) { + repositoryUpdates.anthropicThinkingBudgetPreference = + updates.anthropic_thinking_budget_preference; + } + if (updates.anthropic_adaptive_thinking !== undefined) { + repositoryUpdates.anthropicAdaptiveThinking = updates.anthropic_adaptive_thinking; + } const updatedCount = await updateProvidersBatch(providerIds, repositoryUpdates); @@ -1097,7 +2114,7 @@ export interface BatchDeleteProvidersParams { export async function batchDeleteProviders( params: BatchDeleteProvidersParams -): Promise> { +): Promise> { try { const session = await getSession(); if (!session || session.user.role !== "admin") { @@ -1114,26 +2131,45 @@ export async function batchDeleteProviders( return { ok: false, error: `单次批量操作最多支持 ${BATCH_OPERATION_MAX_SIZE} 个供应商` }; } + const snapshotProviderIds = dedupeProviderIds(providerIds); + const { deleteProvidersBatch } = await import("@/repository/provider"); - const deletedCount = await deleteProvidersBatch(providerIds); + const deletedCount = await deleteProvidersBatch(snapshotProviderIds); - for (const id of providerIds) { + const undoToken = createProviderPatchUndoToken(); + const operationId = createProviderPatchOperationId(); + + await providerDeleteUndoStore.set(undoToken, { + undoToken, + operationId, + providerIds: snapshotProviderIds, + }); + + for (const id of snapshotProviderIds) { clearProviderState(id); clearConfigCache(id); } await broadcastProviderCacheInvalidation({ operation: "remove", - providerId: providerIds[0], + providerId: snapshotProviderIds[0], }); logger.info("batchDeleteProviders:completed", { - requestedCount: providerIds.length, + requestedCount: snapshotProviderIds.length, deletedCount, + operationId, }); - return { ok: true, data: { deletedCount } }; + return { + ok: true, + data: { + deletedCount, + undoToken, + operationId, + }, + }; } catch (error) { logger.error("批量删除供应商失败:", error); const message = error instanceof Error ? error.message : "批量删除供应商失败"; @@ -1141,6 +2177,66 @@ export async function batchDeleteProviders( } } +export async function undoProviderDelete( + input: unknown +): Promise> { + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "无权限执行此操作" }; + } + + const parsed = UndoProviderDeleteSchema.safeParse(input); + if (!parsed.success) { + return buildActionValidationError(parsed.error); + } + + const nowMs = Date.now(); + + const snapshot = await providerDeleteUndoStore.get(parsed.data.undoToken); + if (!snapshot) { + return { + ok: false, + error: "撤销窗口已过期", + errorCode: PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED, + }; + } + + if (snapshot.operationId !== parsed.data.operationId) { + return { + ok: false, + error: "撤销参数与操作不匹配", + errorCode: PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_CONFLICT, + }; + } + + // Delete after validation passes so operationId mismatch doesn't destroy the token + await providerDeleteUndoStore.delete(parsed.data.undoToken); + + const restoredCount = await restoreProvidersBatch(snapshot.providerIds); + + for (const id of snapshot.providerIds) { + clearProviderState(id); + clearConfigCache(id); + } + + await publishProviderCacheInvalidation(); + + return { + ok: true, + data: { + operationId: snapshot.operationId, + restoredAt: new Date(nowMs).toISOString(), + restoredCount, + }, + }; + } catch (error) { + logger.error("撤销批量删除失败:", error); + const message = error instanceof Error ? error.message : "撤销批量删除失败"; + return { ok: false, error: message }; + } +} + export interface BatchResetCircuitParams { providerIds: number[]; } diff --git a/src/app/[locale]/login/loading.tsx b/src/app/[locale]/login/loading.tsx index cc0c65a01..d1dc82c19 100644 --- a/src/app/[locale]/login/loading.tsx +++ b/src/app/[locale]/login/loading.tsx @@ -3,13 +3,32 @@ import { Skeleton } from "@/components/ui/skeleton"; export default function LoginLoading() { return ( -
-
- - - - - +
+ {/* Brand Panel Skeleton - Desktop Only */} +
+
+ + + +
+
+ + {/* Form Panel Skeleton */} +
+ {/* Mobile Brand Skeleton */} +
+ + + +
+ +
+ + + + + +
); diff --git a/src/app/[locale]/login/page.tsx b/src/app/[locale]/login/page.tsx index 170948455..48421f025 100644 --- a/src/app/[locale]/login/page.tsx +++ b/src/app/[locale]/login/page.tsx @@ -1,16 +1,19 @@ "use client"; -import { AlertTriangle, Book, Key, Loader2 } from "lucide-react"; +import { motion } from "framer-motion"; +import { AlertTriangle, Book, ExternalLink, Eye, EyeOff, Key, Loader2 } from "lucide-react"; import { useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useEffect, useRef, useState } from "react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { LanguageSwitcher } from "@/components/ui/language-switcher"; +import { ThemeSwitcher } from "@/components/ui/theme-switcher"; import { Link, useRouter } from "@/i18n/routing"; +import { resolveLoginRedirectTarget } from "./redirect-safety"; export default function LoginPage() { return ( @@ -20,18 +23,92 @@ export default function LoginPage() { ); } +type LoginStatus = "idle" | "submitting" | "success" | "error"; +type LoginType = "admin" | "dashboard_user" | "readonly_user"; + +interface LoginVersionInfo { + current: string; + hasUpdate: boolean; +} + +const DEFAULT_SITE_TITLE = "Claude Code Hub"; + +function parseLoginType(value: unknown): LoginType | null { + if (value === "admin" || value === "dashboard_user" || value === "readonly_user") { + return value; + } + + return null; +} + +function getLoginTypeFallbackPath(loginType: LoginType): string { + return loginType === "readonly_user" ? "/my-usage" : "/dashboard"; +} + +function formatVersionLabel(version: string): string { + const trimmed = version.trim(); + if (!trimmed) return ""; + return /^v/i.test(trimmed) ? `v${trimmed.slice(1)}` : `v${trimmed}`; +} + +const floatAnimation = { + y: [0, -20, 0], + transition: { + duration: 6, + repeat: Number.POSITIVE_INFINITY, + ease: "easeInOut" as const, + }, +}; + +const floatAnimationSlow = { + y: [0, -15, 0], + transition: { + duration: 8, + repeat: Number.POSITIVE_INFINITY, + ease: "easeInOut" as const, + }, +}; + +const brandPanelVariants = { + hidden: { opacity: 0, x: -40 }, + visible: { + opacity: 1, + x: 0, + transition: { type: "spring" as const, stiffness: 300, damping: 30 }, + }, +}; + +const stagger = { + hidden: { opacity: 0, y: 20 }, + visible: (delay: number) => ({ + opacity: 1, + y: 0, + transition: { duration: 0.4, delay, ease: "easeOut" as const }, + }), +}; + function LoginPageContent() { const t = useTranslations("auth"); + const tCustoms = useTranslations("customs"); const router = useRouter(); const searchParams = useSearchParams(); - const from = searchParams.get("from") || "/dashboard"; + const from = searchParams.get("from") || ""; + const apiKeyInputRef = useRef(null); const [apiKey, setApiKey] = useState(""); - const [loading, setLoading] = useState(false); + const [status, setStatus] = useState("idle"); const [error, setError] = useState(""); const [showHttpWarning, setShowHttpWarning] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [versionInfo, setVersionInfo] = useState(null); + const [siteTitle, setSiteTitle] = useState(DEFAULT_SITE_TITLE); + + useEffect(() => { + if (status === "error" && apiKeyInputRef.current) { + apiKeyInputRef.current.focus(); + } + }, [status]); - // 检测是否为 HTTP(非 localhost) useEffect(() => { if (typeof window !== "undefined") { const isHttp = window.location.protocol === "http:"; @@ -41,10 +118,60 @@ function LoginPageContent() { } }, []); + useEffect(() => { + let active = true; + + void fetch("/api/version") + .then((response) => response.json() as Promise<{ current?: unknown; hasUpdate?: unknown }>) + .then((data) => { + if (!active || typeof data.current !== "string") { + return; + } + + setVersionInfo({ + current: data.current, + hasUpdate: Boolean(data.hasUpdate), + }); + }) + .catch(() => {}); + + return () => { + active = false; + }; + }, []); + + useEffect(() => { + let active = true; + + void fetch("/api/system-settings") + .then((response) => { + if (!response.ok) { + return null; + } + + return response.json() as Promise<{ siteTitle?: unknown }>; + }) + .then((data) => { + if (!active || !data || typeof data.siteTitle !== "string") { + return; + } + + const nextSiteTitle = data.siteTitle.trim(); + if (nextSiteTitle) { + setSiteTitle(nextSiteTitle); + } + }) + .catch(() => {}); + + return () => { + active = false; + }; + }, []); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); - setLoading(true); + setStatus("submitting"); try { const response = await fetch("/api/auth/login", { @@ -57,121 +184,248 @@ function LoginPageContent() { if (!response.ok) { setError(data.error || t("errors.loginFailed")); + setStatus("error"); return; } - // 登录成功,按服务端返回的目标跳转,回退到原页面 - const redirectTarget = data.redirectTo || from; + setStatus("success"); + const loginType = parseLoginType(data.loginType); + const fallbackPath = loginType ? getLoginTypeFallbackPath(loginType) : from; + const redirectTarget = resolveLoginRedirectTarget(data.redirectTo, fallbackPath); router.push(redirectTarget); router.refresh(); } catch { setError(t("errors.networkError")); - } finally { - setLoading(false); + setStatus("error"); } }; + const isLoading = status === "submitting" || status === "success"; + return ( -
- {/* Language Switcher - Fixed Top Right */} -
+
+ {/* Fullscreen Loading Overlay */} + {isLoading && ( +
+ +

+ {t("login.loggingIn")} +

+
+ )} + + {/* Top Right Controls */} +
+ + + {t("actions.viewUsageDoc")} + + + + +
-
-
-
+ {/* Background Orbs */} +
+ +
-
- - -
-
- -
-
- {t("form.title")} - {t("form.description")} -
+ {/* Main Layout */} +
+ {/* Brand Panel - Desktop Only */} + + {/* Brand Panel Gradient Background */} +
+ + {/* Brand Panel Animated Orb */} + + +
+
+
- - - {showHttpWarning ? ( - - - {t("security.cookieWarningTitle")} - -

{t("security.cookieWarningDescription")}

-
-

{t("security.solutionTitle")}

-
    -
  1. {t("security.useHttps")}
  2. -
  3. {t("security.disableSecureCookies")}
  4. -
+

{siteTitle}

+

{t("brand.tagline")}

+
+
+ + + {/* Form Panel */} +
+ {/* Mobile Brand Header */} +
+
+ +
+
+

{siteTitle}

+

{t("brand.tagline")}

+
+
+ +
+ + + +
+
- - - ) : null} -
-
-
- -
- - setApiKey(e.target.value)} - className="pl-9" - required - disabled={loading} - /> +
+ + {t("form.title")} + + {t("form.description")}
-
- - {error ? ( - - {error} - - ) : null} -
- -
- -

- {t("security.privacyNote")} -

-
- - - {/* 文档页入口 */} -
- - - {t("actions.viewUsageDoc")} - -
- - + + + {showHttpWarning ? ( + + + {t("security.cookieWarningTitle")} + +

{t("security.cookieWarningDescription")}

+
+

{t("security.solutionTitle")}

+
    +
  1. {t("security.useHttps")}
  2. +
  3. {t("security.disableSecureCookies")}
  4. +
+
+
+
+ ) : null} +
+ +
+ +
+ + setApiKey(e.target.value)} + className="pl-9 pr-10" + required + disabled={isLoading} + /> + +
+
+ + {error ? ( + + {error} + + ) : null} +
+ + + +

+ {t("security.privacyNote")} +

+
+
+
+ + +
+
+
+ + {/* Page Footer */} +
+

+ {siteTitle} +

+ + {versionInfo?.current ? ( +
+ {formatVersionLabel(versionInfo.current)} + {versionInfo.hasUpdate ? ( + {tCustoms("version.updateAvailable")} + ) : null} +
+ ) : null}
); diff --git a/src/app/[locale]/login/redirect-safety.ts b/src/app/[locale]/login/redirect-safety.ts new file mode 100644 index 000000000..641ea8a6a --- /dev/null +++ b/src/app/[locale]/login/redirect-safety.ts @@ -0,0 +1,37 @@ +const DEFAULT_REDIRECT_PATH = "/dashboard"; +const PROTOCOL_LIKE_PATTERN = /^[a-zA-Z][a-zA-Z\d+.-]*:/; + +export function sanitizeRedirectPath(from: string): string { + const candidate = from.trim(); + + if (!candidate) { + return DEFAULT_REDIRECT_PATH; + } + + if (!candidate.startsWith("/")) { + return DEFAULT_REDIRECT_PATH; + } + + if (candidate.startsWith("//")) { + return DEFAULT_REDIRECT_PATH; + } + + if (PROTOCOL_LIKE_PATTERN.test(candidate)) { + return DEFAULT_REDIRECT_PATH; + } + + const withoutLeadingSlash = candidate.slice(1); + if (PROTOCOL_LIKE_PATTERN.test(withoutLeadingSlash)) { + return DEFAULT_REDIRECT_PATH; + } + + return candidate; +} + +export function resolveLoginRedirectTarget(redirectTo: unknown, from: string): string { + if (typeof redirectTo === "string" && redirectTo.trim().length > 0) { + return sanitizeRedirectPath(redirectTo); + } + + return sanitizeRedirectPath(from); +} diff --git a/src/app/[locale]/settings/providers/_components/adaptive-thinking-editor.tsx b/src/app/[locale]/settings/providers/_components/adaptive-thinking-editor.tsx new file mode 100644 index 000000000..f7537553c --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/adaptive-thinking-editor.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { Info } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { TagInput } from "@/components/ui/tag-input"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import type { + AnthropicAdaptiveThinkingConfig, + AnthropicAdaptiveThinkingEffort, + AnthropicAdaptiveThinkingModelMatchMode, +} from "@/types/provider"; +import { SmartInputWrapper, ToggleRow } from "./forms/provider-form/components/section-card"; + +interface AdaptiveThinkingEditorProps { + enabled: boolean; + config: AnthropicAdaptiveThinkingConfig; + onEnabledChange: (enabled: boolean) => void; + onConfigChange: (config: AnthropicAdaptiveThinkingConfig) => void; + disabled?: boolean; +} + +export function AdaptiveThinkingEditor({ + enabled, + config, + onEnabledChange, + onConfigChange, + disabled = false, +}: AdaptiveThinkingEditorProps) { + const t = useTranslations("settings.providers.form"); + + const handleEffortChange = (effort: AnthropicAdaptiveThinkingEffort) => { + onConfigChange({ + ...config, + effort, + }); + }; + + const handleModeChange = (modelMatchMode: AnthropicAdaptiveThinkingModelMatchMode) => { + onConfigChange({ + ...config, + modelMatchMode, + }); + }; + + const handleModelsChange = (models: string[]) => { + onConfigChange({ + ...config, + models, + }); + }; + + return ( +
+ + + + + {enabled && ( +
+ + + +
+ + +
+
+ +

+ {t("sections.routing.anthropicOverrides.adaptiveThinking.effort.help")} +

+
+
+
+ + + + +
+ + +
+
+ +

+ {t("sections.routing.anthropicOverrides.adaptiveThinking.modelMatchMode.help")} +

+
+
+
+ + {config.modelMatchMode === "specific" && ( + + + +
+ + +
+
+ +

+ {t("sections.routing.anthropicOverrides.adaptiveThinking.models.help")} +

+
+
+
+ )} +
+ )} +
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx b/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx index e8d944292..917d30a05 100644 --- a/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx +++ b/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx @@ -1,11 +1,11 @@ "use client"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { ServerCog } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { FormErrorBoundary } from "@/components/form-error-boundary"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { ProviderForm } from "./forms/provider-form"; interface AddProviderDialogProps { diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts b/src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts new file mode 100644 index 000000000..d9b2e2b7e --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts @@ -0,0 +1,290 @@ +import type { ProviderBatchPatchDraft } from "@/types/provider"; +import type { ProviderFormState } from "../forms/provider-form/provider-form-types"; + +/** + * Builds a ProviderBatchPatchDraft from the current form state, + * including only fields that the user has actually modified (dirty fields). + * + * Unit conversions: + * - circuitBreaker.openDurationMinutes (minutes) -> circuit_breaker_open_duration (ms) + * - network.*Seconds (seconds) -> *_ms (ms) + */ +export function buildPatchDraftFromFormState( + state: ProviderFormState, + dirtyFields: Set +): ProviderBatchPatchDraft { + const draft: ProviderBatchPatchDraft = {}; + + // Batch-specific: isEnabled + if (dirtyFields.has("batch.isEnabled")) { + if (state.batch.isEnabled !== "no_change") { + draft.is_enabled = { set: state.batch.isEnabled === "true" }; + } + } + + // Routing fields + if (dirtyFields.has("routing.priority")) { + draft.priority = { set: state.routing.priority }; + } + if (dirtyFields.has("routing.weight")) { + draft.weight = { set: state.routing.weight }; + } + if (dirtyFields.has("routing.costMultiplier")) { + draft.cost_multiplier = { set: state.routing.costMultiplier }; + } + if (dirtyFields.has("routing.groupTag")) { + const joined = state.routing.groupTag.join(", "); + if (joined === "") { + draft.group_tag = { clear: true }; + } else { + draft.group_tag = { set: joined }; + } + } + if (dirtyFields.has("routing.preserveClientIp")) { + draft.preserve_client_ip = { set: state.routing.preserveClientIp }; + } + if (dirtyFields.has("routing.modelRedirects")) { + const entries = Object.keys(state.routing.modelRedirects); + if (entries.length === 0) { + draft.model_redirects = { clear: true }; + } else { + draft.model_redirects = { set: state.routing.modelRedirects }; + } + } + if (dirtyFields.has("routing.allowedModels")) { + if (state.routing.allowedModels.length === 0) { + draft.allowed_models = { clear: true }; + } else { + draft.allowed_models = { set: state.routing.allowedModels }; + } + } + if (dirtyFields.has("routing.groupPriorities")) { + const entries = Object.keys(state.routing.groupPriorities); + if (entries.length === 0) { + draft.group_priorities = { clear: true }; + } else { + draft.group_priorities = { set: state.routing.groupPriorities }; + } + } + if (dirtyFields.has("routing.cacheTtlPreference")) { + if (state.routing.cacheTtlPreference === "inherit") { + draft.cache_ttl_preference = { clear: true }; + } else { + draft.cache_ttl_preference = { set: state.routing.cacheTtlPreference }; + } + } + if (dirtyFields.has("routing.swapCacheTtlBilling")) { + draft.swap_cache_ttl_billing = { set: state.routing.swapCacheTtlBilling }; + } + if (dirtyFields.has("routing.context1mPreference")) { + if (state.routing.context1mPreference === "inherit") { + draft.context_1m_preference = { clear: true }; + } else { + draft.context_1m_preference = { set: state.routing.context1mPreference }; + } + } + + // Codex preferences + if (dirtyFields.has("routing.codexReasoningEffortPreference")) { + if (state.routing.codexReasoningEffortPreference === "inherit") { + draft.codex_reasoning_effort_preference = { clear: true }; + } else { + draft.codex_reasoning_effort_preference = { + set: state.routing.codexReasoningEffortPreference, + }; + } + } + if (dirtyFields.has("routing.codexReasoningSummaryPreference")) { + if (state.routing.codexReasoningSummaryPreference === "inherit") { + draft.codex_reasoning_summary_preference = { clear: true }; + } else { + draft.codex_reasoning_summary_preference = { + set: state.routing.codexReasoningSummaryPreference, + }; + } + } + if (dirtyFields.has("routing.codexTextVerbosityPreference")) { + if (state.routing.codexTextVerbosityPreference === "inherit") { + draft.codex_text_verbosity_preference = { clear: true }; + } else { + draft.codex_text_verbosity_preference = { set: state.routing.codexTextVerbosityPreference }; + } + } + if (dirtyFields.has("routing.codexParallelToolCallsPreference")) { + if (state.routing.codexParallelToolCallsPreference === "inherit") { + draft.codex_parallel_tool_calls_preference = { clear: true }; + } else { + draft.codex_parallel_tool_calls_preference = { + set: state.routing.codexParallelToolCallsPreference, + }; + } + } + + // Anthropic preferences + if (dirtyFields.has("routing.anthropicMaxTokensPreference")) { + if (state.routing.anthropicMaxTokensPreference === "inherit") { + draft.anthropic_max_tokens_preference = { clear: true }; + } else { + draft.anthropic_max_tokens_preference = { set: state.routing.anthropicMaxTokensPreference }; + } + } + if (dirtyFields.has("routing.anthropicThinkingBudgetPreference")) { + if (state.routing.anthropicThinkingBudgetPreference === "inherit") { + draft.anthropic_thinking_budget_preference = { clear: true }; + } else { + draft.anthropic_thinking_budget_preference = { + set: state.routing.anthropicThinkingBudgetPreference, + }; + } + } + if (dirtyFields.has("routing.anthropicAdaptiveThinking")) { + if (state.routing.anthropicAdaptiveThinking === null) { + draft.anthropic_adaptive_thinking = { clear: true }; + } else { + draft.anthropic_adaptive_thinking = { set: state.routing.anthropicAdaptiveThinking }; + } + } + + // Gemini preferences + if (dirtyFields.has("routing.geminiGoogleSearchPreference")) { + if (state.routing.geminiGoogleSearchPreference === "inherit") { + draft.gemini_google_search_preference = { clear: true }; + } else { + draft.gemini_google_search_preference = { set: state.routing.geminiGoogleSearchPreference }; + } + } + + // Rate limit fields + if (dirtyFields.has("rateLimit.limit5hUsd")) { + if (state.rateLimit.limit5hUsd === null) { + draft.limit_5h_usd = { clear: true }; + } else { + draft.limit_5h_usd = { set: state.rateLimit.limit5hUsd }; + } + } + if (dirtyFields.has("rateLimit.limitDailyUsd")) { + if (state.rateLimit.limitDailyUsd === null) { + draft.limit_daily_usd = { clear: true }; + } else { + draft.limit_daily_usd = { set: state.rateLimit.limitDailyUsd }; + } + } + if (dirtyFields.has("rateLimit.dailyResetMode")) { + draft.daily_reset_mode = { set: state.rateLimit.dailyResetMode }; + } + if (dirtyFields.has("rateLimit.dailyResetTime")) { + draft.daily_reset_time = { set: state.rateLimit.dailyResetTime }; + } + if (dirtyFields.has("rateLimit.limitWeeklyUsd")) { + if (state.rateLimit.limitWeeklyUsd === null) { + draft.limit_weekly_usd = { clear: true }; + } else { + draft.limit_weekly_usd = { set: state.rateLimit.limitWeeklyUsd }; + } + } + if (dirtyFields.has("rateLimit.limitMonthlyUsd")) { + if (state.rateLimit.limitMonthlyUsd === null) { + draft.limit_monthly_usd = { clear: true }; + } else { + draft.limit_monthly_usd = { set: state.rateLimit.limitMonthlyUsd }; + } + } + if (dirtyFields.has("rateLimit.limitTotalUsd")) { + if (state.rateLimit.limitTotalUsd === null) { + draft.limit_total_usd = { clear: true }; + } else { + draft.limit_total_usd = { set: state.rateLimit.limitTotalUsd }; + } + } + if (dirtyFields.has("rateLimit.limitConcurrentSessions")) { + if (state.rateLimit.limitConcurrentSessions === null) { + draft.limit_concurrent_sessions = { set: 0 }; + } else { + draft.limit_concurrent_sessions = { set: state.rateLimit.limitConcurrentSessions }; + } + } + + // Circuit breaker fields (minutes -> ms conversion for open duration) + if (dirtyFields.has("circuitBreaker.failureThreshold")) { + if (state.circuitBreaker.failureThreshold === undefined) { + draft.circuit_breaker_failure_threshold = { set: 0 }; + } else { + draft.circuit_breaker_failure_threshold = { set: state.circuitBreaker.failureThreshold }; + } + } + if (dirtyFields.has("circuitBreaker.openDurationMinutes")) { + if (state.circuitBreaker.openDurationMinutes === undefined) { + draft.circuit_breaker_open_duration = { set: 0 }; + } else { + // Convert minutes to milliseconds + draft.circuit_breaker_open_duration = { + set: state.circuitBreaker.openDurationMinutes * 60000, + }; + } + } + if (dirtyFields.has("circuitBreaker.halfOpenSuccessThreshold")) { + if (state.circuitBreaker.halfOpenSuccessThreshold === undefined) { + draft.circuit_breaker_half_open_success_threshold = { set: 0 }; + } else { + draft.circuit_breaker_half_open_success_threshold = { + set: state.circuitBreaker.halfOpenSuccessThreshold, + }; + } + } + if (dirtyFields.has("circuitBreaker.maxRetryAttempts")) { + if (state.circuitBreaker.maxRetryAttempts === null) { + draft.max_retry_attempts = { clear: true }; + } else { + draft.max_retry_attempts = { set: state.circuitBreaker.maxRetryAttempts }; + } + } + + // Network fields (seconds -> ms conversion) + if (dirtyFields.has("network.proxyUrl")) { + if (state.network.proxyUrl === "") { + draft.proxy_url = { clear: true }; + } else { + draft.proxy_url = { set: state.network.proxyUrl }; + } + } + if (dirtyFields.has("network.proxyFallbackToDirect")) { + draft.proxy_fallback_to_direct = { set: state.network.proxyFallbackToDirect }; + } + if (dirtyFields.has("network.firstByteTimeoutStreamingSeconds")) { + if (state.network.firstByteTimeoutStreamingSeconds !== undefined) { + draft.first_byte_timeout_streaming_ms = { + set: state.network.firstByteTimeoutStreamingSeconds * 1000, + }; + } + } + if (dirtyFields.has("network.streamingIdleTimeoutSeconds")) { + if (state.network.streamingIdleTimeoutSeconds !== undefined) { + draft.streaming_idle_timeout_ms = { set: state.network.streamingIdleTimeoutSeconds * 1000 }; + } + } + if (dirtyFields.has("network.requestTimeoutNonStreamingSeconds")) { + if (state.network.requestTimeoutNonStreamingSeconds !== undefined) { + draft.request_timeout_non_streaming_ms = { + set: state.network.requestTimeoutNonStreamingSeconds * 1000, + }; + } + } + + // MCP fields + if (dirtyFields.has("mcp.mcpPassthroughType")) { + if (state.mcp.mcpPassthroughType === "none") { + draft.mcp_passthrough_type = { set: "none" }; + } else { + draft.mcp_passthrough_type = { set: state.mcp.mcpPassthroughType }; + } + } + if (dirtyFields.has("mcp.mcpPassthroughUrl")) { + if (state.mcp.mcpPassthroughUrl === "") { + draft.mcp_passthrough_url = { clear: true }; + } else { + draft.mcp_passthrough_url = { set: state.mcp.mcpPassthroughUrl }; + } + } + + return draft; +} diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx b/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx index 7dc7d2d5e..f55ac2ae7 100644 --- a/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx +++ b/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx @@ -6,10 +6,13 @@ import { useTranslations } from "next-intl"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; import { - type BatchUpdateProvidersParams, + applyProviderBatchPatch, batchDeleteProviders, batchResetProviderCircuits, - batchUpdateProviders, + type PreviewProviderBatchPatchResult, + previewProviderBatchPatch, + undoProviderDelete, + undoProviderPatch, } from "@/actions/providers"; import { AlertDialog, @@ -30,184 +33,345 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Separator } from "@/components/ui/separator"; -import { Switch } from "@/components/ui/switch"; +import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes"; +import type { ProviderDisplay } from "@/types/provider"; +import { FormTabNav } from "../forms/provider-form/components/form-tab-nav"; +import { + ProviderFormProvider, + useProviderForm, +} from "../forms/provider-form/provider-form-context"; +import { BasicInfoSection } from "../forms/provider-form/sections/basic-info-section"; +import { LimitsSection } from "../forms/provider-form/sections/limits-section"; +import { NetworkSection } from "../forms/provider-form/sections/network-section"; +import { RoutingSection } from "../forms/provider-form/sections/routing-section"; +import { TestingSection } from "../forms/provider-form/sections/testing-section"; +import { buildPatchDraftFromFormState } from "./build-patch-draft"; import type { BatchActionMode } from "./provider-batch-actions"; +import { ProviderBatchPreviewStep } from "./provider-batch-preview-step"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- export interface ProviderBatchDialogProps { open: boolean; mode: BatchActionMode; onOpenChange: (open: boolean) => void; selectedProviderIds: Set; + providers: ProviderDisplay[]; onSuccess?: () => void; } -interface EditFieldState { - isEnabledEnabled: boolean; - isEnabled: boolean; - priorityEnabled: boolean; - priority: string; - weightEnabled: boolean; - weight: string; - costMultiplierEnabled: boolean; - costMultiplier: string; - groupTagEnabled: boolean; - groupTag: string; -} - -const INITIAL_EDIT_STATE: EditFieldState = { - isEnabledEnabled: false, - isEnabled: true, - priorityEnabled: false, - priority: "", - weightEnabled: false, - weight: "", - costMultiplierEnabled: false, - costMultiplier: "", - groupTagEnabled: false, - groupTag: "", -}; +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- export function ProviderBatchDialog({ open, mode, onOpenChange, selectedProviderIds, + providers, onSuccess, }: ProviderBatchDialogProps) { - const t = useTranslations("settings.providers.batchEdit"); - const queryClient = useQueryClient(); + // For edit mode: delegate to form-based dialog + if (mode === "edit") { + return ( + + ); + } - const [editState, setEditState] = useState(INITIAL_EDIT_STATE); - const [confirmOpen, setConfirmOpen] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); + // For delete/resetCircuit: use AlertDialog + return ( + + ); +} + +// --------------------------------------------------------------------------- +// BatchEditDialog: Uses ProviderFormProvider mode="batch" +// --------------------------------------------------------------------------- +function BatchEditDialog({ + open, + onOpenChange, + selectedProviderIds, + providers, + onSuccess, +}: Omit) { const selectedCount = selectedProviderIds.size; - const hasEnabledFields = useMemo(() => { - if (mode !== "edit") return true; - return ( - editState.isEnabledEnabled || - editState.priorityEnabled || - editState.weightEnabled || - editState.costMultiplierEnabled || - editState.groupTagEnabled - ); - }, [mode, editState]); + const affectedProviders = useMemo(() => { + return providers.filter((p) => selectedProviderIds.has(p.id)); + }, [providers, selectedProviderIds]); - const resetState = useCallback(() => { - setEditState(INITIAL_EDIT_STATE); - setConfirmOpen(false); - setIsSubmitting(false); - }, []); + return ( + + + + + + + + ); +} + +// Inner component that can use useProviderForm() +type DialogStep = "edit" | "preview"; + +function BatchEditDialogContent({ + selectedProviderIds, + selectedCount, + onOpenChange, + onSuccess, +}: { + selectedProviderIds: Set; + selectedCount: number; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +}) { + const t = useTranslations("settings.providers.batchEdit"); + const queryClient = useQueryClient(); + const { state, dispatch, dirtyFields } = useProviderForm(); - const handleOpenChange = useCallback( - (newOpen: boolean) => { - if (!newOpen) { - resetState(); + const [step, setStep] = useState("edit"); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isLoadingPreview, setIsLoadingPreview] = useState(false); + const [previewResult, setPreviewResult] = useState(null); + const [excludedProviderIds, setExcludedProviderIds] = useState>(new Set()); + + const hasChanges = dirtyFields.size > 0; + + const handleExcludeToggle = useCallback((providerId: number) => { + setExcludedProviderIds((prev) => { + const next = new Set(prev); + if (next.has(providerId)) { + next.delete(providerId); + } else { + next.add(providerId); } - onOpenChange(newOpen); - }, - [onOpenChange, resetState] - ); + return next; + }); + }, []); - const handleNext = useCallback(() => { - if (!hasEnabledFields) { - toast.error(t("dialog.noFieldEnabled")); - return; - } - setConfirmOpen(true); - }, [hasEnabledFields, t]); + const handleNext = useCallback(async () => { + if (!hasChanges) return; - const handleConfirm = useCallback(async () => { - if (isSubmitting) return; - setIsSubmitting(true); + setIsLoadingPreview(true); + setStep("preview"); try { const providerIds = Array.from(selectedProviderIds); + const patch = buildPatchDraftFromFormState(state, dirtyFields); + const result = await previewProviderBatchPatch({ providerIds, patch }); + + if (result.ok) { + setPreviewResult(result.data); + } else { + toast.error(t("toast.previewFailed", { error: result.error })); + setStep("edit"); + } + } catch (error) { + const message = error instanceof Error ? error.message : t("toast.unknownError"); + toast.error(t("toast.previewFailed", { error: message })); + setStep("edit"); + } finally { + setIsLoadingPreview(false); + } + }, [hasChanges, selectedProviderIds, state, dirtyFields, t]); - if (mode === "edit") { - const updates: BatchUpdateProvidersParams["updates"] = {}; + const handleBackToEdit = useCallback(() => { + setStep("edit"); + setPreviewResult(null); + setExcludedProviderIds(new Set()); + }, []); - if (editState.isEnabledEnabled) { - updates.is_enabled = editState.isEnabled; - } - if (editState.priorityEnabled && editState.priority.trim()) { - const val = Number.parseInt(editState.priority, 10); - if (!Number.isNaN(val) && val >= 0) { - updates.priority = val; - } - } - if (editState.weightEnabled && editState.weight.trim()) { - const val = Number.parseInt(editState.weight, 10); - if (!Number.isNaN(val) && val >= 0) { - updates.weight = val; - } - } - if (editState.costMultiplierEnabled && editState.costMultiplier.trim()) { - const val = Number.parseFloat(editState.costMultiplier); - if (!Number.isNaN(val) && val >= 0) { - updates.cost_multiplier = val; - } - } - if (editState.groupTagEnabled) { - updates.group_tag = editState.groupTag.trim() || null; - } + const handleApply = useCallback(async () => { + if (isSubmitting || !previewResult) return; + setIsSubmitting(true); - const result = await batchUpdateProviders({ providerIds, updates }); - if (result.ok) { - toast.success(t("toast.updated", { count: result.data?.updatedCount ?? 0 })); - } else { - toast.error(t("toast.failed", { error: result.error })); - setIsSubmitting(false); - return; - } - } else if (mode === "delete") { - const result = await batchDeleteProviders({ providerIds }); - if (result.ok) { - toast.success(t("toast.deleted", { count: result.data?.deletedCount ?? 0 })); - } else { - toast.error(t("toast.failed", { error: result.error })); - setIsSubmitting(false); - return; - } - } else if (mode === "resetCircuit") { - const result = await batchResetProviderCircuits({ providerIds }); - if (result.ok) { - toast.success(t("toast.circuitReset", { count: result.data?.resetCount ?? 0 })); - } else { - toast.error(t("toast.failed", { error: result.error })); - setIsSubmitting(false); - return; - } + try { + const providerIds = Array.from(selectedProviderIds); + const patch = buildPatchDraftFromFormState(state, dirtyFields); + const result = await applyProviderBatchPatch({ + previewToken: previewResult.previewToken, + previewRevision: previewResult.previewRevision, + providerIds, + patch, + excludeProviderIds: Array.from(excludedProviderIds), + }); + + if (result.ok) { + await queryClient.invalidateQueries({ queryKey: ["providers"] }); + onOpenChange(false); + onSuccess?.(); + + const undoToken = result.data.undoToken; + const operationId = result.data.operationId; + toast.success(t("toast.updated", { count: result.data.updatedCount }), { + duration: 10000, + action: { + label: t("toast.undo"), + onClick: async () => { + try { + const undoResult = await undoProviderPatch({ undoToken, operationId }); + if (undoResult.ok) { + toast.success(t("toast.undoSuccess", { count: undoResult.data.revertedCount })); + queryClient.invalidateQueries({ queryKey: ["providers"] }); + } else { + toast.error(t("toast.undoFailed", { error: undoResult.error })); + } + } catch (err) { + const msg = err instanceof Error ? err.message : t("toast.unknownError"); + toast.error(t("toast.undoFailed", { error: msg })); + } + }, + }, + }); + } else { + toast.error(t("toast.failed", { error: result.error })); } - - await queryClient.invalidateQueries({ queryKey: ["providers"] }); - handleOpenChange(false); - onSuccess?.(); } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; + const message = error instanceof Error ? error.message : t("toast.unknownError"); toast.error(t("toast.failed", { error: message })); } finally { setIsSubmitting(false); } }, [ isSubmitting, + previewResult, selectedProviderIds, - mode, - editState, + state, + dirtyFields, + excludedProviderIds, queryClient, - handleOpenChange, + onOpenChange, onSuccess, t, ]); + return ( + <> + + {step === "preview" ? t("preview.title") : t("dialog.editTitle")} + + {step === "preview" + ? t("preview.description", { count: selectedCount }) + : t("dialog.editDesc", { count: selectedCount })} + + + + {step === "edit" && ( +
+ dispatch({ type: "SET_ACTIVE_TAB", payload: tab })} + layout="horizontal" + /> +
+ {state.ui.activeTab === "basic" && } + {state.ui.activeTab === "routing" && } + {state.ui.activeTab === "limits" && } + {state.ui.activeTab === "network" && } + {state.ui.activeTab === "testing" && } +
+
+ )} + + {step === "preview" && ( +
+ +
+ )} + + + {step === "preview" ? ( + <> + + + + ) : ( + <> + + + + )} + + + ); +} + +// --------------------------------------------------------------------------- +// BatchConfirmDialog: Delete / Reset Circuit (unchanged) +// --------------------------------------------------------------------------- + +function BatchConfirmDialog({ + open, + mode, + onOpenChange, + selectedProviderIds, + providers: _providers, + onSuccess, +}: ProviderBatchDialogProps) { + const t = useTranslations("settings.providers.batchEdit"); + const queryClient = useQueryClient(); + const [isSubmitting, setIsSubmitting] = useState(false); + + const selectedCount = selectedProviderIds.size; + const dialogTitle = useMemo(() => { switch (mode) { - case "edit": - return t("dialog.editTitle"); case "delete": return t("dialog.deleteTitle"); case "resetCircuit": @@ -219,8 +383,6 @@ export function ProviderBatchDialog({ const dialogDescription = useMemo(() => { switch (mode) { - case "edit": - return t("dialog.editDesc", { count: selectedCount }); case "delete": return t("dialog.deleteDesc", { count: selectedCount }); case "resetCircuit": @@ -230,151 +392,93 @@ export function ProviderBatchDialog({ } }, [mode, selectedCount, t]); - return ( - <> - - - - {dialogTitle} - {dialogDescription} - - - {mode === "edit" && ( -
- setEditState((s) => ({ ...s, isEnabledEnabled: v }))} - > - setEditState((s) => ({ ...s, isEnabled: v }))} - /> - - - - - setEditState((s) => ({ ...s, priorityEnabled: v }))} - > - setEditState((s) => ({ ...s, priority: e.target.value }))} - placeholder="0" - className="w-24" - /> - - - setEditState((s) => ({ ...s, weightEnabled: v }))} - > - setEditState((s) => ({ ...s, weight: e.target.value }))} - placeholder="1" - className="w-24" - /> - - - setEditState((s) => ({ ...s, costMultiplierEnabled: v }))} - > - setEditState((s) => ({ ...s, costMultiplier: e.target.value }))} - placeholder="1.0" - className="w-24" - /> - - - - - setEditState((s) => ({ ...s, groupTagEnabled: v }))} - > - setEditState((s) => ({ ...s, groupTag: e.target.value }))} - placeholder="tag1, tag2" - className="w-40" - /> - -
- )} - - {(mode === "delete" || mode === "resetCircuit") && ( -
{dialogDescription}
- )} - - - - - -
-
- - - - - {t("confirm.title")} - {dialogDescription} - - - {t("confirm.goBack")} - - {isSubmitting ? ( - <> - - {t("confirm.processing")} - - ) : ( - t("confirm.confirm") - )} - - - - - - ); -} + const handleConfirm = useCallback(async () => { + if (isSubmitting) return; + setIsSubmitting(true); -interface FieldToggleProps { - label: string; - enabled: boolean; - onEnabledChange: (enabled: boolean) => void; - children: React.ReactNode; -} + try { + const providerIds = Array.from(selectedProviderIds); + + if (mode === "delete") { + const result = await batchDeleteProviders({ providerIds }); + if (result.ok) { + const deletedCount = result.data.deletedCount; + const undoToken = result.data.undoToken; + const operationId = result.data.operationId; + + toast.success(t("undo.batchDeleteSuccess", { count: deletedCount }), { + duration: 10000, + action: { + label: t("undo.button"), + onClick: async () => { + try { + const undoResult = await undoProviderDelete({ undoToken, operationId }); + if (undoResult.ok) { + toast.success( + t("undo.batchDeleteUndone", { count: undoResult.data.restoredCount }) + ); + await queryClient.invalidateQueries({ queryKey: ["providers"] }); + } else if ( + undoResult.errorCode === PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED + ) { + toast.error(t("undo.expired")); + } else { + toast.error(t("undo.failed")); + } + } catch { + toast.error(t("undo.failed")); + } + }, + }, + }); + } else { + toast.error(t("toast.failed", { error: result.error })); + setIsSubmitting(false); + return; + } + } else if (mode === "resetCircuit") { + const result = await batchResetProviderCircuits({ providerIds }); + if (result.ok) { + toast.success(t("toast.circuitReset", { count: result.data?.resetCount ?? 0 })); + } else { + toast.error(t("toast.failed", { error: result.error })); + setIsSubmitting(false); + return; + } + } + + await queryClient.invalidateQueries({ queryKey: ["providers"] }); + onOpenChange(false); + onSuccess?.(); + } catch (error) { + const message = error instanceof Error ? error.message : t("toast.unknownError"); + toast.error(t("toast.failed", { error: message })); + } finally { + setIsSubmitting(false); + } + }, [isSubmitting, selectedProviderIds, mode, queryClient, onOpenChange, onSuccess, t]); -function FieldToggle({ label, enabled, onEnabledChange, children }: FieldToggleProps) { return ( -
-
- - -
-
{children}
-
+ + + + {dialogTitle} + {dialogDescription} + + + {t("confirm.goBack")} + + {isSubmitting ? ( + <> + + {t("confirm.processing")} + + ) : ( + t("confirm.confirm") + )} + + + + ); } diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-preview-step.tsx b/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-preview-step.tsx new file mode 100644 index 000000000..a1f88394e --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-preview-step.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useCallback, useMemo } from "react"; + +// --------------------------------------------------------------------------- +// Field label lookup (uses existing translations with readable fallback) +// --------------------------------------------------------------------------- + +const FIELD_LABEL_KEYS: Record = { + is_enabled: "fields.isEnabled.label", + priority: "fields.priority", + weight: "fields.weight", + cost_multiplier: "fields.costMultiplier", + group_tag: "fields.groupTag.label", + model_redirects: "fields.modelRedirects", + allowed_models: "fields.allowedModels", + anthropic_thinking_budget_preference: "fields.thinkingBudget", + anthropic_adaptive_thinking: "fields.adaptiveThinking", +}; + +import type { ProviderBatchPreviewRow } from "@/actions/providers"; +import { Checkbox } from "@/components/ui/checkbox"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export interface ProviderBatchPreviewStepProps { + rows: ProviderBatchPreviewRow[]; + summary: { providerCount: number; fieldCount: number; skipCount: number }; + excludedProviderIds: Set; + onExcludeToggle: (providerId: number) => void; + isLoading?: boolean; +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ProviderGroup { + providerId: number; + providerName: string; + rows: ProviderBatchPreviewRow[]; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function ProviderBatchPreviewStep({ + rows, + summary, + excludedProviderIds, + onExcludeToggle, + isLoading, +}: ProviderBatchPreviewStepProps) { + const t = useTranslations("settings.providers.batchEdit"); + + const grouped = useMemo(() => { + const map = new Map(); + for (const row of rows) { + let group = map.get(row.providerId); + if (!group) { + group = { providerId: row.providerId, providerName: row.providerName, rows: [] }; + map.set(row.providerId, group); + } + group.rows.push(row); + } + return Array.from(map.values()); + }, [rows]); + + const getFieldLabel = useCallback( + (field: string): string => { + const key = FIELD_LABEL_KEYS[field]; + if (key) return t(key); + return field.replace(/_/g, " "); + }, + [t] + ); + + if (isLoading) { + return ( +
+ + {t("preview.loading")} +
+ ); + } + + if (rows.length === 0) { + return ( +
+ {t("preview.noChanges")} +
+ ); + } + + return ( +
+ {/* Summary */} +

+ {t("preview.summary", { + providerCount: summary.providerCount, + fieldCount: summary.fieldCount, + skipCount: summary.skipCount, + })} +

+ + {/* Provider groups */} +
+ {grouped.map((group) => { + const excluded = excludedProviderIds.has(group.providerId); + return ( +
+ {/* Provider header with exclusion checkbox */} +
+ onExcludeToggle(group.providerId)} + aria-label={t("preview.excludeProvider")} + data-testid={`exclude-checkbox-${group.providerId}`} + /> + + {t("preview.providerHeader", { name: group.providerName })} + +
+ + {/* Field rows */} +
+ {group.rows.map((row) => ( +
+ {row.status === "changed" + ? t("preview.fieldChanged", { + field: getFieldLabel(row.field), + before: formatValue(row.before), + after: formatValue(row.after), + }) + : t("preview.fieldSkipped", { + field: getFieldLabel(row.field), + reason: row.skipReason ?? "", + })} +
+ ))} +
+
+ ); + })} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatValue(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "boolean") return String(value); + if (typeof value === "number") return String(value); + if (typeof value === "string") return value; + return JSON.stringify(value); +} diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-toolbar.tsx b/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-toolbar.tsx index 6225069fe..40ee6c928 100644 --- a/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-toolbar.tsx +++ b/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-toolbar.tsx @@ -1,10 +1,18 @@ "use client"; -import { Pencil, X } from "lucide-react"; +import { ChevronDown, Pencil, X } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; +import type { ProviderDisplay, ProviderType } from "@/types/provider"; export interface ProviderBatchToolbarProps { isMultiSelectMode: boolean; @@ -16,6 +24,9 @@ export interface ProviderBatchToolbarProps { onSelectAll: (checked: boolean) => void; onInvertSelection: () => void; onOpenBatchEdit: () => void; + providers: ProviderDisplay[]; + onSelectByType: (type: ProviderType) => void; + onSelectByGroup: (group: string) => void; } export function ProviderBatchToolbar({ @@ -28,20 +39,58 @@ export function ProviderBatchToolbar({ onSelectAll, onInvertSelection, onOpenBatchEdit, + providers, + onSelectByType, + onSelectByGroup, }: ProviderBatchToolbarProps) { const t = useTranslations("settings.providers.batchEdit"); + const uniqueTypes = useMemo(() => { + const typeMap = new Map(); + for (const p of providers) { + typeMap.set(p.providerType, (typeMap.get(p.providerType) ?? 0) + 1); + } + return Array.from(typeMap.entries()) + .map(([type, count]) => ({ type, count })) + .sort((a, b) => a.type.localeCompare(b.type)); + }, [providers]); + + const uniqueGroups = useMemo(() => { + const groupMap = new Map(); + for (const p of providers) { + if (p.groupTag) { + const tags = p.groupTag + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean); + for (const tag of tags) { + groupMap.set(tag, (groupMap.get(tag) ?? 0) + 1); + } + } + } + return Array.from(groupMap.entries()) + .map(([group, count]) => ({ group, count })) + .sort((a, b) => a.group.localeCompare(b.group)); + }, [providers]); + if (!isMultiSelectMode) { return ( - +
+ + {totalCount > 0 && ( + + {t("selectionHint")} + + )} +
); } @@ -65,6 +114,46 @@ export function ProviderBatchToolbar({ {t("invertSelection")} + {uniqueTypes.length > 1 && ( + + + + + + {uniqueTypes.map(({ type, count }) => ( + onSelectByType(type)}> + {t("selectByTypeItem", { type, count })} + + ))} + + + )} + + {uniqueGroups.length > 0 && ( + + + + + + {uniqueGroups.map(({ group, count }) => ( + onSelectByGroup(group)} + > + {t("selectByGroupItem", { group, count })} + + ))} + + + )} + + ); + })} +
+ + ); + } + return ( <> {/* Desktop: Vertical Sidebar */} diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx index 2942d5be3..907ba3ff4 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx @@ -5,7 +5,13 @@ import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"; import { toast } from "sonner"; import { getProviderEndpoints, getProviderVendors } from "@/actions/provider-endpoints"; -import { addProvider, editProvider, removeProvider } from "@/actions/providers"; +import { + addProvider, + editProvider, + removeProvider, + undoProviderDelete, + undoProviderPatch, +} from "@/actions/providers"; import { getDistinctProviderGroupsAction } from "@/actions/request-filters"; import { AlertDialog, @@ -19,6 +25,7 @@ import { AlertDialogTitle as AlertTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes"; import { isValidUrl } from "@/lib/utils/validation"; import type { ProviderDisplay, @@ -89,6 +96,7 @@ function ProviderFormContent({ resolvedUrl?: string | null; }) { const t = useTranslations("settings.providers.form"); + const tBatchEdit = useTranslations("settings.providers.batchEdit"); const { state, dispatch, mode, provider, hideUrl } = useProviderForm(); const [isPending, startTransition] = useTransition(); const isEdit = mode === "edit"; @@ -363,7 +371,36 @@ function ProviderFormContent({ toast.error(res.error || t("errors.updateFailed")); return; } - toast.success(t("success.updated")); + + const undoToken = res.data.undoToken; + const operationId = res.data.operationId; + + toast.success(tBatchEdit("undo.singleEditSuccess"), { + duration: 10000, + action: { + label: tBatchEdit("undo.button"), + onClick: async () => { + try { + const undoResult = await undoProviderPatch({ undoToken, operationId }); + if (undoResult.ok) { + toast.success(tBatchEdit("undo.singleEditUndone")); + await queryClient.invalidateQueries({ queryKey: ["providers"] }); + await queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + await queryClient.invalidateQueries({ queryKey: ["providers-statistics"] }); + await queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); + } else if ( + undoResult.errorCode === PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED + ) { + toast.error(tBatchEdit("undo.expired")); + } else { + toast.error(tBatchEdit("undo.failed")); + } + } catch { + toast.error(tBatchEdit("undo.failed")); + } + }, + }, + }); void queryClient.invalidateQueries({ queryKey: ["providers"] }); void queryClient.invalidateQueries({ queryKey: ["providers-health"] }); @@ -426,7 +463,39 @@ function ProviderFormContent({ toast.error(res.error || t("errors.deleteFailed")); return; } - toast.success(t("success.deleted")); + + const undoToken = res.data.undoToken; + const operationId = res.data.operationId; + + toast.success(tBatchEdit("undo.singleDeleteSuccess"), { + duration: 10000, + action: { + label: tBatchEdit("undo.button"), + onClick: async () => { + try { + const undoResult = await undoProviderDelete({ undoToken, operationId }); + if (undoResult.ok) { + toast.success(tBatchEdit("undo.singleDeleteUndone")); + await queryClient.invalidateQueries({ queryKey: ["providers"] }); + await queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + await queryClient.invalidateQueries({ queryKey: ["providers-statistics"] }); + await queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); + } else if (undoResult.errorCode === PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED) { + toast.error(tBatchEdit("undo.expired")); + } else { + toast.error(tBatchEdit("undo.failed")); + } + } catch { + toast.error(tBatchEdit("undo.failed")); + } + }, + }, + }); + + void queryClient.invalidateQueries({ queryKey: ["providers"] }); + void queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + void queryClient.invalidateQueries({ queryKey: ["providers-statistics"] }); + void queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); onSuccess?.(); } catch (e) { console.error("Delete error:", e); diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx index facc525c9..a8f79bc67 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx @@ -1,6 +1,15 @@ "use client"; -import { createContext, type ReactNode, useContext, useReducer } from "react"; +import { + createContext, + type Dispatch, + type ReactNode, + useCallback, + useContext, + useMemo, + useReducer, + useRef, +} from "react"; import type { ProviderDisplay, ProviderType } from "@/types/provider"; import type { FormMode, @@ -9,6 +18,52 @@ import type { ProviderFormState, } from "./provider-form-types"; +// Maps action types to dirty field paths for batch mode tracking +const ACTION_TO_FIELD_PATH: Partial> = { + SET_BATCH_IS_ENABLED: "batch.isEnabled", + SET_PRIORITY: "routing.priority", + SET_WEIGHT: "routing.weight", + SET_COST_MULTIPLIER: "routing.costMultiplier", + SET_GROUP_TAG: "routing.groupTag", + SET_PRESERVE_CLIENT_IP: "routing.preserveClientIp", + SET_MODEL_REDIRECTS: "routing.modelRedirects", + SET_ALLOWED_MODELS: "routing.allowedModels", + SET_GROUP_PRIORITIES: "routing.groupPriorities", + SET_CACHE_TTL_PREFERENCE: "routing.cacheTtlPreference", + SET_SWAP_CACHE_TTL_BILLING: "routing.swapCacheTtlBilling", + SET_CONTEXT_1M_PREFERENCE: "routing.context1mPreference", + SET_CODEX_REASONING_EFFORT: "routing.codexReasoningEffortPreference", + SET_CODEX_REASONING_SUMMARY: "routing.codexReasoningSummaryPreference", + SET_CODEX_TEXT_VERBOSITY: "routing.codexTextVerbosityPreference", + SET_CODEX_PARALLEL_TOOL_CALLS: "routing.codexParallelToolCallsPreference", + SET_ANTHROPIC_MAX_TOKENS: "routing.anthropicMaxTokensPreference", + SET_ANTHROPIC_THINKING_BUDGET: "routing.anthropicThinkingBudgetPreference", + SET_ADAPTIVE_THINKING_ENABLED: "routing.anthropicAdaptiveThinking", + SET_ADAPTIVE_THINKING_EFFORT: "routing.anthropicAdaptiveThinking", + SET_ADAPTIVE_THINKING_MODEL_MATCH_MODE: "routing.anthropicAdaptiveThinking", + SET_ADAPTIVE_THINKING_MODELS: "routing.anthropicAdaptiveThinking", + SET_GEMINI_GOOGLE_SEARCH: "routing.geminiGoogleSearchPreference", + SET_LIMIT_5H_USD: "rateLimit.limit5hUsd", + SET_LIMIT_DAILY_USD: "rateLimit.limitDailyUsd", + SET_DAILY_RESET_MODE: "rateLimit.dailyResetMode", + SET_DAILY_RESET_TIME: "rateLimit.dailyResetTime", + SET_LIMIT_WEEKLY_USD: "rateLimit.limitWeeklyUsd", + SET_LIMIT_MONTHLY_USD: "rateLimit.limitMonthlyUsd", + SET_LIMIT_TOTAL_USD: "rateLimit.limitTotalUsd", + SET_LIMIT_CONCURRENT_SESSIONS: "rateLimit.limitConcurrentSessions", + SET_FAILURE_THRESHOLD: "circuitBreaker.failureThreshold", + SET_OPEN_DURATION_MINUTES: "circuitBreaker.openDurationMinutes", + SET_HALF_OPEN_SUCCESS_THRESHOLD: "circuitBreaker.halfOpenSuccessThreshold", + SET_MAX_RETRY_ATTEMPTS: "circuitBreaker.maxRetryAttempts", + SET_PROXY_URL: "network.proxyUrl", + SET_PROXY_FALLBACK_TO_DIRECT: "network.proxyFallbackToDirect", + SET_FIRST_BYTE_TIMEOUT_STREAMING: "network.firstByteTimeoutStreamingSeconds", + SET_STREAMING_IDLE_TIMEOUT: "network.streamingIdleTimeoutSeconds", + SET_REQUEST_TIMEOUT_NON_STREAMING: "network.requestTimeoutNonStreamingSeconds", + SET_MCP_PASSTHROUGH_TYPE: "mcp.mcpPassthroughType", + SET_MCP_PASSTHROUGH_URL: "mcp.mcpPassthroughUrl", +}; + // Initial state factory export function createInitialState( mode: FormMode, @@ -22,9 +77,72 @@ export function createInitialState( } ): ProviderFormState { const isEdit = mode === "edit"; + const isBatch = mode === "batch"; const raw = isEdit ? provider : cloneProvider; const sourceProvider = raw ? structuredClone(raw) : undefined; + // Batch mode: all fields start at neutral defaults (no provider source) + if (isBatch) { + return { + basic: { name: "", url: "", key: "", websiteUrl: "" }, + routing: { + providerType: "claude", + groupTag: [], + preserveClientIp: false, + modelRedirects: {}, + allowedModels: [], + priority: 0, + groupPriorities: {}, + weight: 1, + costMultiplier: 1.0, + cacheTtlPreference: "inherit", + swapCacheTtlBilling: false, + context1mPreference: "inherit", + codexReasoningEffortPreference: "inherit", + codexReasoningSummaryPreference: "inherit", + codexTextVerbosityPreference: "inherit", + codexParallelToolCallsPreference: "inherit", + anthropicMaxTokensPreference: "inherit", + anthropicThinkingBudgetPreference: "inherit", + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: "inherit", + }, + rateLimit: { + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: null, + }, + circuitBreaker: { + failureThreshold: undefined, + openDurationMinutes: undefined, + halfOpenSuccessThreshold: undefined, + maxRetryAttempts: null, + }, + network: { + proxyUrl: "", + proxyFallbackToDirect: false, + firstByteTimeoutStreamingSeconds: undefined, + streamingIdleTimeoutSeconds: undefined, + requestTimeoutNonStreamingSeconds: undefined, + }, + mcp: { + mcpPassthroughType: "none", + mcpPassthroughUrl: "", + }, + batch: { isEnabled: "no_change" }, + ui: { + activeTab: "basic", + isPending: false, + showFailureThresholdConfirm: false, + }, + }; + } + return { basic: { name: isEdit @@ -105,6 +223,7 @@ export function createInitialState( mcpPassthroughType: sourceProvider?.mcpPassthroughType ?? "none", mcpPassthroughUrl: sourceProvider?.mcpPassthroughUrl ?? "", }, + batch: { isEnabled: "no_change" }, ui: { activeTab: "basic", isPending: false, @@ -317,6 +436,10 @@ export function providerFormReducer( case "SET_MCP_PASSTHROUGH_URL": return { ...state, mcp: { ...state.mcp, mcpPassthroughUrl: action.payload } }; + // Batch + case "SET_BATCH_IS_ENABLED": + return { ...state, batch: { ...state.batch, isEnabled: action.payload } }; + // UI case "SET_ACTIVE_TAB": return { ...state, ui: { ...state.ui, activeTab: action.payload } }; @@ -357,6 +480,7 @@ export function ProviderFormProvider({ hideWebsiteUrl = false, preset, groupSuggestions, + batchProviders, }: { children: ReactNode; mode: FormMode; @@ -372,27 +496,58 @@ export function ProviderFormProvider({ providerType?: ProviderType; }; groupSuggestions: string[]; + batchProviders?: ProviderDisplay[]; }) { - const [state, dispatch] = useReducer( + const [state, rawDispatch] = useReducer( providerFormReducer, createInitialState(mode, provider, cloneProvider, preset) ); + const dirtyFieldsRef = useRef(new Set()); + const isBatch = mode === "batch"; + + // Wrap dispatch for batch mode to auto-track dirty fields + const dispatch: Dispatch = useCallback( + (action: ProviderFormAction) => { + if (isBatch) { + const fieldPath = ACTION_TO_FIELD_PATH[action.type]; + if (fieldPath) { + dirtyFieldsRef.current.add(fieldPath); + } + } + rawDispatch(action); + }, + [isBatch] + ); + + const contextValue = useMemo( + () => ({ + state, + dispatch, + mode, + provider, + enableMultiProviderTypes, + hideUrl, + hideWebsiteUrl, + groupSuggestions, + batchProviders, + dirtyFields: dirtyFieldsRef.current, + }), + [ + state, + dispatch, + mode, + provider, + enableMultiProviderTypes, + hideUrl, + hideWebsiteUrl, + groupSuggestions, + batchProviders, + ] + ); + return ( - - {children} - + {children} ); } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts index 60355dd9e..4bec44463 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts @@ -16,7 +16,7 @@ import type { } from "@/types/provider"; // Form mode -export type FormMode = "create" | "edit"; +export type FormMode = "create" | "edit" | "batch"; // Tab identifiers export type TabId = "basic" | "routing" | "limits" | "network" | "testing"; @@ -93,6 +93,10 @@ export interface McpState { mcpPassthroughUrl: string; } +export interface BatchState { + isEnabled: "no_change" | "true" | "false"; +} + export interface UIState { activeTab: TabId; isPending: boolean; @@ -107,6 +111,7 @@ export interface ProviderFormState { circuitBreaker: CircuitBreakerState; network: NetworkState; mcp: McpState; + batch: BatchState; ui: UIState; } @@ -173,7 +178,9 @@ export type ProviderFormAction = | { type: "SET_SHOW_FAILURE_THRESHOLD_CONFIRM"; payload: boolean } // Bulk actions | { type: "RESET_FORM" } - | { type: "LOAD_PROVIDER"; payload: ProviderDisplay }; + | { type: "LOAD_PROVIDER"; payload: ProviderDisplay } + // Batch actions + | { type: "SET_BATCH_IS_ENABLED"; payload: "no_change" | "true" | "false" }; // Form props export interface ProviderFormProps { @@ -204,4 +211,6 @@ export interface ProviderFormContextValue { hideUrl: boolean; hideWebsiteUrl: boolean; groupSuggestions: string[]; + batchProviders?: ProviderDisplay[]; + dirtyFields: Set; } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx index eb7258fd8..48c048042 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section.tsx @@ -7,6 +7,13 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { ProviderEndpointsSection } from "@/app/[locale]/settings/providers/_components/provider-endpoints-table"; import { InlineWarning } from "@/components/ui/inline-warning"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { detectApiKeyWarnings } from "@/lib/utils/validation/api-key-warnings"; import type { ProviderType } from "@/types/provider"; import { UrlPreview } from "../../url-preview"; @@ -14,6 +21,8 @@ import { QuickPasteDialog } from "../components/quick-paste-dialog"; import { SectionCard, SmartInputWrapper } from "../components/section-card"; import { useProviderForm } from "../provider-form-context"; +const MAX_DISPLAYED_PROVIDERS = 5; + interface BasicInfoSectionProps { autoUrlPending?: boolean; endpointPool?: { @@ -25,21 +34,95 @@ interface BasicInfoSectionProps { export function BasicInfoSection({ autoUrlPending, endpointPool }: BasicInfoSectionProps) { const t = useTranslations("settings.providers.form"); + const tBatch = useTranslations("settings.providers.batchEdit"); const tProviders = useTranslations("settings.providers"); - const { state, dispatch, mode, provider, hideUrl, hideWebsiteUrl } = useProviderForm(); + const { state, dispatch, mode, provider, hideUrl, hideWebsiteUrl, batchProviders } = + useProviderForm(); const isEdit = mode === "edit"; + const isBatch = mode === "batch"; const nameInputRef = useRef(null); const [showKey, setShowKey] = useState(false); const apiKeyWarnings = useMemo(() => detectApiKeyWarnings(state.basic.key), [state.basic.key]); - // Auto-focus name input + // Auto-focus name input (skip in batch mode) useEffect(() => { + if (isBatch) return; const timer = setTimeout(() => { nameInputRef.current?.focus(); }, 100); return () => clearTimeout(timer); - }, []); + }, [isBatch]); + + // Batch mode: only isEnabled tri-state + provider summary + if (isBatch) { + const providers = batchProviders ?? []; + const displayed = providers.slice(0, MAX_DISPLAYED_PROVIDERS); + const remaining = providers.length - displayed.length; + + return ( + + +
+ + + + + {providers.length > 0 && ( +
+

+ {tBatch("affectedProviders.title")} ({providers.length}) +

+
+ {displayed.map((p) => ( +

+ {p.name} ({p.maskedKey}) +

+ ))} + {remaining > 0 && ( +

+ {tBatch("affectedProviders.more", { count: remaining })} +

+ )} +
+
+ )} +
+
+
+ ); + } return ( - {/* Proxy Test */} -
-
- -
-
{t("sections.proxy.test.label")}
-

{t("sections.proxy.test.desc")}

+ {/* Proxy Test - hidden in batch mode */} + {!isBatch && ( +
+
+ +
+
{t("sections.proxy.test.label")}
+

+ {t("sections.proxy.test.desc")} +

+
+
- -
+ )} )}
diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx index d9949900e..59c0f7c4a 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx @@ -18,8 +18,6 @@ import { TagInput } from "@/components/ui/tag-input"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { getProviderTypeConfig } from "@/lib/provider-type-utils"; import type { - AnthropicAdaptiveThinkingEffort, - AnthropicAdaptiveThinkingModelMatchMode, CodexParallelToolCallsPreference, CodexReasoningEffortPreference, CodexReasoningSummaryPreference, @@ -27,8 +25,10 @@ import type { GeminiGoogleSearchPreference, ProviderType, } from "@/types/provider"; +import { AdaptiveThinkingEditor } from "../../../adaptive-thinking-editor"; import { ModelMultiSelect } from "../../../model-multi-select"; import { ModelRedirectEditor } from "../../../model-redirect-editor"; +import { ThinkingBudgetEditor } from "../../../thinking-budget-editor"; import { FieldGroup, SectionCard, SmartInputWrapper, ToggleRow } from "../components/section-card"; import { useProviderForm } from "../provider-form-context"; @@ -36,10 +36,13 @@ const GROUP_TAG_MAX_TOTAL_LENGTH = 50; export function RoutingSection() { const t = useTranslations("settings.providers.form"); + const tBatch = useTranslations("settings.providers.batchEdit"); const tUI = useTranslations("ui.tagInput"); const { state, dispatch, mode, provider, enableMultiProviderTypes, groupSuggestions } = useProviderForm(); const isEdit = mode === "edit"; + const isBatch = mode === "batch"; + const { providerType } = state.routing; const renderProviderTypeLabel = (type: ProviderType) => { switch (type) { @@ -76,78 +79,81 @@ export function RoutingSection() { transition={{ duration: 0.2 }} className="space-y-6" > - {/* Provider Type & Group */} - -
- - - {!enableMultiProviderTypes && state.routing.providerType === "openai-compatible" && ( -

- {t("sections.routing.providerTypeDisabledNote")} -

- )} -
+ {/* Provider Type & Group - hidden in batch mode */} + {!isBatch && ( + +
+ + + {!enableMultiProviderTypes && + state.routing.providerType === "openai-compatible" && ( +

+ {t("sections.routing.providerTypeDisabledNote")} +

+ )} +
- - { - const messages: Record = { - empty: tUI("emptyTag"), - duplicate: tUI("duplicateTag"), - too_long: tUI("tooLong", { max: GROUP_TAG_MAX_TOTAL_LENGTH }), - invalid_format: tUI("invalidFormat"), - max_tags: tUI("maxTags"), - }; - toast.error(messages[reason] || reason); - }} - /> - -
-
+ + { + const messages: Record = { + empty: tUI("emptyTag"), + duplicate: tUI("duplicateTag"), + too_long: tUI("tooLong", { max: GROUP_TAG_MAX_TOTAL_LENGTH }), + invalid_format: tUI("invalidFormat"), + max_tags: tUI("maxTags"), + }; + toast.error(messages[reason] || reason); + }} + /> + +
+
+ )} {/* Model Configuration */} - {/* 1M Context Window - Claude type only */} - {state.routing.providerType === "claude" && ( + {/* 1M Context Window - Claude type only (or batch mode) */} + {(providerType === "claude" || providerType === "claude-auth" || isBatch) && ( - {/* Codex Overrides - Codex type only */} - {state.routing.providerType === "codex" && ( + {/* Codex Overrides - Codex type only (or batch mode) */} + {(providerType === "codex" || isBatch) && ( {tBatch("batchNotes.codexOnly")} + ) : undefined + } >
@@ -548,13 +559,17 @@ export function RoutingSection() { )} - {/* Anthropic Overrides - Claude type only */} - {(state.routing.providerType === "claude" || - state.routing.providerType === "claude-auth") && ( + {/* Anthropic Overrides - Claude type only (or batch mode) */} + {(providerType === "claude" || providerType === "claude-auth" || isBatch) && ( {tBatch("batchNotes.claudeOnly")} + ) : undefined + } >
@@ -615,243 +630,61 @@ export function RoutingSection() { - - -
- - {state.routing.anthropicThinkingBudgetPreference !== "inherit" && ( - <> - { - const val = e.target.value; - if (val === "") { - dispatch({ - type: "SET_ANTHROPIC_THINKING_BUDGET", - payload: "inherit", - }); - } else { - dispatch({ - type: "SET_ANTHROPIC_THINKING_BUDGET", - payload: val, - }); - } - }} - placeholder={t( - "sections.routing.anthropicOverrides.thinkingBudget.placeholder" - )} - disabled={state.ui.isPending} - min="1024" - max="32000" - className="flex-1" - /> - - - )} - -
-
- -

- {t("sections.routing.anthropicOverrides.thinkingBudget.help")} -

-
-
-
- - - - dispatch({ type: "SET_ADAPTIVE_THINKING_ENABLED", payload: checked }) + + dispatch({ + type: "SET_ANTHROPIC_THINKING_BUDGET", + payload: val, + }) } disabled={state.ui.isPending} /> - - - {state.routing.anthropicAdaptiveThinking && ( -
- - - -
- - -
-
- -

- {t("sections.routing.anthropicOverrides.adaptiveThinking.effort.help")} -

-
-
-
- - - - -
- - -
-
- -

- {t( - "sections.routing.anthropicOverrides.adaptiveThinking.modelMatchMode.help" - )} -

-
-
-
+ - {state.routing.anthropicAdaptiveThinking.modelMatchMode === "specific" && ( - - - -
- - dispatch({ - type: "SET_ADAPTIVE_THINKING_MODELS", - payload: models, - }) - } - placeholder={t( - "sections.routing.anthropicOverrides.adaptiveThinking.models.placeholder" - )} - disabled={state.ui.isPending} - /> - -
-
- -

- {t("sections.routing.anthropicOverrides.adaptiveThinking.models.help")} -

-
-
-
- )} -
- )} + + dispatch({ type: "SET_ADAPTIVE_THINKING_ENABLED", payload: enabled }) + } + onConfigChange={(newConfig) => { + dispatch({ + type: "SET_ADAPTIVE_THINKING_EFFORT", + payload: newConfig.effort, + }); + dispatch({ + type: "SET_ADAPTIVE_THINKING_MODEL_MATCH_MODE", + payload: newConfig.modelMatchMode, + }); + dispatch({ + type: "SET_ADAPTIVE_THINKING_MODELS", + payload: newConfig.models, + }); + }} + disabled={state.ui.isPending} + />
)} - {/* Gemini Overrides - Gemini type only */} - {(state.routing.providerType === "gemini" || - state.routing.providerType === "gemini-cli") && ( + {/* Gemini Overrides - Gemini type only (or batch mode) */} + {(providerType === "gemini" || providerType === "gemini-cli" || isBatch) && ( {tBatch("batchNotes.geminiOnly")} + ) : undefined + } > + + + + + {t(`${prefix}.options.inherit`)} + {t(`${prefix}.options.custom`)} + + + {mode !== "inherit" && ( + <> + + + + )} + +
+ + +

{t(`${prefix}.help`)}

+
+ + ); +} diff --git a/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx index acc70cfa4..1a679d623 100644 --- a/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx +++ b/src/app/[locale]/settings/providers/_components/vendor-keys-compact-list.tsx @@ -1,13 +1,18 @@ "use client"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { CheckCircle, Copy, Edit2, Loader2, Plus, Trash2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { getProviderEndpoints } from "@/actions/provider-endpoints"; -import { editProvider, getUnmaskedProviderKey, removeProvider } from "@/actions/providers"; +import { + editProvider, + getUnmaskedProviderKey, + removeProvider, + undoProviderDelete, +} from "@/actions/providers"; import { FormErrorBoundary } from "@/components/form-error-boundary"; import { AlertDialog, @@ -39,6 +44,7 @@ import { TableRow, } from "@/components/ui/table"; import { PROVIDER_LIMITS } from "@/lib/constants/provider.constants"; +import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes"; import { getProviderTypeConfig, getProviderTypeTranslationKey } from "@/lib/provider-type-utils"; import { copyToClipboard, isClipboardSupported } from "@/lib/utils/clipboard"; import { type CurrencyCode, formatCurrency } from "@/lib/utils/currency"; @@ -214,6 +220,7 @@ function VendorKeyRow(props: { }) { const t = useTranslations("settings.providers"); const tList = useTranslations("settings.providers.list"); + const tBatchEdit = useTranslations("settings.providers.batchEdit"); const tInline = useTranslations("settings.providers.inlineEdit"); const tTypes = useTranslations("settings.providers.types"); @@ -305,15 +312,41 @@ function VendorKeyRow(props: { mutationFn: async () => { const res = await removeProvider(props.provider.id); if (!res.ok) throw new Error(res.error); + return res.data; }, - onSuccess: () => { + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ["providers"] }); queryClient.invalidateQueries({ queryKey: ["providers-health"] }); queryClient.invalidateQueries({ queryKey: ["providers-statistics"] }); queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); setDeleteDialogOpen(false); - toast.success(tList("deleteSuccess"), { - description: tList("deleteSuccessDesc", { name: props.provider.name }), + + toast.success(tBatchEdit("undo.singleDeleteSuccess"), { + duration: 10000, + action: { + label: tBatchEdit("undo.button"), + onClick: async () => { + try { + const undoResult = await undoProviderDelete({ + undoToken: data.undoToken, + operationId: data.operationId, + }); + if (undoResult.ok) { + toast.success(tBatchEdit("undo.singleDeleteUndone")); + await queryClient.invalidateQueries({ queryKey: ["providers"] }); + await queryClient.invalidateQueries({ queryKey: ["providers-health"] }); + await queryClient.invalidateQueries({ queryKey: ["providers-statistics"] }); + await queryClient.invalidateQueries({ queryKey: ["provider-vendors"] }); + } else if (undoResult.errorCode === PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED) { + toast.error(tBatchEdit("undo.expired")); + } else { + toast.error(tBatchEdit("undo.failed")); + } + } catch { + toast.error(tBatchEdit("undo.failed")); + } + }, + }, }); }, onError: () => { diff --git a/src/app/[locale]/usage-doc/_components/usage-doc-auth-context.tsx b/src/app/[locale]/usage-doc/_components/usage-doc-auth-context.tsx new file mode 100644 index 000000000..dcbb71022 --- /dev/null +++ b/src/app/[locale]/usage-doc/_components/usage-doc-auth-context.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { createContext, type ReactNode, useContext } from "react"; + +interface UsageDocAuthContextValue { + isLoggedIn: boolean; +} + +const UsageDocAuthContext = createContext({ + isLoggedIn: false, +}); + +// Security: HttpOnly cookies are invisible to document.cookie; session state must come from server. +export function UsageDocAuthProvider({ + isLoggedIn, + children, +}: { + isLoggedIn: boolean; + children: ReactNode; +}) { + return ( + {children} + ); +} + +export function useUsageDocAuth(): UsageDocAuthContextValue { + return useContext(UsageDocAuthContext); +} diff --git a/src/app/[locale]/usage-doc/layout.tsx b/src/app/[locale]/usage-doc/layout.tsx index 20572674e..06b1b1044 100644 --- a/src/app/[locale]/usage-doc/layout.tsx +++ b/src/app/[locale]/usage-doc/layout.tsx @@ -5,6 +5,7 @@ import { cache } from "react"; import { Link } from "@/i18n/routing"; import { getSession } from "@/lib/auth"; import { DashboardHeader } from "../dashboard/_components/dashboard-header"; +import { UsageDocAuthProvider } from "./_components/usage-doc-auth-context"; type UsageDocParams = { locale: string }; @@ -63,10 +64,8 @@ export default async function UsageDocLayout({ )} - {/* 文档内容主体 */}
- {/* 文档容器 */} - {children} + {children}
); diff --git a/src/app/[locale]/usage-doc/page.tsx b/src/app/[locale]/usage-doc/page.tsx index ee6a2f6d0..ba25ee6a1 100644 --- a/src/app/[locale]/usage-doc/page.tsx +++ b/src/app/[locale]/usage-doc/page.tsx @@ -8,6 +8,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/co import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { QuickLinks } from "./_components/quick-links"; import { type TocItem, TocNav } from "./_components/toc-nav"; +import { useUsageDocAuth } from "./_components/usage-doc-auth-context"; const headingClasses = { h2: "scroll-m-20 text-2xl font-semibold leading-snug text-foreground", @@ -1774,19 +1775,17 @@ curl -I ${resolvedOrigin}`} */ export default function UsageDocPage() { const t = useTranslations("usage"); + const { isLoggedIn } = useUsageDocAuth(); const [activeId, setActiveId] = useState(""); const [tocItems, setTocItems] = useState([]); const [tocReady, setTocReady] = useState(false); const [serviceOrigin, setServiceOrigin] = useState( () => (typeof window !== "undefined" && window.location.origin) || "" ); - const [isLoggedIn, setIsLoggedIn] = useState(false); const [sheetOpen, setSheetOpen] = useState(false); useEffect(() => { setServiceOrigin(window.location.origin); - // 检查是否已登录(通过检查 auth-token cookie) - setIsLoggedIn(document.cookie.includes("auth-token=")); }, []); // 生成目录并监听滚动 diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 00bdd886e..5d8f4da18 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,12 +1,31 @@ import { type NextRequest, NextResponse } from "next/server"; import { getTranslations } from "next-intl/server"; import { defaultLocale, type Locale, locales } from "@/i18n/config"; -import { getLoginRedirectTarget, setAuthCookie, validateKey } from "@/lib/auth"; +import { + type AuthSession, + getLoginRedirectTarget, + getSessionTokenMode, + setAuthCookie, + toKeyFingerprint, + validateKey, +} from "@/lib/auth"; +import { getEnvConfig } from "@/lib/config/env.schema"; import { logger } from "@/lib/logger"; +import { withAuthResponseHeaders } from "@/lib/security/auth-response-headers"; +import { createCsrfOriginGuard } from "@/lib/security/csrf-origin-guard"; +import { LoginAbusePolicy } from "@/lib/security/login-abuse-policy"; // 需要数据库连接 export const runtime = "nodejs"; +const csrfGuard = createCsrfOriginGuard({ + allowedOrigins: [], + allowSameOrigin: true, + enforceInDevelopment: process.env.VITEST === "true", +}); + +const loginPolicy = new LoginAbusePolicy(); + /** * Get locale from request (cookie or Accept-Language header) */ @@ -52,40 +71,239 @@ async function getAuthErrorTranslations(locale: Locale) { } } +async function getAuthSecurityTranslations(locale: Locale) { + try { + return await getTranslations({ locale, namespace: "auth.security" }); + } catch (error) { + logger.warn("Login route: failed to load auth.security translations", { + locale, + error: error instanceof Error ? error.message : String(error), + }); + + try { + return await getTranslations({ locale: defaultLocale, namespace: "auth.security" }); + } catch (fallbackError) { + logger.error("Login route: failed to load default auth.security translations", { + locale: defaultLocale, + error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError), + }); + return null; + } + } +} + +function hasSecureCookieHttpMismatch(request: NextRequest): boolean { + const env = getEnvConfig(); + const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim(); + return env.ENABLE_SECURE_COOKIES && forwardedProto === "http"; +} + +function shouldIncludeFailureTaxonomy(request: NextRequest): boolean { + return request.headers.has("x-forwarded-proto"); +} + +function getClientIp(request: NextRequest): string { + // 1. Next.js platform-provided IP (trusted in Vercel / managed deployments) + const platformIp = (request as unknown as { ip?: string }).ip; + if (platformIp) { + return platformIp; + } + + // 2. x-real-ip is typically set by the closest trusted reverse proxy + const realIp = request.headers.get("x-real-ip")?.trim(); + if (realIp) { + return realIp; + } + + // 3. x-forwarded-for: take the rightmost (last) entry, which is the IP + // appended by the closest trusted proxy. The leftmost entry is + // client-controlled and can be spoofed. + const forwarded = request.headers.get("x-forwarded-for"); + if (forwarded) { + const ips = forwarded + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (ips.length > 0) { + return ips[ips.length - 1]; + } + } + + return "unknown"; +} + +let sessionStoreInstance: + | import("@/lib/auth-session-store/redis-session-store").RedisSessionStore + | null = null; + +async function getLoginSessionStore() { + if (!sessionStoreInstance) { + const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store"); + sessionStoreInstance = new RedisSessionStore(); + } + return sessionStoreInstance; +} + +async function createOpaqueSession(key: string, session: AuthSession) { + const store = await getLoginSessionStore(); + return store.create({ + keyFingerprint: await toKeyFingerprint(key), + userId: session.user.id, + userRole: session.user.role, + }); +} + export async function POST(request: NextRequest) { + const csrfResult = csrfGuard.check(request); + if (!csrfResult.allowed) { + return withAuthResponseHeaders( + NextResponse.json({ errorCode: "CSRF_REJECTED" }, { status: 403 }) + ); + } + const locale = getLocaleFromRequest(request); const t = await getAuthErrorTranslations(locale); + const clientIp = getClientIp(request); + + const decision = loginPolicy.check(clientIp); + if (!decision.allowed) { + const response = withAuthResponseHeaders( + NextResponse.json( + { + error: t?.("loginFailed") ?? t?.("serverError") ?? "Too many attempts", + errorCode: "RATE_LIMITED", + }, + { status: 429 } + ) + ); + + if (decision.retryAfterSeconds != null) { + response.headers.set("Retry-After", String(decision.retryAfterSeconds)); + } + + return response; + } try { const { key } = await request.json(); - if (!key) { - return NextResponse.json({ error: t?.("apiKeyRequired") }, { status: 400 }); + if (!key || typeof key !== "string") { + loginPolicy.recordFailure(clientIp); + + if (!shouldIncludeFailureTaxonomy(request)) { + return withAuthResponseHeaders( + NextResponse.json( + { error: t?.("apiKeyRequired") ?? "API key is required" }, + { status: 400 } + ) + ); + } + + return withAuthResponseHeaders( + NextResponse.json( + { error: t?.("apiKeyRequired") ?? "API key is required", errorCode: "KEY_REQUIRED" }, + { status: 400 } + ) + ); } const session = await validateKey(key, { allowReadOnlyAccess: true }); if (!session) { - return NextResponse.json({ error: t?.("apiKeyInvalidOrExpired") }, { status: 401 }); + loginPolicy.recordFailure(clientIp); + + if (!shouldIncludeFailureTaxonomy(request)) { + return withAuthResponseHeaders( + NextResponse.json( + { error: t?.("apiKeyInvalidOrExpired") ?? "Authentication failed" }, + { status: 401 } + ) + ); + } + + const responseBody: { + error: string; + errorCode: "KEY_INVALID"; + httpMismatchGuidance?: string; + } = { + error: t?.("apiKeyInvalidOrExpired") ?? "Authentication failed", + errorCode: "KEY_INVALID", + }; + + if (hasSecureCookieHttpMismatch(request)) { + const securityT = await getAuthSecurityTranslations(locale); + responseBody.httpMismatchGuidance = + securityT?.("cookieWarningDescription") ?? + t?.("apiKeyInvalidOrExpired") ?? + t?.("serverError"); + } + + return withAuthResponseHeaders(NextResponse.json(responseBody, { status: 401 })); } - // 设置认证 cookie - await setAuthCookie(key); + const mode = getSessionTokenMode(); + if (mode === "legacy") { + await setAuthCookie(key); + } else if (mode === "dual") { + await setAuthCookie(key); + try { + await createOpaqueSession(key, session); + } catch (error) { + logger.warn("Failed to create opaque session in dual mode", { + error: error instanceof Error ? error.message : String(error), + }); + } + } else { + try { + const opaqueSession = await createOpaqueSession(key, session); + await setAuthCookie(opaqueSession.sessionId); + } catch (error) { + logger.error("Failed to create opaque session in opaque mode", { + error: error instanceof Error ? error.message : String(error), + }); + const serverError = t?.("serverError") ?? "Internal server error"; + return withAuthResponseHeaders( + NextResponse.json( + { error: serverError, errorCode: "SESSION_CREATE_FAILED" }, + { status: 503 } + ) + ); + } + } + + loginPolicy.recordSuccess(clientIp); const redirectTo = getLoginRedirectTarget(session); + const loginType = + session.user.role === "admin" + ? "admin" + : session.key.canLoginWebUi + ? "dashboard_user" + : "readonly_user"; - return NextResponse.json({ - ok: true, - user: { - id: session.user.id, - name: session.user.name, - description: session.user.description, - role: session.user.role, - }, - redirectTo, - }); + return withAuthResponseHeaders( + NextResponse.json({ + ok: true, + user: { + id: session.user.id, + name: session.user.name, + description: session.user.description, + role: session.user.role, + }, + redirectTo, + loginType, + }) + ); } catch (error) { logger.error("Login error:", error); - return NextResponse.json({ error: t?.("serverError") }, { status: 500 }); + const serverError = t?.("serverError") ?? "Internal server error"; + + if (!shouldIncludeFailureTaxonomy(request)) { + return withAuthResponseHeaders(NextResponse.json({ error: serverError }, { status: 500 })); + } + + return withAuthResponseHeaders( + NextResponse.json({ error: serverError, errorCode: "SERVER_ERROR" }, { status: 500 }) + ); } } diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 3a435fc13..3233994e6 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,7 +1,78 @@ -import { NextResponse } from "next/server"; -import { clearAuthCookie } from "@/lib/auth"; +import { type NextRequest, NextResponse } from "next/server"; +import { + clearAuthCookie, + getAuthCookie, + getSessionTokenMode, + type SessionTokenMode, +} from "@/lib/auth"; +import { logger } from "@/lib/logger"; +import { withAuthResponseHeaders } from "@/lib/security/auth-response-headers"; +import { createCsrfOriginGuard } from "@/lib/security/csrf-origin-guard"; + +const csrfGuard = createCsrfOriginGuard({ + allowedOrigins: [], + allowSameOrigin: true, + enforceInDevelopment: process.env.VITEST === "true", +}); + +let sessionStoreInstance: + | import("@/lib/auth-session-store/redis-session-store").RedisSessionStore + | null = null; + +async function getLogoutSessionStore() { + if (!sessionStoreInstance) { + const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store"); + sessionStoreInstance = new RedisSessionStore(); + } + return sessionStoreInstance; +} + +function resolveSessionTokenMode(): SessionTokenMode { + try { + return getSessionTokenMode(); + } catch (err) { + logger.warn("[AuthLogout] Failed to resolve session token mode, defaulting to legacy", { + error: err instanceof Error ? err.message : String(err), + }); + return "legacy"; + } +} + +async function resolveAuthCookieToken(): Promise { + try { + return await getAuthCookie(); + } catch (err) { + logger.warn("[AuthLogout] Failed to read auth cookie", { + error: err instanceof Error ? err.message : String(err), + }); + return undefined; + } +} + +export async function POST(request: NextRequest) { + const csrfResult = csrfGuard.check(request); + if (!csrfResult.allowed) { + return withAuthResponseHeaders( + NextResponse.json({ errorCode: "CSRF_REJECTED" }, { status: 403 }) + ); + } + + const mode = resolveSessionTokenMode(); + + if (mode !== "legacy") { + try { + const sessionId = await resolveAuthCookieToken(); + if (sessionId) { + const store = await getLogoutSessionStore(); + await store.revoke(sessionId); + } + } catch (error) { + logger.warn("[AuthLogout] Failed to revoke opaque session during logout", { + error: error instanceof Error ? error.message : String(error), + }); + } + } -export async function POST() { await clearAuthCookie(); - return NextResponse.json({ ok: true }); + return withAuthResponseHeaders(NextResponse.json({ ok: true })); } diff --git a/src/app/v1/_lib/cors.ts b/src/app/v1/_lib/cors.ts index 6fc3909d5..5756f376e 100644 --- a/src/app/v1/_lib/cors.ts +++ b/src/app/v1/_lib/cors.ts @@ -15,12 +15,21 @@ const DEFAULT_CORS_HEADERS: Record = { /** * 动态构建 CORS 响应头 */ -function buildCorsHeaders(options: { origin?: string | null; requestHeaders?: string | null }) { +function buildCorsHeaders(options: { + origin?: string | null; + requestHeaders?: string | null; + allowCredentials?: boolean; +}) { const headers = new Headers(DEFAULT_CORS_HEADERS); - if (options.origin) { + // Only reflect specific origin when credentials are explicitly opted-in. + // The proxy API uses Bearer tokens; reflecting arbitrary origins with + // credentials enabled would let any malicious site make credentialed + // cross-origin requests. + if (options.allowCredentials && options.origin) { headers.set("Access-Control-Allow-Origin", options.origin); headers.append("Vary", "Origin"); + headers.set("Access-Control-Allow-Credentials", "true"); } if (options.requestHeaders) { @@ -28,10 +37,6 @@ function buildCorsHeaders(options: { origin?: string | null; requestHeaders?: st headers.append("Vary", "Access-Control-Request-Headers"); } - if (headers.get("Access-Control-Allow-Origin") !== "*") { - headers.set("Access-Control-Allow-Credentials", "true"); - } - return headers; } @@ -75,7 +80,7 @@ function mergeVaryHeader(existing: string | null, newValue: string): string { */ export function applyCors( res: Response, - ctx: { origin?: string | null; requestHeaders?: string | null } + ctx: { origin?: string | null; requestHeaders?: string | null; allowCredentials?: boolean } ): Response { const corsHeaders = buildCorsHeaders(ctx); @@ -138,6 +143,7 @@ export function applyCors( export function buildPreflightResponse(options: { origin?: string | null; requestHeaders?: string | null; + allowCredentials?: boolean; }): Response { return new Response(null, { status: 204, headers: buildCorsHeaders(options) }); } diff --git a/src/app/v1/_lib/proxy/auth-guard.ts b/src/app/v1/_lib/proxy/auth-guard.ts index c652116c7..ef8e3a008 100644 --- a/src/app/v1/_lib/proxy/auth-guard.ts +++ b/src/app/v1/_lib/proxy/auth-guard.ts @@ -1,12 +1,67 @@ import { logger } from "@/lib/logger"; +import { LoginAbusePolicy } from "@/lib/security/login-abuse-policy"; import { validateApiKeyAndGetUser } from "@/repository/key"; import { markUserExpired } from "@/repository/user"; import { GEMINI_PROTOCOL } from "../gemini/protocol"; import { ProxyResponses } from "./responses"; import type { AuthState, ProxySession } from "./session"; +/** + * Pre-auth rate limiter: throttles repeated authentication failures per IP + * to prevent brute-force API key enumeration on /v1/* endpoints. + * + * Uses the same LoginAbusePolicy as the login route but with separate + * thresholds appropriate for programmatic API access. + */ +const proxyAuthPolicy = new LoginAbusePolicy({ + maxAttemptsPerIp: 20, + maxAttemptsPerKey: 20, + windowSeconds: 300, + lockoutSeconds: 600, +}); + +function extractClientIp(session: ProxySession): string { + // Prefer x-real-ip (set by trusted reverse proxy), then rightmost + // x-forwarded-for entry, avoiding the client-spoofable leftmost value. + const realIp = session.headers.get("x-real-ip")?.trim(); + if (realIp) return realIp; + + const forwarded = session.headers.get("x-forwarded-for"); + if (forwarded) { + const ips = forwarded + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (ips.length > 0) return ips[ips.length - 1]; + } + + return "unknown"; +} + export class ProxyAuthenticator { static async ensure(session: ProxySession): Promise { + // Pre-auth rate limit: block IPs with too many recent auth failures + const clientIp = extractClientIp(session); + const rateLimitDecision = proxyAuthPolicy.check(clientIp); + if (!rateLimitDecision.allowed) { + const retryAfter = rateLimitDecision.retryAfterSeconds; + const response = ProxyResponses.buildError( + 429, + "Too many authentication failures. Please retry later.", + "rate_limit_error" + ); + if (retryAfter != null) { + const headers = new Headers(response.headers); + headers.set("Retry-After", String(retryAfter)); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } + return response; + } + const authHeader = session.headers.get("authorization") ?? undefined; const apiKeyHeader = session.headers.get("x-api-key") ?? undefined; // Gemini CLI 认证:支持 x-goog-api-key 头部和 key 查询参数 @@ -22,9 +77,13 @@ export class ProxyAuthenticator { session.setAuthState(authState); if (authState.success) { + proxyAuthPolicy.recordSuccess(clientIp); return null; } + // Record failure for rate limiting + proxyAuthPolicy.recordFailure(clientIp); + // 返回详细的错误信息,帮助用户快速定位问题 return authState.errorResponse ?? ProxyResponses.buildError(401, "认证失败"); } diff --git a/src/lib/api/action-adapter-openapi.ts b/src/lib/api/action-adapter-openapi.ts index 80f7950ac..338ec7047 100644 --- a/src/lib/api/action-adapter-openapi.ts +++ b/src/lib/api/action-adapter-openapi.ts @@ -12,7 +12,7 @@ import { createRoute, z } from "@hono/zod-openapi"; import type { Context } from "hono"; import { getCookie } from "hono/cookie"; import type { ActionResult } from "@/actions/types"; -import { runWithAuthSession, validateKey } from "@/lib/auth"; +import { AUTH_COOKIE_NAME, runWithAuthSession, validateAuthToken } from "@/lib/auth"; import { logger } from "@/lib/logger"; function getBearerTokenFromAuthHeader(raw: string | undefined): string | undefined { @@ -300,20 +300,21 @@ export function createActionRoute( const fullPath = `${module}.${actionName}`; try { - let authSession: Awaited> | null = null; + let authSession: Awaited> | null = null; // 0. 认证检查 (如果需要) if (requiresAuth) { const authToken = - getCookie(c, "auth-token") ?? getBearerTokenFromAuthHeader(c.req.header("authorization")); + getCookie(c, AUTH_COOKIE_NAME) ?? + getBearerTokenFromAuthHeader(c.req.header("authorization")); if (!authToken) { - logger.warn(`[ActionAPI] ${fullPath} 认证失败: 缺少 auth-token`); + logger.warn(`[ActionAPI] ${fullPath} 认证失败: 缺少 ${AUTH_COOKIE_NAME}`); return c.json({ ok: false, error: "未认证" }, 401); } - const session = await validateKey(authToken, { allowReadOnlyAccess }); + const session = await validateAuthToken(authToken, { allowReadOnlyAccess }); if (!session) { - logger.warn(`[ActionAPI] ${fullPath} 认证失败: 无效的 auth-token`); + logger.warn(`[ActionAPI] ${fullPath} 认证失败: 无效的 ${AUTH_COOKIE_NAME}`); return c.json({ ok: false, error: "认证无效或已过期" }, 401); } authSession = session; diff --git a/src/lib/auth-session-store/index.ts b/src/lib/auth-session-store/index.ts new file mode 100644 index 000000000..f6f75cc1a --- /dev/null +++ b/src/lib/auth-session-store/index.ts @@ -0,0 +1,20 @@ +export interface SessionData { + sessionId: string; + keyFingerprint: string; + userId: number; + userRole: string; + createdAt: number; + expiresAt: number; +} + +export interface SessionStore { + create( + data: Omit, + ttlSeconds?: number + ): Promise; + read(sessionId: string): Promise; + revoke(sessionId: string): Promise; + rotate(oldSessionId: string): Promise; +} + +export const DEFAULT_SESSION_TTL = 604800; diff --git a/src/lib/auth-session-store/redis-session-store.ts b/src/lib/auth-session-store/redis-session-store.ts new file mode 100644 index 000000000..904358f06 --- /dev/null +++ b/src/lib/auth-session-store/redis-session-store.ts @@ -0,0 +1,225 @@ +import "server-only"; + +import type Redis from "ioredis"; +import { logger } from "@/lib/logger"; +import { getRedisClient } from "@/lib/redis"; +import { DEFAULT_SESSION_TTL, type SessionData, type SessionStore } from "./index"; + +const SESSION_KEY_PREFIX = "cch:session:"; +const MIN_TTL_SECONDS = 1; + +type RedisSessionClient = Pick; + +export interface RedisSessionStoreOptions { + defaultTtlSeconds?: number; + redisClient?: RedisSessionClient | null; +} + +function toLogError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function normalizeTtlSeconds(value: number | undefined): number { + if (!Number.isFinite(value) || typeof value !== "number" || value <= 0) { + return DEFAULT_SESSION_TTL; + } + + return Math.max(MIN_TTL_SECONDS, Math.floor(value)); +} + +function buildSessionKey(sessionId: string): string { + return `${SESSION_KEY_PREFIX}${sessionId}`; +} + +function parseSessionData(raw: string): SessionData | null { + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + + const obj = parsed as Record; + if (typeof obj.sessionId !== "string") return null; + if (typeof obj.keyFingerprint !== "string") return null; + if (typeof obj.userRole !== "string") return null; + if (typeof obj.userId !== "number" || !Number.isInteger(obj.userId)) return null; + if (!Number.isFinite(obj.createdAt) || typeof obj.createdAt !== "number") return null; + if (!Number.isFinite(obj.expiresAt) || typeof obj.expiresAt !== "number") return null; + + return { + sessionId: obj.sessionId, + keyFingerprint: obj.keyFingerprint, + userId: obj.userId as number, + userRole: obj.userRole, + createdAt: obj.createdAt, + expiresAt: obj.expiresAt, + }; + } catch { + return null; + } +} + +function resolveRotateTtlSeconds(expiresAt: number): number | null { + if (!Number.isFinite(expiresAt) || typeof expiresAt !== "number") { + return DEFAULT_SESSION_TTL; + } + + const remainingMs = expiresAt - Date.now(); + if (remainingMs <= 0) { + return null; + } + return Math.max(MIN_TTL_SECONDS, Math.ceil(remainingMs / 1000)); +} + +export class RedisSessionStore implements SessionStore { + private readonly defaultTtlSeconds: number; + private readonly redisClient?: RedisSessionClient | null; + + constructor(options: RedisSessionStoreOptions = {}) { + this.defaultTtlSeconds = normalizeTtlSeconds(options.defaultTtlSeconds); + this.redisClient = options.redisClient; + } + + private resolveRedisClient(): RedisSessionClient | null { + if (this.redisClient !== undefined) { + return this.redisClient; + } + + return getRedisClient({ allowWhenRateLimitDisabled: true }) as RedisSessionClient | null; + } + + private getReadyRedis(): RedisSessionClient | null { + const redis = this.resolveRedisClient(); + if (!redis || redis.status !== "ready") { + return null; + } + + return redis; + } + + async create( + data: Omit, + ttlSeconds = this.defaultTtlSeconds + ): Promise { + const ttl = normalizeTtlSeconds(ttlSeconds); + const createdAt = Date.now(); + const sessionData: SessionData = { + sessionId: `sid_${globalThis.crypto.randomUUID()}`, + keyFingerprint: data.keyFingerprint, + userId: data.userId, + userRole: data.userRole, + createdAt, + expiresAt: createdAt + ttl * 1000, + }; + + const redis = this.getReadyRedis(); + if (!redis) { + throw new Error("Redis not ready: session not persisted"); + } + + try { + await redis.setex(buildSessionKey(sessionData.sessionId), ttl, JSON.stringify(sessionData)); + } catch (error) { + logger.error("[AuthSessionStore] Failed to create session", { + error: toLogError(error), + sessionId: sessionData.sessionId, + }); + throw error; + } + + return sessionData; + } + + async read(sessionId: string): Promise { + const redis = this.getReadyRedis(); + if (!redis) { + return null; + } + + try { + const value = await redis.get(buildSessionKey(sessionId)); + if (!value) { + return null; + } + + const parsed = parseSessionData(value); + if (!parsed) { + logger.warn("[AuthSessionStore] Invalid session payload", { sessionId }); + return null; + } + + return parsed; + } catch (error) { + logger.error("[AuthSessionStore] Failed to read session", { + error: toLogError(error), + sessionId, + }); + return null; + } + } + + async revoke(sessionId: string): Promise { + const redis = this.getReadyRedis(); + if (!redis) { + logger.warn("[AuthSessionStore] Redis not ready during revoke", { sessionId }); + return false; + } + + try { + const deleted = await redis.del(buildSessionKey(sessionId)); + return deleted > 0; + } catch (error) { + logger.error("[AuthSessionStore] Failed to revoke session", { + error: toLogError(error), + sessionId, + }); + return false; + } + } + + async rotate(oldSessionId: string): Promise { + const oldSession = await this.read(oldSessionId); + if (!oldSession) { + return null; + } + + const ttlSeconds = resolveRotateTtlSeconds(oldSession.expiresAt); + if (ttlSeconds === null) { + logger.warn("[AuthSessionStore] Cannot rotate expired session", { + sessionId: oldSessionId, + expiresAt: oldSession.expiresAt, + }); + return null; + } + let nextSession: SessionData; + try { + nextSession = await this.create( + { + keyFingerprint: oldSession.keyFingerprint, + userId: oldSession.userId, + userRole: oldSession.userRole, + }, + ttlSeconds + ); + } catch (error) { + logger.error("[AuthSessionStore] Failed to create rotated session", { + error: toLogError(error), + oldSessionId, + }); + return null; + } + + const revoked = await this.revoke(oldSessionId); + if (!revoked) { + logger.warn( + "[AuthSessionStore] Failed to revoke old session during rotate; old session will expire naturally", + { + oldSessionId, + newSessionId: nextSession.sessionId, + } + ); + } + + return nextSession; + } +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 62a2cac0f..4f6749282 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,10 +1,23 @@ import { cookies, headers } from "next/headers"; +import type { NextResponse } from "next/server"; import { config } from "@/lib/config/config"; import { getEnvConfig } from "@/lib/config/env.schema"; -import { validateApiKeyAndGetUser } from "@/repository/key"; +import { logger } from "@/lib/logger"; +import { constantTimeEqual } from "@/lib/security/constant-time-compare"; +import { findKeyList, validateApiKeyAndGetUser } from "@/repository/key"; import type { Key } from "@/types/key"; import type { User } from "@/types/user"; +/** + * Apply no-store / cache-busting headers to auth responses that mutate session state. + * Prevents browsers and intermediary caches from storing sensitive auth responses. + */ +export function withNoStoreHeaders(response: T): T { + response.headers.set("Cache-Control", "no-store, no-cache, must-revalidate"); + response.headers.set("Pragma", "no-cache"); + return response; +} + export type ScopedAuthContext = { session: AuthSession; /** @@ -25,7 +38,7 @@ declare global { var __cchAuthSessionStorage: AuthSessionStorage | undefined; } -const AUTH_COOKIE_NAME = "auth-token"; +export const AUTH_COOKIE_NAME = "auth-token"; const AUTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days export interface AuthSession { @@ -33,6 +46,95 @@ export interface AuthSession { key: Key; } +export type SessionTokenMode = "legacy" | "dual" | "opaque"; +export type SessionTokenKind = "legacy" | "opaque"; + +export function getSessionTokenMode(): SessionTokenMode { + return getEnvConfig().SESSION_TOKEN_MODE; +} + +// Session contract: opaque token is a random string, not the API key +export interface OpaqueSessionContract { + sessionId: string; // random opaque token + keyFingerprint: string; // hash of the API key (for audit, not auth) + createdAt: number; // unix timestamp + expiresAt: number; // unix timestamp + userId: number; + userRole: string; +} + +export interface SessionTokenMigrationFlags { + dualReadWindowEnabled: boolean; + hardCutoverEnabled: boolean; + emergencyRollbackEnabled: boolean; +} + +export const SESSION_TOKEN_SEMANTICS = { + expiry: "hard_expiry_at_expires_at", + rotation: "rotate_before_expiry_and_revoke_previous_session_id", + revocation: "server_side_revocation_invalidates_session_immediately", + compatibility: { + legacy: "accept_legacy_only", + dual: "accept_legacy_and_opaque", + opaque: "accept_opaque_only", + }, +} as const; + +export function getSessionTokenMigrationFlags( + mode: SessionTokenMode = getSessionTokenMode() +): SessionTokenMigrationFlags { + return { + dualReadWindowEnabled: mode === "dual", + hardCutoverEnabled: mode === "opaque", + emergencyRollbackEnabled: mode === "legacy", + }; +} + +export function isSessionTokenKindAccepted( + mode: SessionTokenMode, + kind: SessionTokenKind +): boolean { + if (mode === "dual") return true; + if (mode === "legacy") return kind === "legacy"; + return kind === "opaque"; +} + +export function isOpaqueSessionContract(value: unknown): value is OpaqueSessionContract { + if (!value || typeof value !== "object") return false; + + const candidate = value as Record; + return ( + typeof candidate.sessionId === "string" && + candidate.sessionId.length > 0 && + typeof candidate.keyFingerprint === "string" && + candidate.keyFingerprint.length > 0 && + typeof candidate.createdAt === "number" && + Number.isFinite(candidate.createdAt) && + typeof candidate.expiresAt === "number" && + Number.isFinite(candidate.expiresAt) && + candidate.expiresAt > candidate.createdAt && + typeof candidate.userId === "number" && + Number.isInteger(candidate.userId) && + typeof candidate.userRole === "string" && + candidate.userRole.length > 0 + ); +} + +const OPAQUE_SESSION_ID_PREFIX = "sid_"; + +export function detectSessionTokenKind(token: string): SessionTokenKind { + const trimmed = token.trim(); + if (!trimmed) return "legacy"; + return trimmed.startsWith(OPAQUE_SESSION_ID_PREFIX) ? "opaque" : "legacy"; +} + +export function isSessionTokenAccepted( + token: string, + mode: SessionTokenMode = getSessionTokenMode() +): boolean { + return isSessionTokenKindAccepted(mode, detectSessionTokenKind(token)); +} + export function runWithAuthSession( session: AuthSession, fn: () => T, @@ -65,7 +167,7 @@ export async function validateKey( const allowReadOnlyAccess = options?.allowReadOnlyAccess ?? false; const adminToken = config.auth.adminToken; - if (adminToken && keyString === adminToken) { + if (adminToken && constantTimeEqual(keyString, adminToken)) { const now = new Date(); const adminUser: User = { id: -1, @@ -158,6 +260,40 @@ export async function clearAuthCookie() { cookieStore.delete(AUTH_COOKIE_NAME); } +export async function validateAuthToken( + token: string, + options?: { allowReadOnlyAccess?: boolean } +): Promise { + const mode = getSessionTokenMode(); + + if (mode !== "legacy") { + try { + const sessionStore = await getSessionStore(); + const sessionData = await sessionStore.read(token); + if (sessionData) { + if (sessionData.expiresAt <= Date.now()) { + logger.warn("Opaque session expired (application-level check)", { + sessionId: sessionData.sessionId, + expiresAt: sessionData.expiresAt, + }); + return null; + } + return convertToAuthSession(sessionData, options); + } + } catch (error) { + logger.warn("Opaque session read failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (mode === "legacy" || mode === "dual") { + return validateKey(token, options); + } + + return null; +} + export async function getSession(options?: { /** * 允许仅访问只读页面(如 my-usage),跳过 canLoginWebUi 校验 @@ -181,7 +317,79 @@ export async function getSession(options?: { return null; } - return validateKey(keyString, options); + return validateAuthToken(keyString, options); +} + +type SessionStoreReader = { + read(sessionId: string): Promise; +}; + +let sessionStorePromise: Promise | null = null; + +async function getSessionStore(): Promise { + if (!sessionStorePromise) { + sessionStorePromise = import("@/lib/auth-session-store/redis-session-store") + .then(({ RedisSessionStore }) => new RedisSessionStore()) + .catch((error) => { + sessionStorePromise = null; + throw error; + }); + } + + return sessionStorePromise; +} + +export async function toKeyFingerprint(keyString: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(keyString)); + const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join( + "" + ); + return `sha256:${hex}`; +} + +function normalizeKeyFingerprint(fingerprint: string): string { + return fingerprint.startsWith("sha256:") ? fingerprint : `sha256:${fingerprint}`; +} + +async function convertToAuthSession( + sessionData: OpaqueSessionContract, + options?: { allowReadOnlyAccess?: boolean } +): Promise { + const expectedFingerprint = normalizeKeyFingerprint(sessionData.keyFingerprint); + + // Admin token uses virtual user (id=-1) which has no DB keys; + // verify fingerprint against the configured admin token directly. + if (sessionData.userId === -1) { + const adminToken = config.auth.adminToken; + if (!adminToken) return null; + const adminFingerprint = await toKeyFingerprint(adminToken); + return constantTimeEqual(adminFingerprint, expectedFingerprint) + ? validateKey(adminToken, options) + : null; + } + + const keyList = await findKeyList(sessionData.userId); + + for (const key of keyList) { + const keyFingerprint = await toKeyFingerprint(key.key); + if (constantTimeEqual(keyFingerprint, expectedFingerprint)) { + return validateKey(key.key, options); + } + } + + return null; +} + +export async function getSessionWithDualRead(options?: { + allowReadOnlyAccess?: boolean; +}): Promise { + return getSession(options); +} + +export async function validateSession(options?: { + allowReadOnlyAccess?: boolean; +}): Promise { + return getSessionWithDualRead(options); } function parseBearerToken(raw: string | null | undefined): string | undefined { diff --git a/src/lib/config/env.schema.ts b/src/lib/config/env.schema.ts index b7dacd738..dcdf167ef 100644 --- a/src/lib/config/env.schema.ts +++ b/src/lib/config/env.schema.ts @@ -93,6 +93,7 @@ export const EnvSchema = z.object({ REDIS_TLS_REJECT_UNAUTHORIZED: z.string().default("true").transform(booleanTransform), ENABLE_RATE_LIMIT: z.string().default("true").transform(booleanTransform), ENABLE_SECURE_COOKIES: z.string().default("true").transform(booleanTransform), + SESSION_TOKEN_MODE: z.enum(["legacy", "dual", "opaque"]).default("opaque"), SESSION_TTL: z.coerce.number().default(300), // 会话消息存储控制 // - false (默认):存储请求/响应体但对 message 内容脱敏 [REDACTED] diff --git a/src/lib/provider-batch-patch-error-codes.ts b/src/lib/provider-batch-patch-error-codes.ts new file mode 100644 index 000000000..597b12306 --- /dev/null +++ b/src/lib/provider-batch-patch-error-codes.ts @@ -0,0 +1,11 @@ +export const PROVIDER_BATCH_PATCH_ERROR_CODES = { + INVALID_INPUT: "INVALID_INPUT", + NOTHING_TO_APPLY: "NOTHING_TO_APPLY", + PREVIEW_EXPIRED: "PREVIEW_EXPIRED", + PREVIEW_STALE: "PREVIEW_STALE", + UNDO_EXPIRED: "UNDO_EXPIRED", + UNDO_CONFLICT: "UNDO_CONFLICT", +} as const; + +export type ProviderBatchPatchErrorCode = + (typeof PROVIDER_BATCH_PATCH_ERROR_CODES)[keyof typeof PROVIDER_BATCH_PATCH_ERROR_CODES]; diff --git a/src/lib/provider-patch-contract.ts b/src/lib/provider-patch-contract.ts new file mode 100644 index 000000000..659176713 --- /dev/null +++ b/src/lib/provider-patch-contract.ts @@ -0,0 +1,974 @@ +import type { + ProviderBatchApplyUpdates, + ProviderBatchPatch, + ProviderBatchPatchDraft, + ProviderBatchPatchField, + ProviderPatchDraftInput, + ProviderPatchOperation, +} from "@/types/provider"; + +export const PROVIDER_PATCH_ERROR_CODES = { + INVALID_PATCH_SHAPE: "INVALID_PATCH_SHAPE", +} as const; + +export type ProviderPatchErrorCode = + (typeof PROVIDER_PATCH_ERROR_CODES)[keyof typeof PROVIDER_PATCH_ERROR_CODES]; + +interface ProviderPatchError { + code: ProviderPatchErrorCode; + field: ProviderBatchPatchField | "__root__"; + message: string; +} + +type ProviderPatchResult = { ok: true; data: T } | { ok: false; error: ProviderPatchError }; + +const PATCH_INPUT_KEYS = new Set(["set", "clear", "no_change"]); +const PATCH_FIELDS: ProviderBatchPatchField[] = [ + "is_enabled", + "priority", + "weight", + "cost_multiplier", + "group_tag", + "model_redirects", + "allowed_models", + "anthropic_thinking_budget_preference", + "anthropic_adaptive_thinking", + // Routing + "preserve_client_ip", + "group_priorities", + "cache_ttl_preference", + "swap_cache_ttl_billing", + "context_1m_preference", + "codex_reasoning_effort_preference", + "codex_reasoning_summary_preference", + "codex_text_verbosity_preference", + "codex_parallel_tool_calls_preference", + "anthropic_max_tokens_preference", + "gemini_google_search_preference", + // Rate Limit + "limit_5h_usd", + "limit_daily_usd", + "daily_reset_mode", + "daily_reset_time", + "limit_weekly_usd", + "limit_monthly_usd", + "limit_total_usd", + "limit_concurrent_sessions", + // Circuit Breaker + "circuit_breaker_failure_threshold", + "circuit_breaker_open_duration", + "circuit_breaker_half_open_success_threshold", + "max_retry_attempts", + // Network + "proxy_url", + "proxy_fallback_to_direct", + "first_byte_timeout_streaming_ms", + "streaming_idle_timeout_ms", + "request_timeout_non_streaming_ms", + // MCP + "mcp_passthrough_type", + "mcp_passthrough_url", +]; +const PATCH_FIELD_SET = new Set(PATCH_FIELDS); + +const CLEARABLE_FIELDS: Record = { + is_enabled: false, + priority: false, + weight: false, + cost_multiplier: false, + group_tag: true, + model_redirects: true, + allowed_models: true, + anthropic_thinking_budget_preference: true, + anthropic_adaptive_thinking: true, + // Routing + preserve_client_ip: false, + group_priorities: true, + cache_ttl_preference: true, + swap_cache_ttl_billing: false, + context_1m_preference: true, + codex_reasoning_effort_preference: true, + codex_reasoning_summary_preference: true, + codex_text_verbosity_preference: true, + codex_parallel_tool_calls_preference: true, + anthropic_max_tokens_preference: true, + gemini_google_search_preference: true, + // Rate Limit + limit_5h_usd: true, + limit_daily_usd: true, + daily_reset_mode: false, + daily_reset_time: false, + limit_weekly_usd: true, + limit_monthly_usd: true, + limit_total_usd: true, + limit_concurrent_sessions: false, + // Circuit Breaker + circuit_breaker_failure_threshold: false, + circuit_breaker_open_duration: false, + circuit_breaker_half_open_success_threshold: false, + max_retry_attempts: true, + // Network + proxy_url: true, + proxy_fallback_to_direct: false, + first_byte_timeout_streaming_ms: false, + streaming_idle_timeout_ms: false, + request_timeout_non_streaming_ms: false, + // MCP + mcp_passthrough_type: false, + mcp_passthrough_url: true, +}; + +function isStringRecord(value: unknown): value is Record { + if (!isRecord(value) || Array.isArray(value)) { + return false; + } + + return Object.entries(value).every( + ([key, entry]) => typeof key === "string" && typeof entry === "string" + ); +} + +function isNumberRecord(value: unknown): value is Record { + if (!isRecord(value) || Array.isArray(value)) { + return false; + } + + return Object.values(value).every((v) => typeof v === "number" && Number.isFinite(v)); +} + +function isAdaptiveThinkingConfig( + value: unknown +): value is NonNullable { + if (!isRecord(value)) { + return false; + } + + const effortValues = new Set(["low", "medium", "high", "max"]); + const modeValues = new Set(["specific", "all"]); + + if (typeof value.effort !== "string" || !effortValues.has(value.effort)) { + return false; + } + + if (typeof value.modelMatchMode !== "string" || !modeValues.has(value.modelMatchMode)) { + return false; + } + + if (!Array.isArray(value.models) || !value.models.every((model) => typeof model === "string")) { + return false; + } + + if (value.modelMatchMode === "specific" && value.models.length === 0) { + return false; + } + + return true; +} + +function isThinkingBudgetPreference(value: unknown): boolean { + if (value === "inherit") { + return true; + } + + if (typeof value !== "string") { + return false; + } + + if (!/^\d+$/.test(value)) { + return false; + } + + const parsed = Number.parseInt(value, 10); + return parsed >= 1024 && parsed <= 32000; +} + +function isMaxTokensPreference(value: unknown): boolean { + if (value === "inherit") { + return true; + } + + if (typeof value !== "string") { + return false; + } + + if (!/^\d+$/.test(value)) { + return false; + } + + const parsed = Number.parseInt(value, 10); + return parsed > 0; +} + +function isValidSetValue(field: ProviderBatchPatchField, value: unknown): boolean { + switch (field) { + case "is_enabled": + case "preserve_client_ip": + case "swap_cache_ttl_billing": + case "proxy_fallback_to_direct": + return typeof value === "boolean"; + case "priority": + case "weight": + case "cost_multiplier": + case "limit_5h_usd": + case "limit_daily_usd": + case "limit_weekly_usd": + case "limit_monthly_usd": + case "limit_total_usd": + case "limit_concurrent_sessions": + case "circuit_breaker_failure_threshold": + case "circuit_breaker_open_duration": + case "circuit_breaker_half_open_success_threshold": + case "max_retry_attempts": + case "first_byte_timeout_streaming_ms": + case "streaming_idle_timeout_ms": + case "request_timeout_non_streaming_ms": + return typeof value === "number" && Number.isFinite(value); + case "group_tag": + case "daily_reset_time": + case "proxy_url": + case "mcp_passthrough_url": + return typeof value === "string"; + case "group_priorities": + return isNumberRecord(value); + case "cache_ttl_preference": + return value === "inherit" || value === "5m" || value === "1h"; + case "context_1m_preference": + return value === "inherit" || value === "force_enable" || value === "disabled"; + case "daily_reset_mode": + return value === "fixed" || value === "rolling"; + case "codex_reasoning_effort_preference": + return ( + value === "inherit" || + value === "none" || + value === "minimal" || + value === "low" || + value === "medium" || + value === "high" || + value === "xhigh" + ); + case "codex_reasoning_summary_preference": + return value === "inherit" || value === "auto" || value === "detailed"; + case "codex_text_verbosity_preference": + return value === "inherit" || value === "low" || value === "medium" || value === "high"; + case "codex_parallel_tool_calls_preference": + return value === "inherit" || value === "true" || value === "false"; + case "anthropic_thinking_budget_preference": + return isThinkingBudgetPreference(value); + case "anthropic_max_tokens_preference": + return isMaxTokensPreference(value); + case "gemini_google_search_preference": + return value === "inherit" || value === "enabled" || value === "disabled"; + case "mcp_passthrough_type": + return value === "none" || value === "minimax" || value === "glm" || value === "custom"; + case "model_redirects": + return isStringRecord(value); + case "allowed_models": + return Array.isArray(value) && value.every((model) => typeof model === "string"); + case "anthropic_adaptive_thinking": + return isAdaptiveThinkingConfig(value); + default: + return false; + } +} + +function createNoChangePatch(): ProviderPatchOperation { + return { mode: "no_change" }; +} + +function createInvalidPatchShapeError( + field: ProviderBatchPatchField, + message: string +): ProviderPatchResult { + return { + ok: false, + error: { + code: PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE, + field, + message, + }, + }; +} + +function createInvalidRootPatchShapeError(message: string): ProviderPatchResult { + return { + ok: false, + error: { + code: PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE, + field: "__root__", + message, + }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function normalizePatchField( + field: ProviderBatchPatchField, + input: ProviderPatchDraftInput +): ProviderPatchResult> { + if (input === undefined) { + return { ok: true, data: createNoChangePatch() }; + } + + if (!isRecord(input)) { + return createInvalidPatchShapeError(field, "Patch input must be an object"); + } + + const unknownKeys = Object.keys(input).filter((key) => !PATCH_INPUT_KEYS.has(key)); + if (unknownKeys.length > 0) { + return createInvalidPatchShapeError( + field, + `Patch input contains unknown keys: ${unknownKeys.join(",")}` + ); + } + + const hasSet = Object.hasOwn(input, "set"); + const hasClear = input.clear === true; + const hasNoChange = input.no_change === true; + const modeCount = [hasSet, hasClear, hasNoChange].filter(Boolean).length; + + if (modeCount !== 1) { + return createInvalidPatchShapeError(field, "Patch input must choose exactly one mode"); + } + + if (hasSet) { + if (input.set === undefined) { + return createInvalidPatchShapeError(field, "set mode requires a defined value"); + } + + if (!isValidSetValue(field, input.set)) { + return createInvalidPatchShapeError(field, "set mode value is invalid for this field"); + } + + return { ok: true, data: { mode: "set", value: input.set as T } }; + } + + if (hasNoChange) { + return { ok: true, data: createNoChangePatch() }; + } + + if (!CLEARABLE_FIELDS[field]) { + return createInvalidPatchShapeError(field, "clear mode is not supported for this field"); + } + + return { ok: true, data: { mode: "clear" } }; +} + +export function normalizeProviderBatchPatchDraft( + draft: unknown +): ProviderPatchResult { + if (!isRecord(draft) || Array.isArray(draft)) { + return createInvalidRootPatchShapeError("Patch draft must be an object"); + } + + const unknownFields = Object.keys(draft).filter( + (key) => !PATCH_FIELD_SET.has(key as ProviderBatchPatchField) + ); + if (unknownFields.length > 0) { + return createInvalidRootPatchShapeError( + `Patch draft contains unknown fields: ${unknownFields.join(",")}` + ); + } + + const typedDraft = draft as ProviderBatchPatchDraft; + + const isEnabled = normalizePatchField("is_enabled", typedDraft.is_enabled); + if (!isEnabled.ok) return isEnabled; + + const priority = normalizePatchField("priority", typedDraft.priority); + if (!priority.ok) return priority; + + const weight = normalizePatchField("weight", typedDraft.weight); + if (!weight.ok) return weight; + + const costMultiplier = normalizePatchField("cost_multiplier", typedDraft.cost_multiplier); + if (!costMultiplier.ok) return costMultiplier; + + const groupTag = normalizePatchField("group_tag", typedDraft.group_tag); + if (!groupTag.ok) return groupTag; + + const modelRedirects = normalizePatchField("model_redirects", typedDraft.model_redirects); + if (!modelRedirects.ok) return modelRedirects; + + const allowedModels = normalizePatchField("allowed_models", typedDraft.allowed_models); + if (!allowedModels.ok) return allowedModels; + + const thinkingBudget = normalizePatchField( + "anthropic_thinking_budget_preference", + typedDraft.anthropic_thinking_budget_preference + ); + if (!thinkingBudget.ok) return thinkingBudget; + + const adaptiveThinking = normalizePatchField( + "anthropic_adaptive_thinking", + typedDraft.anthropic_adaptive_thinking + ); + if (!adaptiveThinking.ok) return adaptiveThinking; + + // Routing + const preserveClientIp = normalizePatchField("preserve_client_ip", typedDraft.preserve_client_ip); + if (!preserveClientIp.ok) return preserveClientIp; + + const groupPriorities = normalizePatchField("group_priorities", typedDraft.group_priorities); + if (!groupPriorities.ok) return groupPriorities; + + const cacheTtlPref = normalizePatchField("cache_ttl_preference", typedDraft.cache_ttl_preference); + if (!cacheTtlPref.ok) return cacheTtlPref; + + const swapCacheTtlBilling = normalizePatchField( + "swap_cache_ttl_billing", + typedDraft.swap_cache_ttl_billing + ); + if (!swapCacheTtlBilling.ok) return swapCacheTtlBilling; + + const context1mPref = normalizePatchField( + "context_1m_preference", + typedDraft.context_1m_preference + ); + if (!context1mPref.ok) return context1mPref; + + const codexReasoningEffort = normalizePatchField( + "codex_reasoning_effort_preference", + typedDraft.codex_reasoning_effort_preference + ); + if (!codexReasoningEffort.ok) return codexReasoningEffort; + + const codexReasoningSummary = normalizePatchField( + "codex_reasoning_summary_preference", + typedDraft.codex_reasoning_summary_preference + ); + if (!codexReasoningSummary.ok) return codexReasoningSummary; + + const codexTextVerbosity = normalizePatchField( + "codex_text_verbosity_preference", + typedDraft.codex_text_verbosity_preference + ); + if (!codexTextVerbosity.ok) return codexTextVerbosity; + + const codexParallelToolCalls = normalizePatchField( + "codex_parallel_tool_calls_preference", + typedDraft.codex_parallel_tool_calls_preference + ); + if (!codexParallelToolCalls.ok) return codexParallelToolCalls; + + const anthropicMaxTokens = normalizePatchField( + "anthropic_max_tokens_preference", + typedDraft.anthropic_max_tokens_preference + ); + if (!anthropicMaxTokens.ok) return anthropicMaxTokens; + + const geminiGoogleSearch = normalizePatchField( + "gemini_google_search_preference", + typedDraft.gemini_google_search_preference + ); + if (!geminiGoogleSearch.ok) return geminiGoogleSearch; + + // Rate Limit + const limit5hUsd = normalizePatchField("limit_5h_usd", typedDraft.limit_5h_usd); + if (!limit5hUsd.ok) return limit5hUsd; + + const limitDailyUsd = normalizePatchField("limit_daily_usd", typedDraft.limit_daily_usd); + if (!limitDailyUsd.ok) return limitDailyUsd; + + const dailyResetMode = normalizePatchField("daily_reset_mode", typedDraft.daily_reset_mode); + if (!dailyResetMode.ok) return dailyResetMode; + + const dailyResetTime = normalizePatchField("daily_reset_time", typedDraft.daily_reset_time); + if (!dailyResetTime.ok) return dailyResetTime; + + const limitWeeklyUsd = normalizePatchField("limit_weekly_usd", typedDraft.limit_weekly_usd); + if (!limitWeeklyUsd.ok) return limitWeeklyUsd; + + const limitMonthlyUsd = normalizePatchField("limit_monthly_usd", typedDraft.limit_monthly_usd); + if (!limitMonthlyUsd.ok) return limitMonthlyUsd; + + const limitTotalUsd = normalizePatchField("limit_total_usd", typedDraft.limit_total_usd); + if (!limitTotalUsd.ok) return limitTotalUsd; + + const limitConcurrentSessions = normalizePatchField( + "limit_concurrent_sessions", + typedDraft.limit_concurrent_sessions + ); + if (!limitConcurrentSessions.ok) return limitConcurrentSessions; + + // Circuit Breaker + const cbFailureThreshold = normalizePatchField( + "circuit_breaker_failure_threshold", + typedDraft.circuit_breaker_failure_threshold + ); + if (!cbFailureThreshold.ok) return cbFailureThreshold; + + const cbOpenDuration = normalizePatchField( + "circuit_breaker_open_duration", + typedDraft.circuit_breaker_open_duration + ); + if (!cbOpenDuration.ok) return cbOpenDuration; + + const cbHalfOpenSuccess = normalizePatchField( + "circuit_breaker_half_open_success_threshold", + typedDraft.circuit_breaker_half_open_success_threshold + ); + if (!cbHalfOpenSuccess.ok) return cbHalfOpenSuccess; + + const maxRetryAttempts = normalizePatchField("max_retry_attempts", typedDraft.max_retry_attempts); + if (!maxRetryAttempts.ok) return maxRetryAttempts; + + // Network + const proxyUrl = normalizePatchField("proxy_url", typedDraft.proxy_url); + if (!proxyUrl.ok) return proxyUrl; + + const proxyFallbackToDirect = normalizePatchField( + "proxy_fallback_to_direct", + typedDraft.proxy_fallback_to_direct + ); + if (!proxyFallbackToDirect.ok) return proxyFallbackToDirect; + + const firstByteTimeout = normalizePatchField( + "first_byte_timeout_streaming_ms", + typedDraft.first_byte_timeout_streaming_ms + ); + if (!firstByteTimeout.ok) return firstByteTimeout; + + const streamingIdleTimeout = normalizePatchField( + "streaming_idle_timeout_ms", + typedDraft.streaming_idle_timeout_ms + ); + if (!streamingIdleTimeout.ok) return streamingIdleTimeout; + + const requestTimeoutNonStreaming = normalizePatchField( + "request_timeout_non_streaming_ms", + typedDraft.request_timeout_non_streaming_ms + ); + if (!requestTimeoutNonStreaming.ok) return requestTimeoutNonStreaming; + + // MCP + const mcpPassthroughType = normalizePatchField( + "mcp_passthrough_type", + typedDraft.mcp_passthrough_type + ); + if (!mcpPassthroughType.ok) return mcpPassthroughType; + + const mcpPassthroughUrl = normalizePatchField( + "mcp_passthrough_url", + typedDraft.mcp_passthrough_url + ); + if (!mcpPassthroughUrl.ok) return mcpPassthroughUrl; + + return { + ok: true, + data: { + is_enabled: isEnabled.data, + priority: priority.data, + weight: weight.data, + cost_multiplier: costMultiplier.data, + group_tag: groupTag.data, + model_redirects: modelRedirects.data, + allowed_models: allowedModels.data, + anthropic_thinking_budget_preference: thinkingBudget.data, + anthropic_adaptive_thinking: adaptiveThinking.data, + // Routing + preserve_client_ip: preserveClientIp.data, + group_priorities: groupPriorities.data, + cache_ttl_preference: cacheTtlPref.data, + swap_cache_ttl_billing: swapCacheTtlBilling.data, + context_1m_preference: context1mPref.data, + codex_reasoning_effort_preference: codexReasoningEffort.data, + codex_reasoning_summary_preference: codexReasoningSummary.data, + codex_text_verbosity_preference: codexTextVerbosity.data, + codex_parallel_tool_calls_preference: codexParallelToolCalls.data, + anthropic_max_tokens_preference: anthropicMaxTokens.data, + gemini_google_search_preference: geminiGoogleSearch.data, + // Rate Limit + limit_5h_usd: limit5hUsd.data, + limit_daily_usd: limitDailyUsd.data, + daily_reset_mode: dailyResetMode.data, + daily_reset_time: dailyResetTime.data, + limit_weekly_usd: limitWeeklyUsd.data, + limit_monthly_usd: limitMonthlyUsd.data, + limit_total_usd: limitTotalUsd.data, + limit_concurrent_sessions: limitConcurrentSessions.data, + // Circuit Breaker + circuit_breaker_failure_threshold: cbFailureThreshold.data, + circuit_breaker_open_duration: cbOpenDuration.data, + circuit_breaker_half_open_success_threshold: cbHalfOpenSuccess.data, + max_retry_attempts: maxRetryAttempts.data, + // Network + proxy_url: proxyUrl.data, + proxy_fallback_to_direct: proxyFallbackToDirect.data, + first_byte_timeout_streaming_ms: firstByteTimeout.data, + streaming_idle_timeout_ms: streamingIdleTimeout.data, + request_timeout_non_streaming_ms: requestTimeoutNonStreaming.data, + // MCP + mcp_passthrough_type: mcpPassthroughType.data, + mcp_passthrough_url: mcpPassthroughUrl.data, + }, + }; +} + +function applyPatchField( + updates: ProviderBatchApplyUpdates, + field: ProviderBatchPatchField, + patch: ProviderPatchOperation +): ProviderPatchResult { + if (patch.mode === "no_change") { + return { ok: true, data: undefined }; + } + + if (patch.mode === "set") { + switch (field) { + case "is_enabled": + updates.is_enabled = patch.value as ProviderBatchApplyUpdates["is_enabled"]; + return { ok: true, data: undefined }; + case "priority": + updates.priority = patch.value as ProviderBatchApplyUpdates["priority"]; + return { ok: true, data: undefined }; + case "weight": + updates.weight = patch.value as ProviderBatchApplyUpdates["weight"]; + return { ok: true, data: undefined }; + case "cost_multiplier": + updates.cost_multiplier = patch.value as ProviderBatchApplyUpdates["cost_multiplier"]; + return { ok: true, data: undefined }; + case "group_tag": + updates.group_tag = patch.value as ProviderBatchApplyUpdates["group_tag"]; + return { ok: true, data: undefined }; + case "model_redirects": + updates.model_redirects = patch.value as ProviderBatchApplyUpdates["model_redirects"]; + return { ok: true, data: undefined }; + case "allowed_models": + updates.allowed_models = + (patch.value as string[]).length > 0 + ? (patch.value as ProviderBatchApplyUpdates["allowed_models"]) + : null; + return { ok: true, data: undefined }; + case "anthropic_thinking_budget_preference": + updates.anthropic_thinking_budget_preference = + patch.value as ProviderBatchApplyUpdates["anthropic_thinking_budget_preference"]; + return { ok: true, data: undefined }; + case "anthropic_adaptive_thinking": + updates.anthropic_adaptive_thinking = + patch.value as ProviderBatchApplyUpdates["anthropic_adaptive_thinking"]; + return { ok: true, data: undefined }; + // Routing + case "preserve_client_ip": + updates.preserve_client_ip = patch.value as ProviderBatchApplyUpdates["preserve_client_ip"]; + return { ok: true, data: undefined }; + case "group_priorities": + updates.group_priorities = patch.value as ProviderBatchApplyUpdates["group_priorities"]; + return { ok: true, data: undefined }; + case "cache_ttl_preference": + updates.cache_ttl_preference = + patch.value as ProviderBatchApplyUpdates["cache_ttl_preference"]; + return { ok: true, data: undefined }; + case "swap_cache_ttl_billing": + updates.swap_cache_ttl_billing = + patch.value as ProviderBatchApplyUpdates["swap_cache_ttl_billing"]; + return { ok: true, data: undefined }; + case "context_1m_preference": + updates.context_1m_preference = + patch.value as ProviderBatchApplyUpdates["context_1m_preference"]; + return { ok: true, data: undefined }; + case "codex_reasoning_effort_preference": + updates.codex_reasoning_effort_preference = + patch.value as ProviderBatchApplyUpdates["codex_reasoning_effort_preference"]; + return { ok: true, data: undefined }; + case "codex_reasoning_summary_preference": + updates.codex_reasoning_summary_preference = + patch.value as ProviderBatchApplyUpdates["codex_reasoning_summary_preference"]; + return { ok: true, data: undefined }; + case "codex_text_verbosity_preference": + updates.codex_text_verbosity_preference = + patch.value as ProviderBatchApplyUpdates["codex_text_verbosity_preference"]; + return { ok: true, data: undefined }; + case "codex_parallel_tool_calls_preference": + updates.codex_parallel_tool_calls_preference = + patch.value as ProviderBatchApplyUpdates["codex_parallel_tool_calls_preference"]; + return { ok: true, data: undefined }; + case "anthropic_max_tokens_preference": + updates.anthropic_max_tokens_preference = + patch.value as ProviderBatchApplyUpdates["anthropic_max_tokens_preference"]; + return { ok: true, data: undefined }; + case "gemini_google_search_preference": + updates.gemini_google_search_preference = + patch.value as ProviderBatchApplyUpdates["gemini_google_search_preference"]; + return { ok: true, data: undefined }; + // Rate Limit + case "limit_5h_usd": + updates.limit_5h_usd = patch.value as ProviderBatchApplyUpdates["limit_5h_usd"]; + return { ok: true, data: undefined }; + case "limit_daily_usd": + updates.limit_daily_usd = patch.value as ProviderBatchApplyUpdates["limit_daily_usd"]; + return { ok: true, data: undefined }; + case "daily_reset_mode": + updates.daily_reset_mode = patch.value as ProviderBatchApplyUpdates["daily_reset_mode"]; + return { ok: true, data: undefined }; + case "daily_reset_time": + updates.daily_reset_time = patch.value as ProviderBatchApplyUpdates["daily_reset_time"]; + return { ok: true, data: undefined }; + case "limit_weekly_usd": + updates.limit_weekly_usd = patch.value as ProviderBatchApplyUpdates["limit_weekly_usd"]; + return { ok: true, data: undefined }; + case "limit_monthly_usd": + updates.limit_monthly_usd = patch.value as ProviderBatchApplyUpdates["limit_monthly_usd"]; + return { ok: true, data: undefined }; + case "limit_total_usd": + updates.limit_total_usd = patch.value as ProviderBatchApplyUpdates["limit_total_usd"]; + return { ok: true, data: undefined }; + case "limit_concurrent_sessions": + updates.limit_concurrent_sessions = + patch.value as ProviderBatchApplyUpdates["limit_concurrent_sessions"]; + return { ok: true, data: undefined }; + // Circuit Breaker + case "circuit_breaker_failure_threshold": + updates.circuit_breaker_failure_threshold = + patch.value as ProviderBatchApplyUpdates["circuit_breaker_failure_threshold"]; + return { ok: true, data: undefined }; + case "circuit_breaker_open_duration": + updates.circuit_breaker_open_duration = + patch.value as ProviderBatchApplyUpdates["circuit_breaker_open_duration"]; + return { ok: true, data: undefined }; + case "circuit_breaker_half_open_success_threshold": + updates.circuit_breaker_half_open_success_threshold = + patch.value as ProviderBatchApplyUpdates["circuit_breaker_half_open_success_threshold"]; + return { ok: true, data: undefined }; + case "max_retry_attempts": + updates.max_retry_attempts = patch.value as ProviderBatchApplyUpdates["max_retry_attempts"]; + return { ok: true, data: undefined }; + // Network + case "proxy_url": + updates.proxy_url = patch.value as ProviderBatchApplyUpdates["proxy_url"]; + return { ok: true, data: undefined }; + case "proxy_fallback_to_direct": + updates.proxy_fallback_to_direct = + patch.value as ProviderBatchApplyUpdates["proxy_fallback_to_direct"]; + return { ok: true, data: undefined }; + case "first_byte_timeout_streaming_ms": + updates.first_byte_timeout_streaming_ms = + patch.value as ProviderBatchApplyUpdates["first_byte_timeout_streaming_ms"]; + return { ok: true, data: undefined }; + case "streaming_idle_timeout_ms": + updates.streaming_idle_timeout_ms = + patch.value as ProviderBatchApplyUpdates["streaming_idle_timeout_ms"]; + return { ok: true, data: undefined }; + case "request_timeout_non_streaming_ms": + updates.request_timeout_non_streaming_ms = + patch.value as ProviderBatchApplyUpdates["request_timeout_non_streaming_ms"]; + return { ok: true, data: undefined }; + // MCP + case "mcp_passthrough_type": + updates.mcp_passthrough_type = + patch.value as ProviderBatchApplyUpdates["mcp_passthrough_type"]; + return { ok: true, data: undefined }; + case "mcp_passthrough_url": + updates.mcp_passthrough_url = + patch.value as ProviderBatchApplyUpdates["mcp_passthrough_url"]; + return { ok: true, data: undefined }; + default: + return createInvalidPatchShapeError(field, "Unsupported patch field"); + } + } + + // clear mode + switch (field) { + case "group_tag": + updates.group_tag = null; + return { ok: true, data: undefined }; + case "model_redirects": + updates.model_redirects = null; + return { ok: true, data: undefined }; + case "allowed_models": + updates.allowed_models = null; + return { ok: true, data: undefined }; + case "anthropic_thinking_budget_preference": + updates.anthropic_thinking_budget_preference = "inherit"; + return { ok: true, data: undefined }; + case "anthropic_adaptive_thinking": + updates.anthropic_adaptive_thinking = null; + return { ok: true, data: undefined }; + // Routing - preference fields clear to "inherit" + case "cache_ttl_preference": + updates.cache_ttl_preference = "inherit"; + return { ok: true, data: undefined }; + case "context_1m_preference": + updates.context_1m_preference = "inherit"; + return { ok: true, data: undefined }; + case "codex_reasoning_effort_preference": + updates.codex_reasoning_effort_preference = "inherit"; + return { ok: true, data: undefined }; + case "codex_reasoning_summary_preference": + updates.codex_reasoning_summary_preference = "inherit"; + return { ok: true, data: undefined }; + case "codex_text_verbosity_preference": + updates.codex_text_verbosity_preference = "inherit"; + return { ok: true, data: undefined }; + case "codex_parallel_tool_calls_preference": + updates.codex_parallel_tool_calls_preference = "inherit"; + return { ok: true, data: undefined }; + case "anthropic_max_tokens_preference": + updates.anthropic_max_tokens_preference = "inherit"; + return { ok: true, data: undefined }; + case "gemini_google_search_preference": + updates.gemini_google_search_preference = "inherit"; + return { ok: true, data: undefined }; + // Routing - nullable fields clear to null + case "group_priorities": + updates.group_priorities = null; + return { ok: true, data: undefined }; + // Rate Limit - nullable number fields clear to null + case "limit_5h_usd": + updates.limit_5h_usd = null; + return { ok: true, data: undefined }; + case "limit_daily_usd": + updates.limit_daily_usd = null; + return { ok: true, data: undefined }; + case "limit_weekly_usd": + updates.limit_weekly_usd = null; + return { ok: true, data: undefined }; + case "limit_monthly_usd": + updates.limit_monthly_usd = null; + return { ok: true, data: undefined }; + case "limit_total_usd": + updates.limit_total_usd = null; + return { ok: true, data: undefined }; + // Circuit Breaker + case "max_retry_attempts": + updates.max_retry_attempts = null; + return { ok: true, data: undefined }; + // Network + case "proxy_url": + updates.proxy_url = null; + return { ok: true, data: undefined }; + // MCP + case "mcp_passthrough_url": + updates.mcp_passthrough_url = null; + return { ok: true, data: undefined }; + default: + return createInvalidPatchShapeError(field, "clear mode is not supported for this field"); + } +} + +export function buildProviderBatchApplyUpdates( + patch: ProviderBatchPatch +): ProviderPatchResult { + const updates: ProviderBatchApplyUpdates = {}; + + const operations: Array<[ProviderBatchPatchField, ProviderPatchOperation]> = [ + ["is_enabled", patch.is_enabled], + ["priority", patch.priority], + ["weight", patch.weight], + ["cost_multiplier", patch.cost_multiplier], + ["group_tag", patch.group_tag], + ["model_redirects", patch.model_redirects], + ["allowed_models", patch.allowed_models], + ["anthropic_thinking_budget_preference", patch.anthropic_thinking_budget_preference], + ["anthropic_adaptive_thinking", patch.anthropic_adaptive_thinking], + // Routing + ["preserve_client_ip", patch.preserve_client_ip], + ["group_priorities", patch.group_priorities], + ["cache_ttl_preference", patch.cache_ttl_preference], + ["swap_cache_ttl_billing", patch.swap_cache_ttl_billing], + ["context_1m_preference", patch.context_1m_preference], + ["codex_reasoning_effort_preference", patch.codex_reasoning_effort_preference], + ["codex_reasoning_summary_preference", patch.codex_reasoning_summary_preference], + ["codex_text_verbosity_preference", patch.codex_text_verbosity_preference], + ["codex_parallel_tool_calls_preference", patch.codex_parallel_tool_calls_preference], + ["anthropic_max_tokens_preference", patch.anthropic_max_tokens_preference], + ["gemini_google_search_preference", patch.gemini_google_search_preference], + // Rate Limit + ["limit_5h_usd", patch.limit_5h_usd], + ["limit_daily_usd", patch.limit_daily_usd], + ["daily_reset_mode", patch.daily_reset_mode], + ["daily_reset_time", patch.daily_reset_time], + ["limit_weekly_usd", patch.limit_weekly_usd], + ["limit_monthly_usd", patch.limit_monthly_usd], + ["limit_total_usd", patch.limit_total_usd], + ["limit_concurrent_sessions", patch.limit_concurrent_sessions], + // Circuit Breaker + ["circuit_breaker_failure_threshold", patch.circuit_breaker_failure_threshold], + ["circuit_breaker_open_duration", patch.circuit_breaker_open_duration], + [ + "circuit_breaker_half_open_success_threshold", + patch.circuit_breaker_half_open_success_threshold, + ], + ["max_retry_attempts", patch.max_retry_attempts], + // Network + ["proxy_url", patch.proxy_url], + ["proxy_fallback_to_direct", patch.proxy_fallback_to_direct], + ["first_byte_timeout_streaming_ms", patch.first_byte_timeout_streaming_ms], + ["streaming_idle_timeout_ms", patch.streaming_idle_timeout_ms], + ["request_timeout_non_streaming_ms", patch.request_timeout_non_streaming_ms], + // MCP + ["mcp_passthrough_type", patch.mcp_passthrough_type], + ["mcp_passthrough_url", patch.mcp_passthrough_url], + ]; + + for (const [field, operation] of operations) { + const applyResult = applyPatchField(updates, field, operation); + if (!applyResult.ok) { + return applyResult; + } + } + + return { ok: true, data: updates }; +} + +export function hasProviderBatchPatchChanges(patch: ProviderBatchPatch): boolean { + return ( + patch.is_enabled.mode !== "no_change" || + patch.priority.mode !== "no_change" || + patch.weight.mode !== "no_change" || + patch.cost_multiplier.mode !== "no_change" || + patch.group_tag.mode !== "no_change" || + patch.model_redirects.mode !== "no_change" || + patch.allowed_models.mode !== "no_change" || + patch.anthropic_thinking_budget_preference.mode !== "no_change" || + patch.anthropic_adaptive_thinking.mode !== "no_change" || + // Routing + patch.preserve_client_ip.mode !== "no_change" || + patch.group_priorities.mode !== "no_change" || + patch.cache_ttl_preference.mode !== "no_change" || + patch.swap_cache_ttl_billing.mode !== "no_change" || + patch.context_1m_preference.mode !== "no_change" || + patch.codex_reasoning_effort_preference.mode !== "no_change" || + patch.codex_reasoning_summary_preference.mode !== "no_change" || + patch.codex_text_verbosity_preference.mode !== "no_change" || + patch.codex_parallel_tool_calls_preference.mode !== "no_change" || + patch.anthropic_max_tokens_preference.mode !== "no_change" || + patch.gemini_google_search_preference.mode !== "no_change" || + // Rate Limit + patch.limit_5h_usd.mode !== "no_change" || + patch.limit_daily_usd.mode !== "no_change" || + patch.daily_reset_mode.mode !== "no_change" || + patch.daily_reset_time.mode !== "no_change" || + patch.limit_weekly_usd.mode !== "no_change" || + patch.limit_monthly_usd.mode !== "no_change" || + patch.limit_total_usd.mode !== "no_change" || + patch.limit_concurrent_sessions.mode !== "no_change" || + // Circuit Breaker + patch.circuit_breaker_failure_threshold.mode !== "no_change" || + patch.circuit_breaker_open_duration.mode !== "no_change" || + patch.circuit_breaker_half_open_success_threshold.mode !== "no_change" || + patch.max_retry_attempts.mode !== "no_change" || + // Network + patch.proxy_url.mode !== "no_change" || + patch.proxy_fallback_to_direct.mode !== "no_change" || + patch.first_byte_timeout_streaming_ms.mode !== "no_change" || + patch.streaming_idle_timeout_ms.mode !== "no_change" || + patch.request_timeout_non_streaming_ms.mode !== "no_change" || + // MCP + patch.mcp_passthrough_type.mode !== "no_change" || + patch.mcp_passthrough_url.mode !== "no_change" + ); +} + +export function prepareProviderBatchApplyUpdates( + draft: unknown +): ProviderPatchResult { + const normalized = normalizeProviderBatchPatchDraft(draft); + if (!normalized.ok) { + return normalized; + } + + return buildProviderBatchApplyUpdates(normalized.data); +} diff --git a/src/lib/providers/undo-store.ts b/src/lib/providers/undo-store.ts new file mode 100644 index 000000000..db4261013 --- /dev/null +++ b/src/lib/providers/undo-store.ts @@ -0,0 +1,81 @@ +import "server-only"; + +import { logger } from "@/lib/logger"; +import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes"; +import { RedisKVStore } from "@/lib/redis/redis-kv-store"; + +const UNDO_SNAPSHOT_TTL_SECONDS = 30; + +export interface UndoSnapshot { + operationId: string; + operationType: "batch_edit" | "single_edit" | "single_delete"; + preimage: unknown; + providerIds: number[]; + createdAt: string; +} + +export interface StoreUndoResult { + undoAvailable: boolean; + undoToken?: string; + expiresAt?: string; +} + +export type ConsumeUndoResult = + | { + ok: true; + snapshot: UndoSnapshot; + } + | { + ok: false; + code: "UNDO_EXPIRED" | "UNDO_CONFLICT"; + }; + +const store = new RedisKVStore({ + prefix: "cch:prov:undo:", + defaultTtlSeconds: UNDO_SNAPSHOT_TTL_SECONDS, +}); + +export async function storeUndoSnapshot(snapshot: UndoSnapshot): Promise { + try { + const undoToken = crypto.randomUUID(); + const expiresAtMs = Date.now() + UNDO_SNAPSHOT_TTL_SECONDS * 1000; + + const stored = await store.set(undoToken, snapshot); + if (!stored) { + logger.warn("[undo-store] Failed to persist undo snapshot; undo unavailable", { + operationId: snapshot.operationId, + }); + return { undoAvailable: false }; + } + + return { + undoAvailable: true, + undoToken, + expiresAt: new Date(expiresAtMs).toISOString(), + }; + } catch { + return { undoAvailable: false }; + } +} + +export async function consumeUndoToken(token: string): Promise { + try { + const snapshot = await store.getAndDelete(token); + if (!snapshot) { + return { + ok: false, + code: PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED, + }; + } + + return { + ok: true, + snapshot, + }; + } catch { + return { + ok: false, + code: PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED, + }; + } +} diff --git a/src/lib/redis/redis-kv-store.ts b/src/lib/redis/redis-kv-store.ts new file mode 100644 index 000000000..bd3787d80 --- /dev/null +++ b/src/lib/redis/redis-kv-store.ts @@ -0,0 +1,142 @@ +import "server-only"; + +import type Redis from "ioredis"; +import { logger } from "@/lib/logger"; +import { getRedisClient } from "./client"; + +type RedisKVClient = Pick & { + // Redis EVAL for Lua scripts (atomic getAndDelete) + eval(...args: [script: string, numkeys: number, ...keys: string[]]): Promise; +}; + +export interface RedisKVStoreOptions { + prefix: string; + defaultTtlSeconds: number; + redisClient?: RedisKVClient | null; +} + +function toLogError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +// Atomic GET + DEL via Lua script -- prevents TOCTOU race where two concurrent +// callers both GET the same single-use token before either DELetes it. +const LUA_GET_AND_DEL = ` +local val = redis.call('GET', KEYS[1]) +if val then redis.call('DEL', KEYS[1]) end +return val`; + +export class RedisKVStore { + private readonly prefix: string; + private readonly defaultTtlSeconds: number; + private readonly injectedClient?: RedisKVClient | null; + + constructor(options: RedisKVStoreOptions) { + this.prefix = options.prefix; + this.defaultTtlSeconds = options.defaultTtlSeconds; + this.injectedClient = options.redisClient; + } + + private resolveRedisClient(): RedisKVClient | null { + if (this.injectedClient !== undefined) { + return this.injectedClient; + } + return getRedisClient({ allowWhenRateLimitDisabled: true }) as RedisKVClient | null; + } + + private getReadyRedis(): RedisKVClient | null { + const redis = this.resolveRedisClient(); + if (!redis || redis.status !== "ready") { + return null; + } + return redis; + } + + private buildKey(key: string): string { + return `${this.prefix}${key}`; + } + + async set(key: string, value: T, ttlSeconds?: number): Promise { + const redis = this.getReadyRedis(); + if (!redis) { + return false; + } + + const ttl = ttlSeconds ?? this.defaultTtlSeconds; + try { + await redis.setex(this.buildKey(key), ttl, JSON.stringify(value)); + return true; + } catch (error) { + logger.error("[RedisKVStore] Failed to set", { + error: toLogError(error), + prefix: this.prefix, + key, + }); + return false; + } + } + + async get(key: string): Promise { + const redis = this.getReadyRedis(); + if (!redis) { + return null; + } + + try { + const raw = await redis.get(this.buildKey(key)); + if (!raw) { + return null; + } + return JSON.parse(raw) as T; + } catch (error) { + logger.error("[RedisKVStore] Failed to get", { + error: toLogError(error), + prefix: this.prefix, + key, + }); + return null; + } + } + + async getAndDelete(key: string): Promise { + const redis = this.getReadyRedis(); + if (!redis) { + return null; + } + + const fullKey = this.buildKey(key); + try { + const raw = (await redis.eval(LUA_GET_AND_DEL, 1, fullKey)) as string | null; + if (!raw) { + return null; + } + return JSON.parse(raw) as T; + } catch (error) { + logger.error("[RedisKVStore] Failed to getAndDelete", { + error: toLogError(error), + prefix: this.prefix, + key, + }); + return null; + } + } + + async delete(key: string): Promise { + const redis = this.getReadyRedis(); + if (!redis) { + return false; + } + + try { + const deleted = await redis.del(this.buildKey(key)); + return deleted > 0; + } catch (error) { + logger.error("[RedisKVStore] Failed to delete", { + error: toLogError(error), + prefix: this.prefix, + key, + }); + return false; + } + } +} diff --git a/src/lib/security/auth-response-headers.ts b/src/lib/security/auth-response-headers.ts new file mode 100644 index 000000000..a9a7ef615 --- /dev/null +++ b/src/lib/security/auth-response-headers.ts @@ -0,0 +1,22 @@ +import type { NextResponse } from "next/server"; +import { withNoStoreHeaders } from "@/lib/auth"; +import { getEnvConfig } from "@/lib/config/env.schema"; +import { buildSecurityHeaders } from "@/lib/security/security-headers"; + +export function applySecurityHeaders(response: NextResponse): NextResponse { + const env = getEnvConfig(); + const headers = buildSecurityHeaders({ + enableHsts: env.ENABLE_SECURE_COOKIES, + cspMode: "report-only", + }); + + for (const [key, value] of Object.entries(headers)) { + response.headers.set(key, value); + } + + return response; +} + +export function withAuthResponseHeaders(response: NextResponse): NextResponse { + return applySecurityHeaders(withNoStoreHeaders(response)); +} diff --git a/src/lib/security/constant-time-compare.ts b/src/lib/security/constant-time-compare.ts new file mode 100644 index 000000000..d35b9aa61 --- /dev/null +++ b/src/lib/security/constant-time-compare.ts @@ -0,0 +1,27 @@ +import { timingSafeEqual } from "node:crypto"; + +/** + * Constant-time string comparison to prevent timing attacks. + * + * Uses crypto.timingSafeEqual internally. When lengths differ, a dummy + * comparison is still performed so the total CPU time does not leak + * length information. + */ +export function constantTimeEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a, "utf-8"); + const bufB = Buffer.from(b, "utf-8"); + + if (bufA.length !== bufB.length) { + // Pad both to the same length so the dummy comparison time does not + // leak which side is shorter (attacker may control either one). + const padLen = Math.max(bufA.length, bufB.length); + const padA = Buffer.alloc(padLen); + const padB = Buffer.alloc(padLen); + bufA.copy(padA); + bufB.copy(padB); + timingSafeEqual(padA, padB); + return false; + } + + return timingSafeEqual(bufA, bufB); +} diff --git a/src/lib/security/csrf-origin-guard.ts b/src/lib/security/csrf-origin-guard.ts new file mode 100644 index 000000000..90e1f9fe2 --- /dev/null +++ b/src/lib/security/csrf-origin-guard.ts @@ -0,0 +1,66 @@ +export interface CsrfGuardConfig { + allowedOrigins: string[]; + allowSameOrigin: boolean; + enforceInDevelopment: boolean; +} + +export interface CsrfGuardResult { + allowed: boolean; + reason?: string; +} + +export interface CsrfGuardRequest { + headers: { + get(name: string): string | null; + }; +} + +function normalizeOrigin(origin: string): string { + return origin.trim().toLowerCase(); +} + +function isDevelopmentRuntime(): boolean { + if (typeof process === "undefined") return false; + return process.env.NODE_ENV === "development"; +} + +export function createCsrfOriginGuard(config: CsrfGuardConfig) { + const allowSameOrigin = config.allowSameOrigin ?? true; + const enforceInDevelopment = config.enforceInDevelopment ?? false; + const allowedOrigins = new Set( + (config.allowedOrigins ?? []).map(normalizeOrigin).filter((origin) => origin.length > 0) + ); + + return { + check(request: CsrfGuardRequest): CsrfGuardResult { + if (isDevelopmentRuntime() && !enforceInDevelopment) { + return { allowed: true, reason: "csrf_guard_bypassed_in_development" }; + } + + const fetchSite = request.headers.get("sec-fetch-site")?.trim().toLowerCase() ?? null; + if (fetchSite === "same-origin" && allowSameOrigin) { + return { allowed: true }; + } + + const originValue = request.headers.get("origin"); + const origin = originValue ? normalizeOrigin(originValue) : null; + + if (!origin) { + if (fetchSite === "cross-site") { + return { + allowed: false, + reason: "Cross-site request blocked: missing Origin header", + }; + } + + return { allowed: true }; + } + + if (allowedOrigins.has(origin)) { + return { allowed: true }; + } + + return { allowed: false, reason: `Origin ${origin} not in allowlist` }; + }, + }; +} diff --git a/src/lib/security/login-abuse-policy.ts b/src/lib/security/login-abuse-policy.ts new file mode 100644 index 000000000..b0ea9bcc8 --- /dev/null +++ b/src/lib/security/login-abuse-policy.ts @@ -0,0 +1,249 @@ +export interface LoginAbuseConfig { + maxAttemptsPerIp: number; + maxAttemptsPerKey: number; + windowSeconds: number; + lockoutSeconds: number; +} + +export interface LoginAbuseDecision { + allowed: boolean; + retryAfterSeconds?: number; + reason?: string; +} + +export const DEFAULT_LOGIN_ABUSE_CONFIG: LoginAbuseConfig = { + maxAttemptsPerIp: 10, + maxAttemptsPerKey: 10, + windowSeconds: 300, + lockoutSeconds: 900, +}; + +type AttemptRecord = { + count: number; + firstAttempt: number; + lockedUntil?: number; +}; + +const MAX_TRACKED_ENTRIES = 10_000; +const SWEEP_INTERVAL_MS = 60_000; + +export class LoginAbusePolicy { + private attempts = new Map(); + private config: LoginAbuseConfig; + private lastSweepAt = 0; + + constructor(config?: Partial) { + this.config = { + ...DEFAULT_LOGIN_ABUSE_CONFIG, + ...config, + }; + } + + private sweepStaleEntries(now: number): void { + if (now - this.lastSweepAt < SWEEP_INTERVAL_MS) { + return; + } + this.lastSweepAt = now; + + for (const [key, record] of this.attempts) { + if (record.lockedUntil != null) { + if (record.lockedUntil <= now) { + this.attempts.delete(key); + } + } else if (this.isWindowExpired(record, now)) { + this.attempts.delete(key); + } + } + + if (this.attempts.size > MAX_TRACKED_ENTRIES) { + const excess = this.attempts.size - MAX_TRACKED_ENTRIES; + const iterator = this.attempts.keys(); + for (let i = 0; i < excess; i++) { + const next = iterator.next(); + if (next.done) break; + this.attempts.delete(next.value); + } + } + } + + check(ip: string, key?: string): LoginAbuseDecision { + const now = Date.now(); + this.sweepStaleEntries(now); + + const ipDecision = this.checkScope({ + scopeKey: this.toIpScope(ip), + threshold: this.config.maxAttemptsPerIp, + reason: "ip_rate_limited", + now, + }); + + if (!ipDecision.allowed || !key) { + return ipDecision; + } + + return this.checkScope({ + scopeKey: this.toKeyScope(key), + threshold: this.config.maxAttemptsPerKey, + reason: "key_rate_limited", + now, + }); + } + + recordFailure(ip: string, key?: string): void { + const now = Date.now(); + + this.recordFailureForScope({ + scopeKey: this.toIpScope(ip), + threshold: this.config.maxAttemptsPerIp, + now, + }); + + if (!key) { + return; + } + + this.recordFailureForScope({ + scopeKey: this.toKeyScope(key), + threshold: this.config.maxAttemptsPerKey, + now, + }); + } + + recordSuccess(ip: string, key?: string): void { + this.reset(ip, key); + } + + reset(ip: string, key?: string): void { + this.attempts.delete(this.toIpScope(ip)); + + if (!key) { + return; + } + + this.attempts.delete(this.toKeyScope(key)); + } + + private checkScope(params: { + scopeKey: string; + threshold: number; + reason: string; + now: number; + }): LoginAbuseDecision { + const { scopeKey, threshold, reason, now } = params; + const record = this.attempts.get(scopeKey); + + if (!record) { + return { allowed: true }; + } + + if (record.lockedUntil != null) { + if (record.lockedUntil > now) { + return { + allowed: false, + retryAfterSeconds: this.calculateRetryAfterSeconds(record.lockedUntil, now), + reason, + }; + } + + this.attempts.delete(scopeKey); + return { allowed: true }; + } + + if (this.isWindowExpired(record, now)) { + this.attempts.delete(scopeKey); + return { allowed: true }; + } + + if (record.count >= threshold) { + const lockedUntil = now + this.config.lockoutSeconds * 1000; + // LRU bump: delete + re-insert so locked entries survive eviction + this.attempts.delete(scopeKey); + this.attempts.set(scopeKey, { ...record, lockedUntil }); + return { + allowed: false, + retryAfterSeconds: this.calculateRetryAfterSeconds(lockedUntil, now), + reason, + }; + } + + // LRU bump: delete + re-insert moves entry to end of Map iteration order, + // so the eviction loop in sweepStaleEntries removes least-recently-used first + this.attempts.delete(scopeKey); + this.attempts.set(scopeKey, record); + + return { allowed: true }; + } + + private recordFailureForScope(params: { + scopeKey: string; + threshold: number; + now: number; + }): void { + const { scopeKey, threshold, now } = params; + const record = this.attempts.get(scopeKey); + + if (!record) { + this.attempts.set(scopeKey, this.createFirstRecord(now, threshold)); + return; + } + + if (record.lockedUntil != null) { + if (record.lockedUntil > now) { + return; + } + + this.attempts.delete(scopeKey); + this.attempts.set(scopeKey, this.createFirstRecord(now, threshold)); + return; + } + + if (this.isWindowExpired(record, now)) { + this.attempts.delete(scopeKey); + this.attempts.set(scopeKey, this.createFirstRecord(now, threshold)); + return; + } + + const nextCount = record.count + 1; + const nextRecord: AttemptRecord = { + count: nextCount, + firstAttempt: record.firstAttempt, + }; + + if (nextCount >= threshold) { + nextRecord.lockedUntil = now + this.config.lockoutSeconds * 1000; + } + + // LRU bump: delete + re-insert moves entry to end of iteration order + this.attempts.delete(scopeKey); + this.attempts.set(scopeKey, nextRecord); + } + + private isWindowExpired(record: AttemptRecord, now: number): boolean { + return now - record.firstAttempt >= this.config.windowSeconds * 1000; + } + + private calculateRetryAfterSeconds(lockedUntil: number, now: number): number { + return Math.max(0, Math.ceil((lockedUntil - now) / 1000)); + } + + private createFirstRecord(now: number, threshold: number): AttemptRecord { + const firstRecord: AttemptRecord = { + count: 1, + firstAttempt: now, + }; + + if (threshold <= 1) { + firstRecord.lockedUntil = now + this.config.lockoutSeconds * 1000; + } + + return firstRecord; + } + + private toIpScope(ip: string): string { + return `ip:${ip}`; + } + + private toKeyScope(key: string): string { + return `key:${key}`; + } +} diff --git a/src/lib/security/security-headers.ts b/src/lib/security/security-headers.ts new file mode 100644 index 000000000..93c3ec44b --- /dev/null +++ b/src/lib/security/security-headers.ts @@ -0,0 +1,63 @@ +export interface SecurityHeadersConfig { + enableHsts: boolean; + cspMode: "report-only" | "enforce" | "disabled"; + cspReportUri?: string; + hstsMaxAge: number; + frameOptions: "DENY" | "SAMEORIGIN"; +} + +export const DEFAULT_SECURITY_HEADERS_CONFIG: SecurityHeadersConfig = { + enableHsts: false, + cspMode: "report-only", + hstsMaxAge: 31536000, + frameOptions: "DENY", +}; + +function isValidCspReportUri(uri: string): boolean { + const trimmed = uri.trim(); + if (!trimmed || trimmed.includes(";") || trimmed.includes(",") || /\s/.test(trimmed)) { + return false; + } + try { + new URL(trimmed); + return true; + } catch { + return false; + } +} + +const DEFAULT_CSP_VALUE = + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' " + + "'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; font-src 'self' data:; " + + "frame-ancestors 'none'"; + +export function buildSecurityHeaders( + config?: Partial +): Record { + const merged = { ...DEFAULT_SECURITY_HEADERS_CONFIG, ...config }; + const headers: Record = {}; + + headers["X-Content-Type-Options"] = "nosniff"; + headers["X-Frame-Options"] = merged.frameOptions; + headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + headers["X-DNS-Prefetch-Control"] = "off"; + + if (merged.enableHsts) { + headers["Strict-Transport-Security"] = `max-age=${merged.hstsMaxAge}; includeSubDomains`; + } + + if (merged.cspMode !== "disabled") { + const headerName = + merged.cspMode === "report-only" + ? "Content-Security-Policy-Report-Only" + : "Content-Security-Policy"; + + if (merged.cspReportUri && isValidCspReportUri(merged.cspReportUri)) { + headers[headerName] = `${DEFAULT_CSP_VALUE}; report-uri ${merged.cspReportUri}`; + } else { + headers[headerName] = DEFAULT_CSP_VALUE; + } + } + + return headers; +} diff --git a/src/proxy.ts b/src/proxy.ts index 9157a1ceb..05cae00ac 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from "next/server"; import createMiddleware from "next-intl/middleware"; import type { Locale } from "@/i18n/config"; import { routing } from "@/i18n/routing"; -import { validateKey } from "@/lib/auth"; +import { AUTH_COOKIE_NAME } from "@/lib/auth"; import { isDevelopment } from "@/lib/config/env.schema"; import { logger } from "@/lib/logger"; @@ -10,16 +10,12 @@ import { logger } from "@/lib/logger"; // Note: These paths will be automatically prefixed with locale by next-intl middleware const PUBLIC_PATH_PATTERNS = ["/login", "/usage-doc", "/api/auth/login", "/api/auth/logout"]; -// Paths that allow read-only access (for canLoginWebUi=false keys) -// These paths bypass the canLoginWebUi check in validateKey -const READ_ONLY_PATH_PATTERNS = ["/my-usage"]; - const API_PROXY_PATH = "/v1"; // Create next-intl middleware for locale detection and routing const intlMiddleware = createMiddleware(routing); -async function proxyHandler(request: NextRequest) { +function proxyHandler(request: NextRequest) { const method = request.method; const pathname = request.nextUrl.pathname; @@ -61,13 +57,12 @@ async function proxyHandler(request: NextRequest) { return localeResponse; } - // Check if current path allows read-only access (for canLoginWebUi=false keys) - const isReadOnlyPath = READ_ONLY_PATH_PATTERNS.some( - (pattern) => pathWithoutLocale === pattern || pathWithoutLocale.startsWith(`${pattern}/`) - ); - - // Check authentication for protected routes - const authToken = request.cookies.get("auth-token"); + // Check authentication for protected routes (cookie existence only). + // Full session validation (Redis lookup, key permissions, expiry) is handled + // by downstream layouts (dashboard/layout.tsx, etc.) which run in Node.js + // runtime with guaranteed Redis/DB access. This avoids a death loop where + // the proxy deletes the cookie on transient validation failures. + const authToken = request.cookies.get(AUTH_COOKIE_NAME); if (!authToken) { // Not authenticated, redirect to login page @@ -79,21 +74,7 @@ async function proxyHandler(request: NextRequest) { return NextResponse.redirect(url); } - // Validate key permissions (canLoginWebUi, isEnabled, expiresAt, etc.) - const session = await validateKey(authToken.value, { allowReadOnlyAccess: isReadOnlyPath }); - if (!session) { - // Invalid key or insufficient permissions, clear cookie and redirect to login - const url = request.nextUrl.clone(); - // Preserve locale in redirect - const locale = isLocaleInPath ? potentialLocale : routing.defaultLocale; - url.pathname = `/${locale}/login`; - url.searchParams.set("from", pathWithoutLocale || "/dashboard"); - const response = NextResponse.redirect(url); - response.cookies.delete("auth-token"); - return response; - } - - // Authentication passed, return locale response + // Cookie exists - pass through to layout for full validation return localeResponse; } diff --git a/src/repository/index.ts b/src/repository/index.ts index e03e1b7e0..a4f28f4fa 100644 --- a/src/repository/index.ts +++ b/src/repository/index.ts @@ -42,6 +42,8 @@ export { findProviderById, findProviderList, getDistinctProviderGroups, + restoreProvider, + restoreProvidersBatch, updateProvider, } from "./provider"; export type { ProviderEndpointProbeTarget } from "./provider-endpoints"; diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 4d6b24fad..5c24866e2 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -7,7 +7,12 @@ import { getCachedProviders } from "@/lib/cache/provider-cache"; import { resetEndpointCircuit } from "@/lib/endpoint-circuit-breaker"; import { logger } from "@/lib/logger"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; -import type { CreateProviderData, Provider, UpdateProviderData } from "@/types/provider"; +import type { + AnthropicAdaptiveThinkingConfig, + CreateProviderData, + Provider, + UpdateProviderData, +} from "@/types/provider"; import { toProvider } from "./_shared/transformers"; import { ensureProviderEndpointExistsForUrl, @@ -16,6 +21,150 @@ import { tryDeleteProviderVendorIfEmpty, } from "./provider-endpoints"; +type ProviderTransaction = Parameters[0]>[0]; + +const PROVIDER_RESTORE_MAX_AGE_MS = 60_000; +const ENDPOINT_RESTORE_TIME_TOLERANCE_MS = 1_000; + +interface ProviderRestoreCandidate { + id: number; + providerVendorId: number | null; + providerType: Provider["providerType"]; + url: string; + deletedAt: Date | null; +} + +async function restoreSoftDeletedEndpointForProvider( + tx: ProviderTransaction, + provider: ProviderRestoreCandidate, + now: Date +): Promise { + if (provider.providerVendorId == null || !provider.url || !provider.deletedAt) { + return; + } + + const trimmedUrl = provider.url.trim(); + if (!trimmedUrl) { + return; + } + + const [activeReference] = await tx + .select({ id: providers.id }) + .from(providers) + .where( + and( + eq(providers.providerVendorId, provider.providerVendorId), + eq(providers.providerType, provider.providerType), + eq(providers.url, trimmedUrl), + eq(providers.isEnabled, true), + isNull(providers.deletedAt), + ne(providers.id, provider.id) + ) + ) + .limit(1); + + if (activeReference) { + return; + } + + const [activeEndpoint] = await tx + .select({ id: providerEndpoints.id }) + .from(providerEndpoints) + .where( + and( + eq(providerEndpoints.vendorId, provider.providerVendorId), + eq(providerEndpoints.providerType, provider.providerType), + eq(providerEndpoints.url, trimmedUrl), + isNull(providerEndpoints.deletedAt) + ) + ) + .limit(1); + + if (activeEndpoint) { + return; + } + + const lowerBound = new Date(provider.deletedAt.getTime() - ENDPOINT_RESTORE_TIME_TOLERANCE_MS); + const upperBound = new Date(provider.deletedAt.getTime() + ENDPOINT_RESTORE_TIME_TOLERANCE_MS); + + const [endpointToRestore] = await tx + .select({ id: providerEndpoints.id }) + .from(providerEndpoints) + .where( + and( + eq(providerEndpoints.vendorId, provider.providerVendorId), + eq(providerEndpoints.providerType, provider.providerType), + eq(providerEndpoints.url, trimmedUrl), + isNotNull(providerEndpoints.deletedAt), + sql`${providerEndpoints.deletedAt} >= ${lowerBound}`, + sql`${providerEndpoints.deletedAt} <= ${upperBound}` + ) + ) + .orderBy(desc(providerEndpoints.deletedAt), desc(providerEndpoints.id)) + .limit(1); + + if (!endpointToRestore) { + return; + } + + await tx + .update(providerEndpoints) + .set({ + deletedAt: null, + isEnabled: true, + updatedAt: now, + }) + .where( + and(eq(providerEndpoints.id, endpointToRestore.id), isNotNull(providerEndpoints.deletedAt)) + ); +} + +async function restoreProviderInTransaction( + tx: ProviderTransaction, + providerId: number, + now: Date +): Promise { + const [candidate] = await tx + .select({ + id: providers.id, + providerVendorId: providers.providerVendorId, + providerType: providers.providerType, + url: providers.url, + deletedAt: providers.deletedAt, + }) + .from(providers) + .where(and(eq(providers.id, providerId), isNotNull(providers.deletedAt))) + .limit(1); + + if (!candidate?.deletedAt) { + return false; + } + + if (now.getTime() - candidate.deletedAt.getTime() > PROVIDER_RESTORE_MAX_AGE_MS) { + return false; + } + + const restored = await tx + .update(providers) + .set({ deletedAt: null, updatedAt: now }) + .where( + and( + eq(providers.id, providerId), + isNotNull(providers.deletedAt), + eq(providers.deletedAt, candidate.deletedAt) + ) + ) + .returning({ id: providers.id }); + + if (restored.length === 0) { + return false; + } + + await restoreSoftDeletedEndpointForProvider(tx, candidate, now); + + return true; +} + export async function createProvider(providerData: CreateProviderData): Promise { const dbData = { name: providerData.name, @@ -803,12 +952,64 @@ export async function deleteProvider(id: number): Promise { return deleted; } +/** + * 恢复单个软删除供应商及其关联端点。 + * + * 安全策略:仅允许恢复 60 秒内删除的供应商。 + */ +export async function restoreProvider(id: number): Promise { + const now = new Date(); + + const restored = await db.transaction(async (tx) => restoreProviderInTransaction(tx, id, now)); + + return restored; +} + export interface BatchProviderUpdates { isEnabled?: boolean; priority?: number; weight?: number; costMultiplier?: string; groupTag?: string | null; + modelRedirects?: Record | null; + allowedModels?: string[] | null; + anthropicThinkingBudgetPreference?: string | null; + anthropicAdaptiveThinking?: AnthropicAdaptiveThinkingConfig | null; + // Routing + preserveClientIp?: boolean; + groupPriorities?: Record | null; + cacheTtlPreference?: string | null; + swapCacheTtlBilling?: boolean; + context1mPreference?: string | null; + codexReasoningEffortPreference?: string | null; + codexReasoningSummaryPreference?: string | null; + codexTextVerbosityPreference?: string | null; + codexParallelToolCallsPreference?: string | null; + anthropicMaxTokensPreference?: string | null; + geminiGoogleSearchPreference?: string | null; + // Rate Limit + limit5hUsd?: string | null; + limitDailyUsd?: string | null; + dailyResetMode?: string; + dailyResetTime?: string; + limitWeeklyUsd?: string | null; + limitMonthlyUsd?: string | null; + limitTotalUsd?: string | null; + limitConcurrentSessions?: number; + // Circuit Breaker + circuitBreakerFailureThreshold?: number; + circuitBreakerOpenDuration?: number; + circuitBreakerHalfOpenSuccessThreshold?: number; + maxRetryAttempts?: number | null; + // Network + proxyUrl?: string | null; + proxyFallbackToDirect?: boolean; + firstByteTimeoutStreamingMs?: number; + streamingIdleTimeoutMs?: number; + requestTimeoutNonStreamingMs?: number; + // MCP + mcpPassthroughType?: string; + mcpPassthroughUrl?: string | null; } export async function updateProvidersBatch( @@ -838,6 +1039,114 @@ export async function updateProvidersBatch( if (updates.groupTag !== undefined) { setClauses.groupTag = updates.groupTag; } + if (updates.modelRedirects !== undefined) { + setClauses.modelRedirects = updates.modelRedirects; + } + if (updates.allowedModels !== undefined) { + setClauses.allowedModels = updates.allowedModels; + } + if (updates.anthropicThinkingBudgetPreference !== undefined) { + setClauses.anthropicThinkingBudgetPreference = updates.anthropicThinkingBudgetPreference; + } + if (updates.anthropicAdaptiveThinking !== undefined) { + setClauses.anthropicAdaptiveThinking = updates.anthropicAdaptiveThinking; + } + // Routing + if (updates.preserveClientIp !== undefined) { + setClauses.preserveClientIp = updates.preserveClientIp; + } + if (updates.groupPriorities !== undefined) { + setClauses.groupPriorities = updates.groupPriorities; + } + if (updates.cacheTtlPreference !== undefined) { + setClauses.cacheTtlPreference = updates.cacheTtlPreference; + } + if (updates.swapCacheTtlBilling !== undefined) { + setClauses.swapCacheTtlBilling = updates.swapCacheTtlBilling; + } + if (updates.context1mPreference !== undefined) { + setClauses.context1mPreference = updates.context1mPreference; + } + if (updates.codexReasoningEffortPreference !== undefined) { + setClauses.codexReasoningEffortPreference = updates.codexReasoningEffortPreference; + } + if (updates.codexReasoningSummaryPreference !== undefined) { + setClauses.codexReasoningSummaryPreference = updates.codexReasoningSummaryPreference; + } + if (updates.codexTextVerbosityPreference !== undefined) { + setClauses.codexTextVerbosityPreference = updates.codexTextVerbosityPreference; + } + if (updates.codexParallelToolCallsPreference !== undefined) { + setClauses.codexParallelToolCallsPreference = updates.codexParallelToolCallsPreference; + } + if (updates.anthropicMaxTokensPreference !== undefined) { + setClauses.anthropicMaxTokensPreference = updates.anthropicMaxTokensPreference; + } + if (updates.geminiGoogleSearchPreference !== undefined) { + setClauses.geminiGoogleSearchPreference = updates.geminiGoogleSearchPreference; + } + // Rate Limit + if (updates.limit5hUsd !== undefined) { + setClauses.limit5hUsd = updates.limit5hUsd; + } + if (updates.limitDailyUsd !== undefined) { + setClauses.limitDailyUsd = updates.limitDailyUsd; + } + if (updates.dailyResetMode !== undefined) { + setClauses.dailyResetMode = updates.dailyResetMode; + } + if (updates.dailyResetTime !== undefined) { + setClauses.dailyResetTime = updates.dailyResetTime; + } + if (updates.limitWeeklyUsd !== undefined) { + setClauses.limitWeeklyUsd = updates.limitWeeklyUsd; + } + if (updates.limitMonthlyUsd !== undefined) { + setClauses.limitMonthlyUsd = updates.limitMonthlyUsd; + } + if (updates.limitTotalUsd !== undefined) { + setClauses.limitTotalUsd = updates.limitTotalUsd; + } + if (updates.limitConcurrentSessions !== undefined) { + setClauses.limitConcurrentSessions = updates.limitConcurrentSessions; + } + // Circuit Breaker + if (updates.circuitBreakerFailureThreshold !== undefined) { + setClauses.circuitBreakerFailureThreshold = updates.circuitBreakerFailureThreshold; + } + if (updates.circuitBreakerOpenDuration !== undefined) { + setClauses.circuitBreakerOpenDuration = updates.circuitBreakerOpenDuration; + } + if (updates.circuitBreakerHalfOpenSuccessThreshold !== undefined) { + setClauses.circuitBreakerHalfOpenSuccessThreshold = + updates.circuitBreakerHalfOpenSuccessThreshold; + } + if (updates.maxRetryAttempts !== undefined) { + setClauses.maxRetryAttempts = updates.maxRetryAttempts; + } + // Network + if (updates.proxyUrl !== undefined) { + setClauses.proxyUrl = updates.proxyUrl; + } + if (updates.proxyFallbackToDirect !== undefined) { + setClauses.proxyFallbackToDirect = updates.proxyFallbackToDirect; + } + if (updates.firstByteTimeoutStreamingMs !== undefined) { + setClauses.firstByteTimeoutStreamingMs = updates.firstByteTimeoutStreamingMs; + } + if (updates.streamingIdleTimeoutMs !== undefined) { + setClauses.streamingIdleTimeoutMs = updates.streamingIdleTimeoutMs; + } + if (updates.requestTimeoutNonStreamingMs !== undefined) { + setClauses.requestTimeoutNonStreamingMs = updates.requestTimeoutNonStreamingMs; + } + // MCP + if (updates.mcpPassthroughType !== undefined) { + setClauses.mcpPassthroughType = updates.mcpPassthroughType; + } + if (updates.mcpPassthroughUrl !== undefined) { + setClauses.mcpPassthroughUrl = updates.mcpPassthroughUrl; + } if (Object.keys(setClauses).length === 1) { return 0; @@ -1038,6 +1347,39 @@ export async function deleteProvidersBatch(ids: number[]): Promise { return deletedCount; } +/** + * 批量恢复软删除供应商及其关联端点(事务内逐个恢复)。 + * + * 安全策略:仅允许恢复 60 秒内删除的供应商。 + */ +export async function restoreProvidersBatch(ids: number[]): Promise { + if (ids.length === 0) { + return 0; + } + + const uniqueIds = [...new Set(ids)]; + const now = new Date(); + + const restoredCount = await db.transaction(async (tx) => { + let restored = 0; + + for (const id of uniqueIds) { + if (await restoreProviderInTransaction(tx, id, now)) { + restored += 1; + } + } + + return restored; + }); + + logger.debug("restoreProvidersBatch:completed", { + requestedIds: uniqueIds.length, + restoredCount, + }); + + return restoredCount; +} + /** * 手动重置供应商"总消费"统计起点 * diff --git a/src/types/provider.ts b/src/types/provider.ts index aed85a685..94480e6d0 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -45,6 +45,208 @@ export interface AnthropicAdaptiveThinkingConfig { models: string[]; } +export type ProviderPatchOperation = + | { mode: "no_change" } + | { mode: "set"; value: T } + | { mode: "clear" }; + +export type ProviderPatchDraftInput = + | { set: T; clear?: never; no_change?: never } + | { clear: true; set?: never; no_change?: never } + | { no_change: true; set?: never; clear?: never } + | undefined; + +export type ProviderBatchPatchField = + // Basic / existing + | "is_enabled" + | "priority" + | "weight" + | "cost_multiplier" + | "group_tag" + | "model_redirects" + | "allowed_models" + | "anthropic_thinking_budget_preference" + | "anthropic_adaptive_thinking" + // Routing + | "preserve_client_ip" + | "group_priorities" + | "cache_ttl_preference" + | "swap_cache_ttl_billing" + | "context_1m_preference" + | "codex_reasoning_effort_preference" + | "codex_reasoning_summary_preference" + | "codex_text_verbosity_preference" + | "codex_parallel_tool_calls_preference" + | "anthropic_max_tokens_preference" + | "gemini_google_search_preference" + // Rate Limit + | "limit_5h_usd" + | "limit_daily_usd" + | "daily_reset_mode" + | "daily_reset_time" + | "limit_weekly_usd" + | "limit_monthly_usd" + | "limit_total_usd" + | "limit_concurrent_sessions" + // Circuit Breaker + | "circuit_breaker_failure_threshold" + | "circuit_breaker_open_duration" + | "circuit_breaker_half_open_success_threshold" + | "max_retry_attempts" + // Network + | "proxy_url" + | "proxy_fallback_to_direct" + | "first_byte_timeout_streaming_ms" + | "streaming_idle_timeout_ms" + | "request_timeout_non_streaming_ms" + // MCP + | "mcp_passthrough_type" + | "mcp_passthrough_url"; + +export interface ProviderBatchPatchDraft { + // Basic / existing + is_enabled?: ProviderPatchDraftInput; + priority?: ProviderPatchDraftInput; + weight?: ProviderPatchDraftInput; + cost_multiplier?: ProviderPatchDraftInput; + group_tag?: ProviderPatchDraftInput; + model_redirects?: ProviderPatchDraftInput>; + allowed_models?: ProviderPatchDraftInput; + anthropic_thinking_budget_preference?: ProviderPatchDraftInput; + anthropic_adaptive_thinking?: ProviderPatchDraftInput; + // Routing + preserve_client_ip?: ProviderPatchDraftInput; + group_priorities?: ProviderPatchDraftInput>; + cache_ttl_preference?: ProviderPatchDraftInput; + swap_cache_ttl_billing?: ProviderPatchDraftInput; + context_1m_preference?: ProviderPatchDraftInput; + codex_reasoning_effort_preference?: ProviderPatchDraftInput; + codex_reasoning_summary_preference?: ProviderPatchDraftInput; + codex_text_verbosity_preference?: ProviderPatchDraftInput; + codex_parallel_tool_calls_preference?: ProviderPatchDraftInput; + anthropic_max_tokens_preference?: ProviderPatchDraftInput; + gemini_google_search_preference?: ProviderPatchDraftInput; + // Rate Limit + limit_5h_usd?: ProviderPatchDraftInput; + limit_daily_usd?: ProviderPatchDraftInput; + daily_reset_mode?: ProviderPatchDraftInput<"fixed" | "rolling">; + daily_reset_time?: ProviderPatchDraftInput; + limit_weekly_usd?: ProviderPatchDraftInput; + limit_monthly_usd?: ProviderPatchDraftInput; + limit_total_usd?: ProviderPatchDraftInput; + limit_concurrent_sessions?: ProviderPatchDraftInput; + // Circuit Breaker + circuit_breaker_failure_threshold?: ProviderPatchDraftInput; + circuit_breaker_open_duration?: ProviderPatchDraftInput; + circuit_breaker_half_open_success_threshold?: ProviderPatchDraftInput; + max_retry_attempts?: ProviderPatchDraftInput; + // Network + proxy_url?: ProviderPatchDraftInput; + proxy_fallback_to_direct?: ProviderPatchDraftInput; + first_byte_timeout_streaming_ms?: ProviderPatchDraftInput; + streaming_idle_timeout_ms?: ProviderPatchDraftInput; + request_timeout_non_streaming_ms?: ProviderPatchDraftInput; + // MCP + mcp_passthrough_type?: ProviderPatchDraftInput; + mcp_passthrough_url?: ProviderPatchDraftInput; +} + +export interface ProviderBatchPatch { + // Basic / existing + is_enabled: ProviderPatchOperation; + priority: ProviderPatchOperation; + weight: ProviderPatchOperation; + cost_multiplier: ProviderPatchOperation; + group_tag: ProviderPatchOperation; + model_redirects: ProviderPatchOperation>; + allowed_models: ProviderPatchOperation; + anthropic_thinking_budget_preference: ProviderPatchOperation; + anthropic_adaptive_thinking: ProviderPatchOperation; + // Routing + preserve_client_ip: ProviderPatchOperation; + group_priorities: ProviderPatchOperation>; + cache_ttl_preference: ProviderPatchOperation; + swap_cache_ttl_billing: ProviderPatchOperation; + context_1m_preference: ProviderPatchOperation; + codex_reasoning_effort_preference: ProviderPatchOperation; + codex_reasoning_summary_preference: ProviderPatchOperation; + codex_text_verbosity_preference: ProviderPatchOperation; + codex_parallel_tool_calls_preference: ProviderPatchOperation; + anthropic_max_tokens_preference: ProviderPatchOperation; + gemini_google_search_preference: ProviderPatchOperation; + // Rate Limit + limit_5h_usd: ProviderPatchOperation; + limit_daily_usd: ProviderPatchOperation; + daily_reset_mode: ProviderPatchOperation<"fixed" | "rolling">; + daily_reset_time: ProviderPatchOperation; + limit_weekly_usd: ProviderPatchOperation; + limit_monthly_usd: ProviderPatchOperation; + limit_total_usd: ProviderPatchOperation; + limit_concurrent_sessions: ProviderPatchOperation; + // Circuit Breaker + circuit_breaker_failure_threshold: ProviderPatchOperation; + circuit_breaker_open_duration: ProviderPatchOperation; + circuit_breaker_half_open_success_threshold: ProviderPatchOperation; + max_retry_attempts: ProviderPatchOperation; + // Network + proxy_url: ProviderPatchOperation; + proxy_fallback_to_direct: ProviderPatchOperation; + first_byte_timeout_streaming_ms: ProviderPatchOperation; + streaming_idle_timeout_ms: ProviderPatchOperation; + request_timeout_non_streaming_ms: ProviderPatchOperation; + // MCP + mcp_passthrough_type: ProviderPatchOperation; + mcp_passthrough_url: ProviderPatchOperation; +} + +export interface ProviderBatchApplyUpdates { + // Basic / existing + is_enabled?: boolean; + priority?: number; + weight?: number; + cost_multiplier?: number; + group_tag?: string | null; + model_redirects?: Record | null; + allowed_models?: string[] | null; + anthropic_thinking_budget_preference?: AnthropicThinkingBudgetPreference | null; + anthropic_adaptive_thinking?: AnthropicAdaptiveThinkingConfig | null; + // Routing + preserve_client_ip?: boolean; + group_priorities?: Record | null; + cache_ttl_preference?: CacheTtlPreference | null; + swap_cache_ttl_billing?: boolean; + context_1m_preference?: Context1mPreference | null; + codex_reasoning_effort_preference?: CodexReasoningEffortPreference | null; + codex_reasoning_summary_preference?: CodexReasoningSummaryPreference | null; + codex_text_verbosity_preference?: CodexTextVerbosityPreference | null; + codex_parallel_tool_calls_preference?: CodexParallelToolCallsPreference | null; + anthropic_max_tokens_preference?: AnthropicMaxTokensPreference | null; + gemini_google_search_preference?: GeminiGoogleSearchPreference | null; + // Rate Limit + limit_5h_usd?: number | null; + limit_daily_usd?: number | null; + daily_reset_mode?: "fixed" | "rolling"; + daily_reset_time?: string; + limit_weekly_usd?: number | null; + limit_monthly_usd?: number | null; + limit_total_usd?: number | null; + limit_concurrent_sessions?: number; + // Circuit Breaker + circuit_breaker_failure_threshold?: number; + circuit_breaker_open_duration?: number; + circuit_breaker_half_open_success_threshold?: number; + max_retry_attempts?: number | null; + // Network + proxy_url?: string | null; + proxy_fallback_to_direct?: boolean; + first_byte_timeout_streaming_ms?: number; + streaming_idle_timeout_ms?: number; + request_timeout_non_streaming_ms?: number; + // MCP + mcp_passthrough_type?: McpPassthroughType; + mcp_passthrough_url?: string | null; +} + // Gemini (generateContent API) parameter overrides // - "inherit": follow client request (default) // - "enabled": force inject googleSearch tool diff --git a/tests/api/action-adapter-auth-session.unit.test.ts b/tests/api/action-adapter-auth-session.unit.test.ts index 16eace9ce..e54025d84 100644 --- a/tests/api/action-adapter-auth-session.unit.test.ts +++ b/tests/api/action-adapter-auth-session.unit.test.ts @@ -76,11 +76,12 @@ describe("Action Adapter:会话透传", () => { return { ...actual, validateKey: vi.fn(async () => mockSession), + validateAuthToken: vi.fn(async () => mockSession), }; }); const { createActionRoute } = await import("@/lib/api/action-adapter-openapi"); - const { getSession, validateKey } = await import("@/lib/auth"); + const { getSession, validateAuthToken } = await import("@/lib/auth"); const action = vi.fn(async () => { const session = await getSession(); @@ -115,7 +116,7 @@ describe("Action Adapter:会话透传", () => { }), } as any)) as Response; - expect(validateKey).toHaveBeenCalledTimes(1); + expect(validateAuthToken).toHaveBeenCalledTimes(1); expect(action).toHaveBeenCalledTimes(1); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ diff --git a/tests/security/auth-bruteforce-integration.test.ts b/tests/security/auth-bruteforce-integration.test.ts new file mode 100644 index 000000000..57eb09186 --- /dev/null +++ b/tests/security/auth-bruteforce-integration.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mockValidateKey = vi.hoisted(() => vi.fn()); +const mockSetAuthCookie = vi.hoisted(() => vi.fn()); +const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn()); +const mockGetSessionTokenMode = vi.hoisted(() => vi.fn()); +const mockGetTranslations = vi.hoisted(() => vi.fn()); +const mockLogger = vi.hoisted(() => ({ + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + validateKey: mockValidateKey, + setAuthCookie: mockSetAuthCookie, + getLoginRedirectTarget: mockGetLoginRedirectTarget, + getSessionTokenMode: mockGetSessionTokenMode, + withNoStoreHeaders: (res: T): T => { + (res as Response).headers.set("Cache-Control", "no-store, no-cache, must-revalidate"); + (res as Response).headers.set("Pragma", "no-cache"); + return res; + }, +})); + +vi.mock("next-intl/server", () => ({ + getTranslations: mockGetTranslations, +})); + +vi.mock("@/lib/logger", () => ({ + logger: mockLogger, +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: () => ({ ENABLE_SECURE_COOKIES: false, SESSION_TOKEN_MODE: "legacy" }), +})); + +vi.mock("@/lib/security/auth-response-headers", () => ({ + withAuthResponseHeaders: (res: T): T => { + (res as Response).headers.set("Cache-Control", "no-store, no-cache, must-revalidate"); + (res as Response).headers.set("Pragma", "no-cache"); + return res; + }, +})); + +function makeRequest(body: unknown, ip: string): NextRequest { + return new NextRequest("http://localhost/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-forwarded-for": ip, + "x-forwarded-proto": "https", + }, + body: JSON.stringify(body), + }); +} + +const fakeSession = { + user: { + id: 1, + name: "Test User", + description: "desc", + role: "user" as const, + }, + key: { canLoginWebUi: true }, +}; + +async function exhaustFailures( + POST: (request: NextRequest) => Promise, + ip: string, + count = 10 +) { + for (let i = 0; i < count; i++) { + const res = await POST(makeRequest({ key: `bad-${i}` }, ip)); + expect(res.status).toBe(401); + } +} + +describe("auth login anti-bruteforce integration", () => { + let POST: (request: NextRequest) => Promise; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + + const mockT = vi.fn((key: string) => `translated:${key}`); + mockGetTranslations.mockResolvedValue(mockT); + mockSetAuthCookie.mockResolvedValue(undefined); + mockGetLoginRedirectTarget.mockReturnValue("/dashboard"); + mockGetSessionTokenMode.mockReturnValue("legacy"); + + const mod = await import("../../src/app/api/auth/login/route"); + POST = mod.POST; + }); + + it("normal request passes rate-limit check", async () => { + mockValidateKey.mockResolvedValue(null); + + const res = await POST(makeRequest({ key: "bad-key" }, "198.51.100.10")); + + expect(res.status).toBe(401); + expect(res.headers.get("Retry-After")).toBeNull(); + expect(mockValidateKey).toHaveBeenCalledWith("bad-key", { allowReadOnlyAccess: true }); + }); + + it("returns 429 with Retry-After after max failures", async () => { + const ip = "198.51.100.20"; + mockValidateKey.mockResolvedValue(null); + + await exhaustFailures(POST, ip); + + const blockedRes = await POST(makeRequest({ key: "blocked-now" }, ip)); + + expect(blockedRes.status).toBe(429); + expect(blockedRes.headers.get("Retry-After")).not.toBeNull(); + expect(Number.parseInt(blockedRes.headers.get("Retry-After") ?? "0", 10)).toBeGreaterThan(0); + expect(mockValidateKey).toHaveBeenCalledTimes(10); + }); + + it("successful login resets failure counter", async () => { + const ip = "198.51.100.30"; + mockValidateKey.mockImplementation(async (key: string) => { + return key === "valid-key" ? fakeSession : null; + }); + + for (let i = 0; i < 9; i++) { + const res = await POST(makeRequest({ key: `bad-before-success-${i}` }, ip)); + expect(res.status).toBe(401); + } + + const successRes = await POST(makeRequest({ key: "valid-key" }, ip)); + expect(successRes.status).toBe(200); + + const firstAfterSuccess = await POST(makeRequest({ key: "bad-after-success-1" }, ip)); + const secondAfterSuccess = await POST(makeRequest({ key: "bad-after-success-2" }, ip)); + + expect(firstAfterSuccess.status).toBe(401); + expect(secondAfterSuccess.status).toBe(401); + expect(secondAfterSuccess.headers.get("Retry-After")).toBeNull(); + expect(mockSetAuthCookie).toHaveBeenCalledWith("valid-key"); + }); + + it("429 response includes errorCode RATE_LIMITED", async () => { + const ip = "198.51.100.40"; + mockValidateKey.mockResolvedValue(null); + + await exhaustFailures(POST, ip); + + const blockedRes = await POST(makeRequest({ key: "blocked-key" }, ip)); + + expect(blockedRes.status).toBe(429); + await expect(blockedRes.json()).resolves.toMatchObject({ + errorCode: "RATE_LIMITED", + }); + }); + + it("tracks different IPs independently", async () => { + const blockedIp = "198.51.100.50"; + const freshIp = "198.51.100.51"; + mockValidateKey.mockResolvedValue(null); + + await exhaustFailures(POST, blockedIp); + + const blockedRes = await POST(makeRequest({ key: "blocked-key" }, blockedIp)); + const freshRes = await POST(makeRequest({ key: "fresh-ip-key" }, freshIp)); + + expect(blockedRes.status).toBe(429); + expect(freshRes.status).toBe(401); + }); +}); diff --git a/tests/security/auth-csrf-route-integration.test.ts b/tests/security/auth-csrf-route-integration.test.ts new file mode 100644 index 000000000..867f80a42 --- /dev/null +++ b/tests/security/auth-csrf-route-integration.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { NextRequest } from "next/server"; + +const mockValidateKey = vi.hoisted(() => vi.fn()); +const mockSetAuthCookie = vi.hoisted(() => vi.fn()); +const mockGetSessionTokenMode = vi.hoisted(() => vi.fn()); +const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn()); +const mockClearAuthCookie = vi.hoisted(() => vi.fn()); +const mockGetAuthCookie = vi.hoisted(() => vi.fn()); +const mockGetTranslations = vi.hoisted(() => vi.fn()); +const mockGetEnvConfig = vi.hoisted(() => vi.fn()); +const mockLogger = vi.hoisted(() => ({ + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + validateKey: mockValidateKey, + setAuthCookie: mockSetAuthCookie, + getSessionTokenMode: mockGetSessionTokenMode, + getLoginRedirectTarget: mockGetLoginRedirectTarget, + clearAuthCookie: mockClearAuthCookie, + getAuthCookie: mockGetAuthCookie, + toKeyFingerprint: vi.fn().mockResolvedValue("sha256:mock"), + withNoStoreHeaders: (res: T): T => { + (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate"); + (res as any).headers.set("Pragma", "no-cache"); + return res; + }, +})); + +vi.mock("next-intl/server", () => ({ + getTranslations: mockGetTranslations, +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: mockGetEnvConfig, +})); + +vi.mock("@/lib/logger", () => ({ + logger: mockLogger, +})); + +vi.mock("@/lib/security/auth-response-headers", () => ({ + withAuthResponseHeaders: (res: T): T => { + (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate"); + (res as any).headers.set("Pragma", "no-cache"); + return res; + }, +})); + +type LoginPostHandler = (request: NextRequest) => Promise; +type LogoutPostHandler = (request: NextRequest) => Promise; + +function makeLoginRequest(headers: Record = {}, key = "valid-key"): NextRequest { + const requestHeaders = new Headers({ + "content-type": "application/json", + ...headers, + }); + + return { + headers: requestHeaders, + cookies: { + get: () => undefined, + }, + json: async () => ({ key }), + } as unknown as NextRequest; +} + +function makeLogoutRequest(headers: Record = {}): NextRequest { + return { + headers: new Headers(headers), + } as unknown as NextRequest; +} + +describe("auth route csrf guard integration", () => { + const originalNodeEnv = process.env.NODE_ENV; + let loginPost: LoginPostHandler; + let logoutPost: LogoutPostHandler; + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + process.env.NODE_ENV = "test"; + + mockGetTranslations.mockResolvedValue( + vi.fn((messageKey: string) => `translated:${messageKey}`) + ); + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false }); + mockValidateKey.mockResolvedValue({ + user: { + id: 1, + name: "Test User", + description: "desc", + role: "user", + }, + key: { + canLoginWebUi: true, + }, + }); + mockSetAuthCookie.mockResolvedValue(undefined); + mockGetLoginRedirectTarget.mockReturnValue("/dashboard"); + mockClearAuthCookie.mockResolvedValue(undefined); + mockGetAuthCookie.mockResolvedValue(undefined); + mockGetSessionTokenMode.mockReturnValue("legacy"); + + const loginRoute = await import("@/app/api/auth/login/route"); + loginPost = loginRoute.POST; + + const logoutRoute = await import("@/app/api/auth/logout/route"); + logoutPost = logoutRoute.POST; + }); + + it("allows same-origin login request to pass through", async () => { + const res = await loginPost(makeLoginRequest({ "sec-fetch-site": "same-origin" })); + + expect(res.status).toBe(200); + expect(mockValidateKey).toHaveBeenCalledWith("valid-key", { allowReadOnlyAccess: true }); + }); + + it("blocks cross-origin login request with csrf rejected error", async () => { + const request = makeLoginRequest({ + "sec-fetch-site": "cross-site", + origin: "https://evil.example.com", + }); + + const res = await loginPost(request); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ errorCode: "CSRF_REJECTED" }); + expect(mockValidateKey).not.toHaveBeenCalled(); + }); + + it("allows login request without origin header for non-browser clients", async () => { + const res = await loginPost(makeLoginRequest()); + + expect(res.status).toBe(200); + expect(mockValidateKey).toHaveBeenCalledTimes(1); + }); + + it("allows same-origin logout request to pass through", async () => { + const res = await logoutPost(makeLogoutRequest({ "sec-fetch-site": "same-origin" })); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + expect(mockClearAuthCookie).toHaveBeenCalledTimes(1); + }); + + it("blocks cross-origin logout request with csrf rejected error", async () => { + const request = makeLogoutRequest({ + "sec-fetch-site": "cross-site", + origin: "https://evil.example.com", + }); + + const res = await logoutPost(request); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ errorCode: "CSRF_REJECTED" }); + expect(mockClearAuthCookie).not.toHaveBeenCalled(); + }); + + it("allows logout request without origin header for non-browser clients", async () => { + const res = await logoutPost(makeLogoutRequest()); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + expect(mockClearAuthCookie).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/security/auth-dual-read.test.ts b/tests/security/auth-dual-read.test.ts new file mode 100644 index 000000000..a843a7885 --- /dev/null +++ b/tests/security/auth-dual-read.test.ts @@ -0,0 +1,264 @@ +import crypto from "node:crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Key } from "@/types/key"; +import type { User } from "@/types/user"; + +const mockCookies = vi.hoisted(() => vi.fn()); +const mockHeaders = vi.hoisted(() => vi.fn()); +const mockGetEnvConfig = vi.hoisted(() => vi.fn()); +const mockValidateApiKeyAndGetUser = vi.hoisted(() => vi.fn()); +const mockFindKeyList = vi.hoisted(() => vi.fn()); +const mockReadSession = vi.hoisted(() => vi.fn()); +const mockCookieStore = vi.hoisted(() => ({ + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), +})); +const mockHeadersStore = vi.hoisted(() => ({ + get: vi.fn(), +})); +const loggerMock = vi.hoisted(() => ({ + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), +})); + +vi.mock("next/headers", () => ({ + cookies: mockCookies, + headers: mockHeaders, +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: mockGetEnvConfig, +})); + +vi.mock("@/repository/key", () => ({ + validateApiKeyAndGetUser: mockValidateApiKeyAndGetUser, + findKeyList: mockFindKeyList, +})); + +vi.mock("@/lib/auth-session-store/redis-session-store", () => ({ + RedisSessionStore: class { + read = mockReadSession; + create = vi.fn(); + revoke = vi.fn(); + rotate = vi.fn(); + }, +})); + +vi.mock("@/lib/logger", () => ({ + logger: loggerMock, +})); + +vi.mock("@/lib/config/config", () => ({ + config: { auth: { adminToken: "" } }, +})); + +function setSessionMode(mode: "legacy" | "dual" | "opaque") { + mockGetEnvConfig.mockReturnValue({ + SESSION_TOKEN_MODE: mode, + ENABLE_SECURE_COOKIES: false, + }); +} + +function setAuthToken(token?: string) { + mockCookieStore.get.mockReturnValue(token ? { value: token } : undefined); +} + +function toFingerprint(keyString: string): string { + return `sha256:${crypto.createHash("sha256").update(keyString, "utf8").digest("hex")}`; +} + +function buildUser(id: number): User { + const now = new Date("2026-02-18T10:00:00.000Z"); + return { + id, + name: `user-${id}`, + description: "test user", + role: "user", + rpm: 100, + dailyQuota: 100, + providerGroup: null, + tags: [], + createdAt: now, + updatedAt: now, + limit5hUsd: 0, + limitWeeklyUsd: 0, + limitMonthlyUsd: 0, + limitTotalUsd: null, + limitConcurrentSessions: 0, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + isEnabled: true, + expiresAt: null, + allowedClients: [], + allowedModels: [], + }; +} + +function buildKey(id: number, userId: number, keyString: string, canLoginWebUi = true): Key { + const now = new Date("2026-02-18T10:00:00.000Z"); + return { + id, + userId, + name: `key-${id}`, + key: keyString, + isEnabled: true, + canLoginWebUi, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: 0, + providerGroup: null, + cacheTtlPreference: null, + createdAt: now, + updatedAt: now, + }; +} + +function buildAuthResult(keyString: string, userId = 1) { + return { + user: buildUser(userId), + key: buildKey(userId, userId, keyString), + }; +} + +describe("auth dual-read session resolver", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + + mockCookies.mockResolvedValue(mockCookieStore); + mockHeaders.mockResolvedValue(mockHeadersStore); + mockHeadersStore.get.mockReturnValue(null); + mockCookieStore.get.mockReturnValue(undefined); + + setSessionMode("legacy"); + mockReadSession.mockResolvedValue(null); + mockFindKeyList.mockResolvedValue([]); + mockValidateApiKeyAndGetUser.mockResolvedValue(null); + }); + + it("legacy mode keeps legacy key validation path unchanged", async () => { + setSessionMode("legacy"); + setAuthToken("sk-legacy"); + const authResult = buildAuthResult("sk-legacy", 11); + mockValidateApiKeyAndGetUser.mockResolvedValue(authResult); + + const { getSessionWithDualRead } = await import("@/lib/auth"); + const session = await getSessionWithDualRead(); + + expect(session).toEqual(authResult); + expect(mockReadSession).not.toHaveBeenCalled(); + expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledTimes(1); + expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledWith("sk-legacy"); + }); + + it("dual mode tries opaque read first and then falls back to legacy cookie", async () => { + setSessionMode("dual"); + setAuthToken("sk-dual"); + const authResult = buildAuthResult("sk-dual", 12); + mockReadSession.mockResolvedValue(null); + mockValidateApiKeyAndGetUser.mockResolvedValue(authResult); + + const { getSessionWithDualRead } = await import("@/lib/auth"); + const session = await getSessionWithDualRead(); + + expect(session).toEqual(authResult); + expect(mockReadSession).toHaveBeenCalledTimes(1); + expect(mockReadSession).toHaveBeenCalledWith("sk-dual"); + expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledWith("sk-dual"); + expect(mockReadSession.mock.invocationCallOrder[0]).toBeLessThan( + mockValidateApiKeyAndGetUser.mock.invocationCallOrder[0] + ); + }); + + it("opaque mode only reads opaque session and never falls back to legacy", async () => { + setSessionMode("opaque"); + setAuthToken("sk-legacy-in-opaque"); + mockReadSession.mockResolvedValue(null); + mockValidateApiKeyAndGetUser.mockResolvedValue(buildAuthResult("sk-legacy-in-opaque", 13)); + + const { getSessionWithDualRead } = await import("@/lib/auth"); + const session = await getSessionWithDualRead(); + + expect(session).toBeNull(); + expect(mockReadSession).toHaveBeenCalledTimes(1); + expect(mockReadSession).toHaveBeenCalledWith("sk-legacy-in-opaque"); + expect(mockValidateApiKeyAndGetUser).not.toHaveBeenCalled(); + }); + + it("returns a valid auth session when opaque session is found", async () => { + setSessionMode("dual"); + setAuthToken("sid_opaque_found"); + + const keyString = "sk-opaque-source"; + const authResult = buildAuthResult(keyString, 21); + mockReadSession.mockResolvedValue({ + sessionId: "sid_opaque_found", + keyFingerprint: toFingerprint(keyString), + userId: 21, + userRole: "user", + createdAt: Date.now(), + expiresAt: Date.now() + 3_600_000, + }); + mockFindKeyList.mockResolvedValue([ + buildKey(1, 21, "sk-not-match"), + buildKey(2, 21, keyString), + ]); + mockValidateApiKeyAndGetUser.mockResolvedValue(authResult); + + const { getSessionWithDualRead } = await import("@/lib/auth"); + const session = await getSessionWithDualRead({ allowReadOnlyAccess: true }); + + expect(session).toEqual(authResult); + expect(mockReadSession).toHaveBeenCalledWith("sid_opaque_found"); + expect(mockFindKeyList).toHaveBeenCalledWith(21); + expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledTimes(1); + expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledWith(keyString); + }); + + it("validateSession falls back to legacy path when opaque session is missing in dual mode", async () => { + setSessionMode("dual"); + setAuthToken("sk-dual-fallback"); + const authResult = buildAuthResult("sk-dual-fallback", 22); + mockReadSession.mockResolvedValue(null); + mockValidateApiKeyAndGetUser.mockResolvedValue(authResult); + + const { validateSession } = await import("@/lib/auth"); + const session = await validateSession(); + + expect(session).toEqual(authResult); + expect(mockReadSession).toHaveBeenCalledTimes(1); + expect(mockReadSession).toHaveBeenCalledWith("sk-dual-fallback"); + expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledTimes(1); + expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledWith("sk-dual-fallback"); + }); + + it("dual mode gracefully falls back to legacy when opaque session store read fails", async () => { + setSessionMode("dual"); + setAuthToken("sk-store-error"); + const authResult = buildAuthResult("sk-store-error", 23); + mockReadSession.mockRejectedValue(new Error("redis unavailable")); + mockValidateApiKeyAndGetUser.mockResolvedValue(authResult); + + const { getSessionWithDualRead } = await import("@/lib/auth"); + const session = await getSessionWithDualRead(); + + expect(session).toEqual(authResult); + expect(mockReadSession).toHaveBeenCalledTimes(1); + expect(mockValidateApiKeyAndGetUser).toHaveBeenCalledTimes(1); + expect(loggerMock.warn).toHaveBeenCalledWith( + "Opaque session read failed", + expect.objectContaining({ + error: expect.stringContaining("redis unavailable"), + }) + ); + }); +}); diff --git a/tests/security/constant-time-compare.test.ts b/tests/security/constant-time-compare.test.ts new file mode 100644 index 000000000..7177b2b4c --- /dev/null +++ b/tests/security/constant-time-compare.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { constantTimeEqual } from "@/lib/security/constant-time-compare"; + +describe("constantTimeEqual", () => { + it("returns true for equal strings", () => { + expect(constantTimeEqual("hello", "hello")).toBe(true); + }); + + it("returns false for different strings of same length", () => { + expect(constantTimeEqual("hello", "world")).toBe(false); + }); + + it("returns false for strings of different lengths", () => { + expect(constantTimeEqual("short", "a-much-longer-string")).toBe(false); + }); + + it("returns true for empty strings", () => { + expect(constantTimeEqual("", "")).toBe(true); + }); + + it("returns false when one string is empty and the other is not", () => { + expect(constantTimeEqual("", "nonempty")).toBe(false); + expect(constantTimeEqual("nonempty", "")).toBe(false); + }); + + it("handles unicode correctly", () => { + expect(constantTimeEqual("\u00e9", "\u00e9")).toBe(true); + expect(constantTimeEqual("\u00e9", "e")).toBe(false); + }); + + it("handles long token-like strings", () => { + const tokenA = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + const tokenB = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + const tokenC = "sk-ant-api03-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; + expect(constantTimeEqual(tokenA, tokenB)).toBe(true); + expect(constantTimeEqual(tokenA, tokenC)).toBe(false); + }); + + it("is reflexive", () => { + const s = "test-token-value"; + expect(constantTimeEqual(s, s)).toBe(true); + }); +}); diff --git a/tests/security/csrf-origin-guard.test.ts b/tests/security/csrf-origin-guard.test.ts new file mode 100644 index 000000000..3382caf95 --- /dev/null +++ b/tests/security/csrf-origin-guard.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createCsrfOriginGuard } from "@/lib/security/csrf-origin-guard"; + +function createRequest(headers: Record) { + return { + headers: new Headers(headers), + }; +} + +describe("createCsrfOriginGuard", () => { + const originalNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + it("allows same-origin request when allowSameOrigin is enabled", () => { + const guard = createCsrfOriginGuard({ + allowedOrigins: [], + allowSameOrigin: true, + enforceInDevelopment: true, + }); + + const result = guard.check( + createRequest({ + "sec-fetch-site": "same-origin", + }) + ); + + expect(result).toEqual({ allowed: true }); + }); + + it("allows request when Origin is in allowlist", () => { + const origin = "https://example.com"; + const guard = createCsrfOriginGuard({ + allowedOrigins: [origin], + allowSameOrigin: false, + enforceInDevelopment: true, + }); + + const result = guard.check( + createRequest({ + "sec-fetch-site": "cross-site", + origin, + }) + ); + + expect(result).toEqual({ allowed: true }); + }); + + it("blocks request when Origin is not in allowlist", () => { + const guard = createCsrfOriginGuard({ + allowedOrigins: ["https://allowed.example.com"], + allowSameOrigin: false, + enforceInDevelopment: true, + }); + + const result = guard.check( + createRequest({ + origin: "https://evil.example.com", + }) + ); + + expect(result.allowed).toBe(false); + expect(result.reason).toBe("Origin https://evil.example.com not in allowlist"); + }); + + it("allows request without Origin header", () => { + const guard = createCsrfOriginGuard({ + allowedOrigins: [], + allowSameOrigin: true, + enforceInDevelopment: true, + }); + + const result = guard.check(createRequest({})); + + expect(result).toEqual({ allowed: true }); + }); + + it("blocks cross-site request when Origin header is missing", () => { + const guard = createCsrfOriginGuard({ + allowedOrigins: ["https://example.com"], + allowSameOrigin: true, + enforceInDevelopment: true, + }); + + const result = guard.check( + createRequest({ + "sec-fetch-site": "cross-site", + }) + ); + + expect(result.allowed).toBe(false); + expect(result.reason).toBe("Cross-site request blocked: missing Origin header"); + }); + + it("matches allowedOrigins case-insensitively", () => { + const guard = createCsrfOriginGuard({ + allowedOrigins: ["https://Example.COM"], + allowSameOrigin: false, + enforceInDevelopment: true, + }); + + const result = guard.check( + createRequest({ + "sec-fetch-site": "cross-site", + origin: "https://example.com", + }) + ); + + expect(result).toEqual({ allowed: true }); + }); + + it("bypasses guard in development when enforceInDevelopment is disabled", () => { + process.env.NODE_ENV = "development"; + + const guard = createCsrfOriginGuard({ + allowedOrigins: ["https://allowed.example.com"], + allowSameOrigin: false, + enforceInDevelopment: false, + }); + + const result = guard.check( + createRequest({ + "sec-fetch-site": "cross-site", + origin: "https://evil.example.com", + }) + ); + + expect(result.allowed).toBe(true); + expect(result.reason).toBe("csrf_guard_bypassed_in_development"); + }); +}); diff --git a/tests/security/full-security-regression.test.ts b/tests/security/full-security-regression.test.ts new file mode 100644 index 000000000..26d0c0dd7 --- /dev/null +++ b/tests/security/full-security-regression.test.ts @@ -0,0 +1,283 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createCsrfOriginGuard } from "../../src/lib/security/csrf-origin-guard"; +import { LoginAbusePolicy } from "../../src/lib/security/login-abuse-policy"; +import { + buildSecurityHeaders, + DEFAULT_SECURITY_HEADERS_CONFIG, +} from "../../src/lib/security/security-headers"; + +const mockCookieSet = vi.hoisted(() => vi.fn()); +const mockCookies = vi.hoisted(() => vi.fn()); +const mockGetRedisClient = vi.hoisted(() => vi.fn()); + +vi.mock("next/headers", () => ({ + cookies: mockCookies, + headers: vi.fn().mockResolvedValue(new Headers()), +})); + +vi.mock("@/lib/config/config", () => ({ + config: { + auth: { + adminToken: "test-admin-token", + }, + }, +})); + +vi.mock("@/repository/key", () => ({ + findKeyList: vi.fn(), + validateApiKeyAndGetUser: vi.fn(), +})); + +vi.mock("@/lib/redis", () => ({ + getRedisClient: mockGetRedisClient, +})); + +const ORIGINAL_SESSION_TOKEN_MODE = process.env.SESSION_TOKEN_MODE; +const ORIGINAL_ENABLE_SECURE_COOKIES = process.env.ENABLE_SECURE_COOKIES; + +function restoreAuthEnv() { + if (ORIGINAL_SESSION_TOKEN_MODE === undefined) { + delete process.env.SESSION_TOKEN_MODE; + } else { + process.env.SESSION_TOKEN_MODE = ORIGINAL_SESSION_TOKEN_MODE; + } + + if (ORIGINAL_ENABLE_SECURE_COOKIES === undefined) { + delete process.env.ENABLE_SECURE_COOKIES; + } else { + process.env.ENABLE_SECURE_COOKIES = ORIGINAL_ENABLE_SECURE_COOKIES; + } +} + +function setupCookieStoreMock() { + mockCookieSet.mockClear(); + mockCookies.mockResolvedValue({ + set: mockCookieSet, + get: vi.fn(), + delete: vi.fn(), + }); +} + +class FakeRedisClient { + status: "ready" = "ready"; + private readonly values = new Map(); + + async setex(key: string, _ttl: number, value: string): Promise<"OK"> { + this.values.set(key, value); + return "OK"; + } + + async get(key: string): Promise { + return this.values.get(key) ?? null; + } + + async del(key: string): Promise { + return this.values.delete(key) ? 1 : 0; + } +} + +describe("Full Security Regression Suite", () => { + beforeEach(() => { + setupCookieStoreMock(); + }); + + afterEach(() => { + restoreAuthEnv(); + vi.useRealTimers(); + vi.clearAllMocks(); + vi.resetModules(); + }); + + describe("Session Contract", () => { + it("SESSION_TOKEN_MODE defaults to opaque", async () => { + delete process.env.SESSION_TOKEN_MODE; + + vi.resetModules(); + const { getSessionTokenMode } = await import("../../src/lib/auth"); + + expect(getSessionTokenMode()).toBe("opaque"); + }); + + it("OpaqueSessionContract has required fields", async () => { + vi.resetModules(); + const { isOpaqueSessionContract } = await import("../../src/lib/auth"); + + const contract = { + sessionId: "sid_opaque_session_123", + keyFingerprint: "sha256:abc123", + createdAt: 1_700_000_000, + expiresAt: 1_700_000_300, + userId: 42, + userRole: "admin", + }; + + expect(isOpaqueSessionContract(contract)).toBe(true); + + const missingUserRole = { ...contract } as Partial; + delete missingUserRole.userRole; + expect(isOpaqueSessionContract(missingUserRole)).toBe(false); + }); + }); + + describe("Session Store", () => { + it("create returns valid session data", async () => { + const redis = new FakeRedisClient(); + mockGetRedisClient.mockReturnValue(redis); + const { RedisSessionStore } = await import( + "../../src/lib/auth-session-store/redis-session-store" + ); + + const store = new RedisSessionStore(); + + const created = await store.create({ + keyFingerprint: "sha256:fp-1", + userId: 101, + userRole: "user", + }); + + expect(created.sessionId).toMatch(/^sid_[0-9a-f-]{36}$/i); + expect(created.keyFingerprint).toBe("sha256:fp-1"); + expect(created.userId).toBe(101); + expect(created.userRole).toBe("user"); + expect(created.expiresAt).toBeGreaterThan(created.createdAt); + await expect(store.read(created.sessionId)).resolves.toEqual(created); + }); + + it("read returns null for non-existent session", async () => { + const redis = new FakeRedisClient(); + mockGetRedisClient.mockReturnValue(redis); + const { RedisSessionStore } = await import( + "../../src/lib/auth-session-store/redis-session-store" + ); + + const store = new RedisSessionStore(); + + await expect(store.read("missing-session")).resolves.toBeNull(); + }); + }); + + describe("Cookie Hardening", () => { + it("auth cookie is HttpOnly", async () => { + process.env.ENABLE_SECURE_COOKIES = "true"; + + vi.resetModules(); + const { AUTH_COOKIE_NAME, setAuthCookie } = await import("../../src/lib/auth"); + + await setAuthCookie("test-key"); + + expect(mockCookieSet).toHaveBeenCalledTimes(1); + const [name, value, options] = mockCookieSet.mock.calls[0]; + expect(name).toBe(AUTH_COOKIE_NAME); + expect(value).toBe("test-key"); + expect(options.httpOnly).toBe(true); + }); + + it("auth cookie secure flag matches env", async () => { + const cases = [ + { envValue: "true", expected: true }, + { envValue: "false", expected: false }, + ] as const; + + for (const testCase of cases) { + mockCookieSet.mockClear(); + process.env.ENABLE_SECURE_COOKIES = testCase.envValue; + + vi.resetModules(); + const { setAuthCookie } = await import("../../src/lib/auth"); + await setAuthCookie("env-test"); + + const [, , options] = mockCookieSet.mock.calls[0]; + expect(options.secure).toBe(testCase.expected); + } + }); + }); + + describe("Anti-Bruteforce", () => { + it("blocks after threshold", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-18T10:00:00.000Z")); + + const policy = new LoginAbusePolicy({ maxAttemptsPerIp: 2, lockoutSeconds: 60 }); + const ip = "198.51.100.10"; + + policy.recordFailure(ip); + policy.recordFailure(ip); + + const decision = policy.check(ip); + expect(decision.allowed).toBe(false); + expect(decision.reason).toBe("ip_rate_limited"); + expect(decision.retryAfterSeconds).toBeGreaterThan(0); + }); + + it("resets on success", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-18T10:00:00.000Z")); + + const policy = new LoginAbusePolicy({ maxAttemptsPerIp: 2, lockoutSeconds: 60 }); + const ip = "198.51.100.11"; + + policy.recordFailure(ip); + policy.recordFailure(ip); + expect(policy.check(ip).allowed).toBe(false); + + policy.recordSuccess(ip); + expect(policy.check(ip)).toEqual({ allowed: true }); + }); + }); + + describe("CSRF Guard", () => { + it("allows same-origin", () => { + const guard = createCsrfOriginGuard({ + allowedOrigins: ["https://safe.example.com"], + allowSameOrigin: true, + enforceInDevelopment: true, + }); + + const result = guard.check({ + headers: new Headers({ + "sec-fetch-site": "same-origin", + }), + }); + + expect(result).toEqual({ allowed: true }); + }); + + it("blocks cross-origin", () => { + const guard = createCsrfOriginGuard({ + allowedOrigins: ["https://safe.example.com"], + allowSameOrigin: true, + enforceInDevelopment: true, + }); + + const result = guard.check({ + headers: new Headers({ + "sec-fetch-site": "cross-site", + origin: "https://evil.example.com", + }), + }); + + expect(result.allowed).toBe(false); + expect(result.reason).toBe("Origin https://evil.example.com not in allowlist"); + }); + }); + + describe("Security Headers", () => { + it("includes all required headers", () => { + const headers = buildSecurityHeaders(); + + expect(headers["X-Content-Type-Options"]).toBe("nosniff"); + expect(headers["X-Frame-Options"]).toBe(DEFAULT_SECURITY_HEADERS_CONFIG.frameOptions); + expect(headers["Referrer-Policy"]).toBe("strict-origin-when-cross-origin"); + expect(headers["X-DNS-Prefetch-Control"]).toBe("off"); + expect(headers["Content-Security-Policy-Report-Only"]).toContain("default-src 'self'"); + }); + + it("CSP report-only by default", () => { + expect(DEFAULT_SECURITY_HEADERS_CONFIG.cspMode).toBe("report-only"); + + const headers = buildSecurityHeaders(); + expect(headers["Content-Security-Policy-Report-Only"]).toContain("default-src 'self'"); + expect(headers["Content-Security-Policy"]).toBeUndefined(); + }); + }); +}); diff --git a/tests/security/login-abuse-policy.test.ts b/tests/security/login-abuse-policy.test.ts new file mode 100644 index 000000000..90cbf62c5 --- /dev/null +++ b/tests/security/login-abuse-policy.test.ts @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LoginAbusePolicy } from "@/lib/security/login-abuse-policy"; + +describe("LoginAbusePolicy", () => { + const nowMs = 1_700_000_000_000; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(nowMs)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("allows requests under threshold", () => { + const policy = new LoginAbusePolicy({ maxAttemptsPerIp: 3 }); + const ip = "192.168.0.1"; + + expect(policy.check(ip)).toEqual({ allowed: true }); + policy.recordFailure(ip); + expect(policy.check(ip)).toEqual({ allowed: true }); + policy.recordFailure(ip); + expect(policy.check(ip)).toEqual({ allowed: true }); + }); + + it("blocks after maxAttemptsPerIp failures", () => { + const policy = new LoginAbusePolicy({ maxAttemptsPerIp: 3, lockoutSeconds: 60 }); + const ip = "192.168.0.2"; + + policy.recordFailure(ip); + policy.recordFailure(ip); + policy.recordFailure(ip); + + const decision = policy.check(ip); + expect(decision.allowed).toBe(false); + expect(decision.reason).toBe("ip_rate_limited"); + }); + + it("returns retryAfterSeconds when blocked", () => { + const policy = new LoginAbusePolicy({ maxAttemptsPerIp: 1, lockoutSeconds: 90 }); + const ip = "192.168.0.3"; + + policy.recordFailure(ip); + + const decision = policy.check(ip); + expect(decision.allowed).toBe(false); + expect(decision.retryAfterSeconds).toBe(90); + }); + + it("lockout remains active even after window expires", () => { + const policy = new LoginAbusePolicy({ + maxAttemptsPerIp: 1, + windowSeconds: 5, + lockoutSeconds: 20, + }); + const ip = "192.168.0.33"; + + policy.recordFailure(ip); + vi.advanceTimersByTime(6_000); + + const decision = policy.check(ip); + expect(decision.allowed).toBe(false); + expect(decision.reason).toBe("ip_rate_limited"); + expect(decision.retryAfterSeconds).toBe(14); + }); + + it("recordSuccess resets the counter", () => { + const policy = new LoginAbusePolicy({ maxAttemptsPerIp: 2, lockoutSeconds: 60 }); + const ip = "192.168.0.4"; + + policy.recordFailure(ip); + policy.recordFailure(ip); + expect(policy.check(ip).allowed).toBe(false); + + policy.recordSuccess(ip); + + expect(policy.check(ip)).toEqual({ allowed: true }); + }); + + it("expired window resets automatically", () => { + const policy = new LoginAbusePolicy({ + maxAttemptsPerIp: 2, + windowSeconds: 10, + lockoutSeconds: 60, + }); + const ip = "192.168.0.5"; + + policy.recordFailure(ip); + vi.advanceTimersByTime(11_000); + + policy.recordFailure(ip); + expect(policy.check(ip)).toEqual({ allowed: true }); + }); + + it("custom config overrides defaults", () => { + const policy = new LoginAbusePolicy({ + maxAttemptsPerIp: 1, + maxAttemptsPerKey: 2, + windowSeconds: 30, + lockoutSeconds: 120, + }); + const ip = "192.168.0.6"; + + policy.recordFailure(ip); + + const decision = policy.check(ip); + expect(decision.allowed).toBe(false); + expect(decision.retryAfterSeconds).toBe(120); + }); + + it("tracks different IPs independently", () => { + const policy = new LoginAbusePolicy({ maxAttemptsPerIp: 1, lockoutSeconds: 60 }); + const blockedIp = "10.0.0.1"; + const allowedIp = "10.0.0.2"; + + policy.recordFailure(blockedIp); + + expect(policy.check(blockedIp).allowed).toBe(false); + expect(policy.check(allowedIp)).toEqual({ allowed: true }); + }); + + it("supports key-based throttling with separate threshold", () => { + const policy = new LoginAbusePolicy({ + maxAttemptsPerIp: 10, + maxAttemptsPerKey: 2, + lockoutSeconds: 60, + }); + + policy.recordFailure("10.0.0.10", "user@example.com"); + policy.recordFailure("10.0.0.11", "user@example.com"); + + const blockedByKey = policy.check("10.0.0.12", "user@example.com"); + expect(blockedByKey.allowed).toBe(false); + expect(blockedByKey.reason).toBe("key_rate_limited"); + + expect(policy.check("10.0.0.10", "other@example.com")).toEqual({ allowed: true }); + }); + + it("sweeps stale entries to prevent unbounded memory growth", () => { + const policy = new LoginAbusePolicy({ + maxAttemptsPerIp: 2, + windowSeconds: 5, + lockoutSeconds: 10, + }); + + for (let i = 0; i < 100; i++) { + policy.recordFailure(`10.0.${Math.floor(i / 256)}.${i % 256}`); + } + + vi.advanceTimersByTime(61_000); + + policy.check("10.0.99.99"); + + for (let i = 0; i < 100; i++) { + const ip = `10.0.${Math.floor(i / 256)}.${i % 256}`; + expect(policy.check(ip)).toEqual({ allowed: true }); + } + }); + + it("uses LRU eviction: recently accessed entries survive over stale ones", () => { + const policy = new LoginAbusePolicy({ + maxAttemptsPerIp: 5, + windowSeconds: 600, + lockoutSeconds: 900, + }); + + // Fill 10_050 entries via recordFailure (does NOT trigger sweep). + const totalEntries = 10_050; + for (let i = 0; i < totalEntries; i++) { + const ip = `${Math.floor(i / 65536) % 256}.${Math.floor(i / 256) % 256}.${i % 256}.1`; + policy.recordFailure(ip); + } + + // "Touch" an early IP via recordFailure - LRU bump moves it to the end. + // Position 10 (i=10) is inside the eviction range [0..49], so without + // the LRU bump this entry WOULD be evicted. + const touchedIp = "0.0.10.1"; + policy.recordFailure(touchedIp); + + // Pick an un-bumped IP also inside the eviction range to verify it IS evicted. + const evictedIp = "0.0.5.1"; + + // Trigger a sweep by calling check (lastSweepAt=0, so sweep interval met). + // Sweep finds size 10_050 > 10_000, evicts 50 from the start. + // The touchedIp was bumped to end, so it survives eviction. + vi.advanceTimersByTime(61_000); + policy.check("99.99.99.99"); + + // Negative assertion: un-bumped early entry was evicted (starts fresh). + expect(policy.check(evictedIp)).toEqual({ allowed: true }); + + // touchedIp had 1 (initial) + 1 (bump) = 2 failures. + // Record 3 more to hit threshold of 5. + policy.recordFailure(touchedIp); + policy.recordFailure(touchedIp); + policy.recordFailure(touchedIp); + + const decision = policy.check(touchedIp); + expect(decision.allowed).toBe(false); + expect(decision.reason).toBe("ip_rate_limited"); + }); + + it("LRU bump in recordFailureForScope preserves active entries", () => { + const policy = new LoginAbusePolicy({ + maxAttemptsPerIp: 10, + windowSeconds: 600, + lockoutSeconds: 900, + }); + + // Fill with stale entries + for (let i = 0; i < 10_050; i++) { + const ip = `${Math.floor(i / 65536) % 256}.${Math.floor(i / 256) % 256}.${i % 256}.2`; + policy.recordFailure(ip); + } + + // Record additional failures on an early entry (LRU bump via recordFailure) + const activeIp = "0.0.10.2"; + policy.recordFailure(activeIp); + + // Trigger sweep + vi.advanceTimersByTime(61_000); + policy.check("99.99.99.99"); + + // The actively-failed IP should still be tracked + // Record enough total failures to trigger lockout (it had 1 initial + 1 bump = 2) + for (let j = 0; j < 8; j++) { + policy.recordFailure(activeIp); + } + const decision = policy.check(activeIp); + expect(decision.allowed).toBe(false); + expect(decision.reason).toBe("ip_rate_limited"); + }); +}); diff --git a/tests/security/proxy-auth-rate-limit.test.ts b/tests/security/proxy-auth-rate-limit.test.ts new file mode 100644 index 000000000..debd6870a --- /dev/null +++ b/tests/security/proxy-auth-rate-limit.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * Tests for the proxy auth pre-auth rate limiter. + * + * The rate limiter is a module-level LoginAbusePolicy instance inside + * auth-guard.ts. Since it relies on ProxySession (which depends on Hono + * Context), we test the underlying LoginAbusePolicy behaviour that the + * guard delegates to, plus the IP extraction helper logic. + */ + +// We test the LoginAbusePolicy directly with proxy-specific config +import { LoginAbusePolicy } from "@/lib/security/login-abuse-policy"; + +describe("Proxy pre-auth rate limiter (LoginAbusePolicy with proxy config)", () => { + const nowMs = 1_700_000_000_000; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(nowMs)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("allows requests below the proxy threshold (20)", () => { + const policy = new LoginAbusePolicy({ + maxAttemptsPerIp: 20, + maxAttemptsPerKey: 20, + windowSeconds: 300, + lockoutSeconds: 600, + }); + const ip = "10.0.0.1"; + + for (let i = 0; i < 19; i++) { + policy.recordFailure(ip); + } + + expect(policy.check(ip)).toEqual({ allowed: true }); + }); + + it("blocks after 20 consecutive failures", () => { + const policy = new LoginAbusePolicy({ + maxAttemptsPerIp: 20, + maxAttemptsPerKey: 20, + windowSeconds: 300, + lockoutSeconds: 600, + }); + const ip = "10.0.0.2"; + + for (let i = 0; i < 20; i++) { + policy.recordFailure(ip); + } + + const decision = policy.check(ip); + expect(decision.allowed).toBe(false); + expect(decision.retryAfterSeconds).toBe(600); + }); + + it("resets failure count after success", () => { + const policy = new LoginAbusePolicy({ + maxAttemptsPerIp: 20, + maxAttemptsPerKey: 20, + windowSeconds: 300, + lockoutSeconds: 600, + }); + const ip = "10.0.0.3"; + + for (let i = 0; i < 15; i++) { + policy.recordFailure(ip); + } + + policy.recordSuccess(ip); + + // After success, counter is reset — 5 more failures should be allowed + for (let i = 0; i < 5; i++) { + policy.recordFailure(ip); + } + expect(policy.check(ip)).toEqual({ allowed: true }); + }); + + it("unlocks after lockout period expires", () => { + const policy = new LoginAbusePolicy({ + maxAttemptsPerIp: 20, + maxAttemptsPerKey: 20, + windowSeconds: 300, + lockoutSeconds: 600, + }); + const ip = "10.0.0.4"; + + for (let i = 0; i < 20; i++) { + policy.recordFailure(ip); + } + + expect(policy.check(ip).allowed).toBe(false); + + // Advance past lockout + vi.advanceTimersByTime(601_000); + expect(policy.check(ip).allowed).toBe(true); + }); + + it("tracks different IPs independently", () => { + const policy = new LoginAbusePolicy({ + maxAttemptsPerIp: 3, + maxAttemptsPerKey: 3, + windowSeconds: 300, + lockoutSeconds: 600, + }); + + const ipA = "10.0.0.10"; + const ipB = "10.0.0.11"; + + for (let i = 0; i < 3; i++) { + policy.recordFailure(ipA); + } + + expect(policy.check(ipA).allowed).toBe(false); + expect(policy.check(ipB).allowed).toBe(true); + }); +}); + +describe("extractClientIp logic (rightmost x-forwarded-for)", () => { + it("takes rightmost IP from x-forwarded-for", () => { + // Simulates: client spoofs leftmost, proxy appends real IP + const forwarded = "spoofed-ip, real-client-ip"; + const ips = forwarded + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + expect(ips[ips.length - 1]).toBe("real-client-ip"); + }); + + it("handles single IP in x-forwarded-for", () => { + const forwarded = "192.168.1.1"; + const ips = forwarded + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + expect(ips[ips.length - 1]).toBe("192.168.1.1"); + }); + + it("prefers x-real-ip over x-forwarded-for", () => { + // The implementation checks x-real-ip first + const realIp = "10.0.0.1"; + const forwarded = "spoofed, 10.0.0.2"; + + // x-real-ip is present and non-empty → use it + const result = realIp.trim() || undefined; + expect(result).toBe("10.0.0.1"); + }); + + it("returns 'unknown' when no headers present", () => { + const realIp: string | null = null; + const forwarded: string | null = null; + + const result = realIp?.trim() || forwarded || "unknown"; + expect(result).toBe("unknown"); + }); +}); diff --git a/tests/security/security-headers-integration.test.ts b/tests/security/security-headers-integration.test.ts new file mode 100644 index 000000000..ae97746b9 --- /dev/null +++ b/tests/security/security-headers-integration.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { applyCors } from "../../src/app/v1/_lib/cors"; + +const mockValidateKey = vi.hoisted(() => vi.fn()); +const mockSetAuthCookie = vi.hoisted(() => vi.fn()); +const mockGetSessionTokenMode = vi.hoisted(() => vi.fn()); +const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn()); +const mockClearAuthCookie = vi.hoisted(() => vi.fn()); +const mockGetAuthCookie = vi.hoisted(() => vi.fn()); +const mockGetTranslations = vi.hoisted(() => vi.fn()); +const mockGetEnvConfig = vi.hoisted(() => vi.fn()); +const mockLogger = vi.hoisted(() => ({ + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + validateKey: mockValidateKey, + setAuthCookie: mockSetAuthCookie, + getSessionTokenMode: mockGetSessionTokenMode, + getLoginRedirectTarget: mockGetLoginRedirectTarget, + clearAuthCookie: mockClearAuthCookie, + getAuthCookie: mockGetAuthCookie, + withNoStoreHeaders: (response: T): T => { + (response as Response).headers.set("Cache-Control", "no-store, no-cache, must-revalidate"); + (response as Response).headers.set("Pragma", "no-cache"); + return response; + }, +})); + +vi.mock("next-intl/server", () => ({ + getTranslations: mockGetTranslations, +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: mockGetEnvConfig, +})); + +vi.mock("@/lib/logger", () => ({ + logger: mockLogger, +})); + +type LoginPostHandler = (request: NextRequest) => Promise; +type LogoutPostHandler = (request: NextRequest) => Promise; + +function makeLoginRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function makeLogoutRequest(): NextRequest { + return new NextRequest("http://localhost/api/auth/logout", { + method: "POST", + }); +} + +function expectSharedSecurityHeaders(response: Response) { + expect(response.headers.get("X-Frame-Options")).toBe("DENY"); + expect(response.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin"); + expect(response.headers.get("X-DNS-Prefetch-Control")).toBe("off"); +} + +const fakeSession = { + user: { + id: 1, + name: "Test User", + description: "desc", + role: "user" as const, + }, + key: { + canLoginWebUi: true, + }, +}; + +describe("security headers auth route integration", () => { + let loginPost: LoginPostHandler; + let logoutPost: LogoutPostHandler; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + + const t = vi.fn((messageKey: string) => `translated:${messageKey}`); + mockGetTranslations.mockResolvedValue(t); + mockValidateKey.mockResolvedValue(fakeSession); + mockSetAuthCookie.mockResolvedValue(undefined); + mockGetSessionTokenMode.mockReturnValue("legacy"); + mockGetLoginRedirectTarget.mockReturnValue("/dashboard"); + mockClearAuthCookie.mockResolvedValue(undefined); + mockGetAuthCookie.mockResolvedValue(undefined); + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false }); + + const loginRoute = await import("../../src/app/api/auth/login/route"); + loginPost = loginRoute.POST; + + const logoutRoute = await import("../../src/app/api/auth/logout/route"); + logoutPost = logoutRoute.POST; + }); + + it("login success response includes security headers", async () => { + const res = await loginPost(makeLoginRequest({ key: "valid-key" })); + + expect(res.status).toBe(200); + expectSharedSecurityHeaders(res); + expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); + }); + + it("login error response includes security headers", async () => { + const res = await loginPost(makeLoginRequest({})); + + expect(res.status).toBe(400); + expectSharedSecurityHeaders(res); + expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); + }); + + it("logout response includes security headers", async () => { + const res = await logoutPost(makeLogoutRequest()); + + expect(res.status).toBe(200); + expectSharedSecurityHeaders(res); + expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); + }); + + it("CSP is applied in report-only mode by default", async () => { + const res = await loginPost(makeLoginRequest({ key: "valid-key" })); + + expect(res.headers.get("Content-Security-Policy-Report-Only")).toContain("default-src 'self'"); + expect(res.headers.get("Content-Security-Policy")).toBeNull(); + }); + + it("HSTS is present when ENABLE_SECURE_COOKIES=true", async () => { + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: true }); + + const res = await loginPost(makeLoginRequest({ key: "valid-key" })); + + expect(res.headers.get("Strict-Transport-Security")).toBe( + "max-age=31536000; includeSubDomains" + ); + }); + + it("HSTS is absent when ENABLE_SECURE_COOKIES=false", async () => { + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false }); + + const res = await logoutPost(makeLogoutRequest()); + + expect(res.headers.get("Strict-Transport-Security")).toBeNull(); + }); + + it("X-Content-Type-Options is always nosniff", async () => { + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: true }); + const secureRes = await loginPost(makeLoginRequest({ key: "valid-key" })); + + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false }); + const errorRes = await loginPost(makeLoginRequest({})); + const logoutRes = await logoutPost(makeLogoutRequest()); + + expect(secureRes.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(errorRes.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(logoutRes.headers.get("X-Content-Type-Options")).toBe("nosniff"); + }); + + it("security headers remain compatible with existing CORS headers", async () => { + const res = await loginPost(makeLoginRequest({ key: "valid-key" })); + const corsRes = applyCors(res, { + origin: "https://client.example.com", + requestHeaders: "content-type,x-api-key", + }); + + // Without allowCredentials, origin is NOT reflected — stays as wildcard + expect(corsRes.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(corsRes.headers.get("Access-Control-Allow-Credentials")).toBeNull(); + expect(corsRes.headers.get("Access-Control-Allow-Headers")).toBe("content-type,x-api-key"); + expect(corsRes.headers.get("Content-Security-Policy-Report-Only")).toContain( + "default-src 'self'" + ); + expect(corsRes.headers.get("X-Content-Type-Options")).toBe("nosniff"); + }); + + it("CORS reflects origin only when allowCredentials is explicitly set", async () => { + const res = await loginPost(makeLoginRequest({ key: "valid-key" })); + const corsRes = applyCors(res, { + origin: "https://trusted.example.com", + requestHeaders: "content-type", + allowCredentials: true, + }); + + expect(corsRes.headers.get("Access-Control-Allow-Origin")).toBe("https://trusted.example.com"); + expect(corsRes.headers.get("Access-Control-Allow-Credentials")).toBe("true"); + }); +}); diff --git a/tests/security/security-headers.test.ts b/tests/security/security-headers.test.ts new file mode 100644 index 000000000..7647a7294 --- /dev/null +++ b/tests/security/security-headers.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "vitest"; +import { + buildSecurityHeaders, + DEFAULT_SECURITY_HEADERS_CONFIG, +} from "../../src/lib/security/security-headers"; + +describe("buildSecurityHeaders", () => { + test("默认配置应生成预期安全头", () => { + const headers = buildSecurityHeaders(); + + expect(headers["X-Content-Type-Options"]).toBe("nosniff"); + expect(headers["X-Frame-Options"]).toBe(DEFAULT_SECURITY_HEADERS_CONFIG.frameOptions); + expect(headers["Referrer-Policy"]).toBe("strict-origin-when-cross-origin"); + expect(headers["X-DNS-Prefetch-Control"]).toBe("off"); + expect(headers["Strict-Transport-Security"]).toBeUndefined(); + expect(headers["Content-Security-Policy"]).toBeUndefined(); + expect(headers["Content-Security-Policy-Report-Only"]).toContain("default-src 'self'"); + }); + + test("enableHsts=true 时应包含 HSTS 头", () => { + const headers = buildSecurityHeaders({ enableHsts: true }); + + expect(headers["Strict-Transport-Security"]).toBe( + `max-age=${DEFAULT_SECURITY_HEADERS_CONFIG.hstsMaxAge}; includeSubDomains` + ); + }); + + test("enableHsts=false 时不应包含 HSTS 头", () => { + const headers = buildSecurityHeaders({ enableHsts: false }); + + expect(headers["Strict-Transport-Security"]).toBeUndefined(); + }); + + test("CSP report-only 模式应使用 Report-Only 头", () => { + const headers = buildSecurityHeaders({ cspMode: "report-only" }); + + expect(headers["Content-Security-Policy-Report-Only"]).toContain("default-src 'self'"); + expect(headers["Content-Security-Policy"]).toBeUndefined(); + }); + + test("CSP enforce 模式应使用强制策略头", () => { + const headers = buildSecurityHeaders({ cspMode: "enforce" }); + + expect(headers["Content-Security-Policy"]).toContain("default-src 'self'"); + expect(headers["Content-Security-Policy-Report-Only"]).toBeUndefined(); + }); + + test("CSP disabled 模式不应输出任何 CSP 头", () => { + const headers = buildSecurityHeaders({ cspMode: "disabled" }); + + expect(headers["Content-Security-Policy"]).toBeUndefined(); + expect(headers["Content-Security-Policy-Report-Only"]).toBeUndefined(); + }); + + test("X-Content-Type-Options 始终为 nosniff", () => { + const defaultHeaders = buildSecurityHeaders(); + const disabledCspHeaders = buildSecurityHeaders({ cspMode: "disabled" }); + const enforceCspHeaders = buildSecurityHeaders({ cspMode: "enforce", enableHsts: true }); + + expect(defaultHeaders["X-Content-Type-Options"]).toBe("nosniff"); + expect(disabledCspHeaders["X-Content-Type-Options"]).toBe("nosniff"); + expect(enforceCspHeaders["X-Content-Type-Options"]).toBe("nosniff"); + }); + + test("X-Frame-Options 应与配置一致", () => { + const denyHeaders = buildSecurityHeaders({ frameOptions: "DENY" }); + const sameOriginHeaders = buildSecurityHeaders({ frameOptions: "SAMEORIGIN" }); + + expect(denyHeaders["X-Frame-Options"]).toBe("DENY"); + expect(sameOriginHeaders["X-Frame-Options"]).toBe("SAMEORIGIN"); + }); + + test("cspReportUri with valid URL appends report-uri directive", () => { + const headers = buildSecurityHeaders({ + cspMode: "report-only", + cspReportUri: "https://csp.example.com/report", + }); + + expect(headers["Content-Security-Policy-Report-Only"]).toContain( + "; report-uri https://csp.example.com/report" + ); + }); + + test("cspReportUri with semicolons is rejected to prevent directive injection", () => { + const headers = buildSecurityHeaders({ + cspMode: "enforce", + cspReportUri: "https://evil.com; script-src 'unsafe-eval'", + }); + + expect(headers["Content-Security-Policy"]).not.toContain("report-uri"); + expect(headers["Content-Security-Policy"]).not.toContain("evil.com"); + }); + + test("cspReportUri with non-URL value is rejected", () => { + const headers = buildSecurityHeaders({ + cspMode: "enforce", + cspReportUri: "not a url", + }); + + expect(headers["Content-Security-Policy"]).not.toContain("report-uri"); + }); + + test("cspReportUri with empty string is rejected", () => { + const headers = buildSecurityHeaders({ + cspMode: "enforce", + cspReportUri: "", + }); + + expect(headers["Content-Security-Policy"]).not.toContain("report-uri"); + }); +}); diff --git a/tests/security/session-contract.test.ts b/tests/security/session-contract.test.ts new file mode 100644 index 000000000..f94929736 --- /dev/null +++ b/tests/security/session-contract.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const ORIGINAL_SESSION_TOKEN_MODE = process.env.SESSION_TOKEN_MODE; + +function restoreSessionTokenModeEnv() { + if (ORIGINAL_SESSION_TOKEN_MODE === undefined) { + delete process.env.SESSION_TOKEN_MODE; + return; + } + process.env.SESSION_TOKEN_MODE = ORIGINAL_SESSION_TOKEN_MODE; +} + +describe("session token contract and migration flags", () => { + afterEach(() => { + restoreSessionTokenModeEnv(); + vi.resetModules(); + }); + + it("SESSION_TOKEN_MODE defaults to opaque", async () => { + delete process.env.SESSION_TOKEN_MODE; + + vi.resetModules(); + const { getSessionTokenMode } = await import("@/lib/auth"); + + expect(getSessionTokenMode()).toBe("opaque"); + }); + + it("getSessionTokenMode returns configured mode values", async () => { + const modes = ["legacy", "dual", "opaque"] as const; + + for (const mode of modes) { + process.env.SESSION_TOKEN_MODE = mode; + + vi.resetModules(); + const { getSessionTokenMode } = await import("@/lib/auth"); + + expect(getSessionTokenMode()).toBe(mode); + } + }); + + it("validates OpaqueSessionContract runtime shape strictly", async () => { + vi.resetModules(); + const { isOpaqueSessionContract } = await import("@/lib/auth"); + + const validContract = { + sessionId: "sid_opaque_session_123", + keyFingerprint: "sha256:abc123", + createdAt: 1_700_000_000, + expiresAt: 1_700_000_300, + userId: 42, + userRole: "admin", + }; + + expect(isOpaqueSessionContract(validContract)).toBe(true); + expect( + isOpaqueSessionContract({ + ...validContract, + keyFingerprint: "", + }) + ).toBe(false); + expect( + isOpaqueSessionContract({ + ...validContract, + expiresAt: validContract.createdAt, + }) + ).toBe(false); + expect( + isOpaqueSessionContract({ + ...validContract, + userId: 3.14, + }) + ).toBe(false); + }); + + it("accepts both legacy cookie and opaque session in dual mode", async () => { + process.env.SESSION_TOKEN_MODE = "dual"; + + vi.resetModules(); + const { getSessionTokenMode, getSessionTokenMigrationFlags, isSessionTokenAccepted } = + await import("@/lib/auth"); + + const mode = getSessionTokenMode(); + expect(mode).toBe("dual"); + expect(getSessionTokenMigrationFlags(mode)).toEqual({ + dualReadWindowEnabled: true, + hardCutoverEnabled: false, + emergencyRollbackEnabled: false, + }); + + expect(isSessionTokenAccepted("sk-legacy-cookie", mode)).toBe(true); + expect(isSessionTokenAccepted("sid_opaque_session_cookie", mode)).toBe(true); + }); + + it("accepts only legacy cookie in legacy mode", async () => { + process.env.SESSION_TOKEN_MODE = "legacy"; + + vi.resetModules(); + const { getSessionTokenMode, getSessionTokenMigrationFlags, isSessionTokenAccepted } = + await import("@/lib/auth"); + + const mode = getSessionTokenMode(); + expect(mode).toBe("legacy"); + expect(getSessionTokenMigrationFlags(mode)).toEqual({ + dualReadWindowEnabled: false, + hardCutoverEnabled: false, + emergencyRollbackEnabled: true, + }); + + expect(isSessionTokenAccepted("sk-legacy-cookie", mode)).toBe(true); + expect(isSessionTokenAccepted("sid_opaque_session_cookie", mode)).toBe(false); + }); +}); diff --git a/tests/security/session-cookie-hardening.test.ts b/tests/security/session-cookie-hardening.test.ts new file mode 100644 index 000000000..45dd85149 --- /dev/null +++ b/tests/security/session-cookie-hardening.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +const mockValidateKey = vi.hoisted(() => vi.fn()); +const mockSetAuthCookie = vi.hoisted(() => vi.fn()); +const mockGetSessionTokenMode = vi.hoisted(() => vi.fn()); +const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn()); +const mockGetTranslations = vi.hoisted(() => vi.fn()); +const mockLogger = vi.hoisted(() => ({ + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})); +const mockCookieSet = vi.hoisted(() => vi.fn()); +const mockCookies = vi.hoisted(() => vi.fn()); +const mockGetEnvConfig = vi.hoisted(() => vi.fn()); +const mockClearAuthCookie = vi.hoisted(() => vi.fn()); + +const realWithNoStoreHeaders = vi.hoisted(() => { + return >(response: T): T => { + response.headers.set("Cache-Control", "no-store, no-cache, must-revalidate"); + response.headers.set("Pragma", "no-cache"); + return response; + }; +}); + +vi.mock("@/lib/auth", () => ({ + validateKey: mockValidateKey, + setAuthCookie: mockSetAuthCookie, + getSessionTokenMode: mockGetSessionTokenMode, + clearAuthCookie: mockClearAuthCookie, + getLoginRedirectTarget: mockGetLoginRedirectTarget, + toKeyFingerprint: vi.fn().mockResolvedValue("sha256:mock"), + withNoStoreHeaders: realWithNoStoreHeaders, +})); + +vi.mock("next-intl/server", () => ({ + getTranslations: mockGetTranslations, +})); + +vi.mock("@/lib/logger", () => ({ + logger: mockLogger, +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: mockGetEnvConfig, +})); + +vi.mock("@/lib/security/auth-response-headers", () => ({ + withAuthResponseHeaders: realWithNoStoreHeaders, +})); + +vi.mock("@/lib/config/config", () => ({ config: { auth: { adminToken: "test" } } })); +vi.mock("@/repository/key", () => ({ validateApiKeyAndGetUser: vi.fn() })); + +vi.mock("next/headers", () => ({ + cookies: mockCookies, + headers: vi.fn().mockResolvedValue(new Headers()), +})); + +const EXPECTED_CACHE_CONTROL = "no-store, no-cache, must-revalidate"; +const EXPECTED_PRAGMA = "no-cache"; + +function makeLoginRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function makeLogoutRequest(): NextRequest { + return new NextRequest("http://localhost/api/auth/logout", { + method: "POST", + }); +} + +const fakeSession = { + user: { id: 1, name: "Test User", description: "desc", role: "user" as const }, + key: { canLoginWebUi: true }, +}; + +describe("session cookie hardening", () => { + describe("withNoStoreHeaders utility", () => { + it("sets Cache-Control header", () => { + const res = NextResponse.json({ ok: true }); + const result = realWithNoStoreHeaders(res); + expect(result.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL); + }); + + it("sets Pragma header", () => { + const res = NextResponse.json({ ok: true }); + const result = realWithNoStoreHeaders(res); + expect(result.headers.get("Pragma")).toBe(EXPECTED_PRAGMA); + }); + + it("returns the same response object", () => { + const res = NextResponse.json({ ok: true }); + const result = realWithNoStoreHeaders(res); + expect(result).toBe(res); + }); + }); + + describe("login route no-store headers", () => { + let POST: (request: NextRequest) => Promise; + + beforeEach(async () => { + vi.clearAllMocks(); + const mockT = vi.fn((key: string) => `translated:${key}`); + mockGetTranslations.mockResolvedValue(mockT); + mockSetAuthCookie.mockResolvedValue(undefined); + mockGetSessionTokenMode.mockReturnValue("legacy"); + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false }); + + const mod = await import("@/app/api/auth/login/route"); + POST = mod.POST; + }); + + it("success response includes Cache-Control: no-store", async () => { + mockValidateKey.mockResolvedValue(fakeSession); + mockGetLoginRedirectTarget.mockReturnValue("/dashboard"); + + const res = await POST(makeLoginRequest({ key: "valid" })); + + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL); + }); + + it("success response includes Pragma: no-cache", async () => { + mockValidateKey.mockResolvedValue(fakeSession); + mockGetLoginRedirectTarget.mockReturnValue("/dashboard"); + + const res = await POST(makeLoginRequest({ key: "valid" })); + + expect(res.headers.get("Pragma")).toBe(EXPECTED_PRAGMA); + }); + + it("400 error response includes Cache-Control: no-store", async () => { + const res = await POST(makeLoginRequest({})); + + expect(res.status).toBe(400); + expect(res.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL); + }); + + it("400 error response includes Pragma: no-cache", async () => { + const res = await POST(makeLoginRequest({})); + + expect(res.headers.get("Pragma")).toBe(EXPECTED_PRAGMA); + }); + + it("401 error response includes Cache-Control: no-store", async () => { + mockValidateKey.mockResolvedValue(null); + + const res = await POST(makeLoginRequest({ key: "bad" })); + + expect(res.status).toBe(401); + expect(res.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL); + }); + + it("401 error response includes Pragma: no-cache", async () => { + mockValidateKey.mockResolvedValue(null); + + const res = await POST(makeLoginRequest({ key: "bad" })); + + expect(res.headers.get("Pragma")).toBe(EXPECTED_PRAGMA); + }); + + it("500 error response includes no-store headers", async () => { + mockValidateKey.mockRejectedValue(new Error("db down")); + + const res = await POST(makeLoginRequest({ key: "any" })); + + expect(res.status).toBe(500); + expect(res.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL); + expect(res.headers.get("Pragma")).toBe(EXPECTED_PRAGMA); + }); + }); + + describe("logout route no-store headers", () => { + let POST: (request: NextRequest) => Promise; + + beforeEach(async () => { + vi.clearAllMocks(); + mockClearAuthCookie.mockResolvedValue(undefined); + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false }); + + const mod = await import("@/app/api/auth/logout/route"); + POST = mod.POST; + }); + + it("response includes Cache-Control: no-store", async () => { + const res = await POST(makeLogoutRequest()); + + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe(EXPECTED_CACHE_CONTROL); + }); + + it("response includes Pragma: no-cache", async () => { + const res = await POST(makeLogoutRequest()); + + expect(res.headers.get("Pragma")).toBe(EXPECTED_PRAGMA); + }); + }); +}); diff --git a/tests/security/session-fixation-rotation.test.ts b/tests/security/session-fixation-rotation.test.ts new file mode 100644 index 000000000..a43ceec68 --- /dev/null +++ b/tests/security/session-fixation-rotation.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import type { NextResponse } from "next/server"; + +const { + mockClearAuthCookie, + mockGetAuthCookie, + mockGetSessionTokenMode, + mockRevoke, + mockRotate, + mockRedisSessionStoreCtor, + mockLogger, +} = vi.hoisted(() => { + const mockRevoke = vi.fn(); + const mockRotate = vi.fn(); + + return { + mockClearAuthCookie: vi.fn(), + mockGetAuthCookie: vi.fn(), + mockGetSessionTokenMode: vi.fn(), + mockRevoke, + mockRotate, + mockRedisSessionStoreCtor: vi.fn().mockImplementation(function RedisSessionStoreMock() { + return { + revoke: mockRevoke, + rotate: mockRotate, + }; + }), + mockLogger: { + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + }, + }; +}); + +const realWithNoStoreHeaders = vi.hoisted(() => { + return >(response: T): T => { + response.headers.set("Cache-Control", "no-store, no-cache, must-revalidate"); + response.headers.set("Pragma", "no-cache"); + return response; + }; +}); + +vi.mock("@/lib/auth", () => ({ + clearAuthCookie: mockClearAuthCookie, + getAuthCookie: mockGetAuthCookie, + getSessionTokenMode: mockGetSessionTokenMode, + withNoStoreHeaders: realWithNoStoreHeaders, +})); + +vi.mock("@/lib/auth-session-store/redis-session-store", () => ({ + RedisSessionStore: mockRedisSessionStoreCtor, +})); + +vi.mock("@/lib/logger", () => ({ + logger: mockLogger, +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: vi.fn().mockReturnValue({ ENABLE_SECURE_COOKIES: false }), +})); + +vi.mock("@/lib/security/auth-response-headers", () => ({ + withAuthResponseHeaders: realWithNoStoreHeaders, +})); + +function makeLogoutRequest(): NextRequest { + return new NextRequest("http://localhost/api/auth/logout", { + method: "POST", + headers: { + "sec-fetch-site": "same-origin", + }, + }); +} + +async function loadLogoutPost(): Promise<(request: NextRequest) => Promise> { + const mod = await import("@/app/api/auth/logout/route"); + return mod.POST; +} + +async function simulatePostLoginSessionRotation( + oldSessionId: string, + rotate: (sessionId: string) => Promise<{ sessionId: string } | null> +): Promise { + const rotated = await rotate(oldSessionId); + return rotated?.sessionId ?? null; +} + +describe("session fixation rotation and logout revocation", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mockRedisSessionStoreCtor.mockImplementation(function RedisSessionStoreMock() { + return { + revoke: mockRevoke, + rotate: mockRotate, + }; + }); + mockClearAuthCookie.mockResolvedValue(undefined); + mockGetAuthCookie.mockResolvedValue(undefined); + mockGetSessionTokenMode.mockReturnValue("legacy"); + mockRevoke.mockResolvedValue(true); + mockRotate.mockResolvedValue(null); + }); + + it("legacy mode logout only clears cookie without session store revocation", async () => { + mockGetSessionTokenMode.mockReturnValue("legacy"); + const POST = await loadLogoutPost(); + + const response = await POST(makeLogoutRequest()); + + expect(response.status).toBe(200); + expect(mockRedisSessionStoreCtor).not.toHaveBeenCalled(); + expect(mockRevoke).not.toHaveBeenCalled(); + expect(mockClearAuthCookie).toHaveBeenCalledTimes(1); + }); + + it("dual mode logout revokes session and clears cookie", async () => { + mockGetSessionTokenMode.mockReturnValue("dual"); + mockGetAuthCookie.mockResolvedValue("sid_dual_session"); + const POST = await loadLogoutPost(); + + const response = await POST(makeLogoutRequest()); + + expect(response.status).toBe(200); + expect(mockRedisSessionStoreCtor).toHaveBeenCalledTimes(1); + expect(mockRevoke).toHaveBeenCalledWith("sid_dual_session"); + expect(mockClearAuthCookie).toHaveBeenCalledTimes(1); + }); + + it("opaque mode logout revokes session and clears cookie", async () => { + mockGetSessionTokenMode.mockReturnValue("opaque"); + mockGetAuthCookie.mockResolvedValue("sid_opaque_session"); + const POST = await loadLogoutPost(); + + const response = await POST(makeLogoutRequest()); + + expect(response.status).toBe(200); + expect(mockRedisSessionStoreCtor).toHaveBeenCalledTimes(1); + expect(mockRevoke).toHaveBeenCalledWith("sid_opaque_session"); + expect(mockClearAuthCookie).toHaveBeenCalledTimes(1); + }); + + it("logout still clears cookie when session revocation fails", async () => { + mockGetSessionTokenMode.mockReturnValue("opaque"); + mockGetAuthCookie.mockResolvedValue("sid_revocation_failure"); + mockRevoke.mockRejectedValue(new Error("redis down")); + const POST = await loadLogoutPost(); + + const response = await POST(makeLogoutRequest()); + + expect(response.status).toBe(200); + expect(mockRevoke).toHaveBeenCalledWith("sid_revocation_failure"); + expect(mockClearAuthCookie).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + }); + + it("post-login rotation returns a different session id", async () => { + const oldSessionId = "sid_existing_session"; + mockRotate.mockResolvedValue({ + sessionId: "sid_rotated_session", + keyFingerprint: "fp-login", + userId: 7, + userRole: "user", + createdAt: 1_700_000_000_000, + expiresAt: 1_700_000_300_000, + }); + + const rotatedSessionId = await simulatePostLoginSessionRotation(oldSessionId, mockRotate); + + expect(mockRotate).toHaveBeenCalledWith(oldSessionId); + expect(rotatedSessionId).toBe("sid_rotated_session"); + expect(rotatedSessionId).not.toBe(oldSessionId); + }); +}); diff --git a/tests/security/session-login-integration.test.ts b/tests/security/session-login-integration.test.ts new file mode 100644 index 000000000..4c825e248 --- /dev/null +++ b/tests/security/session-login-integration.test.ts @@ -0,0 +1,237 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mockValidateKey = vi.hoisted(() => vi.fn()); +const mockSetAuthCookie = vi.hoisted(() => vi.fn()); +const mockGetSessionTokenMode = vi.hoisted(() => vi.fn()); +const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn()); +const mockToKeyFingerprint = vi.hoisted(() => vi.fn()); +const mockGetTranslations = vi.hoisted(() => vi.fn()); +const mockCreateSession = vi.hoisted(() => vi.fn()); +const mockGetEnvConfig = vi.hoisted(() => vi.fn()); +const mockLogger = vi.hoisted(() => ({ + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})); + +const realWithNoStoreHeaders = vi.hoisted(() => { + return (response: any) => { + response.headers.set("Cache-Control", "no-store, no-cache, must-revalidate"); + response.headers.set("Pragma", "no-cache"); + return response; + }; +}); + +vi.mock("@/lib/auth", () => ({ + validateKey: mockValidateKey, + setAuthCookie: mockSetAuthCookie, + getSessionTokenMode: mockGetSessionTokenMode, + getLoginRedirectTarget: mockGetLoginRedirectTarget, + toKeyFingerprint: mockToKeyFingerprint, + withNoStoreHeaders: realWithNoStoreHeaders, +})); + +vi.mock("@/lib/auth-session-store/redis-session-store", () => ({ + RedisSessionStore: class { + create = mockCreateSession; + }, +})); + +vi.mock("next-intl/server", () => ({ + getTranslations: mockGetTranslations, +})); + +vi.mock("@/lib/logger", () => ({ + logger: mockLogger, +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: mockGetEnvConfig, +})); + +vi.mock("@/lib/security/auth-response-headers", () => ({ + withAuthResponseHeaders: realWithNoStoreHeaders, +})); + +function makeRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const dashboardSession = { + user: { + id: 1, + name: "Test User", + description: "desc", + role: "user" as const, + }, + key: { canLoginWebUi: true }, +}; + +const readonlySession = { + user: { + id: 2, + name: "Readonly User", + description: "readonly", + role: "user" as const, + }, + key: { canLoginWebUi: false }, +}; + +describe("POST /api/auth/login session token mode integration", () => { + let POST: (request: NextRequest) => Promise; + + beforeEach(async () => { + vi.clearAllMocks(); + const mockT = vi.fn((key: string) => `translated:${key}`); + mockGetTranslations.mockResolvedValue(mockT); + + mockValidateKey.mockResolvedValue(dashboardSession); + mockSetAuthCookie.mockResolvedValue(undefined); + mockGetSessionTokenMode.mockReturnValue("legacy"); + mockGetLoginRedirectTarget.mockReturnValue("/dashboard"); + mockToKeyFingerprint.mockResolvedValue( + "sha256:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + ); + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false }); + mockCreateSession.mockResolvedValue({ + sessionId: "sid_opaque_session_123", + keyFingerprint: "sha256:abcdef", + userId: 1, + userRole: "user", + createdAt: 100, + expiresAt: 200, + }); + + const mod = await import("../../src/app/api/auth/login/route"); + POST = mod.POST; + }); + + it("legacy mode keeps raw key cookie and does not create opaque session", async () => { + mockGetSessionTokenMode.mockReturnValue("legacy"); + + const res = await POST(makeRequest({ key: "legacy-key" })); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(mockSetAuthCookie).toHaveBeenCalledTimes(1); + expect(mockSetAuthCookie).toHaveBeenCalledWith("legacy-key"); + expect(mockCreateSession).not.toHaveBeenCalled(); + expect(json.redirectTo).toBe("/dashboard"); + expect(json.loginType).toBe("dashboard_user"); + }); + + it("dual mode sets legacy cookie and creates opaque session in store", async () => { + mockGetSessionTokenMode.mockReturnValue("dual"); + + const res = await POST(makeRequest({ key: "dual-key" })); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(mockSetAuthCookie).toHaveBeenCalledTimes(1); + expect(mockSetAuthCookie).toHaveBeenCalledWith("dual-key"); + expect(mockCreateSession).toHaveBeenCalledTimes(1); + expect(mockCreateSession).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 1, + userRole: "user", + keyFingerprint: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), + }) + ); + expect(json.redirectTo).toBe("/dashboard"); + expect(json.loginType).toBe("dashboard_user"); + }); + + it("opaque mode writes sessionId cookie instead of raw key", async () => { + mockGetSessionTokenMode.mockReturnValue("opaque"); + mockCreateSession.mockResolvedValue({ + sessionId: "sid_opaque_session_cookie", + keyFingerprint: "sha256:abcdef", + userId: 1, + userRole: "user", + createdAt: 100, + expiresAt: 200, + }); + + const res = await POST(makeRequest({ key: "opaque-key" })); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(mockCreateSession).toHaveBeenCalledTimes(1); + expect(mockSetAuthCookie).toHaveBeenCalledTimes(1); + expect(mockSetAuthCookie).toHaveBeenCalledWith("sid_opaque_session_cookie"); + expect(mockSetAuthCookie).not.toHaveBeenCalledWith("opaque-key"); + expect(json.redirectTo).toBe("/dashboard"); + expect(json.loginType).toBe("dashboard_user"); + }); + + it("dual mode remains successful when opaque session creation fails", async () => { + mockGetSessionTokenMode.mockReturnValue("dual"); + mockCreateSession.mockRejectedValue(new Error("redis unavailable")); + + const res = await POST(makeRequest({ key: "dual-fallback-key" })); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.ok).toBe(true); + expect(mockSetAuthCookie).toHaveBeenCalledTimes(1); + expect(mockSetAuthCookie).toHaveBeenCalledWith("dual-fallback-key"); + expect(mockCreateSession).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + "Failed to create opaque session in dual mode", + expect.objectContaining({ + error: expect.stringContaining("redis unavailable"), + }) + ); + }); + + it("all modes preserve readonly redirect semantics", async () => { + mockValidateKey.mockResolvedValue(readonlySession); + mockGetLoginRedirectTarget.mockReturnValue("/my-usage"); + + const modes = ["legacy", "dual", "opaque"] as const; + + for (const mode of modes) { + vi.clearAllMocks(); + mockGetSessionTokenMode.mockReturnValue(mode); + mockValidateKey.mockResolvedValue(readonlySession); + mockGetLoginRedirectTarget.mockReturnValue("/my-usage"); + mockSetAuthCookie.mockResolvedValue(undefined); + mockCreateSession.mockResolvedValue({ + sessionId: `sid_${mode}_session`, + keyFingerprint: "sha256:abcdef", + userId: 2, + userRole: "user", + createdAt: 100, + expiresAt: 200, + }); + + const res = await POST(makeRequest({ key: `${mode}-readonly-key` })); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.redirectTo).toBe("/my-usage"); + expect(json.loginType).toBe("readonly_user"); + + if (mode === "legacy") { + expect(mockCreateSession).not.toHaveBeenCalled(); + expect(mockSetAuthCookie).toHaveBeenCalledWith("legacy-readonly-key"); + } + + if (mode === "dual") { + expect(mockCreateSession).toHaveBeenCalledTimes(1); + expect(mockSetAuthCookie).toHaveBeenCalledWith("dual-readonly-key"); + } + + if (mode === "opaque") { + expect(mockCreateSession).toHaveBeenCalledTimes(1); + expect(mockSetAuthCookie).toHaveBeenCalledWith("sid_opaque_session"); + } + } + }); +}); diff --git a/tests/security/session-store.test.ts b/tests/security/session-store.test.ts new file mode 100644 index 000000000..bba336877 --- /dev/null +++ b/tests/security/session-store.test.ts @@ -0,0 +1,262 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { getRedisClientMock, loggerMock } = vi.hoisted(() => ({ + getRedisClientMock: vi.fn(), + loggerMock: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + }, +})); + +vi.mock("@/lib/redis", () => ({ + getRedisClient: getRedisClientMock, +})); + +vi.mock("@/lib/logger", () => ({ + logger: loggerMock, +})); + +class FakeRedis { + status: "ready" | "end" = "ready"; + readonly store = new Map(); + readonly ttlByKey = new Map(); + + throwOnGet = false; + throwOnSetex = false; + throwOnDel = false; + + readonly get = vi.fn(async (key: string) => { + if (this.throwOnGet) throw new Error("redis get failed"); + return this.store.get(key) ?? null; + }); + + readonly setex = vi.fn(async (key: string, ttlSeconds: number, value: string) => { + if (this.throwOnSetex) throw new Error("redis setex failed"); + this.store.set(key, value); + this.ttlByKey.set(key, ttlSeconds); + return "OK"; + }); + + readonly del = vi.fn(async (key: string) => { + if (this.throwOnDel) throw new Error("redis del failed"); + const existed = this.store.delete(key); + this.ttlByKey.delete(key); + return existed ? 1 : 0; + }); +} + +describe("RedisSessionStore", () => { + let redis: FakeRedis; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-18T10:00:00.000Z")); + vi.clearAllMocks(); + + redis = new FakeRedis(); + getRedisClientMock.mockReturnValue(redis); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("create() returns session data with generated sessionId", async () => { + const { DEFAULT_SESSION_TTL } = await import("@/lib/auth-session-store"); + const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store"); + + const store = new RedisSessionStore(); + const created = await store.create({ keyFingerprint: "fp-1", userId: 101, userRole: "user" }); + + expect(created.sessionId).toMatch(/^sid_[0-9a-f-]{36}$/i); + expect(created.keyFingerprint).toBe("fp-1"); + expect(created.userId).toBe(101); + expect(created.userRole).toBe("user"); + expect(created.createdAt).toBe(new Date("2026-02-18T10:00:00.000Z").getTime()); + expect(created.expiresAt).toBe(created.createdAt + DEFAULT_SESSION_TTL * 1000); + }); + + it("read() returns data for existing session", async () => { + const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store"); + + const session = { + sessionId: "6b5097ff-a11e-4425-aad0-f57f7d2206fc", + keyFingerprint: "fp-existing", + userId: 7, + userRole: "admin", + createdAt: 1_700_000_000_000, + expiresAt: 1_700_000_360_000, + }; + redis.store.set(`cch:session:${session.sessionId}`, JSON.stringify(session)); + + const store = new RedisSessionStore(); + const found = await store.read(session.sessionId); + + expect(found).toEqual(session); + }); + + it("read() returns null for non-existent session", async () => { + const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store"); + + const store = new RedisSessionStore(); + const found = await store.read("missing-session"); + + expect(found).toBeNull(); + }); + + it("read() returns null when Redis read fails", async () => { + const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store"); + + redis.throwOnGet = true; + const store = new RedisSessionStore(); + const found = await store.read("any-session"); + + expect(found).toBeNull(); + expect(loggerMock.error).toHaveBeenCalled(); + }); + + it("revoke() deletes session", async () => { + const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store"); + + const sessionId = "f327f4f4-c95f-40ab-a017-af714df7a3f8"; + redis.store.set(`cch:session:${sessionId}`, JSON.stringify({ sessionId })); + + const store = new RedisSessionStore(); + const revoked = await store.revoke(sessionId); + + expect(revoked).toBe(true); + expect(redis.store.has(`cch:session:${sessionId}`)).toBe(false); + }); + + it("rotate() creates new session and revokes old session", async () => { + const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store"); + + const oldSession = { + sessionId: "e7f7bf87-c3b9-4525-ac0c-c2cf7cd5006b", + keyFingerprint: "fp-rotate", + userId: 18, + userRole: "user", + createdAt: Date.now() - 10_000, + expiresAt: Date.now() + 120_000, + }; + redis.store.set(`cch:session:${oldSession.sessionId}`, JSON.stringify(oldSession)); + + const store = new RedisSessionStore(); + const rotated = await store.rotate(oldSession.sessionId); + + expect(rotated).not.toBeNull(); + expect(rotated?.sessionId).not.toBe(oldSession.sessionId); + expect(rotated?.keyFingerprint).toBe(oldSession.keyFingerprint); + expect(rotated?.userId).toBe(oldSession.userId); + expect(rotated?.userRole).toBe(oldSession.userRole); + expect(redis.store.has(`cch:session:${oldSession.sessionId}`)).toBe(false); + expect(rotated ? redis.store.has(`cch:session:${rotated.sessionId}`) : false).toBe(true); + }); + + it("create() applies TTL and stores expiresAt deterministically", async () => { + const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store"); + + const store = new RedisSessionStore(); + const created = await store.create( + { keyFingerprint: "fp-ttl", userId: 9, userRole: "user" }, + 120 + ); + + const key = `cch:session:${created.sessionId}`; + expect(redis.ttlByKey.get(key)).toBe(120); + expect(created.expiresAt - created.createdAt).toBe(120_000); + }); + + it("create() throws when Redis setex fails", async () => { + const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store"); + + redis.throwOnSetex = true; + const store = new RedisSessionStore(); + + await expect( + store.create({ keyFingerprint: "fp-fail", userId: 3, userRole: "user" }) + ).rejects.toThrow("redis setex failed"); + expect(loggerMock.error).toHaveBeenCalled(); + }); + + it("create() throws when Redis is not ready", async () => { + const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store"); + + redis.status = "end"; + const store = new RedisSessionStore(); + + await expect( + store.create({ keyFingerprint: "fp-noredis", userId: 4, userRole: "user" }) + ).rejects.toThrow("Redis not ready"); + }); + + it("rotate() returns null when Redis setex fails during create", async () => { + const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store"); + + const oldSession = { + sessionId: "2a036ab4-902a-4f31-a782-ec18344e17b9", + keyFingerprint: "fp-failure", + userId: 3, + userRole: "user", + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, + }; + redis.store.set(`cch:session:${oldSession.sessionId}`, JSON.stringify(oldSession)); + redis.throwOnSetex = true; + + const store = new RedisSessionStore(); + const rotated = await store.rotate(oldSession.sessionId); + + expect(rotated).toBeNull(); + expect(redis.store.has(`cch:session:${oldSession.sessionId}`)).toBe(true); + expect(loggerMock.error).toHaveBeenCalled(); + }); + + it("rotate() keeps new session when old session revocation fails", async () => { + const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store"); + + const oldSession = { + sessionId: "aaa-old-session", + keyFingerprint: "fp-revoke-fail", + userId: 5, + userRole: "user", + createdAt: Date.now() - 10_000, + expiresAt: Date.now() + 120_000, + }; + redis.store.set(`cch:session:${oldSession.sessionId}`, JSON.stringify(oldSession)); + redis.throwOnDel = true; + + const store = new RedisSessionStore(); + const rotated = await store.rotate(oldSession.sessionId); + + expect(rotated).not.toBeNull(); + expect(rotated?.keyFingerprint).toBe(oldSession.keyFingerprint); + expect(loggerMock.warn).toHaveBeenCalled(); + }); + + it("rotate() returns null for already-expired session", async () => { + const { RedisSessionStore } = await import("@/lib/auth-session-store/redis-session-store"); + + const expiredSession = { + sessionId: "bbb-expired-session", + keyFingerprint: "fp-expired", + userId: 6, + userRole: "user", + createdAt: Date.now() - 120_000, + expiresAt: Date.now() - 1_000, + }; + redis.store.set(`cch:session:${expiredSession.sessionId}`, JSON.stringify(expiredSession)); + + const store = new RedisSessionStore(); + const rotated = await store.rotate(expiredSession.sessionId); + + expect(rotated).toBeNull(); + expect(loggerMock.warn).toHaveBeenCalledWith( + "[AuthSessionStore] Cannot rotate expired session", + expect.objectContaining({ sessionId: expiredSession.sessionId }) + ); + }); +}); diff --git a/tests/unit/actions/provider-undo-delete.test.ts b/tests/unit/actions/provider-undo-delete.test.ts new file mode 100644 index 000000000..6ab0f21d5 --- /dev/null +++ b/tests/unit/actions/provider-undo-delete.test.ts @@ -0,0 +1,253 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "../../../src/lib/provider-batch-patch-error-codes"; + +const getSessionMock = vi.fn(); +const deleteProvidersBatchMock = vi.fn(); +const restoreProvidersBatchMock = vi.fn(); +const publishCacheInvalidationMock = vi.fn(); +const clearProviderStateMock = vi.fn(); +const clearConfigCacheMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/repository/provider", () => ({ + deleteProvidersBatch: deleteProvidersBatchMock, + findAllProvidersFresh: vi.fn(), + updateProvidersBatch: vi.fn(), +})); + +vi.mock("@/repository", () => ({ + restoreProvidersBatch: restoreProvidersBatchMock, +})); + +vi.mock("@/lib/cache/provider-cache", () => ({ + publishProviderCacheInvalidation: publishCacheInvalidationMock, +})); + +vi.mock("@/lib/circuit-breaker", () => ({ + clearProviderState: clearProviderStateMock, + clearConfigCache: clearConfigCacheMock, + resetCircuit: vi.fn(), + getAllHealthStatusAsync: vi.fn(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe("Provider Delete Undo Actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + deleteProvidersBatchMock.mockResolvedValue(2); + restoreProvidersBatchMock.mockResolvedValue(2); + publishCacheInvalidationMock.mockResolvedValue(undefined); + clearProviderStateMock.mockReturnValue(undefined); + clearConfigCacheMock.mockReturnValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("batchDeleteProviders should return undoToken and operationId", async () => { + const { batchDeleteProviders } = await import("../../../src/actions/providers"); + const result = await batchDeleteProviders({ providerIds: [3, 1, 3] }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(deleteProvidersBatchMock).toHaveBeenCalledWith([1, 3]); + expect(result.data.deletedCount).toBe(2); + expect(result.data.undoToken).toMatch(/^provider_patch_undo_/); + expect(result.data.operationId).toMatch(/^provider_patch_apply_/); + }); + + it("batchDeleteProviders should return repository errors", async () => { + deleteProvidersBatchMock.mockRejectedValueOnce(new Error("delete failed")); + + const { batchDeleteProviders } = await import("../../../src/actions/providers"); + const result = await batchDeleteProviders({ providerIds: [7] }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error).toBe("delete failed"); + }); + + it("batchDeleteProviders should reject non-admin session", async () => { + getSessionMock.mockResolvedValueOnce({ user: { id: 3, role: "user" } }); + + const { batchDeleteProviders } = await import("../../../src/actions/providers"); + const result = await batchDeleteProviders({ providerIds: [1] }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error).toBe("无权限执行此操作"); + expect(deleteProvidersBatchMock).not.toHaveBeenCalled(); + }); + + it("batchDeleteProviders should reject empty provider list", async () => { + const { batchDeleteProviders } = await import("../../../src/actions/providers"); + const result = await batchDeleteProviders({ providerIds: [] }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error).toBe("请选择要删除的供应商"); + expect(deleteProvidersBatchMock).not.toHaveBeenCalled(); + }); + + it("batchDeleteProviders should reject provider lists over max size", async () => { + const { batchDeleteProviders } = await import("../../../src/actions/providers"); + const result = await batchDeleteProviders({ + providerIds: Array.from({ length: 501 }, (_, index) => index + 1), + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error).toContain("单次批量操作最多支持"); + expect(deleteProvidersBatchMock).not.toHaveBeenCalled(); + }); + + it("undoProviderDelete should restore providers by snapshot", async () => { + const { batchDeleteProviders, undoProviderDelete } = await import( + "../../../src/actions/providers" + ); + + const deleted = await batchDeleteProviders({ providerIds: [2, 4] }); + if (!deleted.ok) throw new Error(`Delete should succeed: ${deleted.error}`); + + restoreProvidersBatchMock.mockClear(); + publishCacheInvalidationMock.mockClear(); + clearProviderStateMock.mockClear(); + clearConfigCacheMock.mockClear(); + + const undone = await undoProviderDelete({ + undoToken: deleted.data.undoToken, + operationId: deleted.data.operationId, + }); + + expect(undone.ok).toBe(true); + if (!undone.ok) return; + + expect(restoreProvidersBatchMock).toHaveBeenCalledWith([2, 4]); + expect(undone.data.operationId).toBe(deleted.data.operationId); + expect(undone.data.restoredCount).toBe(2); + expect(clearProviderStateMock).toHaveBeenCalledTimes(2); + expect(clearConfigCacheMock).toHaveBeenCalledTimes(2); + expect(publishCacheInvalidationMock).toHaveBeenCalledTimes(1); + }); + + it("undoProviderDelete should expire after 61 seconds", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-19T00:00:00.000Z")); + + const { batchDeleteProviders, undoProviderDelete } = await import( + "../../../src/actions/providers" + ); + + const deleted = await batchDeleteProviders({ providerIds: [9] }); + if (!deleted.ok) throw new Error(`Delete should succeed: ${deleted.error}`); + + restoreProvidersBatchMock.mockClear(); + vi.advanceTimersByTime(61_000); + + const undone = await undoProviderDelete({ + undoToken: deleted.data.undoToken, + operationId: deleted.data.operationId, + }); + + expect(undone.ok).toBe(false); + if (undone.ok) return; + + expect(undone.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED); + expect(restoreProvidersBatchMock).not.toHaveBeenCalled(); + }); + + it("undoProviderDelete should reject mismatched operation id", async () => { + const { batchDeleteProviders, undoProviderDelete } = await import( + "../../../src/actions/providers" + ); + + const deleted = await batchDeleteProviders({ providerIds: [10, 11] }); + if (!deleted.ok) throw new Error(`Delete should succeed: ${deleted.error}`); + + restoreProvidersBatchMock.mockClear(); + + const undone = await undoProviderDelete({ + undoToken: deleted.data.undoToken, + operationId: `${deleted.data.operationId}-mismatch`, + }); + + expect(undone.ok).toBe(false); + if (undone.ok) return; + + expect(undone.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_CONFLICT); + expect(restoreProvidersBatchMock).not.toHaveBeenCalled(); + }); + + it("undoProviderDelete should reject invalid payload", async () => { + const { undoProviderDelete } = await import("../../../src/actions/providers"); + + const undone = await undoProviderDelete({ + undoToken: "", + operationId: "provider_patch_apply_x", + }); + + expect(undone.ok).toBe(false); + if (undone.ok) return; + + expect(undone.errorCode).toBeDefined(); + expect(restoreProvidersBatchMock).not.toHaveBeenCalled(); + }); + + it("undoProviderDelete should reject non-admin session", async () => { + getSessionMock.mockResolvedValueOnce({ user: { id: 2, role: "user" } }); + + const { undoProviderDelete } = await import("../../../src/actions/providers"); + + const undone = await undoProviderDelete({ + undoToken: "provider_patch_undo_x", + operationId: "provider_patch_apply_x", + }); + + expect(undone.ok).toBe(false); + if (undone.ok) return; + + expect(undone.error).toBe("无权限执行此操作"); + expect(restoreProvidersBatchMock).not.toHaveBeenCalled(); + }); + + it("undoProviderDelete should return repository errors when restore fails", async () => { + const { batchDeleteProviders, undoProviderDelete } = await import( + "../../../src/actions/providers" + ); + + const deleted = await batchDeleteProviders({ providerIds: [12] }); + if (!deleted.ok) throw new Error(`Delete should succeed: ${deleted.error}`); + + restoreProvidersBatchMock.mockRejectedValueOnce(new Error("restore failed")); + + const undone = await undoProviderDelete({ + undoToken: deleted.data.undoToken, + operationId: deleted.data.operationId, + }); + + expect(undone.ok).toBe(false); + if (undone.ok) return; + + expect(undone.error).toBe("restore failed"); + }); +}); diff --git a/tests/unit/actions/provider-undo-edit.test.ts b/tests/unit/actions/provider-undo-edit.test.ts new file mode 100644 index 000000000..4a0466346 --- /dev/null +++ b/tests/unit/actions/provider-undo-edit.test.ts @@ -0,0 +1,396 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "../../../src/lib/provider-batch-patch-error-codes"; + +const getSessionMock = vi.fn(); +const findProviderByIdMock = vi.fn(); +const updateProviderMock = vi.fn(); +const updateProvidersBatchMock = vi.fn(); +const publishCacheInvalidationMock = vi.fn(); +const clearProviderStateMock = vi.fn(); +const clearConfigCacheMock = vi.fn(); +const saveProviderCircuitConfigMock = vi.fn(); +const deleteProviderCircuitConfigMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/repository/provider", () => ({ + findProviderById: findProviderByIdMock, + findAllProvidersFresh: vi.fn(), + updateProvider: updateProviderMock, + updateProvidersBatch: updateProvidersBatchMock, + deleteProvidersBatch: vi.fn(), +})); + +vi.mock("@/repository", () => ({ + restoreProvidersBatch: vi.fn(), +})); + +vi.mock("@/lib/cache/provider-cache", () => ({ + publishProviderCacheInvalidation: publishCacheInvalidationMock, +})); + +vi.mock("@/lib/circuit-breaker", () => ({ + clearProviderState: clearProviderStateMock, + clearConfigCache: clearConfigCacheMock, + resetCircuit: vi.fn(), + getAllHealthStatusAsync: vi.fn(), +})); + +vi.mock("@/lib/redis/circuit-breaker-config", () => ({ + saveProviderCircuitConfig: saveProviderCircuitConfigMock, + deleteProviderCircuitConfig: deleteProviderCircuitConfigMock, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +function makeProvider(id: number, overrides: Record = {}) { + return { + id, + name: `Provider-${id}`, + url: "https://api.example.com/v1", + key: "sk-test", + providerVendorId: null, + isEnabled: true, + weight: 100, + priority: 1, + groupPriorities: null, + costMultiplier: 1.0, + groupTag: null, + providerType: "claude", + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + totalCostResetAt: null, + limitConcurrentSessions: null, + maxRetryAttempts: null, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 1800000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 30000, + streamingIdleTimeoutMs: 10000, + requestTimeoutNonStreamingMs: 600000, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + swapCacheTtlBilling: false, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: null, + tpm: null, + rpm: null, + rpd: null, + cc: null, + createdAt: new Date("2025-01-01"), + updatedAt: new Date("2025-01-01"), + deletedAt: null, + ...overrides, + }; +} + +describe("Provider Single Edit Undo Actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findProviderByIdMock.mockResolvedValue(makeProvider(1, { name: "Before Name", key: "sk-old" })); + updateProviderMock.mockResolvedValue(makeProvider(1, { name: "After Name", key: "sk-new" })); + updateProvidersBatchMock.mockResolvedValue(1); + publishCacheInvalidationMock.mockResolvedValue(undefined); + clearProviderStateMock.mockReturnValue(undefined); + clearConfigCacheMock.mockReturnValue(undefined); + saveProviderCircuitConfigMock.mockResolvedValue(undefined); + deleteProviderCircuitConfigMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("editProvider should return undoToken and operationId", async () => { + const { editProvider } = await import("../../../src/actions/providers"); + + const result = await editProvider(1, { name: "After Name" }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.undoToken).toMatch(/^provider_patch_undo_/); + expect(result.data.operationId).toMatch(/^provider_patch_apply_/); + expect(findProviderByIdMock).toHaveBeenCalledWith(1); + expect(updateProviderMock).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + name: "After Name", + }) + ); + }); + + it("editProvider should reject when provider is missing before update", async () => { + findProviderByIdMock.mockResolvedValueOnce(null); + + const { editProvider } = await import("../../../src/actions/providers"); + const result = await editProvider(999, { name: "After Name" }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error).toBe("供应商不存在"); + expect(updateProviderMock).not.toHaveBeenCalled(); + }); + + it("editProvider should reject when repository update returns null", async () => { + updateProviderMock.mockResolvedValueOnce(null); + + const { editProvider } = await import("../../../src/actions/providers"); + const result = await editProvider(1, { name: "After Name" }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error).toBe("供应商不存在"); + }); + + it("editProvider should continue when circuit config sync fails", async () => { + updateProviderMock.mockResolvedValueOnce( + makeProvider(1, { + circuitBreakerFailureThreshold: 8, + circuitBreakerOpenDuration: 1800000, + circuitBreakerHalfOpenSuccessThreshold: 2, + }) + ); + saveProviderCircuitConfigMock.mockRejectedValueOnce(new Error("redis down")); + + const { editProvider } = await import("../../../src/actions/providers"); + const result = await editProvider(1, { + name: "After Name", + circuit_breaker_failure_threshold: 8, + }); + + expect(result.ok).toBe(true); + expect(saveProviderCircuitConfigMock).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + failureThreshold: 8, + }) + ); + expect(clearConfigCacheMock).not.toHaveBeenCalled(); + }); + + it("undoProviderPatch should revert a single edit", async () => { + const { editProvider, undoProviderPatch } = await import("../../../src/actions/providers"); + + const edited = await editProvider(1, { name: "After Name" }); + if (!edited.ok) throw new Error(`Edit should succeed: ${edited.error}`); + + updateProvidersBatchMock.mockClear(); + publishCacheInvalidationMock.mockClear(); + + const undone = await undoProviderPatch({ + undoToken: edited.data.undoToken, + operationId: edited.data.operationId, + }); + + expect(undone.ok).toBe(true); + if (!undone.ok) return; + + expect(updateProvidersBatchMock).toHaveBeenCalledWith( + [1], + expect.objectContaining({ + name: "Before Name", + }) + ); + expect(undone.data.revertedCount).toBe(1); + expect(publishCacheInvalidationMock).toHaveBeenCalledTimes(1); + }); + + it("undoProviderPatch should not include key field in preimage", async () => { + findProviderByIdMock.mockResolvedValueOnce(makeProvider(1, { key: "sk-before" })); + updateProviderMock.mockResolvedValueOnce(makeProvider(1, { key: "sk-after" })); + + const { editProvider, undoProviderPatch } = await import("../../../src/actions/providers"); + + const edited = await editProvider(1, { key: "sk-after" }); + if (!edited.ok) throw new Error(`Edit should succeed: ${edited.error}`); + + updateProvidersBatchMock.mockClear(); + + const undone = await undoProviderPatch({ + undoToken: edited.data.undoToken, + operationId: edited.data.operationId, + }); + + expect(undone.ok).toBe(true); + if (!undone.ok) return; + + expect(undone.data.revertedCount).toBe(0); + expect(updateProvidersBatchMock).not.toHaveBeenCalled(); + }); + + it("undoProviderPatch should skip unchanged values in single-edit preimage", async () => { + findProviderByIdMock.mockResolvedValueOnce(makeProvider(1, { name: "Stable Name" })); + updateProviderMock.mockResolvedValueOnce(makeProvider(1, { name: "Stable Name" })); + + const { editProvider, undoProviderPatch } = await import("../../../src/actions/providers"); + + const edited = await editProvider(1, { name: "Stable Name" }); + if (!edited.ok) throw new Error(`Edit should succeed: ${edited.error}`); + + updateProvidersBatchMock.mockClear(); + publishCacheInvalidationMock.mockClear(); + + const undone = await undoProviderPatch({ + undoToken: edited.data.undoToken, + operationId: edited.data.operationId, + }); + + expect(undone.ok).toBe(true); + if (!undone.ok) return; + + expect(undone.data.revertedCount).toBe(0); + expect(updateProvidersBatchMock).not.toHaveBeenCalled(); + expect(publishCacheInvalidationMock).not.toHaveBeenCalled(); + }); + + it("undoProviderPatch should stringify numeric costMultiplier on revert", async () => { + findProviderByIdMock.mockResolvedValueOnce(makeProvider(1, { costMultiplier: 1.25 })); + updateProviderMock.mockResolvedValueOnce(makeProvider(1, { costMultiplier: 2.5 })); + + const { editProvider, undoProviderPatch } = await import("../../../src/actions/providers"); + + const edited = await editProvider(1, { cost_multiplier: 2.5 }); + if (!edited.ok) throw new Error(`Edit should succeed: ${edited.error}`); + + updateProvidersBatchMock.mockClear(); + + const undone = await undoProviderPatch({ + undoToken: edited.data.undoToken, + operationId: edited.data.operationId, + }); + + expect(undone.ok).toBe(true); + if (!undone.ok) return; + + expect(updateProvidersBatchMock).toHaveBeenCalledWith( + [1], + expect.objectContaining({ costMultiplier: "1.25" }) + ); + }); + + it("undoProviderPatch should expire after patch undo TTL", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-19T00:00:00.000Z")); + + const { editProvider, undoProviderPatch } = await import("../../../src/actions/providers"); + + const edited = await editProvider(1, { name: "After Name" }); + if (!edited.ok) throw new Error(`Edit should succeed: ${edited.error}`); + + vi.advanceTimersByTime(10_001); + + const undone = await undoProviderPatch({ + undoToken: edited.data.undoToken, + operationId: edited.data.operationId, + }); + + expect(undone.ok).toBe(false); + if (undone.ok) return; + + expect(undone.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED); + }); + + it("undoProviderPatch should reject mismatched operation id", async () => { + const { editProvider, undoProviderPatch } = await import("../../../src/actions/providers"); + + const edited = await editProvider(1, { name: "After Name" }); + if (!edited.ok) throw new Error(`Edit should succeed: ${edited.error}`); + + const undone = await undoProviderPatch({ + undoToken: edited.data.undoToken, + operationId: `${edited.data.operationId}-mismatch`, + }); + + expect(undone.ok).toBe(false); + if (undone.ok) return; + + expect(undone.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_CONFLICT); + expect(updateProvidersBatchMock).not.toHaveBeenCalled(); + }); + + it("undoProviderPatch should reject invalid payload", async () => { + const { undoProviderPatch } = await import("../../../src/actions/providers"); + + const undone = await undoProviderPatch({ + undoToken: "", + operationId: "provider_patch_apply_x", + }); + + expect(undone.ok).toBe(false); + if (undone.ok) return; + + expect(undone.errorCode).toBeDefined(); + expect(updateProvidersBatchMock).not.toHaveBeenCalled(); + }); + + it("undoProviderPatch should reject non-admin session", async () => { + getSessionMock.mockResolvedValueOnce({ user: { id: 2, role: "user" } }); + + const { undoProviderPatch } = await import("../../../src/actions/providers"); + + const undone = await undoProviderPatch({ + undoToken: "provider_patch_undo_x", + operationId: "provider_patch_apply_x", + }); + + expect(undone.ok).toBe(false); + if (undone.ok) return; + + expect(undone.error).toBe("无权限执行此操作"); + expect(updateProvidersBatchMock).not.toHaveBeenCalled(); + }); + + it("undoProviderPatch should return repository errors when revert update fails", async () => { + const { editProvider, undoProviderPatch } = await import("../../../src/actions/providers"); + + const edited = await editProvider(1, { name: "After Name" }); + if (!edited.ok) throw new Error(`Edit should succeed: ${edited.error}`); + + updateProvidersBatchMock.mockRejectedValueOnce(new Error("undo write failed")); + + const undone = await undoProviderPatch({ + undoToken: edited.data.undoToken, + operationId: edited.data.operationId, + }); + + expect(undone.ok).toBe(false); + if (undone.ok) return; + + expect(undone.error).toBe("undo write failed"); + }); +}); diff --git a/tests/unit/actions/providers-apply-engine.test.ts b/tests/unit/actions/providers-apply-engine.test.ts new file mode 100644 index 000000000..559f250c9 --- /dev/null +++ b/tests/unit/actions/providers-apply-engine.test.ts @@ -0,0 +1,425 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes"; + +const getSessionMock = vi.fn(); +const findAllProvidersFreshMock = vi.fn(); +const updateProvidersBatchMock = vi.fn(); +const publishCacheInvalidationMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/repository/provider", () => ({ + findAllProvidersFresh: findAllProvidersFreshMock, + updateProvidersBatch: updateProvidersBatchMock, + deleteProvidersBatch: vi.fn(), +})); + +vi.mock("@/lib/cache/provider-cache", () => ({ + publishProviderCacheInvalidation: publishCacheInvalidationMock, +})); + +vi.mock("@/lib/circuit-breaker", () => ({ + clearProviderState: vi.fn(), + clearConfigCache: vi.fn(), + resetCircuit: vi.fn(), + getAllHealthStatusAsync: vi.fn(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +function makeProvider(id: number, overrides: Record = {}) { + return { + id, + name: `Provider-${id}`, + url: "https://api.example.com/v1", + key: "sk-test", + providerVendorId: null, + isEnabled: true, + weight: 100, + priority: 1, + groupPriorities: null, + costMultiplier: 1.0, + groupTag: null, + providerType: "claude", + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + totalCostResetAt: null, + limitConcurrentSessions: null, + maxRetryAttempts: null, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 1800000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 30000, + streamingIdleTimeoutMs: 10000, + requestTimeoutNonStreamingMs: 600000, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + swapCacheTtlBilling: false, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: null, + tpm: null, + rpm: null, + rpd: null, + cc: null, + createdAt: new Date("2025-01-01"), + updatedAt: new Date("2025-01-01"), + deletedAt: null, + ...overrides, + }; +} + +describe("Apply Provider Batch Patch Engine", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findAllProvidersFreshMock.mockResolvedValue([]); + updateProvidersBatchMock.mockResolvedValue(0); + publishCacheInvalidationMock.mockResolvedValue(undefined); + }); + + /** Helper: create preview then apply with optional overrides */ + async function setupPreviewAndApply( + providerIds: number[], + patch: Record, + applyOverrides: Record = {} + ) { + const { previewProviderBatchPatch, applyProviderBatchPatch } = await import( + "@/actions/providers" + ); + + const preview = await previewProviderBatchPatch({ providerIds, patch }); + if (!preview.ok) throw new Error(`Preview failed: ${preview.error}`); + + const applyInput = { + previewToken: preview.data.previewToken, + previewRevision: preview.data.previewRevision, + providerIds, + patch, + ...applyOverrides, + }; + + const apply = await applyProviderBatchPatch(applyInput); + return { preview, apply, applyProviderBatchPatch }; + } + + it("should call updateProvidersBatch with correct IDs and updates", async () => { + const providers = [makeProvider(1, { groupTag: "old" }), makeProvider(2, { groupTag: "old" })]; + findAllProvidersFreshMock.mockResolvedValue(providers); + updateProvidersBatchMock.mockResolvedValue(2); + + const { apply } = await setupPreviewAndApply([1, 2], { group_tag: { set: "new-group" } }); + + expect(apply.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledOnce(); + expect(updateProvidersBatchMock).toHaveBeenCalledWith( + [1, 2], + expect.objectContaining({ groupTag: "new-group" }) + ); + }); + + it("should publish cache invalidation after successful write", async () => { + findAllProvidersFreshMock.mockResolvedValue([makeProvider(1)]); + updateProvidersBatchMock.mockResolvedValue(1); + + const { apply } = await setupPreviewAndApply([1], { is_enabled: { set: false } }); + + expect(apply.ok).toBe(true); + expect(publishCacheInvalidationMock).toHaveBeenCalledOnce(); + }); + + it("should fetch providers for preimage during apply", async () => { + const providers = [ + makeProvider(1, { groupTag: "alpha", priority: 5 }), + makeProvider(2, { groupTag: "beta", priority: 10 }), + ]; + findAllProvidersFreshMock.mockResolvedValue(providers); + updateProvidersBatchMock.mockResolvedValue(2); + + const { apply } = await setupPreviewAndApply([1, 2], { group_tag: { set: "gamma" } }); + + expect(apply.ok).toBe(true); + // preview calls findAllProvidersFresh once, apply calls it once more + expect(findAllProvidersFreshMock).toHaveBeenCalledTimes(2); + }); + + it("should only apply to non-excluded providers with excludeProviderIds", async () => { + const providers = [ + makeProvider(1, { groupTag: "a" }), + makeProvider(2, { groupTag: "b" }), + makeProvider(3, { groupTag: "c" }), + ]; + findAllProvidersFreshMock.mockResolvedValue(providers); + updateProvidersBatchMock.mockResolvedValue(2); + + const { apply } = await setupPreviewAndApply( + [1, 2, 3], + { group_tag: { set: "unified" } }, + { excludeProviderIds: [2] } + ); + + expect(apply.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith( + [1, 3], + expect.objectContaining({ groupTag: "unified" }) + ); + }); + + it("should return NOTHING_TO_APPLY when all providers are excluded", async () => { + findAllProvidersFreshMock.mockResolvedValue([makeProvider(1), makeProvider(2)]); + + const { apply } = await setupPreviewAndApply( + [1, 2], + { group_tag: { set: "x" } }, + { excludeProviderIds: [1, 2] } + ); + + expect(apply.ok).toBe(false); + if (apply.ok) return; + expect(apply.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.NOTHING_TO_APPLY); + expect(updateProvidersBatchMock).not.toHaveBeenCalled(); + }); + + it("should set updatedCount from updateProvidersBatch return value", async () => { + findAllProvidersFreshMock.mockResolvedValue([ + makeProvider(1), + makeProvider(2), + makeProvider(3), + ]); + updateProvidersBatchMock.mockResolvedValue(3); + + const { apply } = await setupPreviewAndApply([1, 2, 3], { weight: { set: 50 } }); + + expect(apply.ok).toBe(true); + if (!apply.ok) return; + expect(apply.data.updatedCount).toBe(3); + }); + + it("should reflect exclusions in updatedCount", async () => { + findAllProvidersFreshMock.mockResolvedValue([ + makeProvider(1), + makeProvider(2), + makeProvider(3), + ]); + updateProvidersBatchMock.mockResolvedValue(2); + + const { apply } = await setupPreviewAndApply( + [1, 2, 3], + { weight: { set: 50 } }, + { excludeProviderIds: [3] } + ); + + expect(apply.ok).toBe(true); + if (!apply.ok) return; + expect(apply.data.updatedCount).toBe(2); + }); + + it("should return PREVIEW_EXPIRED for unknown preview token", async () => { + const { applyProviderBatchPatch } = await import("@/actions/providers"); + + const result = await applyProviderBatchPatch({ + previewToken: "provider_patch_preview_nonexistent", + previewRevision: "rev", + providerIds: [1], + patch: { group_tag: { set: "x" } }, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.PREVIEW_EXPIRED); + }); + + it("should return PREVIEW_STALE for mismatched patch", async () => { + findAllProvidersFreshMock.mockResolvedValue([makeProvider(1)]); + + const { previewProviderBatchPatch, applyProviderBatchPatch } = await import( + "@/actions/providers" + ); + + const preview = await previewProviderBatchPatch({ + providerIds: [1], + patch: { group_tag: { set: "original" } }, + }); + if (!preview.ok) throw new Error("Preview should succeed"); + + const result = await applyProviderBatchPatch({ + previewToken: preview.data.previewToken, + previewRevision: preview.data.previewRevision, + providerIds: [1], + patch: { group_tag: { set: "different" } }, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.PREVIEW_STALE); + }); + + it("should return cached result for same idempotencyKey without re-writing to DB", async () => { + findAllProvidersFreshMock.mockResolvedValue([makeProvider(1), makeProvider(2)]); + updateProvidersBatchMock.mockResolvedValue(2); + + const { previewProviderBatchPatch, applyProviderBatchPatch } = await import( + "@/actions/providers" + ); + + const preview = await previewProviderBatchPatch({ + providerIds: [1, 2], + patch: { group_tag: { set: "idem" } }, + }); + if (!preview.ok) throw new Error("Preview should succeed"); + + const applyInput = { + previewToken: preview.data.previewToken, + previewRevision: preview.data.previewRevision, + providerIds: [1, 2], + patch: { group_tag: { set: "idem" } }, + idempotencyKey: "idem-key-1", + }; + + const first = await applyProviderBatchPatch(applyInput); + const second = await applyProviderBatchPatch(applyInput); + + expect(first.ok).toBe(true); + expect(second.ok).toBe(true); + if (!first.ok || !second.ok) return; + + expect(second.data.operationId).toBe(first.data.operationId); + expect(updateProvidersBatchMock).toHaveBeenCalledOnce(); + }); + + it("should prevent double-apply by marking snapshot as applied", async () => { + findAllProvidersFreshMock.mockResolvedValue([makeProvider(1)]); + updateProvidersBatchMock.mockResolvedValue(1); + + const { previewProviderBatchPatch, applyProviderBatchPatch } = await import( + "@/actions/providers" + ); + + const preview = await previewProviderBatchPatch({ + providerIds: [1], + patch: { group_tag: { set: "x" } }, + }); + if (!preview.ok) throw new Error("Preview should succeed"); + + const applyInput = { + previewToken: preview.data.previewToken, + previewRevision: preview.data.previewRevision, + providerIds: [1], + patch: { group_tag: { set: "x" } }, + }; + + const first = await applyProviderBatchPatch(applyInput); + const second = await applyProviderBatchPatch(applyInput); + + expect(first.ok).toBe(true); + expect(second.ok).toBe(false); + if (second.ok) return; + expect(second.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.PREVIEW_STALE); + }); + + it("should map cost_multiplier to string for repository", async () => { + findAllProvidersFreshMock.mockResolvedValue([makeProvider(1, { costMultiplier: 1.0 })]); + updateProvidersBatchMock.mockResolvedValue(1); + + const { apply } = await setupPreviewAndApply([1], { cost_multiplier: { set: 2.5 } }); + + expect(apply.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith( + [1], + expect.objectContaining({ costMultiplier: "2.5" }) + ); + }); + + it("should map multiple fields correctly to repository format", async () => { + findAllProvidersFreshMock.mockResolvedValue([ + makeProvider(1, { groupTag: "old", weight: 100, priority: 1 }), + ]); + updateProvidersBatchMock.mockResolvedValue(1); + + const { apply } = await setupPreviewAndApply([1], { + group_tag: { set: "new" }, + weight: { set: 80 }, + priority: { set: 5 }, + }); + + expect(apply.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith( + [1], + expect.objectContaining({ + groupTag: "new", + weight: 80, + priority: 5, + }) + ); + }); + + it("should map clear mode to null for clearable fields", async () => { + findAllProvidersFreshMock.mockResolvedValue([ + makeProvider(1, { groupTag: "has-tag", modelRedirects: { a: "b" } }), + ]); + updateProvidersBatchMock.mockResolvedValue(1); + + const { apply } = await setupPreviewAndApply([1], { + group_tag: { clear: true }, + model_redirects: { clear: true }, + }); + + expect(apply.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith( + [1], + expect.objectContaining({ + groupTag: null, + modelRedirects: null, + }) + ); + }); + + it("should map anthropic_thinking_budget_preference clear to inherit", async () => { + findAllProvidersFreshMock.mockResolvedValue([ + makeProvider(1, { anthropicThinkingBudgetPreference: "8192" }), + ]); + updateProvidersBatchMock.mockResolvedValue(1); + + const { apply } = await setupPreviewAndApply([1], { + anthropic_thinking_budget_preference: { clear: true }, + }); + + expect(apply.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith( + [1], + expect.objectContaining({ + anthropicThinkingBudgetPreference: "inherit", + }) + ); + }); +}); diff --git a/tests/unit/actions/providers-batch-field-mapping.test.ts b/tests/unit/actions/providers-batch-field-mapping.test.ts new file mode 100644 index 000000000..d304ef4e2 --- /dev/null +++ b/tests/unit/actions/providers-batch-field-mapping.test.ts @@ -0,0 +1,256 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getSessionMock = vi.fn(); + +const updateProvidersBatchMock = vi.fn(); + +const publishProviderCacheInvalidationMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/repository/provider", () => ({ + updateProvidersBatch: updateProvidersBatchMock, +})); + +vi.mock("@/lib/cache/provider-cache", () => ({ + publishProviderCacheInvalidation: publishProviderCacheInvalidationMock, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe("batchUpdateProviders - advanced field mapping", () => { + beforeEach(() => { + vi.clearAllMocks(); + + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + updateProvidersBatchMock.mockResolvedValue(2); + publishProviderCacheInvalidationMock.mockResolvedValue(undefined); + }); + + it("should still map basic fields correctly (backward compat)", async () => { + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [1, 2], + updates: { + is_enabled: true, + priority: 3, + weight: 5, + cost_multiplier: 1.2, + group_tag: "legacy", + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.updatedCount).toBe(2); + expect(updateProvidersBatchMock).toHaveBeenCalledWith([1, 2], { + isEnabled: true, + priority: 3, + weight: 5, + costMultiplier: "1.2", + groupTag: "legacy", + }); + }); + + it("should map model_redirects to repository modelRedirects", async () => { + const redirects = { "claude-3-opus": "claude-3.5-sonnet", "gpt-4": "gpt-4o" }; + + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [10, 20], + updates: { model_redirects: redirects }, + }); + + expect(result.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith([10, 20], { + modelRedirects: redirects, + }); + }); + + it("should map model_redirects=null to repository modelRedirects=null", async () => { + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [5], + updates: { model_redirects: null }, + }); + + expect(result.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith([5], { + modelRedirects: null, + }); + }); + + it("should map allowed_models with values correctly", async () => { + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [1, 2], + updates: { allowed_models: ["model-a", "model-b"] }, + }); + + expect(result.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith([1, 2], { + allowedModels: ["model-a", "model-b"], + }); + }); + + it("should normalize allowed_models=[] to null (allow-all)", async () => { + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [1], + updates: { allowed_models: [] }, + }); + + expect(result.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith([1], { + allowedModels: null, + }); + }); + + it("should map allowed_models=null to repository allowedModels=null", async () => { + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [3], + updates: { allowed_models: null }, + }); + + expect(result.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith([3], { + allowedModels: null, + }); + }); + + it("should map anthropic_thinking_budget_preference correctly", async () => { + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [7, 8], + updates: { anthropic_thinking_budget_preference: "10000" }, + }); + + expect(result.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith([7, 8], { + anthropicThinkingBudgetPreference: "10000", + }); + }); + + it("should map anthropic_thinking_budget_preference=inherit correctly", async () => { + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [1], + updates: { anthropic_thinking_budget_preference: "inherit" }, + }); + + expect(result.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith([1], { + anthropicThinkingBudgetPreference: "inherit", + }); + }); + + it("should map anthropic_thinking_budget_preference=null correctly", async () => { + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [1], + updates: { anthropic_thinking_budget_preference: null }, + }); + + expect(result.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith([1], { + anthropicThinkingBudgetPreference: null, + }); + }); + + it("should map anthropic_adaptive_thinking config correctly", async () => { + const config = { + effort: "high" as const, + modelMatchMode: "all" as const, + models: [], + }; + + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [4, 5], + updates: { anthropic_adaptive_thinking: config }, + }); + + expect(result.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith([4, 5], { + anthropicAdaptiveThinking: config, + }); + }); + + it("should map anthropic_adaptive_thinking=null correctly", async () => { + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [6], + updates: { anthropic_adaptive_thinking: null }, + }); + + expect(result.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledWith([6], { + anthropicAdaptiveThinking: null, + }); + }); + + it("should handle mix of old and new fields together", async () => { + const adaptiveConfig = { + effort: "medium" as const, + modelMatchMode: "specific" as const, + models: ["claude-3-opus", "claude-3.5-sonnet"], + }; + + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [1, 2, 3], + updates: { + is_enabled: true, + priority: 10, + weight: 3, + cost_multiplier: 0.8, + group_tag: "mixed-batch", + model_redirects: { "old-model": "new-model" }, + allowed_models: ["claude-3-opus"], + anthropic_thinking_budget_preference: "5000", + anthropic_adaptive_thinking: adaptiveConfig, + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.updatedCount).toBe(2); + expect(updateProvidersBatchMock).toHaveBeenCalledWith([1, 2, 3], { + isEnabled: true, + priority: 10, + weight: 3, + costMultiplier: "0.8", + groupTag: "mixed-batch", + modelRedirects: { "old-model": "new-model" }, + allowedModels: ["claude-3-opus"], + anthropicThinkingBudgetPreference: "5000", + anthropicAdaptiveThinking: adaptiveConfig, + }); + }); + + it("should detect new fields as valid updates (not reject as empty)", async () => { + const { batchUpdateProviders } = await import("@/actions/providers"); + + // Only new fields, no old fields -- must still be treated as having updates + const result = await batchUpdateProviders({ + providerIds: [1], + updates: { anthropic_thinking_budget_preference: "inherit" }, + }); + + expect(result.ok).toBe(true); + expect(updateProvidersBatchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/actions/providers-patch-actions-contract.test.ts b/tests/unit/actions/providers-patch-actions-contract.test.ts new file mode 100644 index 000000000..a760b3513 --- /dev/null +++ b/tests/unit/actions/providers-patch-actions-contract.test.ts @@ -0,0 +1,305 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes"; + +const getSessionMock = vi.fn(); +const findAllProvidersFreshMock = vi.fn(); +const updateProvidersBatchMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/repository/provider", () => ({ + findAllProvidersFresh: findAllProvidersFreshMock, + updateProvidersBatch: updateProvidersBatchMock, + deleteProvidersBatch: vi.fn(), +})); + +vi.mock("@/lib/cache/provider-cache", () => ({ + publishProviderCacheInvalidation: vi.fn(), +})); + +vi.mock("@/lib/circuit-breaker", () => ({ + clearProviderState: vi.fn(), + clearConfigCache: vi.fn(), + resetCircuit: vi.fn(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +function makeProvider(id: number, overrides: Record = {}) { + return { + id, + name: `Provider-${id}`, + url: "https://api.example.com/v1", + key: "sk-test", + providerVendorId: null, + isEnabled: true, + weight: 100, + priority: 1, + groupPriorities: null, + costMultiplier: 1.0, + groupTag: null, + providerType: "claude", + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + totalCostResetAt: null, + limitConcurrentSessions: null, + maxRetryAttempts: null, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 1800000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 30000, + streamingIdleTimeoutMs: 10000, + requestTimeoutNonStreamingMs: 600000, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + swapCacheTtlBilling: false, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: null, + tpm: null, + rpm: null, + rpd: null, + cc: null, + createdAt: new Date("2025-01-01"), + updatedAt: new Date("2025-01-01"), + deletedAt: null, + ...overrides, + }; +} + +describe("Provider Batch Patch Action Contracts", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findAllProvidersFreshMock.mockResolvedValue([]); + updateProvidersBatchMock.mockResolvedValue(0); + }); + + it("previewProviderBatchPatch should require admin role", async () => { + getSessionMock.mockResolvedValueOnce({ user: { id: 2, role: "user" } }); + + const { previewProviderBatchPatch } = await import("@/actions/providers"); + const result = await previewProviderBatchPatch({ + providerIds: [1, 2], + patch: { group_tag: { set: "ops" } }, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error).toBe("无权限执行此操作"); + }); + + it("previewProviderBatchPatch should return structured preview payload", async () => { + const { previewProviderBatchPatch } = await import("@/actions/providers"); + const result = await previewProviderBatchPatch({ + providerIds: [3, 1, 3, 2], + patch: { + group_tag: { set: "blue" }, + allowed_models: { clear: true }, + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.providerIds).toEqual([1, 2, 3]); + expect(result.data.summary.providerCount).toBe(3); + expect(result.data.summary.fieldCount).toBe(2); + expect(result.data.changedFields).toEqual(["group_tag", "allowed_models"]); + expect(result.data.previewToken).toMatch(/^provider_patch_preview_/); + expect(result.data.previewRevision.length).toBeGreaterThan(0); + expect(result.data.previewExpiresAt.length).toBeGreaterThan(0); + }); + + it("previewProviderBatchPatch should return NOTHING_TO_APPLY when patch has no changes", async () => { + const { previewProviderBatchPatch } = await import("@/actions/providers"); + const result = await previewProviderBatchPatch({ + providerIds: [1], + patch: { group_tag: { no_change: true } }, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.NOTHING_TO_APPLY); + }); + + it("applyProviderBatchPatch should reject unknown preview token", async () => { + const { applyProviderBatchPatch } = await import("@/actions/providers"); + const result = await applyProviderBatchPatch({ + previewToken: "provider_patch_preview_missing", + previewRevision: "rev", + providerIds: [1], + patch: { group_tag: { set: "x" } }, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.PREVIEW_EXPIRED); + }); + + it("applyProviderBatchPatch should reject stale revision", async () => { + const { previewProviderBatchPatch, applyProviderBatchPatch } = await import( + "@/actions/providers" + ); + const preview = await previewProviderBatchPatch({ + providerIds: [1], + patch: { group_tag: { set: "x" } }, + }); + if (!preview.ok) throw new Error("Preview should be ok in test setup"); + + const apply = await applyProviderBatchPatch({ + previewToken: preview.data.previewToken, + previewRevision: `${preview.data.previewRevision}-stale`, + providerIds: [1], + patch: { group_tag: { set: "x" } }, + }); + + expect(apply.ok).toBe(false); + if (apply.ok) return; + + expect(apply.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.PREVIEW_STALE); + }); + + it("applyProviderBatchPatch should return idempotent result for same idempotency key", async () => { + const { previewProviderBatchPatch, applyProviderBatchPatch } = await import( + "@/actions/providers" + ); + const preview = await previewProviderBatchPatch({ + providerIds: [1, 2], + patch: { group_tag: { set: "x" } }, + }); + if (!preview.ok) throw new Error("Preview should be ok in test setup"); + + const firstApply = await applyProviderBatchPatch({ + previewToken: preview.data.previewToken, + previewRevision: preview.data.previewRevision, + providerIds: [1, 2], + patch: { group_tag: { set: "x" } }, + idempotencyKey: "idempotency-key-1", + }); + const secondApply = await applyProviderBatchPatch({ + previewToken: preview.data.previewToken, + previewRevision: preview.data.previewRevision, + providerIds: [1, 2], + patch: { group_tag: { set: "x" } }, + idempotencyKey: "idempotency-key-1", + }); + + expect(firstApply.ok).toBe(true); + expect(secondApply.ok).toBe(true); + if (!firstApply.ok || !secondApply.ok) return; + + expect(secondApply.data.operationId).toBe(firstApply.data.operationId); + expect(secondApply.data.undoToken).toBe(firstApply.data.undoToken); + }); + + it("undoProviderPatch should reject mismatched operation id", async () => { + const { previewProviderBatchPatch, applyProviderBatchPatch, undoProviderPatch } = await import( + "@/actions/providers" + ); + + const preview = await previewProviderBatchPatch({ + providerIds: [10], + patch: { group_tag: { set: "undo-test" } }, + }); + if (!preview.ok) throw new Error("Preview should be ok in test setup"); + + const apply = await applyProviderBatchPatch({ + previewToken: preview.data.previewToken, + previewRevision: preview.data.previewRevision, + providerIds: [10], + patch: { group_tag: { set: "undo-test" } }, + idempotencyKey: "undo-case", + }); + if (!apply.ok) throw new Error("Apply should be ok in test setup"); + + const undo = await undoProviderPatch({ + undoToken: apply.data.undoToken, + operationId: `${apply.data.operationId}-invalid`, + }); + + expect(undo.ok).toBe(false); + if (undo.ok) return; + + expect(undo.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_CONFLICT); + }); + + it("undoProviderPatch should consume token on success", async () => { + findAllProvidersFreshMock.mockResolvedValue([ + makeProvider(12, { groupTag: "before-12" }), + makeProvider(13, { groupTag: "before-13" }), + ]); + updateProvidersBatchMock.mockResolvedValue(1); + + const { previewProviderBatchPatch, applyProviderBatchPatch, undoProviderPatch } = await import( + "@/actions/providers" + ); + + const preview = await previewProviderBatchPatch({ + providerIds: [12, 13], + patch: { group_tag: { set: "rollback" } }, + }); + if (!preview.ok) throw new Error("Preview should be ok in test setup"); + + const apply = await applyProviderBatchPatch({ + previewToken: preview.data.previewToken, + previewRevision: preview.data.previewRevision, + providerIds: [12, 13], + patch: { group_tag: { set: "rollback" } }, + idempotencyKey: "undo-consume", + }); + if (!apply.ok) throw new Error("Apply should be ok in test setup"); + + const firstUndo = await undoProviderPatch({ + undoToken: apply.data.undoToken, + operationId: apply.data.operationId, + }); + const secondUndo = await undoProviderPatch({ + undoToken: apply.data.undoToken, + operationId: apply.data.operationId, + }); + + expect(firstUndo.ok).toBe(true); + if (firstUndo.ok) { + expect(firstUndo.data.revertedCount).toBe(2); + } + + expect(secondUndo.ok).toBe(false); + if (secondUndo.ok) return; + + expect(secondUndo.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED); + }); +}); diff --git a/tests/unit/actions/providers-patch-contract.test.ts b/tests/unit/actions/providers-patch-contract.test.ts new file mode 100644 index 000000000..6e93a065a --- /dev/null +++ b/tests/unit/actions/providers-patch-contract.test.ts @@ -0,0 +1,922 @@ +import { describe, expect, it } from "vitest"; +import { + buildProviderBatchApplyUpdates, + hasProviderBatchPatchChanges, + normalizeProviderBatchPatchDraft, + prepareProviderBatchApplyUpdates, + PROVIDER_PATCH_ERROR_CODES, +} from "@/lib/provider-patch-contract"; + +describe("provider patch contract", () => { + it("normalizes undefined fields as no_change and omits them from apply payload", () => { + const normalized = normalizeProviderBatchPatchDraft({}); + + expect(normalized.ok).toBe(true); + if (!normalized.ok) return; + + expect(normalized.data.group_tag.mode).toBe("no_change"); + expect(hasProviderBatchPatchChanges(normalized.data)).toBe(false); + + const applyPayload = buildProviderBatchApplyUpdates(normalized.data); + expect(applyPayload.ok).toBe(true); + if (!applyPayload.ok) return; + + expect(applyPayload.data).toEqual({}); + }); + + it("serializes set and clear with distinct payload shapes", () => { + const setResult = prepareProviderBatchApplyUpdates({ + group_tag: { set: "primary" }, + allowed_models: { set: ["claude-3-7-sonnet"] }, + }); + const clearResult = prepareProviderBatchApplyUpdates({ + group_tag: { clear: true }, + allowed_models: { clear: true }, + }); + + expect(setResult.ok).toBe(true); + if (!setResult.ok) return; + + expect(clearResult.ok).toBe(true); + if (!clearResult.ok) return; + + expect(setResult.data.group_tag).toBe("primary"); + expect(clearResult.data.group_tag).toBeNull(); + expect(setResult.data.allowed_models).toEqual(["claude-3-7-sonnet"]); + expect(clearResult.data.allowed_models).toBeNull(); + }); + + it("maps empty allowed_models set payload to null", () => { + const result = prepareProviderBatchApplyUpdates({ + allowed_models: { set: [] }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.allowed_models).toBeNull(); + }); + + it("maps thinking budget clear to inherit", () => { + const result = prepareProviderBatchApplyUpdates({ + anthropic_thinking_budget_preference: { clear: true }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.anthropic_thinking_budget_preference).toBe("inherit"); + }); + + it("rejects conflicting set and clear modes", () => { + const result = normalizeProviderBatchPatchDraft({ + group_tag: { + set: "ops", + clear: true, + } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE); + expect(result.error.field).toBe("group_tag"); + }); + + it("rejects clear on non-clearable fields", () => { + const result = normalizeProviderBatchPatchDraft({ + priority: { + clear: true, + } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE); + expect(result.error.field).toBe("priority"); + }); + + it("rejects invalid set runtime shape", () => { + const result = normalizeProviderBatchPatchDraft({ + weight: { + set: null, + } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE); + expect(result.error.field).toBe("weight"); + }); + + it("rejects model_redirects arrays", () => { + const result = normalizeProviderBatchPatchDraft({ + model_redirects: { + set: ["not-a-record"], + } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE); + expect(result.error.field).toBe("model_redirects"); + }); + + it("rejects invalid thinking budget string values", () => { + const result = normalizeProviderBatchPatchDraft({ + anthropic_thinking_budget_preference: { + set: "abc", + } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE); + expect(result.error.field).toBe("anthropic_thinking_budget_preference"); + }); + + it("rejects adaptive thinking specific mode with empty models", () => { + const result = normalizeProviderBatchPatchDraft({ + anthropic_adaptive_thinking: { + set: { + effort: "high", + modelMatchMode: "specific", + models: [], + }, + }, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE); + expect(result.error.field).toBe("anthropic_adaptive_thinking"); + }); + + it("supports explicit no_change mode", () => { + const result = normalizeProviderBatchPatchDraft({ + model_redirects: { no_change: true }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.model_redirects.mode).toBe("no_change"); + }); + + it("rejects unknown top-level fields", () => { + const result = normalizeProviderBatchPatchDraft({ + unknown_field: { set: 1 }, + } as never); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE); + expect(result.error.field).toBe("__root__"); + }); + + it("rejects non-object draft payloads", () => { + const result = normalizeProviderBatchPatchDraft(null as never); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE); + expect(result.error.field).toBe("__root__"); + }); + + describe("routing fields", () => { + it("accepts boolean set for preserve_client_ip and swap_cache_ttl_billing", () => { + const result = prepareProviderBatchApplyUpdates({ + preserve_client_ip: { set: true }, + swap_cache_ttl_billing: { set: false }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.preserve_client_ip).toBe(true); + expect(result.data.swap_cache_ttl_billing).toBe(false); + }); + + it("accepts group_priorities as Record", () => { + const result = prepareProviderBatchApplyUpdates({ + group_priorities: { set: { us: 10, eu: 5 } }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.group_priorities).toEqual({ us: 10, eu: 5 }); + }); + + it("rejects group_priorities with non-number values", () => { + const result = normalizeProviderBatchPatchDraft({ + group_priorities: { set: { us: "high" } } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("group_priorities"); + }); + + it("rejects group_priorities when array", () => { + const result = normalizeProviderBatchPatchDraft({ + group_priorities: { set: [1, 2, 3] } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("group_priorities"); + }); + + it("clears group_priorities to null", () => { + const result = prepareProviderBatchApplyUpdates({ + group_priorities: { clear: true }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.group_priorities).toBeNull(); + }); + + it.each([ + ["cache_ttl_preference", "inherit"], + ["cache_ttl_preference", "5m"], + ["cache_ttl_preference", "1h"], + ] as const)("accepts valid %s value: %s", (field, value) => { + const result = prepareProviderBatchApplyUpdates({ + [field]: { set: value }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data[field]).toBe(value); + }); + + it("rejects invalid cache_ttl_preference value", () => { + const result = normalizeProviderBatchPatchDraft({ + cache_ttl_preference: { set: "30m" } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("cache_ttl_preference"); + }); + + it.each([ + ["context_1m_preference", "inherit"], + ["context_1m_preference", "force_enable"], + ["context_1m_preference", "disabled"], + ] as const)("accepts valid %s value: %s", (field, value) => { + const result = prepareProviderBatchApplyUpdates({ + [field]: { set: value }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data[field]).toBe(value); + }); + + it.each([ + ["codex_reasoning_effort_preference", "inherit"], + ["codex_reasoning_effort_preference", "none"], + ["codex_reasoning_effort_preference", "minimal"], + ["codex_reasoning_effort_preference", "low"], + ["codex_reasoning_effort_preference", "medium"], + ["codex_reasoning_effort_preference", "high"], + ["codex_reasoning_effort_preference", "xhigh"], + ] as const)("accepts valid %s value: %s", (field, value) => { + const result = prepareProviderBatchApplyUpdates({ + [field]: { set: value }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data[field]).toBe(value); + }); + + it("rejects invalid codex_reasoning_effort_preference value", () => { + const result = normalizeProviderBatchPatchDraft({ + codex_reasoning_effort_preference: { set: "ultra" } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("codex_reasoning_effort_preference"); + }); + + it.each([ + ["codex_reasoning_summary_preference", "inherit"], + ["codex_reasoning_summary_preference", "auto"], + ["codex_reasoning_summary_preference", "detailed"], + ] as const)("accepts valid %s value: %s", (field, value) => { + const result = prepareProviderBatchApplyUpdates({ + [field]: { set: value }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data[field]).toBe(value); + }); + + it.each([ + ["codex_text_verbosity_preference", "inherit"], + ["codex_text_verbosity_preference", "low"], + ["codex_text_verbosity_preference", "medium"], + ["codex_text_verbosity_preference", "high"], + ] as const)("accepts valid %s value: %s", (field, value) => { + const result = prepareProviderBatchApplyUpdates({ + [field]: { set: value }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data[field]).toBe(value); + }); + + it.each([ + ["codex_parallel_tool_calls_preference", "inherit"], + ["codex_parallel_tool_calls_preference", "true"], + ["codex_parallel_tool_calls_preference", "false"], + ] as const)("accepts valid %s value: %s", (field, value) => { + const result = prepareProviderBatchApplyUpdates({ + [field]: { set: value }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data[field]).toBe(value); + }); + + it.each([ + ["gemini_google_search_preference", "inherit"], + ["gemini_google_search_preference", "enabled"], + ["gemini_google_search_preference", "disabled"], + ] as const)("accepts valid %s value: %s", (field, value) => { + const result = prepareProviderBatchApplyUpdates({ + [field]: { set: value }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data[field]).toBe(value); + }); + + it("rejects invalid gemini_google_search_preference value", () => { + const result = normalizeProviderBatchPatchDraft({ + gemini_google_search_preference: { set: "auto" } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("gemini_google_search_preference"); + }); + }); + + describe("anthropic_max_tokens_preference", () => { + it("accepts inherit", () => { + const result = prepareProviderBatchApplyUpdates({ + anthropic_max_tokens_preference: { set: "inherit" }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.anthropic_max_tokens_preference).toBe("inherit"); + }); + + it("accepts positive numeric string", () => { + const result = prepareProviderBatchApplyUpdates({ + anthropic_max_tokens_preference: { set: "8192" }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.anthropic_max_tokens_preference).toBe("8192"); + }); + + it("accepts small positive numeric string (no range restriction)", () => { + const result = prepareProviderBatchApplyUpdates({ + anthropic_max_tokens_preference: { set: "1" }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.anthropic_max_tokens_preference).toBe("1"); + }); + + it("rejects non-numeric string", () => { + const result = normalizeProviderBatchPatchDraft({ + anthropic_max_tokens_preference: { set: "abc" } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("anthropic_max_tokens_preference"); + }); + + it("rejects zero", () => { + const result = normalizeProviderBatchPatchDraft({ + anthropic_max_tokens_preference: { set: "0" } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("anthropic_max_tokens_preference"); + }); + + it("clears to inherit", () => { + const result = prepareProviderBatchApplyUpdates({ + anthropic_max_tokens_preference: { clear: true }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.anthropic_max_tokens_preference).toBe("inherit"); + }); + }); + + describe("rate limit fields", () => { + it.each([ + "limit_5h_usd", + "limit_daily_usd", + "limit_weekly_usd", + "limit_monthly_usd", + "limit_total_usd", + ] as const)("accepts number set and clears to null for %s", (field) => { + const setResult = prepareProviderBatchApplyUpdates({ + [field]: { set: 100.5 }, + }); + + expect(setResult.ok).toBe(true); + if (!setResult.ok) return; + + expect(setResult.data[field]).toBe(100.5); + + const clearResult = prepareProviderBatchApplyUpdates({ + [field]: { clear: true }, + }); + + expect(clearResult.ok).toBe(true); + if (!clearResult.ok) return; + + expect(clearResult.data[field]).toBeNull(); + }); + + it("rejects non-number for limit_5h_usd", () => { + const result = normalizeProviderBatchPatchDraft({ + limit_5h_usd: { set: "100" } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("limit_5h_usd"); + }); + + it("rejects NaN for number fields", () => { + const result = normalizeProviderBatchPatchDraft({ + limit_daily_usd: { set: Number.NaN } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("limit_daily_usd"); + }); + + it("rejects Infinity for number fields", () => { + const result = normalizeProviderBatchPatchDraft({ + limit_weekly_usd: { set: Number.POSITIVE_INFINITY } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("limit_weekly_usd"); + }); + + it("accepts limit_concurrent_sessions as number (non-clearable)", () => { + const result = prepareProviderBatchApplyUpdates({ + limit_concurrent_sessions: { set: 5 }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.limit_concurrent_sessions).toBe(5); + }); + + it("rejects clear on limit_concurrent_sessions", () => { + const result = normalizeProviderBatchPatchDraft({ + limit_concurrent_sessions: { clear: true } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("limit_concurrent_sessions"); + }); + + it.each(["fixed", "rolling"] as const)("accepts daily_reset_mode value: %s", (value) => { + const result = prepareProviderBatchApplyUpdates({ + daily_reset_mode: { set: value }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.daily_reset_mode).toBe(value); + }); + + it("rejects invalid daily_reset_mode value", () => { + const result = normalizeProviderBatchPatchDraft({ + daily_reset_mode: { set: "hourly" } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("daily_reset_mode"); + }); + + it("rejects clear on daily_reset_mode", () => { + const result = normalizeProviderBatchPatchDraft({ + daily_reset_mode: { clear: true } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("daily_reset_mode"); + }); + + it("accepts daily_reset_time as string (non-clearable)", () => { + const result = prepareProviderBatchApplyUpdates({ + daily_reset_time: { set: "00:00" }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.daily_reset_time).toBe("00:00"); + }); + + it("rejects clear on daily_reset_time", () => { + const result = normalizeProviderBatchPatchDraft({ + daily_reset_time: { clear: true } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("daily_reset_time"); + }); + }); + + describe("circuit breaker fields", () => { + it.each([ + "circuit_breaker_failure_threshold", + "circuit_breaker_open_duration", + "circuit_breaker_half_open_success_threshold", + ] as const)("accepts number set for %s (non-clearable)", (field) => { + const result = prepareProviderBatchApplyUpdates({ + [field]: { set: 10 }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data[field]).toBe(10); + }); + + it.each([ + "circuit_breaker_failure_threshold", + "circuit_breaker_open_duration", + "circuit_breaker_half_open_success_threshold", + ] as const)("rejects clear on %s", (field) => { + const result = normalizeProviderBatchPatchDraft({ + [field]: { clear: true } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe(field); + }); + + it("accepts max_retry_attempts and clears to null", () => { + const setResult = prepareProviderBatchApplyUpdates({ + max_retry_attempts: { set: 3 }, + }); + + expect(setResult.ok).toBe(true); + if (!setResult.ok) return; + + expect(setResult.data.max_retry_attempts).toBe(3); + + const clearResult = prepareProviderBatchApplyUpdates({ + max_retry_attempts: { clear: true }, + }); + + expect(clearResult.ok).toBe(true); + if (!clearResult.ok) return; + + expect(clearResult.data.max_retry_attempts).toBeNull(); + }); + }); + + describe("network fields", () => { + it("accepts proxy_url as string and clears to null", () => { + const setResult = prepareProviderBatchApplyUpdates({ + proxy_url: { set: "socks5://proxy.example.com:1080" }, + }); + + expect(setResult.ok).toBe(true); + if (!setResult.ok) return; + + expect(setResult.data.proxy_url).toBe("socks5://proxy.example.com:1080"); + + const clearResult = prepareProviderBatchApplyUpdates({ + proxy_url: { clear: true }, + }); + + expect(clearResult.ok).toBe(true); + if (!clearResult.ok) return; + + expect(clearResult.data.proxy_url).toBeNull(); + }); + + it("accepts boolean set for proxy_fallback_to_direct (non-clearable)", () => { + const result = prepareProviderBatchApplyUpdates({ + proxy_fallback_to_direct: { set: true }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.proxy_fallback_to_direct).toBe(true); + }); + + it("rejects clear on proxy_fallback_to_direct", () => { + const result = normalizeProviderBatchPatchDraft({ + proxy_fallback_to_direct: { clear: true } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("proxy_fallback_to_direct"); + }); + + it.each([ + "first_byte_timeout_streaming_ms", + "streaming_idle_timeout_ms", + "request_timeout_non_streaming_ms", + ] as const)("accepts number set for %s (non-clearable)", (field) => { + const result = prepareProviderBatchApplyUpdates({ + [field]: { set: 30000 }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data[field]).toBe(30000); + }); + + it.each([ + "first_byte_timeout_streaming_ms", + "streaming_idle_timeout_ms", + "request_timeout_non_streaming_ms", + ] as const)("rejects clear on %s", (field) => { + const result = normalizeProviderBatchPatchDraft({ + [field]: { clear: true } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe(field); + }); + }); + + describe("MCP fields", () => { + it.each([ + "none", + "minimax", + "glm", + "custom", + ] as const)("accepts mcp_passthrough_type value: %s", (value) => { + const result = prepareProviderBatchApplyUpdates({ + mcp_passthrough_type: { set: value }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.mcp_passthrough_type).toBe(value); + }); + + it("rejects invalid mcp_passthrough_type value", () => { + const result = normalizeProviderBatchPatchDraft({ + mcp_passthrough_type: { set: "openai" } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("mcp_passthrough_type"); + }); + + it("rejects clear on mcp_passthrough_type", () => { + const result = normalizeProviderBatchPatchDraft({ + mcp_passthrough_type: { clear: true } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("mcp_passthrough_type"); + }); + + it("accepts mcp_passthrough_url as string and clears to null", () => { + const setResult = prepareProviderBatchApplyUpdates({ + mcp_passthrough_url: { set: "https://api.minimaxi.com" }, + }); + + expect(setResult.ok).toBe(true); + if (!setResult.ok) return; + + expect(setResult.data.mcp_passthrough_url).toBe("https://api.minimaxi.com"); + + const clearResult = prepareProviderBatchApplyUpdates({ + mcp_passthrough_url: { clear: true }, + }); + + expect(clearResult.ok).toBe(true); + if (!clearResult.ok) return; + + expect(clearResult.data.mcp_passthrough_url).toBeNull(); + }); + }); + + describe("preference fields clear to inherit", () => { + it.each([ + "cache_ttl_preference", + "context_1m_preference", + "codex_reasoning_effort_preference", + "codex_reasoning_summary_preference", + "codex_text_verbosity_preference", + "codex_parallel_tool_calls_preference", + "anthropic_max_tokens_preference", + "gemini_google_search_preference", + ] as const)("clears %s to inherit", (field) => { + const result = prepareProviderBatchApplyUpdates({ + [field]: { clear: true }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data[field]).toBe("inherit"); + }); + }); + + describe("non-clearable field rejection", () => { + it.each([ + "preserve_client_ip", + "swap_cache_ttl_billing", + "daily_reset_mode", + "daily_reset_time", + "limit_concurrent_sessions", + "circuit_breaker_failure_threshold", + "circuit_breaker_open_duration", + "circuit_breaker_half_open_success_threshold", + "proxy_fallback_to_direct", + "first_byte_timeout_streaming_ms", + "streaming_idle_timeout_ms", + "request_timeout_non_streaming_ms", + "mcp_passthrough_type", + ] as const)("rejects clear on non-clearable field: %s", (field) => { + const result = normalizeProviderBatchPatchDraft({ + [field]: { clear: true } as never, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE); + expect(result.error.field).toBe(field); + }); + }); + + describe("hasProviderBatchPatchChanges for new fields", () => { + it("detects change on a single new field", () => { + const normalized = normalizeProviderBatchPatchDraft({ + preserve_client_ip: { set: true }, + }); + + expect(normalized.ok).toBe(true); + if (!normalized.ok) return; + + expect(hasProviderBatchPatchChanges(normalized.data)).toBe(true); + }); + + it("detects change on mcp_passthrough_url (last field)", () => { + const normalized = normalizeProviderBatchPatchDraft({ + mcp_passthrough_url: { set: "https://example.com" }, + }); + + expect(normalized.ok).toBe(true); + if (!normalized.ok) return; + + expect(hasProviderBatchPatchChanges(normalized.data)).toBe(true); + }); + + it("reports no change when all new fields are no_change", () => { + const normalized = normalizeProviderBatchPatchDraft({ + preserve_client_ip: { no_change: true }, + limit_5h_usd: { no_change: true }, + proxy_url: { no_change: true }, + }); + + expect(normalized.ok).toBe(true); + if (!normalized.ok) return; + + expect(hasProviderBatchPatchChanges(normalized.data)).toBe(false); + }); + }); + + describe("combined set across all categories", () => { + it("handles a batch patch touching all field categories at once", () => { + const result = prepareProviderBatchApplyUpdates({ + // existing + is_enabled: { set: true }, + group_tag: { set: "batch-test" }, + // routing + preserve_client_ip: { set: false }, + cache_ttl_preference: { set: "1h" }, + codex_reasoning_effort_preference: { set: "high" }, + anthropic_max_tokens_preference: { set: "16384" }, + // rate limit + limit_5h_usd: { set: 50 }, + daily_reset_mode: { set: "rolling" }, + daily_reset_time: { set: "08:00" }, + // circuit breaker + circuit_breaker_failure_threshold: { set: 5 }, + max_retry_attempts: { set: 2 }, + // network + proxy_url: { set: "https://proxy.local" }, + proxy_fallback_to_direct: { set: true }, + first_byte_timeout_streaming_ms: { set: 15000 }, + // mcp + mcp_passthrough_type: { set: "minimax" }, + mcp_passthrough_url: { set: "https://api.minimaxi.com" }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.is_enabled).toBe(true); + expect(result.data.group_tag).toBe("batch-test"); + expect(result.data.preserve_client_ip).toBe(false); + expect(result.data.cache_ttl_preference).toBe("1h"); + expect(result.data.codex_reasoning_effort_preference).toBe("high"); + expect(result.data.anthropic_max_tokens_preference).toBe("16384"); + expect(result.data.limit_5h_usd).toBe(50); + expect(result.data.daily_reset_mode).toBe("rolling"); + expect(result.data.daily_reset_time).toBe("08:00"); + expect(result.data.circuit_breaker_failure_threshold).toBe(5); + expect(result.data.max_retry_attempts).toBe(2); + expect(result.data.proxy_url).toBe("https://proxy.local"); + expect(result.data.proxy_fallback_to_direct).toBe(true); + expect(result.data.first_byte_timeout_streaming_ms).toBe(15000); + expect(result.data.mcp_passthrough_type).toBe("minimax"); + expect(result.data.mcp_passthrough_url).toBe("https://api.minimaxi.com"); + }); + }); +}); diff --git a/tests/unit/actions/providers-preview-engine.test.ts b/tests/unit/actions/providers-preview-engine.test.ts new file mode 100644 index 000000000..744365abe --- /dev/null +++ b/tests/unit/actions/providers-preview-engine.test.ts @@ -0,0 +1,563 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Provider } from "@/types/provider"; + +const getSessionMock = vi.fn(); +const findAllProvidersFreshMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/repository/provider", () => ({ + findAllProvidersFresh: findAllProvidersFreshMock, + updateProvidersBatch: vi.fn(), + deleteProvidersBatch: vi.fn(), +})); + +vi.mock("@/lib/cache/provider-cache", () => ({ + publishProviderCacheInvalidation: vi.fn(), +})); + +vi.mock("@/lib/circuit-breaker", () => ({ + clearProviderState: vi.fn(), + clearConfigCache: vi.fn(), + resetCircuit: vi.fn(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +function buildTestProvider(overrides: Partial = {}): Provider { + return { + id: 1, + name: "Test Provider", + url: "https://api.example.com", + key: "test-key", + providerVendorId: null, + isEnabled: true, + weight: 10, + priority: 1, + groupPriorities: null, + costMultiplier: 1.0, + groupTag: null, + providerType: "claude", + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + totalCostResetAt: null, + limitConcurrentSessions: 10, + maxRetryAttempts: null, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 1800000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 30000, + streamingIdleTimeoutMs: 10000, + requestTimeoutNonStreamingMs: 600000, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + swapCacheTtlBilling: false, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: null, + tpm: null, + rpm: null, + rpd: null, + cc: null, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), + ...overrides, + }; +} + +describe("Provider Batch Preview Engine - Row Generation", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + }); + + it("generates correct before/after row for single provider single field change", async () => { + const provider = buildTestProvider({ + id: 5, + name: "Claude One", + groupTag: "old-group", + }); + findAllProvidersFreshMock.mockResolvedValue([provider]); + + const { previewProviderBatchPatch } = await import("@/actions/providers"); + const result = await previewProviderBatchPatch({ + providerIds: [5], + patch: { group_tag: { set: "new-group" } }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.rows).toHaveLength(1); + expect(result.data.rows[0]).toEqual({ + providerId: 5, + providerName: "Claude One", + field: "group_tag", + status: "changed", + before: "old-group", + after: "new-group", + }); + }); + + it("generates rows for each provider-field combination", async () => { + const providerA = buildTestProvider({ + id: 1, + name: "Provider A", + priority: 5, + weight: 10, + }); + const providerB = buildTestProvider({ + id: 2, + name: "Provider B", + priority: 3, + weight: 20, + }); + findAllProvidersFreshMock.mockResolvedValue([providerA, providerB]); + + const { previewProviderBatchPatch } = await import("@/actions/providers"); + const result = await previewProviderBatchPatch({ + providerIds: [1, 2], + patch: { + priority: { set: 10 }, + weight: { set: 50 }, + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.rows).toHaveLength(4); + + expect(result.data.rows).toContainEqual({ + providerId: 1, + providerName: "Provider A", + field: "priority", + status: "changed", + before: 5, + after: 10, + }); + expect(result.data.rows).toContainEqual({ + providerId: 1, + providerName: "Provider A", + field: "weight", + status: "changed", + before: 10, + after: 50, + }); + expect(result.data.rows).toContainEqual({ + providerId: 2, + providerName: "Provider B", + field: "priority", + status: "changed", + before: 3, + after: 10, + }); + expect(result.data.rows).toContainEqual({ + providerId: 2, + providerName: "Provider B", + field: "weight", + status: "changed", + before: 20, + after: 50, + }); + }); + + it("marks anthropic fields as skipped for non-claude providers", async () => { + const provider = buildTestProvider({ + id: 10, + name: "OpenAI Compat", + providerType: "openai-compatible", + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + }); + findAllProvidersFreshMock.mockResolvedValue([provider]); + + const { previewProviderBatchPatch } = await import("@/actions/providers"); + const result = await previewProviderBatchPatch({ + providerIds: [10], + patch: { + anthropic_thinking_budget_preference: { set: "8192" }, + anthropic_adaptive_thinking: { + set: { effort: "high", modelMatchMode: "all", models: [] }, + }, + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.rows).toHaveLength(2); + + const budgetRow = result.data.rows.find( + (r: { field: string }) => r.field === "anthropic_thinking_budget_preference" + ); + expect(budgetRow).toEqual({ + providerId: 10, + providerName: "OpenAI Compat", + field: "anthropic_thinking_budget_preference", + status: "skipped", + before: null, + after: "8192", + skipReason: expect.any(String), + }); + + const adaptiveRow = result.data.rows.find( + (r: { field: string }) => r.field === "anthropic_adaptive_thinking" + ); + expect(adaptiveRow).toEqual({ + providerId: 10, + providerName: "OpenAI Compat", + field: "anthropic_adaptive_thinking", + status: "skipped", + before: null, + after: { effort: "high", modelMatchMode: "all", models: [] }, + skipReason: expect.any(String), + }); + }); + + it("marks anthropic fields as changed for claude providers", async () => { + const provider = buildTestProvider({ + id: 20, + name: "Claude Main", + providerType: "claude", + anthropicThinkingBudgetPreference: "inherit", + }); + findAllProvidersFreshMock.mockResolvedValue([provider]); + + const { previewProviderBatchPatch } = await import("@/actions/providers"); + const result = await previewProviderBatchPatch({ + providerIds: [20], + patch: { anthropic_thinking_budget_preference: { set: "16000" } }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.rows).toHaveLength(1); + expect(result.data.rows[0]).toEqual({ + providerId: 20, + providerName: "Claude Main", + field: "anthropic_thinking_budget_preference", + status: "changed", + before: "inherit", + after: "16000", + }); + }); + + it("marks anthropic fields as changed for claude-auth providers", async () => { + const provider = buildTestProvider({ + id: 21, + name: "Claude Auth", + providerType: "claude-auth", + anthropicAdaptiveThinking: null, + }); + findAllProvidersFreshMock.mockResolvedValue([provider]); + + const { previewProviderBatchPatch } = await import("@/actions/providers"); + const result = await previewProviderBatchPatch({ + providerIds: [21], + patch: { + anthropic_adaptive_thinking: { + set: { effort: "medium", modelMatchMode: "all", models: [] }, + }, + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.rows).toHaveLength(1); + expect(result.data.rows[0].status).toBe("changed"); + expect(result.data.rows[0].providerId).toBe(21); + }); + + it("computes correct after values for clear mode", async () => { + const provider = buildTestProvider({ + id: 30, + name: "Clear Test", + providerType: "claude", + groupTag: "old-tag", + modelRedirects: { "model-a": "model-b" }, + allowedModels: ["claude-3"], + anthropicThinkingBudgetPreference: "8192", + anthropicAdaptiveThinking: { + effort: "high", + modelMatchMode: "all", + models: [], + }, + }); + findAllProvidersFreshMock.mockResolvedValue([provider]); + + const { previewProviderBatchPatch } = await import("@/actions/providers"); + const result = await previewProviderBatchPatch({ + providerIds: [30], + patch: { + group_tag: { clear: true }, + model_redirects: { clear: true }, + allowed_models: { clear: true }, + anthropic_thinking_budget_preference: { clear: true }, + anthropic_adaptive_thinking: { clear: true }, + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.rows).toHaveLength(5); + + const groupTagRow = result.data.rows.find((r: { field: string }) => r.field === "group_tag"); + expect(groupTagRow?.before).toBe("old-tag"); + expect(groupTagRow?.after).toBeNull(); + + const modelRedirectsRow = result.data.rows.find( + (r: { field: string }) => r.field === "model_redirects" + ); + expect(modelRedirectsRow?.before).toEqual({ "model-a": "model-b" }); + expect(modelRedirectsRow?.after).toBeNull(); + + const allowedModelsRow = result.data.rows.find( + (r: { field: string }) => r.field === "allowed_models" + ); + expect(allowedModelsRow?.before).toEqual(["claude-3"]); + expect(allowedModelsRow?.after).toBeNull(); + + // anthropic_thinking_budget_preference clears to "inherit" + const budgetRow = result.data.rows.find( + (r: { field: string }) => r.field === "anthropic_thinking_budget_preference" + ); + expect(budgetRow?.before).toBe("8192"); + expect(budgetRow?.after).toBe("inherit"); + + const adaptiveRow = result.data.rows.find( + (r: { field: string }) => r.field === "anthropic_adaptive_thinking" + ); + expect(adaptiveRow?.before).toEqual({ + effort: "high", + modelMatchMode: "all", + models: [], + }); + expect(adaptiveRow?.after).toBeNull(); + }); + + it("normalizes empty allowed_models array to null in after value", async () => { + const provider = buildTestProvider({ + id: 40, + name: "Models Test", + allowedModels: ["claude-3"], + }); + findAllProvidersFreshMock.mockResolvedValue([provider]); + + const { previewProviderBatchPatch } = await import("@/actions/providers"); + const result = await previewProviderBatchPatch({ + providerIds: [40], + patch: { allowed_models: { set: [] } }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.rows).toHaveLength(1); + expect(result.data.rows[0].before).toEqual(["claude-3"]); + expect(result.data.rows[0].after).toBeNull(); + }); + + it("includes correct skipCount in summary", async () => { + const claudeProvider = buildTestProvider({ + id: 50, + name: "Claude", + providerType: "claude", + }); + const openaiProvider = buildTestProvider({ + id: 51, + name: "OpenAI", + providerType: "openai-compatible", + }); + const geminiProvider = buildTestProvider({ + id: 52, + name: "Gemini", + providerType: "gemini", + }); + findAllProvidersFreshMock.mockResolvedValue([claudeProvider, openaiProvider, geminiProvider]); + + const { previewProviderBatchPatch } = await import("@/actions/providers"); + const result = await previewProviderBatchPatch({ + providerIds: [50, 51, 52], + patch: { + anthropic_thinking_budget_preference: { set: "8192" }, + group_tag: { set: "new-tag" }, + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + // 3 providers x 2 fields = 6 rows + expect(result.data.rows).toHaveLength(6); + // 2 non-claude providers x 1 anthropic field = 2 skipped + expect(result.data.summary.skipCount).toBe(2); + expect(result.data.summary.providerCount).toBe(3); + expect(result.data.summary.fieldCount).toBe(2); + }); + + it("returns rows in the preview result for snapshot storage", async () => { + const provider = buildTestProvider({ + id: 60, + name: "Snapshot Test", + isEnabled: true, + }); + findAllProvidersFreshMock.mockResolvedValue([provider]); + + const { previewProviderBatchPatch } = await import("@/actions/providers"); + const result = await previewProviderBatchPatch({ + providerIds: [60], + patch: { is_enabled: { set: false } }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.rows).toBeDefined(); + expect(Array.isArray(result.data.rows)).toBe(true); + expect(result.data.rows).toHaveLength(1); + expect(result.data.rows[0]).toEqual({ + providerId: 60, + providerName: "Snapshot Test", + field: "is_enabled", + status: "changed", + before: true, + after: false, + }); + }); + + it("only generates rows for providers matching requested IDs", async () => { + const providerA = buildTestProvider({ id: 100, name: "Match" }); + const providerB = buildTestProvider({ id: 200, name: "No Match" }); + findAllProvidersFreshMock.mockResolvedValue([providerA, providerB]); + + const { previewProviderBatchPatch } = await import("@/actions/providers"); + const result = await previewProviderBatchPatch({ + providerIds: [100], + patch: { priority: { set: 99 } }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.rows).toHaveLength(1); + expect(result.data.rows[0].providerId).toBe(100); + }); + + it("skips anthropic fields for all non-claude provider types", async () => { + const codexProvider = buildTestProvider({ + id: 70, + name: "Codex", + providerType: "codex", + }); + const geminiCliProvider = buildTestProvider({ + id: 71, + name: "Gemini CLI", + providerType: "gemini-cli", + }); + findAllProvidersFreshMock.mockResolvedValue([codexProvider, geminiCliProvider]); + + const { previewProviderBatchPatch } = await import("@/actions/providers"); + const result = await previewProviderBatchPatch({ + providerIds: [70, 71], + patch: { + anthropic_adaptive_thinking: { + set: { effort: "low", modelMatchMode: "all", models: [] }, + }, + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.rows).toHaveLength(2); + expect(result.data.rows.every((r: { status: string }) => r.status === "skipped")).toBe(true); + expect(result.data.summary.skipCount).toBe(2); + }); + + it("handles mixed changed and skipped rows across providers", async () => { + const claudeProvider = buildTestProvider({ + id: 80, + name: "Claude", + providerType: "claude", + groupTag: "alpha", + anthropicThinkingBudgetPreference: null, + }); + const openaiProvider = buildTestProvider({ + id: 81, + name: "OpenAI", + providerType: "openai-compatible", + groupTag: "beta", + anthropicThinkingBudgetPreference: null, + }); + findAllProvidersFreshMock.mockResolvedValue([claudeProvider, openaiProvider]); + + const { previewProviderBatchPatch } = await import("@/actions/providers"); + const result = await previewProviderBatchPatch({ + providerIds: [80, 81], + patch: { + group_tag: { set: "gamma" }, + anthropic_thinking_budget_preference: { set: "4096" }, + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + // 2 providers x 2 fields = 4 rows + expect(result.data.rows).toHaveLength(4); + + // group_tag: both changed (universal field) + const groupTagRows = result.data.rows.filter((r: { field: string }) => r.field === "group_tag"); + expect(groupTagRows).toHaveLength(2); + expect(groupTagRows.every((r: { status: string }) => r.status === "changed")).toBe(true); + + // anthropic_thinking_budget_preference: claude changed, openai skipped + const budgetRows = result.data.rows.filter( + (r: { field: string }) => r.field === "anthropic_thinking_budget_preference" + ); + expect(budgetRows).toHaveLength(2); + + const claudeBudget = budgetRows.find((r: { providerId: number }) => r.providerId === 80); + expect(claudeBudget?.status).toBe("changed"); + + const openaiBudget = budgetRows.find((r: { providerId: number }) => r.providerId === 81); + expect(openaiBudget?.status).toBe("skipped"); + expect(openaiBudget?.skipReason).toBeTruthy(); + + expect(result.data.summary.skipCount).toBe(1); + }); +}); diff --git a/tests/unit/actions/providers-undo-engine.test.ts b/tests/unit/actions/providers-undo-engine.test.ts new file mode 100644 index 000000000..b7f094da8 --- /dev/null +++ b/tests/unit/actions/providers-undo-engine.test.ts @@ -0,0 +1,391 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes"; + +const getSessionMock = vi.fn(); +const findAllProvidersFreshMock = vi.fn(); +const updateProvidersBatchMock = vi.fn(); +const publishCacheInvalidationMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/repository/provider", () => ({ + findAllProvidersFresh: findAllProvidersFreshMock, + updateProvidersBatch: updateProvidersBatchMock, + deleteProvidersBatch: vi.fn(), +})); + +vi.mock("@/lib/cache/provider-cache", () => ({ + publishProviderCacheInvalidation: publishCacheInvalidationMock, +})); + +vi.mock("@/lib/circuit-breaker", () => ({ + clearProviderState: vi.fn(), + clearConfigCache: vi.fn(), + resetCircuit: vi.fn(), + getAllHealthStatusAsync: vi.fn(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +function makeProvider(id: number, overrides: Record = {}) { + return { + id, + name: `Provider-${id}`, + url: "https://api.example.com/v1", + key: "sk-test", + providerVendorId: null, + isEnabled: true, + weight: 100, + priority: 1, + groupPriorities: null, + costMultiplier: 1.0, + groupTag: null, + providerType: "claude", + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + totalCostResetAt: null, + limitConcurrentSessions: null, + maxRetryAttempts: null, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 1800000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 30000, + streamingIdleTimeoutMs: 10000, + requestTimeoutNonStreamingMs: 600000, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + swapCacheTtlBilling: false, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: null, + tpm: null, + rpm: null, + rpd: null, + cc: null, + createdAt: new Date("2025-01-01"), + updatedAt: new Date("2025-01-01"), + deletedAt: null, + ...overrides, + }; +} + +describe("Undo Provider Batch Patch Engine", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findAllProvidersFreshMock.mockResolvedValue([]); + updateProvidersBatchMock.mockResolvedValue(0); + publishCacheInvalidationMock.mockResolvedValue(undefined); + }); + + /** Helper: preview -> apply -> return undo token + operationId + undoProviderPatch */ + async function setupPreviewApplyAndGetUndo( + providers: ReturnType[], + providerIds: number[], + patch: Record, + applyOverrides: Record = {} + ) { + findAllProvidersFreshMock.mockResolvedValue(providers); + updateProvidersBatchMock.mockResolvedValue(providers.length); + + const { previewProviderBatchPatch, applyProviderBatchPatch, undoProviderPatch } = await import( + "@/actions/providers" + ); + + const preview = await previewProviderBatchPatch({ providerIds, patch }); + if (!preview.ok) throw new Error(`Preview failed: ${preview.error}`); + + const apply = await applyProviderBatchPatch({ + previewToken: preview.data.previewToken, + previewRevision: preview.data.previewRevision, + providerIds, + patch, + ...applyOverrides, + }); + if (!apply.ok) throw new Error(`Apply failed: ${apply.error}`); + + // Reset mocks after apply so undo assertions are clean + updateProvidersBatchMock.mockClear(); + publishCacheInvalidationMock.mockClear(); + + return { + undoToken: apply.data.undoToken, + operationId: apply.data.operationId, + undoProviderPatch, + }; + } + + it("should revert each provider's fields to preimage values", async () => { + const providers = [ + makeProvider(1, { groupTag: "alpha" }), + makeProvider(2, { groupTag: "beta" }), + ]; + + const { undoToken, operationId, undoProviderPatch } = await setupPreviewApplyAndGetUndo( + providers, + [1, 2], + { group_tag: { set: "gamma" } } + ); + + updateProvidersBatchMock.mockResolvedValue(1); + + const result = await undoProviderPatch({ undoToken, operationId }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + // Provider 1 had groupTag "alpha", provider 2 had "beta" -- different preimages + expect(updateProvidersBatchMock).toHaveBeenCalledWith( + [1], + expect.objectContaining({ groupTag: "alpha" }) + ); + expect(updateProvidersBatchMock).toHaveBeenCalledWith( + [2], + expect.objectContaining({ groupTag: "beta" }) + ); + }); + + it("should call updateProvidersBatch per unique preimage group", async () => { + const providers = [ + makeProvider(1, { groupTag: "same" }), + makeProvider(2, { groupTag: "same" }), + makeProvider(3, { groupTag: "different" }), + ]; + + const { undoToken, operationId, undoProviderPatch } = await setupPreviewApplyAndGetUndo( + providers, + [1, 2, 3], + { group_tag: { set: "new-value" } } + ); + + updateProvidersBatchMock.mockResolvedValue(1); + + await undoProviderPatch({ undoToken, operationId }); + + // 2 groups: [1,2] with "same" and [3] with "different" + expect(updateProvidersBatchMock).toHaveBeenCalledTimes(2); + // One call should batch providers 1 and 2 together + const calls = updateProvidersBatchMock.mock.calls as Array<[number[], Record]>; + const groupedCall = calls.find((c) => c[0].length === 2); + expect(groupedCall).toBeDefined(); + expect(groupedCall![0]).toEqual(expect.arrayContaining([1, 2])); + }); + + it("should publish cache invalidation after undo", async () => { + const providers = [makeProvider(1, { groupTag: "old" })]; + + const { undoToken, operationId, undoProviderPatch } = await setupPreviewApplyAndGetUndo( + providers, + [1], + { group_tag: { set: "new" } } + ); + + updateProvidersBatchMock.mockResolvedValue(1); + + const result = await undoProviderPatch({ undoToken, operationId }); + + expect(result.ok).toBe(true); + expect(publishCacheInvalidationMock).toHaveBeenCalledOnce(); + }); + + it("should return correct revertedCount from actual DB writes", async () => { + const providers = [ + makeProvider(1, { groupTag: "a" }), + makeProvider(2, { groupTag: "b" }), + makeProvider(3, { groupTag: "c" }), + ]; + + const { undoToken, operationId, undoProviderPatch } = await setupPreviewApplyAndGetUndo( + providers, + [1, 2, 3], + { group_tag: { set: "unified" } } + ); + + // Each per-group call returns 1 + updateProvidersBatchMock.mockResolvedValue(1); + + const result = await undoProviderPatch({ undoToken, operationId }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + // 3 different preimages -> 3 calls, each returning 1 + expect(result.data.revertedCount).toBe(3); + }); + + it("should return UNDO_EXPIRED for missing token", async () => { + const { undoProviderPatch } = await import("@/actions/providers"); + + const result = await undoProviderPatch({ + undoToken: "nonexistent_token", + operationId: "op_123", + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED); + }); + + it("should return UNDO_CONFLICT for mismatched operationId", async () => { + const providers = [makeProvider(1, { groupTag: "old" })]; + + const { undoToken, undoProviderPatch } = await setupPreviewApplyAndGetUndo(providers, [1], { + group_tag: { set: "new" }, + }); + + const result = await undoProviderPatch({ + undoToken, + operationId: "wrong_operation_id", + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_CONFLICT); + expect(updateProvidersBatchMock).not.toHaveBeenCalled(); + }); + + it("should consume undo token after successful undo", async () => { + const providers = [makeProvider(1, { groupTag: "old" })]; + + const { undoToken, operationId, undoProviderPatch } = await setupPreviewApplyAndGetUndo( + providers, + [1], + { group_tag: { set: "new" } } + ); + + updateProvidersBatchMock.mockResolvedValue(1); + + const first = await undoProviderPatch({ undoToken, operationId }); + expect(first.ok).toBe(true); + + // Second undo with same token should fail -- token was consumed + const second = await undoProviderPatch({ undoToken, operationId }); + expect(second.ok).toBe(false); + if (second.ok) return; + expect(second.errorCode).toBe(PROVIDER_BATCH_PATCH_ERROR_CODES.UNDO_EXPIRED); + }); + + it("should handle costMultiplier number-to-string conversion", async () => { + const providers = [makeProvider(1, { costMultiplier: 1.5 })]; + + const { undoToken, operationId, undoProviderPatch } = await setupPreviewApplyAndGetUndo( + providers, + [1], + { cost_multiplier: { set: 2.5 } } + ); + + updateProvidersBatchMock.mockResolvedValue(1); + + const result = await undoProviderPatch({ undoToken, operationId }); + + expect(result.ok).toBe(true); + // The preimage stored costMultiplier as number 1.5; undo must convert to string "1.5" + expect(updateProvidersBatchMock).toHaveBeenCalledWith( + [1], + expect.objectContaining({ costMultiplier: "1.5" }) + ); + }); + + it("should handle providers with different preimage values individually", async () => { + const providers = [ + makeProvider(1, { priority: 5, weight: 80 }), + makeProvider(2, { priority: 10, weight: 60 }), + ]; + + const { undoToken, operationId, undoProviderPatch } = await setupPreviewApplyAndGetUndo( + providers, + [1, 2], + { priority: { set: 1 }, weight: { set: 100 } } + ); + + updateProvidersBatchMock.mockResolvedValue(1); + + const result = await undoProviderPatch({ undoToken, operationId }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + // Each provider should be reverted with its own original values + expect(updateProvidersBatchMock).toHaveBeenCalledWith( + [1], + expect.objectContaining({ priority: 5, weight: 80 }) + ); + expect(updateProvidersBatchMock).toHaveBeenCalledWith( + [2], + expect.objectContaining({ priority: 10, weight: 60 }) + ); + expect(result.data.revertedCount).toBe(2); + }); + + it("should handle providerIds without preimage entries gracefully", async () => { + // Only provider 1 exists in DB; provider 999 has no preimage + const providers = [makeProvider(1, { groupTag: "old" })]; + findAllProvidersFreshMock.mockResolvedValue(providers); + updateProvidersBatchMock.mockResolvedValue(1); + + const { previewProviderBatchPatch, applyProviderBatchPatch, undoProviderPatch } = await import( + "@/actions/providers" + ); + + const preview = await previewProviderBatchPatch({ + providerIds: [1, 999], + patch: { group_tag: { set: "new" } }, + }); + if (!preview.ok) throw new Error(`Preview failed: ${preview.error}`); + + const apply = await applyProviderBatchPatch({ + previewToken: preview.data.previewToken, + previewRevision: preview.data.previewRevision, + providerIds: [1, 999], + patch: { group_tag: { set: "new" } }, + }); + if (!apply.ok) throw new Error(`Apply failed: ${apply.error}`); + + updateProvidersBatchMock.mockClear(); + publishCacheInvalidationMock.mockClear(); + updateProvidersBatchMock.mockResolvedValue(1); + + const result = await undoProviderPatch({ + undoToken: apply.data.undoToken, + operationId: apply.data.operationId, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + // Only provider 1 has preimage, provider 999 is skipped + expect(updateProvidersBatchMock).toHaveBeenCalledTimes(1); + expect(updateProvidersBatchMock).toHaveBeenCalledWith( + [1], + expect.objectContaining({ groupTag: "old" }) + ); + expect(result.data.revertedCount).toBe(1); + }); +}); diff --git a/tests/unit/actions/providers-undo-store.test.ts b/tests/unit/actions/providers-undo-store.test.ts new file mode 100644 index 000000000..dbc495f6d --- /dev/null +++ b/tests/unit/actions/providers-undo-store.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const setexMock = vi.fn(); +const getMock = vi.fn(); +const delMock = vi.fn(); +const evalMock = vi.fn(); + +vi.mock("@/lib/redis/client", () => ({ + getRedisClient: () => ({ + status: "ready", + setex: setexMock, + get: getMock, + del: delMock, + eval: evalMock, + }), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock("server-only", () => ({})); + +function buildSnapshot(overrides: Partial> = {}) { + return { + operationId: "op-1", + operationType: "batch_edit" as const, + preimage: { before: "state" }, + providerIds: [1, 2], + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +describe("providers undo store", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-18T00:00:00.000Z")); + vi.resetModules(); + vi.clearAllMocks(); + setexMock.mockResolvedValue("OK"); + delMock.mockResolvedValue(1); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("stores snapshot and consumes token within TTL", async () => { + const token = "11111111-1111-1111-1111-111111111111"; + vi.spyOn(crypto, "randomUUID").mockReturnValue(token); + + const snapshot = buildSnapshot(); + evalMock.mockResolvedValue(JSON.stringify(snapshot)); + + const { storeUndoSnapshot, consumeUndoToken } = await import("@/lib/providers/undo-store"); + + const storeResult = await storeUndoSnapshot(snapshot); + + expect(storeResult).toEqual({ + undoAvailable: true, + undoToken: token, + expiresAt: "2026-02-18T00:00:30.000Z", + }); + expect(setexMock).toHaveBeenCalledWith(`cch:prov:undo:${token}`, 30, JSON.stringify(snapshot)); + + const consumeResult = await consumeUndoToken(token); + expect(consumeResult).toEqual({ + ok: true, + snapshot, + }); + expect(evalMock).toHaveBeenCalledWith(expect.any(String), 1, `cch:prov:undo:${token}`); + }); + + it("returns UNDO_EXPIRED when Redis returns null (TTL passed)", async () => { + const token = "22222222-2222-2222-2222-222222222222"; + evalMock.mockResolvedValue(null); + + const { consumeUndoToken } = await import("@/lib/providers/undo-store"); + + const consumeResult = await consumeUndoToken(token); + expect(consumeResult).toEqual({ + ok: false, + code: "UNDO_EXPIRED", + }); + }); + + it("consumes a token only once (getAndDelete)", async () => { + const token = "33333333-3333-3333-3333-333333333333"; + vi.spyOn(crypto, "randomUUID").mockReturnValue(token); + + const snapshot = buildSnapshot({ operationId: "op-3" }); + + const { storeUndoSnapshot, consumeUndoToken } = await import("@/lib/providers/undo-store"); + + await storeUndoSnapshot(snapshot); + + evalMock.mockResolvedValueOnce(JSON.stringify(snapshot)).mockResolvedValueOnce(null); + + const first = await consumeUndoToken(token); + const second = await consumeUndoToken(token); + + expect(first).toEqual({ ok: true, snapshot }); + expect(second).toEqual({ ok: false, code: "UNDO_EXPIRED" }); + }); + + it("returns UNDO_EXPIRED for unknown token", async () => { + evalMock.mockResolvedValue(null); + + const { consumeUndoToken } = await import("@/lib/providers/undo-store"); + const result = await consumeUndoToken("undo-token-missing"); + + expect(result).toEqual({ + ok: false, + code: "UNDO_EXPIRED", + }); + }); + + it("stores multiple snapshots with independent tokens", async () => { + const tokenA = "44444444-4444-4444-4444-444444444444"; + const tokenB = "55555555-5555-5555-5555-555555555555"; + vi.spyOn(crypto, "randomUUID").mockReturnValueOnce(tokenA).mockReturnValueOnce(tokenB); + + const { storeUndoSnapshot, consumeUndoToken } = await import("@/lib/providers/undo-store"); + + const snapshotA = buildSnapshot({ operationId: "op-4", providerIds: [11] }); + const snapshotB = buildSnapshot({ + operationId: "op-5", + operationType: "single_edit", + providerIds: [22, 23], + }); + + const storeA = await storeUndoSnapshot(snapshotA); + const storeB = await storeUndoSnapshot(snapshotB); + + expect(storeA.undoToken).toBe(tokenA); + expect(storeB.undoToken).toBe(tokenB); + + evalMock + .mockResolvedValueOnce(JSON.stringify(snapshotA)) + .mockResolvedValueOnce(JSON.stringify(snapshotB)); + + await expect(consumeUndoToken(tokenA)).resolves.toEqual({ + ok: true, + snapshot: snapshotA, + }); + await expect(consumeUndoToken(tokenB)).resolves.toEqual({ + ok: true, + snapshot: snapshotB, + }); + }); + + it("fails open when storage backend throws", async () => { + vi.spyOn(crypto, "randomUUID").mockImplementation(() => { + throw new Error("uuid failed"); + }); + + const { storeUndoSnapshot } = await import("@/lib/providers/undo-store"); + const result = await storeUndoSnapshot(buildSnapshot({ operationId: "op-6" })); + + expect(result).toEqual({ undoAvailable: false }); + }); + + it("returns undoAvailable false when Redis set fails", async () => { + const token = "66666666-6666-6666-6666-666666666666"; + vi.spyOn(crypto, "randomUUID").mockReturnValue(token); + setexMock.mockRejectedValue(new Error("Redis write error")); + + const { storeUndoSnapshot } = await import("@/lib/providers/undo-store"); + const result = await storeUndoSnapshot(buildSnapshot({ operationId: "op-7" })); + + expect(result).toEqual({ undoAvailable: false }); + }); +}); diff --git a/tests/unit/actions/providers.test.ts b/tests/unit/actions/providers.test.ts index 30bc0b4c6..b219ff4c0 100644 --- a/tests/unit/actions/providers.test.ts +++ b/tests/unit/actions/providers.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const getSessionMock = vi.fn(); +const findProviderByIdMock = vi.fn(); const findAllProvidersFreshMock = vi.fn(); const getProviderStatisticsMock = vi.fn(); const createProviderMock = vi.fn(); @@ -26,7 +27,7 @@ vi.mock("@/repository/provider", () => ({ deleteProvider: deleteProviderMock, findAllProviders: vi.fn(async () => []), findAllProvidersFresh: findAllProvidersFreshMock, - findProviderById: vi.fn(async () => null), + findProviderById: findProviderByIdMock, getProviderStatistics: getProviderStatisticsMock, resetProviderTotalCostResetAt: vi.fn(async () => {}), updateProvider: updateProviderMock, @@ -142,6 +143,11 @@ describe("Provider Actions - Async Optimization", () => { getProviderStatisticsMock.mockResolvedValue([]); + findProviderByIdMock.mockImplementation(async (id: number) => { + const providers = await findAllProvidersFreshMock(); + return providers.find((p: { id: number }) => p.id === id) ?? null; + }); + createProviderMock.mockResolvedValue({ id: 123, circuitBreakerFailureThreshold: 5, diff --git a/tests/unit/api/auth-login-failure-taxonomy.test.ts b/tests/unit/api/auth-login-failure-taxonomy.test.ts new file mode 100644 index 000000000..b3f5bbd2e --- /dev/null +++ b/tests/unit/api/auth-login-failure-taxonomy.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mockValidateKey = vi.hoisted(() => vi.fn()); +const mockSetAuthCookie = vi.hoisted(() => vi.fn()); +const mockGetSessionTokenMode = vi.hoisted(() => vi.fn()); +const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn()); +const mockGetTranslations = vi.hoisted(() => vi.fn()); +const mockGetEnvConfig = vi.hoisted(() => vi.fn()); +const mockLogger = vi.hoisted(() => ({ + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + validateKey: mockValidateKey, + setAuthCookie: mockSetAuthCookie, + getSessionTokenMode: mockGetSessionTokenMode, + getLoginRedirectTarget: mockGetLoginRedirectTarget, + withNoStoreHeaders: (res: T): T => { + (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate"); + (res as any).headers.set("Pragma", "no-cache"); + return res; + }, +})); + +vi.mock("next-intl/server", () => ({ + getTranslations: mockGetTranslations, +})); + +vi.mock("@/lib/logger", () => ({ + logger: mockLogger, +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: mockGetEnvConfig, +})); + +vi.mock("@/lib/security/auth-response-headers", () => ({ + withAuthResponseHeaders: (res: T): T => { + (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate"); + (res as any).headers.set("Pragma", "no-cache"); + return res; + }, +})); + +function makeRequest( + body: unknown, + opts?: { locale?: string; acceptLanguage?: string; xForwardedProto?: string } +): NextRequest { + const headers: Record = { "Content-Type": "application/json" }; + + if (opts?.acceptLanguage) { + headers["accept-language"] = opts.acceptLanguage; + } + + headers["x-forwarded-proto"] = opts?.xForwardedProto ?? "https"; + + const req = new NextRequest("http://localhost/api/auth/login", { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (opts?.locale) { + req.cookies.set("NEXT_LOCALE", opts.locale); + } + + return req; +} + +describe("POST /api/auth/login failure taxonomy", () => { + let POST: (request: NextRequest) => Promise; + + beforeEach(async () => { + const mockT = vi.fn((key: string) => `translated:${key}`); + mockGetTranslations.mockResolvedValue(mockT); + mockSetAuthCookie.mockResolvedValue(undefined); + mockGetSessionTokenMode.mockReturnValue("legacy"); + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false }); + + const mod = await import("../../../src/app/api/auth/login/route"); + POST = mod.POST; + }); + + it("returns KEY_REQUIRED taxonomy for missing key", async () => { + const res = await POST(makeRequest({})); + + expect(res.status).toBe(400); + const json = await res.json(); + expect(json).toEqual({ + error: "translated:apiKeyRequired", + errorCode: "KEY_REQUIRED", + }); + expect(mockValidateKey).not.toHaveBeenCalled(); + }); + + it("returns KEY_INVALID taxonomy for invalid key", async () => { + mockValidateKey.mockResolvedValue(null); + + const res = await POST(makeRequest({ key: "bad-key" })); + + expect(res.status).toBe(401); + const json = await res.json(); + expect(json).toEqual({ + error: "translated:apiKeyInvalidOrExpired", + errorCode: "KEY_INVALID", + }); + }); + + it("returns SERVER_ERROR taxonomy when validation throws", async () => { + mockValidateKey.mockRejectedValue(new Error("DB connection failed")); + + const res = await POST(makeRequest({ key: "some-key" })); + + expect(res.status).toBe(500); + const json = await res.json(); + expect(json).toEqual({ + error: "translated:serverError", + errorCode: "SERVER_ERROR", + }); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it("adds httpMismatchGuidance on invalid key when secure cookies require HTTPS", async () => { + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: true }); + mockValidateKey.mockResolvedValue(null); + + const res = await POST(makeRequest({ key: "bad-key" }, { xForwardedProto: "http" })); + + expect(res.status).toBe(401); + const json = await res.json(); + expect(json.error).toBe("translated:apiKeyInvalidOrExpired"); + expect(json.errorCode).toBe("KEY_INVALID"); + expect(typeof json.httpMismatchGuidance).toBe("string"); + expect(json.httpMismatchGuidance.length).toBeGreaterThan(0); + }); + + it("does not add httpMismatchGuidance when no HTTPS mismatch", async () => { + mockValidateKey.mockResolvedValue(null); + + const noSecureCookieRes = await POST( + makeRequest({ key: "bad-key" }, { xForwardedProto: "http" }) + ); + + expect(noSecureCookieRes.status).toBe(401); + expect(await noSecureCookieRes.json()).toEqual({ + error: "translated:apiKeyInvalidOrExpired", + errorCode: "KEY_INVALID", + }); + + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: true }); + const httpsRes = await POST(makeRequest({ key: "bad-key" }, { xForwardedProto: "https" })); + + expect(httpsRes.status).toBe(401); + expect(await httpsRes.json()).toEqual({ + error: "translated:apiKeyInvalidOrExpired", + errorCode: "KEY_INVALID", + }); + }); +}); diff --git a/tests/unit/api/auth-login-route.test.ts b/tests/unit/api/auth-login-route.test.ts new file mode 100644 index 000000000..e37ae27c2 --- /dev/null +++ b/tests/unit/api/auth-login-route.test.ts @@ -0,0 +1,316 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mockValidateKey = vi.hoisted(() => vi.fn()); +const mockSetAuthCookie = vi.hoisted(() => vi.fn()); +const mockGetSessionTokenMode = vi.hoisted(() => vi.fn()); +const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn()); +const mockGetTranslations = vi.hoisted(() => vi.fn()); +const mockLogger = vi.hoisted(() => ({ + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + validateKey: mockValidateKey, + setAuthCookie: mockSetAuthCookie, + getSessionTokenMode: mockGetSessionTokenMode, + getLoginRedirectTarget: mockGetLoginRedirectTarget, + toKeyFingerprint: vi.fn().mockResolvedValue("sha256:fake"), + withNoStoreHeaders: (res: T): T => { + (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate"); + (res as any).headers.set("Pragma", "no-cache"); + return res; + }, +})); + +vi.mock("next-intl/server", () => ({ + getTranslations: mockGetTranslations, +})); + +vi.mock("@/lib/logger", () => ({ + logger: mockLogger, +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: vi.fn().mockReturnValue({ ENABLE_SECURE_COOKIES: false }), +})); + +vi.mock("@/lib/security/auth-response-headers", () => ({ + withAuthResponseHeaders: (res: T): T => { + (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate"); + (res as any).headers.set("Pragma", "no-cache"); + return res; + }, +})); + +function makeRequest( + body: unknown, + opts?: { locale?: string; acceptLanguage?: string } +): NextRequest { + const headers: Record = { "Content-Type": "application/json" }; + + if (opts?.acceptLanguage) { + headers["accept-language"] = opts.acceptLanguage; + } + + const req = new NextRequest("http://localhost/api/auth/login", { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (opts?.locale) { + req.cookies.set("NEXT_LOCALE", opts.locale); + } + + return req; +} + +const fakeSession = { + user: { + id: 1, + name: "Test User", + description: "desc", + role: "user" as const, + }, + key: { canLoginWebUi: true }, +}; + +const adminSession = { + user: { + id: -1, + name: "Admin Token", + description: "Environment admin session", + role: "admin" as const, + }, + key: { canLoginWebUi: true }, +}; + +const readonlySession = { + user: { + id: 2, + name: "Readonly User", + description: "readonly", + role: "user" as const, + }, + key: { canLoginWebUi: false }, +}; + +describe("POST /api/auth/login", () => { + let POST: (request: NextRequest) => Promise; + + beforeEach(async () => { + vi.resetModules(); + const mockT = vi.fn((key: string) => `translated:${key}`); + mockGetTranslations.mockResolvedValue(mockT); + mockSetAuthCookie.mockResolvedValue(undefined); + mockGetSessionTokenMode.mockReturnValue("legacy"); + + const mod = await import("@/app/api/auth/login/route"); + POST = mod.POST; + }); + + it("returns 400 when key is missing from body", async () => { + const res = await POST(makeRequest({})); + + expect(res.status).toBe(400); + const json = await res.json(); + expect(json).toEqual({ error: "translated:apiKeyRequired" }); + expect(mockValidateKey).not.toHaveBeenCalled(); + }); + + it("returns 400 when key is empty string", async () => { + const res = await POST(makeRequest({ key: "" })); + + expect(res.status).toBe(400); + const json = await res.json(); + expect(json).toEqual({ error: "translated:apiKeyRequired" }); + }); + + it("returns 401 when validateKey returns null", async () => { + mockValidateKey.mockResolvedValue(null); + + const res = await POST(makeRequest({ key: "bad-key" })); + + expect(res.status).toBe(401); + const json = await res.json(); + expect(json).toEqual({ error: "translated:apiKeyInvalidOrExpired" }); + expect(mockValidateKey).toHaveBeenCalledWith("bad-key", { + allowReadOnlyAccess: true, + }); + }); + + it("returns 200 with correct body shape on valid key", async () => { + mockValidateKey.mockResolvedValue(fakeSession); + mockGetLoginRedirectTarget.mockReturnValue("/dashboard"); + + const res = await POST(makeRequest({ key: "valid-key" })); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(json).toEqual({ + ok: true, + user: { + id: 1, + name: "Test User", + description: "desc", + role: "user", + }, + redirectTo: "/dashboard", + loginType: "dashboard_user", + }); + }); + + it("calls setAuthCookie exactly once on success", async () => { + mockValidateKey.mockResolvedValue(fakeSession); + mockGetLoginRedirectTarget.mockReturnValue("/dashboard"); + + await POST(makeRequest({ key: "valid-key" })); + + expect(mockSetAuthCookie).toHaveBeenCalledTimes(1); + expect(mockSetAuthCookie).toHaveBeenCalledWith("valid-key"); + }); + + it("returns redirectTo from getLoginRedirectTarget", async () => { + mockValidateKey.mockResolvedValue(fakeSession); + mockGetLoginRedirectTarget.mockReturnValue("/my-usage"); + + const res = await POST(makeRequest({ key: "readonly-key" })); + const json = await res.json(); + + expect(json.redirectTo).toBe("/my-usage"); + expect(mockGetLoginRedirectTarget).toHaveBeenCalledWith(fakeSession); + }); + + it("returns loginType admin for admin session", async () => { + mockValidateKey.mockResolvedValue(adminSession); + mockGetLoginRedirectTarget.mockReturnValue("/dashboard"); + + const res = await POST(makeRequest({ key: "admin-key" })); + const json = await res.json(); + + expect(json.loginType).toBe("admin"); + expect(json.redirectTo).toBe("/dashboard"); + }); + + it("returns loginType dashboard_user for canLoginWebUi user session", async () => { + mockValidateKey.mockResolvedValue(fakeSession); + mockGetLoginRedirectTarget.mockReturnValue("/dashboard"); + + const res = await POST(makeRequest({ key: "dashboard-key" })); + const json = await res.json(); + + expect(json.loginType).toBe("dashboard_user"); + expect(json.redirectTo).toBe("/dashboard"); + }); + + it("returns loginType readonly_user for readonly session", async () => { + mockValidateKey.mockResolvedValue(readonlySession); + mockGetLoginRedirectTarget.mockReturnValue("/my-usage"); + + const res = await POST(makeRequest({ key: "readonly-key" })); + const json = await res.json(); + + expect(json.loginType).toBe("readonly_user"); + expect(json.redirectTo).toBe("/my-usage"); + }); + + it("returns 500 when validateKey throws", async () => { + mockValidateKey.mockRejectedValue(new Error("DB connection failed")); + + const res = await POST(makeRequest({ key: "some-key" })); + + expect(res.status).toBe(500); + const json = await res.json(); + expect(json).toEqual({ error: "translated:serverError" }); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it("returns 500 when request.json() throws (malformed body)", async () => { + const req = new NextRequest("http://localhost/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not-valid-json{{{", + }); + + const res = await POST(req); + + expect(res.status).toBe(500); + const json = await res.json(); + expect(json).toEqual({ error: "translated:serverError" }); + }); + + it("uses NEXT_LOCALE cookie for translations", async () => { + mockValidateKey.mockResolvedValue(null); + + await POST(makeRequest({ key: "x" }, { locale: "ja" })); + + expect(mockGetTranslations).toHaveBeenCalledWith({ + locale: "ja", + namespace: "auth.errors", + }); + }); + + it("detects locale from accept-language header", async () => { + mockValidateKey.mockResolvedValue(null); + + await POST(makeRequest({ key: "x" }, { acceptLanguage: "ru;q=1.0" })); + + expect(mockGetTranslations).toHaveBeenCalledWith({ + locale: "ru", + namespace: "auth.errors", + }); + }); + + it("falls back to defaultLocale when getTranslations fails for requested locale", async () => { + const mockT = vi.fn((key: string) => `fallback:${key}`); + mockGetTranslations + .mockRejectedValueOnce(new Error("locale not found")) + .mockResolvedValueOnce(mockT); + mockValidateKey.mockResolvedValue(null); + + const res = await POST(makeRequest({ key: "x" }, { locale: "ja" })); + + expect(mockGetTranslations).toHaveBeenCalledTimes(2); + expect(mockGetTranslations).toHaveBeenNthCalledWith(1, { + locale: "ja", + namespace: "auth.errors", + }); + expect(mockGetTranslations).toHaveBeenNthCalledWith(2, { + locale: "zh-CN", + namespace: "auth.errors", + }); + + const json = await res.json(); + expect(json.error).toBe("fallback:apiKeyInvalidOrExpired"); + }); + + it("returns null translation when both locale and fallback fail", async () => { + mockGetTranslations + .mockRejectedValueOnce(new Error("fail")) + .mockRejectedValueOnce(new Error("fallback fail")); + mockValidateKey.mockResolvedValue(null); + + const res = await POST(makeRequest({ key: "x" })); + + expect(res.status).toBe(401); + const json = await res.json(); + expect(json).toEqual({ error: "Authentication failed" }); + expect(mockLogger.warn).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it("falls back to defaultLocale when no locale cookie or accept-language", async () => { + mockValidateKey.mockResolvedValue(null); + + await POST(makeRequest({ key: "x" })); + + expect(mockGetTranslations).toHaveBeenCalledWith({ + locale: "zh-CN", + namespace: "auth.errors", + }); + }); +}); diff --git a/tests/unit/auth/auth-cookie-constant-sync.test.ts b/tests/unit/auth/auth-cookie-constant-sync.test.ts new file mode 100644 index 000000000..ed672e8cc --- /dev/null +++ b/tests/unit/auth/auth-cookie-constant-sync.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { AUTH_COOKIE_NAME } from "@/lib/auth"; + +const readSource = (relativePath: string) => + readFileSync(join(process.cwd(), relativePath), "utf8"); + +describe("auth cookie constant sync", () => { + it("keeps AUTH_COOKIE_NAME stable", () => { + expect(AUTH_COOKIE_NAME).toBe("auth-token"); + }); + + it("removes hardcoded auth-token cookie literals from core auth layers", () => { + const proxySource = readSource("src/proxy.ts"); + const actionAdapterSource = readSource("src/lib/api/action-adapter-openapi.ts"); + + expect(proxySource).not.toMatch(/["']auth-token["']/); + expect(actionAdapterSource).not.toMatch(/["']auth-token["']/); + expect(proxySource).toContain("AUTH_COOKIE_NAME"); + expect(actionAdapterSource).toContain("AUTH_COOKIE_NAME"); + }); +}); diff --git a/tests/unit/auth/login-redirect-safety.test.ts b/tests/unit/auth/login-redirect-safety.test.ts new file mode 100644 index 000000000..2496f441f --- /dev/null +++ b/tests/unit/auth/login-redirect-safety.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + resolveLoginRedirectTarget, + sanitizeRedirectPath, +} from "@/app/[locale]/login/redirect-safety"; +import { getLoginRedirectTarget } from "@/lib/auth"; + +describe("sanitizeRedirectPath", () => { + it("keeps safe relative path /settings", () => { + expect(sanitizeRedirectPath("/settings")).toBe("/settings"); + }); + + it("keeps safe nested path /dashboard/users", () => { + expect(sanitizeRedirectPath("/dashboard/users")).toBe("/dashboard/users"); + }); + + it("rejects absolute external URL", () => { + expect(sanitizeRedirectPath("https://evil.example/phish")).toBe("/dashboard"); + }); + + it("rejects protocol-relative URL", () => { + expect(sanitizeRedirectPath("//evil.example")).toBe("/dashboard"); + }); + + it("rejects empty string", () => { + expect(sanitizeRedirectPath("")).toBe("/dashboard"); + }); + + it("keeps relative path with query string", () => { + expect(sanitizeRedirectPath("/settings?tab=general")).toBe("/settings?tab=general"); + }); + + it("rejects protocol-like path payload", () => { + expect(sanitizeRedirectPath("/https://evil.example/path")).toBe("/dashboard"); + }); +}); + +describe("resolveLoginRedirectTarget", () => { + it("always prioritizes server redirectTo over from", () => { + expect(resolveLoginRedirectTarget("/my-usage", "/settings")).toBe("/my-usage"); + expect(resolveLoginRedirectTarget("/my-usage", "https://evil.example/phish")).toBe("/my-usage"); + }); + + it("uses sanitized from when server redirectTo is empty", () => { + expect(resolveLoginRedirectTarget(undefined, "/settings")).toBe("/settings"); + expect(resolveLoginRedirectTarget("", "https://evil.example/phish")).toBe("/dashboard"); + }); +}); + +describe("getLoginRedirectTarget invariants", () => { + it("routes admin user to /dashboard", () => { + expect( + getLoginRedirectTarget({ + user: { role: "admin" } as any, + key: { canLoginWebUi: false } as any, + }) + ).toBe("/dashboard"); + }); + + it("routes canLoginWebUi user to /dashboard", () => { + expect( + getLoginRedirectTarget({ + user: { role: "user" } as any, + key: { canLoginWebUi: true } as any, + }) + ).toBe("/dashboard"); + }); + + it("routes readonly user to /my-usage", () => { + expect( + getLoginRedirectTarget({ + user: { role: "user" } as any, + key: { canLoginWebUi: false } as any, + }) + ).toBe("/my-usage"); + }); +}); diff --git a/tests/unit/auth/opaque-admin-session.test.ts b/tests/unit/auth/opaque-admin-session.test.ts new file mode 100644 index 000000000..fc7e8ad5a --- /dev/null +++ b/tests/unit/auth/opaque-admin-session.test.ts @@ -0,0 +1,137 @@ +import crypto from "node:crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Hoisted mocks +const mockCookies = vi.hoisted(() => vi.fn()); +const mockHeaders = vi.hoisted(() => vi.fn()); +const mockGetEnvConfig = vi.hoisted(() => vi.fn()); +const mockValidateApiKeyAndGetUser = vi.hoisted(() => vi.fn()); +const mockFindKeyList = vi.hoisted(() => vi.fn()); +const mockReadSession = vi.hoisted(() => vi.fn()); +const mockCookieStore = vi.hoisted(() => ({ + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), +})); +const mockHeadersStore = vi.hoisted(() => ({ + get: vi.fn(), +})); +const mockConfig = vi.hoisted(() => ({ + auth: { adminToken: "test-admin-token-secret" }, +})); + +vi.mock("next/headers", () => ({ + cookies: mockCookies, + headers: mockHeaders, +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: mockGetEnvConfig, +})); + +vi.mock("@/repository/key", () => ({ + validateApiKeyAndGetUser: mockValidateApiKeyAndGetUser, + findKeyList: mockFindKeyList, +})); + +vi.mock("@/lib/auth-session-store/redis-session-store", () => ({ + RedisSessionStore: class { + read = mockReadSession; + create = vi.fn(); + revoke = vi.fn(); + rotate = vi.fn(); + }, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }, +})); + +vi.mock("@/lib/config/config", () => ({ + config: mockConfig, +})); + +function toFingerprint(keyString: string): string { + return `sha256:${crypto.createHash("sha256").update(keyString, "utf8").digest("hex")}`; +} + +describe("opaque session with admin token (userId=-1)", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + + mockCookies.mockResolvedValue(mockCookieStore); + mockHeaders.mockResolvedValue(mockHeadersStore); + mockHeadersStore.get.mockReturnValue(null); + mockCookieStore.get.mockReturnValue(undefined); + + mockGetEnvConfig.mockReturnValue({ + SESSION_TOKEN_MODE: "opaque", + ENABLE_SECURE_COOKIES: false, + }); + mockReadSession.mockResolvedValue(null); + mockFindKeyList.mockResolvedValue([]); + mockValidateApiKeyAndGetUser.mockResolvedValue(null); + mockConfig.auth.adminToken = "test-admin-token-secret"; + }); + + it("resolves admin session from opaque token with userId=-1", async () => { + const adminToken = "test-admin-token-secret"; + mockCookieStore.get.mockReturnValue({ value: "sid_admin_test" }); + mockReadSession.mockResolvedValue({ + sessionId: "sid_admin_test", + keyFingerprint: toFingerprint(adminToken), + userId: -1, + userRole: "admin", + createdAt: Date.now() - 1000, + expiresAt: Date.now() + 86400_000, + }); + + const { getSession } = await import("@/lib/auth"); + const session = await getSession(); + + expect(session).not.toBeNull(); + expect(session!.user.id).toBe(-1); + expect(session!.user.role).toBe("admin"); + expect(session!.key.name).toBe("ADMIN_TOKEN"); + // Must NOT call findKeyList -- virtual admin user has no DB keys + expect(mockFindKeyList).not.toHaveBeenCalled(); + }); + + it("returns null when admin token is not configured but session has userId=-1", async () => { + mockConfig.auth.adminToken = ""; + mockCookieStore.get.mockReturnValue({ value: "sid_admin_test" }); + mockReadSession.mockResolvedValue({ + sessionId: "sid_admin_test", + keyFingerprint: toFingerprint("test-admin-token-secret"), + userId: -1, + userRole: "admin", + createdAt: Date.now() - 1000, + expiresAt: Date.now() + 86400_000, + }); + + const { getSession } = await import("@/lib/auth"); + const session = await getSession(); + + expect(session).toBeNull(); + expect(mockFindKeyList).not.toHaveBeenCalled(); + }); + + it("returns null when fingerprint does not match admin token", async () => { + mockCookieStore.get.mockReturnValue({ value: "sid_admin_test" }); + mockReadSession.mockResolvedValue({ + sessionId: "sid_admin_test", + keyFingerprint: toFingerprint("wrong-token"), + userId: -1, + userRole: "admin", + createdAt: Date.now() - 1000, + expiresAt: Date.now() + 86400_000, + }); + + const { getSession } = await import("@/lib/auth"); + const session = await getSession(); + + expect(session).toBeNull(); + expect(mockFindKeyList).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/auth/set-auth-cookie-options.test.ts b/tests/unit/auth/set-auth-cookie-options.test.ts new file mode 100644 index 000000000..0e31c813c --- /dev/null +++ b/tests/unit/auth/set-auth-cookie-options.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockCookieSet = vi.hoisted(() => vi.fn()); +const mockCookies = vi.hoisted(() => vi.fn()); +const mockGetEnvConfig = vi.hoisted(() => vi.fn()); +const mockIsDevelopment = vi.hoisted(() => vi.fn(() => false)); + +vi.mock("next/headers", () => ({ + cookies: mockCookies, + headers: vi.fn().mockResolvedValue(new Headers()), +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: mockGetEnvConfig, + isDevelopment: mockIsDevelopment, +})); + +vi.mock("@/lib/config/config", () => ({ config: { auth: { adminToken: "test" } } })); +vi.mock("@/repository/key", () => ({ validateApiKeyAndGetUser: vi.fn() })); + +import { setAuthCookie } from "@/lib/auth"; + +describe("setAuthCookie options", () => { + beforeEach(() => { + mockCookieSet.mockClear(); + mockCookies.mockResolvedValue({ set: mockCookieSet, get: vi.fn(), delete: vi.fn() }); + }); + + describe("when ENABLE_SECURE_COOKIES is true", () => { + beforeEach(() => { + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: true }); + }); + + it("sets secure=true", async () => { + await setAuthCookie("test-key-123"); + + expect(mockCookieSet).toHaveBeenCalledTimes(1); + const [, , options] = mockCookieSet.mock.calls[0]; + expect(options.secure).toBe(true); + }); + }); + + describe("when ENABLE_SECURE_COOKIES is false", () => { + beforeEach(() => { + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false }); + }); + + it("sets secure=false", async () => { + await setAuthCookie("test-key-456"); + + expect(mockCookieSet).toHaveBeenCalledTimes(1); + const [, , options] = mockCookieSet.mock.calls[0]; + expect(options.secure).toBe(false); + }); + }); + + describe("invariant cookie options", () => { + beforeEach(() => { + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: true }); + }); + + it("always sets httpOnly to true", async () => { + await setAuthCookie("any-key"); + + const [, , options] = mockCookieSet.mock.calls[0]; + expect(options.httpOnly).toBe(true); + }); + + it("always sets sameSite to lax", async () => { + await setAuthCookie("any-key"); + + const [, , options] = mockCookieSet.mock.calls[0]; + expect(options.sameSite).toBe("lax"); + }); + + it("always sets maxAge to 7 days (604800 seconds)", async () => { + await setAuthCookie("any-key"); + + const [, , options] = mockCookieSet.mock.calls[0]; + expect(options.maxAge).toBe(604800); + }); + + it("always sets path to /", async () => { + await setAuthCookie("any-key"); + + const [, , options] = mockCookieSet.mock.calls[0]; + expect(options.path).toBe("/"); + }); + }); + + describe("cookie name and value", () => { + beforeEach(() => { + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: true }); + }); + + it("sets cookie name to auth-token", async () => { + await setAuthCookie("my-secret-key"); + + const [name] = mockCookieSet.mock.calls[0]; + expect(name).toBe("auth-token"); + }); + + it("sets cookie value to the provided keyString", async () => { + await setAuthCookie("my-secret-key"); + + const [, value] = mockCookieSet.mock.calls[0]; + expect(value).toBe("my-secret-key"); + }); + }); +}); diff --git a/tests/unit/i18n/auth-login-keys.test.ts b/tests/unit/i18n/auth-login-keys.test.ts new file mode 100644 index 000000000..146f1018d --- /dev/null +++ b/tests/unit/i18n/auth-login-keys.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import enAuth from "../../../messages/en/auth.json"; +import jaAuth from "../../../messages/ja/auth.json"; +import ruAuth from "../../../messages/ru/auth.json"; +import zhCNAuth from "../../../messages/zh-CN/auth.json"; +import zhTWAuth from "../../../messages/zh-TW/auth.json"; + +/** + * Recursively extract all dot-separated key paths from a nested object. + * e.g. { a: { b: 1, c: 2 } } -> ["a.b", "a.c"] + */ +function extractKeys(obj: Record, prefix = ""): string[] { + const keys: string[] = []; + for (const key of Object.keys(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + const value = obj[key]; + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + keys.push(...extractKeys(value as Record, fullKey)); + } else { + keys.push(fullKey); + } + } + return keys.sort(); +} + +const locales: Record> = { + en: enAuth, + "zh-CN": zhCNAuth, + "zh-TW": zhTWAuth, + ja: jaAuth, + ru: ruAuth, +}; + +const baselineKeys = extractKeys(locales.en); + +describe("auth.json locale key parity", () => { + it("English baseline has expected top-level sections", () => { + const topLevel = Object.keys(enAuth).sort(); + expect(topLevel).toEqual( + ["actions", "brand", "errors", "form", "login", "logout", "placeholders", "security"].sort() + ); + }); + + for (const [locale, data] of Object.entries(locales)) { + if (locale === "en") continue; + + it(`${locale} has all keys present in English baseline`, () => { + const localeKeys = extractKeys(data); + const missing = baselineKeys.filter((k) => !localeKeys.includes(k)); + expect(missing, `${locale} is missing keys: ${missing.join(", ")}`).toEqual([]); + }); + + it(`${locale} has no extra keys beyond English baseline`, () => { + const localeKeys = extractKeys(data); + const extra = localeKeys.filter((k) => !baselineKeys.includes(k)); + expect(extra, `${locale} has extra keys: ${extra.join(", ")}`).toEqual([]); + }); + } + + it("all 5 locales have identical key sets", () => { + for (const [locale, data] of Object.entries(locales)) { + const localeKeys = extractKeys(data); + expect(localeKeys, `${locale} key mismatch`).toEqual(baselineKeys); + } + }); +}); diff --git a/tests/unit/lib/redis/redis-kv-store.test.ts b/tests/unit/lib/redis/redis-kv-store.test.ts new file mode 100644 index 000000000..ce5debf0b --- /dev/null +++ b/tests/unit/lib/redis/redis-kv-store.test.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const setexMock = vi.fn(); +const getMock = vi.fn(); +const delMock = vi.fn(); +const evalMock = vi.fn(); + +function createMockRedis(status = "ready") { + return { + status, + setex: setexMock, + get: getMock, + del: delMock, + eval: evalMock, + }; +} + +vi.mock("@/lib/logger", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock("@/lib/redis/client", () => ({ + getRedisClient: vi.fn(), +})); + +vi.mock("server-only", () => ({})); + +describe("RedisKVStore", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function createStore(options?: { status?: string }) { + const { RedisKVStore } = await import("@/lib/redis/redis-kv-store"); + const redis = createMockRedis(options?.status); + return { + store: new RedisKVStore({ + prefix: "test:", + defaultTtlSeconds: 60, + redisClient: redis, + }), + redis, + }; + } + + describe("set", () => { + it("stores value with SETEX and default TTL", async () => { + const { store } = await createStore<{ name: string }>(); + setexMock.mockResolvedValue("OK"); + + const result = await store.set("key1", { name: "alice" }); + + expect(result).toBe(true); + expect(setexMock).toHaveBeenCalledWith("test:key1", 60, JSON.stringify({ name: "alice" })); + }); + + it("uses custom TTL when provided", async () => { + const { store } = await createStore(); + setexMock.mockResolvedValue("OK"); + + await store.set("key2", "value", 30); + + expect(setexMock).toHaveBeenCalledWith("test:key2", 30, JSON.stringify("value")); + }); + + it("returns false when Redis is not ready", async () => { + const { store } = await createStore({ status: "connecting" }); + + const result = await store.set("key3", "value"); + + expect(result).toBe(false); + expect(setexMock).not.toHaveBeenCalled(); + }); + + it("returns false when SETEX throws", async () => { + const { store } = await createStore(); + setexMock.mockRejectedValue(new Error("Redis write error")); + + const result = await store.set("key4", "value"); + + expect(result).toBe(false); + }); + }); + + describe("get", () => { + it("retrieves and deserializes stored value", async () => { + const { store } = await createStore<{ count: number }>(); + getMock.mockResolvedValue(JSON.stringify({ count: 42 })); + + const result = await store.get("key1"); + + expect(result).toEqual({ count: 42 }); + expect(getMock).toHaveBeenCalledWith("test:key1"); + }); + + it("returns null for missing key", async () => { + const { store } = await createStore(); + getMock.mockResolvedValue(null); + + const result = await store.get("missing"); + + expect(result).toBeNull(); + }); + + it("returns null when Redis is not ready", async () => { + const { store } = await createStore({ status: "connecting" }); + + const result = await store.get("key1"); + + expect(result).toBeNull(); + expect(getMock).not.toHaveBeenCalled(); + }); + + it("returns null when GET throws", async () => { + const { store } = await createStore(); + getMock.mockRejectedValue(new Error("Redis read error")); + + const result = await store.get("key1"); + + expect(result).toBeNull(); + }); + + it("returns null when stored value is malformed JSON", async () => { + const { store } = await createStore<{ count: number }>(); + getMock.mockResolvedValue("not-valid-json"); + + const result = await store.get("corrupted"); + + expect(result).toBeNull(); + }); + }); + + describe("getAndDelete", () => { + it("atomically retrieves and deletes key via Lua script", async () => { + const { store } = await createStore<{ id: string }>(); + evalMock.mockResolvedValue(JSON.stringify({ id: "abc" })); + + const result = await store.getAndDelete("key1"); + + expect(result).toEqual({ id: "abc" }); + expect(evalMock).toHaveBeenCalledWith(expect.any(String), 1, "test:key1"); + }); + + it("returns null for missing key", async () => { + const { store } = await createStore(); + evalMock.mockResolvedValue(null); + + const result = await store.getAndDelete("missing"); + + expect(result).toBeNull(); + }); + + it("returns null when Redis is not ready", async () => { + const { store } = await createStore({ status: "end" }); + + const result = await store.getAndDelete("key1"); + + expect(result).toBeNull(); + }); + + it("returns null when eval throws", async () => { + const { store } = await createStore(); + evalMock.mockRejectedValue(new Error("Redis eval error")); + + const result = await store.getAndDelete("key1"); + + expect(result).toBeNull(); + }); + + it("returns null when stored value is malformed JSON", async () => { + const { store } = await createStore<{ count: number }>(); + evalMock.mockResolvedValue("{invalid json..."); + + const result = await store.getAndDelete("corrupted-key"); + + expect(result).toBeNull(); + }); + }); + + describe("delete", () => { + it("deletes key and returns true when key existed", async () => { + const { store } = await createStore(); + delMock.mockResolvedValue(1); + + const result = await store.delete("key1"); + + expect(result).toBe(true); + expect(delMock).toHaveBeenCalledWith("test:key1"); + }); + + it("returns false when key did not exist", async () => { + const { store } = await createStore(); + delMock.mockResolvedValue(0); + + const result = await store.delete("missing"); + + expect(result).toBe(false); + }); + + it("returns false when Redis is not ready", async () => { + const { store } = await createStore({ status: "connecting" }); + + const result = await store.delete("key1"); + + expect(result).toBe(false); + }); + + it("returns false when DEL throws", async () => { + const { store } = await createStore(); + delMock.mockRejectedValue(new Error("Redis delete error")); + + const result = await store.delete("key1"); + + expect(result).toBe(false); + }); + }); + + describe("key prefixing", () => { + it("prepends prefix to all operations", async () => { + const { store } = await createStore(); + setexMock.mockResolvedValue("OK"); + getMock.mockResolvedValue(null); + delMock.mockResolvedValue(0); + + await store.set("mykey", "val"); + await store.get("mykey"); + await store.delete("mykey"); + + expect(setexMock).toHaveBeenCalledWith("test:mykey", expect.any(Number), expect.any(String)); + expect(getMock).toHaveBeenCalledWith("test:mykey"); + expect(delMock).toHaveBeenCalledWith("test:mykey"); + }); + }); + + describe("injected client", () => { + it("returns null for all ops when injected client is null", async () => { + const { RedisKVStore } = await import("@/lib/redis/redis-kv-store"); + const store = new RedisKVStore({ + prefix: "test:", + defaultTtlSeconds: 60, + redisClient: null, + }); + + expect(await store.set("k", "v")).toBe(false); + expect(await store.get("k")).toBeNull(); + expect(await store.getAndDelete("k")).toBeNull(); + expect(await store.delete("k")).toBe(false); + }); + }); +}); diff --git a/tests/unit/login/login-footer-system-name.test.tsx b/tests/unit/login/login-footer-system-name.test.tsx new file mode 100644 index 000000000..a20473278 --- /dev/null +++ b/tests/unit/login/login-footer-system-name.test.tsx @@ -0,0 +1,151 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import LoginPage from "../../../src/app/[locale]/login/page"; + +const mockPush = vi.hoisted(() => vi.fn()); +const mockRefresh = vi.hoisted(() => vi.fn()); +const mockUseRouter = vi.hoisted(() => vi.fn(() => ({ push: mockPush, refresh: mockRefresh }))); +const mockUseSearchParams = vi.hoisted(() => vi.fn(() => ({ get: vi.fn(() => null) }))); +const mockUseTranslations = vi.hoisted(() => vi.fn(() => (key: string) => `t:${key}`)); +const mockUseLocale = vi.hoisted(() => vi.fn(() => "en")); +const mockUsePathname = vi.hoisted(() => vi.fn(() => "/login")); + +vi.mock("next/navigation", () => ({ + useSearchParams: mockUseSearchParams, + useRouter: mockUseRouter, + usePathname: mockUsePathname, +})); + +vi.mock("next-intl", () => ({ + useTranslations: mockUseTranslations, + useLocale: mockUseLocale, +})); + +vi.mock("@/i18n/routing", () => ({ + Link: ({ children, ...props }: { children: React.ReactNode }) => {children}, + useRouter: mockUseRouter, + usePathname: mockUsePathname, +})); + +vi.mock("next-themes", () => ({ + useTheme: vi.fn(() => ({ theme: "system", setTheme: vi.fn() })), +})); + +const globalFetch = global.fetch; +const DEFAULT_SITE_TITLE = "Claude Code Hub"; + +function getRequestPath(input: string | URL | Request): string { + if (typeof input === "string") { + return input; + } + + if (input instanceof URL) { + return input.pathname; + } + + return input.url; +} + +function mockJsonResponse(payload: unknown, ok = true): Response { + return { + ok, + json: async () => payload, + } as Response; +} + +describe("LoginPage footer system name", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + vi.clearAllMocks(); + global.fetch = vi.fn(); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + global.fetch = globalFetch; + }); + + const render = async () => { + await act(async () => { + root.render(); + }); + }; + + const flushMicrotasks = async () => { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + }; + + const getSiteTitleFooter = () => + container.querySelector('[data-testid="login-site-title-footer"]'); + + it("renders configured site title when API returns it", async () => { + (global.fetch as ReturnType).mockImplementation( + (input: string | URL | Request) => { + const path = getRequestPath(input); + + if (path === "/api/system-settings") { + return Promise.resolve(mockJsonResponse({ siteTitle: "My Custom Hub" })); + } + + return Promise.resolve(mockJsonResponse({ current: "1.0.0", hasUpdate: false })); + } + ); + + await render(); + await flushMicrotasks(); + + expect(getSiteTitleFooter()).not.toBeNull(); + expect(getSiteTitleFooter()?.textContent).toBe("My Custom Hub"); + }); + + it("falls back to default title when API fails", async () => { + (global.fetch as ReturnType).mockImplementation( + (input: string | URL | Request) => { + const path = getRequestPath(input); + + if (path === "/api/system-settings") { + return Promise.resolve(mockJsonResponse({ error: "Unauthorized" }, false)); + } + + return Promise.resolve(mockJsonResponse({ current: "1.0.0", hasUpdate: false })); + } + ); + + await render(); + await flushMicrotasks(); + + expect(getSiteTitleFooter()).not.toBeNull(); + expect(getSiteTitleFooter()?.textContent).toBe(DEFAULT_SITE_TITLE); + }); + + it("shows default title while loading", async () => { + (global.fetch as ReturnType).mockImplementation( + (input: string | URL | Request) => { + const path = getRequestPath(input); + + if (path === "/api/system-settings") { + return new Promise(() => {}); + } + + return Promise.resolve(mockJsonResponse({ current: "1.0.0", hasUpdate: false })); + } + ); + + await render(); + + expect(getSiteTitleFooter()).not.toBeNull(); + expect(getSiteTitleFooter()?.textContent).toBe(DEFAULT_SITE_TITLE); + }); +}); diff --git a/tests/unit/login/login-footer-version.test.tsx b/tests/unit/login/login-footer-version.test.tsx new file mode 100644 index 000000000..349a57a32 --- /dev/null +++ b/tests/unit/login/login-footer-version.test.tsx @@ -0,0 +1,101 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import LoginPage from "@/app/[locale]/login/page"; + +const mockPush = vi.hoisted(() => vi.fn()); +const mockRefresh = vi.hoisted(() => vi.fn()); +const mockUseRouter = vi.hoisted(() => vi.fn(() => ({ push: mockPush, refresh: mockRefresh }))); +const mockUseSearchParams = vi.hoisted(() => vi.fn(() => ({ get: vi.fn(() => null) }))); +const mockUseTranslations = vi.hoisted(() => vi.fn(() => (key: string) => `t:${key}`)); +const mockUseLocale = vi.hoisted(() => vi.fn(() => "en")); +const mockUsePathname = vi.hoisted(() => vi.fn(() => "/login")); + +vi.mock("next/navigation", () => ({ + useSearchParams: mockUseSearchParams, + useRouter: mockUseRouter, + usePathname: mockUsePathname, +})); + +vi.mock("next-intl", () => ({ + useTranslations: mockUseTranslations, + useLocale: mockUseLocale, +})); + +vi.mock("@/i18n/routing", () => ({ + Link: ({ children, ...props }: any) => {children}, + useRouter: mockUseRouter, + usePathname: mockUsePathname, +})); + +vi.mock("next-themes", () => ({ + useTheme: vi.fn(() => ({ theme: "system", setTheme: vi.fn() })), +})); + +const globalFetch = global.fetch; + +describe("LoginPage Footer Version", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + vi.clearAllMocks(); + global.fetch = vi.fn(); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + document.body.removeChild(container); + global.fetch = globalFetch; + }); + + const render = async () => { + await act(async () => { + root.render(); + }); + + await act(async () => { + await Promise.resolve(); + }); + }; + + it("shows version and update hint when hasUpdate=true", async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => ({ current: "0.5.0", latest: "0.6.0", hasUpdate: true }), + }); + + await render(); + + expect((global.fetch as any).mock.calls[0]?.[0]).toBe("/api/version"); + const footer = container.querySelector('[data-testid="login-footer-version"]'); + expect(footer?.textContent).toContain("v0.5.0"); + expect(footer?.textContent).toContain("t:version.updateAvailable"); + }); + + it("shows version without update hint when hasUpdate=false", async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => ({ current: "0.5.0", latest: "0.5.0", hasUpdate: false }), + }); + + await render(); + + const footer = container.querySelector('[data-testid="login-footer-version"]'); + expect(footer?.textContent).toContain("v0.5.0"); + expect(footer?.textContent).not.toContain("t:version.updateAvailable"); + }); + + it("gracefully handles version fetch error without rendering version", async () => { + (global.fetch as any).mockRejectedValue(new Error("network fail")); + + await render(); + + expect(container.querySelector('[data-testid="login-footer-version"]')).toBeNull(); + }); +}); diff --git a/tests/unit/login/login-loading-state.test.tsx b/tests/unit/login/login-loading-state.test.tsx new file mode 100644 index 000000000..00d7314e5 --- /dev/null +++ b/tests/unit/login/login-loading-state.test.tsx @@ -0,0 +1,191 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import LoginPage from "@/app/[locale]/login/page"; + +const mockPush = vi.hoisted(() => vi.fn()); +const mockRefresh = vi.hoisted(() => vi.fn()); +const mockUseRouter = vi.hoisted(() => vi.fn(() => ({ push: mockPush, refresh: mockRefresh }))); +const mockUseSearchParams = vi.hoisted(() => vi.fn(() => ({ get: vi.fn(() => null) }))); +const mockUseTranslations = vi.hoisted(() => vi.fn(() => (key: string) => `t:${key}`)); +const mockUseLocale = vi.hoisted(() => vi.fn(() => "en")); +const mockUsePathname = vi.hoisted(() => vi.fn(() => "/login")); + +vi.mock("next/navigation", () => ({ + useSearchParams: mockUseSearchParams, + useRouter: mockUseRouter, + usePathname: mockUsePathname, +})); + +vi.mock("next-intl", () => ({ + useTranslations: mockUseTranslations, + useLocale: mockUseLocale, +})); + +vi.mock("@/i18n/routing", () => ({ + Link: ({ children, ...props }: any) => {children}, + useRouter: mockUseRouter, + usePathname: mockUsePathname, +})); + +vi.mock("next-themes", () => ({ + useTheme: vi.fn(() => ({ theme: "system", setTheme: vi.fn() })), +})); + +const globalFetch = global.fetch; + +describe("LoginPage Loading State", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + vi.clearAllMocks(); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + }); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + document.body.removeChild(container); + global.fetch = globalFetch; + }); + + const render = async () => { + await act(async () => { + root.render(); + }); + }; + + const setInputValue = (input: HTMLInputElement, value: string) => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + if (nativeInputValueSetter) { + nativeInputValueSetter.call(input, value); + } else { + input.value = value; + } + input.dispatchEvent(new Event("input", { bubbles: true })); + }; + + const getSubmitButton = () => + container.querySelector('button[type="submit"]') as HTMLButtonElement; + const getApiKeyInput = () => container.querySelector("input#apiKey") as HTMLInputElement; + const getOverlay = () => container.querySelector('[data-testid="loading-overlay"]'); + + it("starts in idle state with no overlay", async () => { + await render(); + + expect(getOverlay()).toBeNull(); + expect(getSubmitButton().disabled).toBe(true); + expect(getApiKeyInput().disabled).toBe(false); + }); + + it("shows fullscreen overlay during submission", async () => { + let resolveFetch: (value: any) => void; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + + (global.fetch as any).mockReturnValue(fetchPromise); + + await render(); + + const input = getApiKeyInput(); + await act(async () => { + setInputValue(input, "test-api-key"); + }); + + const button = getSubmitButton(); + await act(async () => { + button.click(); + }); + + const overlay = getOverlay(); + expect(overlay).not.toBeNull(); + expect(overlay?.textContent).toContain("t:login.loggingIn"); + expect(getSubmitButton().disabled).toBe(true); + expect(getApiKeyInput().disabled).toBe(true); + + await act(async () => { + resolveFetch!({ + ok: true, + json: async () => ({ redirectTo: "/dashboard" }), + }); + }); + }); + + it("keeps overlay on success until redirect", async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => ({ redirectTo: "/dashboard" }), + }); + + await render(); + + const input = getApiKeyInput(); + await act(async () => { + setInputValue(input, "test-api-key"); + }); + + await act(async () => { + getSubmitButton().click(); + }); + + const overlay = getOverlay(); + expect(overlay).not.toBeNull(); + + expect(mockPush).toHaveBeenCalledWith("/dashboard"); + expect(mockRefresh).toHaveBeenCalled(); + }); + + it("removes overlay and shows error on failure", async () => { + (global.fetch as any).mockResolvedValue({ + ok: false, + json: async () => ({ error: "Invalid key" }), + }); + + await render(); + + const input = getApiKeyInput(); + await act(async () => { + setInputValue(input, "test-api-key"); + }); + + await act(async () => { + getSubmitButton().click(); + }); + + expect(getOverlay()).toBeNull(); + expect(container.textContent).toContain("Invalid key"); + expect(getSubmitButton().disabled).toBe(false); + expect(getApiKeyInput().disabled).toBe(false); + }); + + it("removes overlay and shows error on network exception", async () => { + (global.fetch as any).mockRejectedValue(new Error("Network error")); + + await render(); + + const input = getApiKeyInput(); + await act(async () => { + setInputValue(input, "test-api-key"); + }); + + await act(async () => { + getSubmitButton().click(); + }); + + expect(getOverlay()).toBeNull(); + expect(container.textContent).toContain("t:errors.networkError"); + expect(getSubmitButton().disabled).toBe(false); + }); +}); diff --git a/tests/unit/login/login-overlay-a11y.test.tsx b/tests/unit/login/login-overlay-a11y.test.tsx new file mode 100644 index 000000000..8e9311a4d --- /dev/null +++ b/tests/unit/login/login-overlay-a11y.test.tsx @@ -0,0 +1,147 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import LoginPage from "@/app/[locale]/login/page"; + +const mockPush = vi.hoisted(() => vi.fn()); +const mockRefresh = vi.hoisted(() => vi.fn()); +const mockUseRouter = vi.hoisted(() => vi.fn(() => ({ push: mockPush, refresh: mockRefresh }))); +const mockUseSearchParams = vi.hoisted(() => vi.fn(() => ({ get: vi.fn(() => null) }))); +const mockUseTranslations = vi.hoisted(() => vi.fn(() => (key: string) => `t:${key}`)); +const mockUseLocale = vi.hoisted(() => vi.fn(() => "en")); +const mockUsePathname = vi.hoisted(() => vi.fn(() => "/login")); + +vi.mock("next/navigation", () => ({ + useSearchParams: mockUseSearchParams, + useRouter: mockUseRouter, + usePathname: mockUsePathname, +})); + +vi.mock("next-intl", () => ({ + useTranslations: mockUseTranslations, + useLocale: mockUseLocale, +})); + +vi.mock("@/i18n/routing", () => ({ + Link: ({ children, ...props }: any) => {children}, + useRouter: mockUseRouter, + usePathname: mockUsePathname, +})); + +vi.mock("next-themes", () => ({ + useTheme: vi.fn(() => ({ theme: "system", setTheme: vi.fn() })), +})); + +const globalFetch = global.fetch; + +describe("LoginPage Accessibility", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + vi.clearAllMocks(); + global.fetch = vi.fn(); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + document.body.removeChild(container); + global.fetch = globalFetch; + }); + + const render = async () => { + await act(async () => { + root.render(); + }); + }; + + const setInputValue = (input: HTMLInputElement, value: string) => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + if (nativeInputValueSetter) { + nativeInputValueSetter.call(input, value); + } else { + input.value = value; + } + input.dispatchEvent(new Event("input", { bubbles: true })); + }; + + const getSubmitButton = () => + container.querySelector('button[type="submit"]') as HTMLButtonElement; + const getApiKeyInput = () => container.querySelector("input#apiKey") as HTMLInputElement; + const getOverlay = () => container.querySelector('[data-testid="loading-overlay"]'); + + it("loading overlay has correct ARIA attributes", async () => { + let resolveFetch: (value: any) => void; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + (global.fetch as any).mockReturnValue(fetchPromise); + + await render(); + + const input = getApiKeyInput(); + await act(async () => { + setInputValue(input, "test-api-key"); + }); + + const button = getSubmitButton(); + await act(async () => { + button.click(); + }); + + const overlay = getOverlay(); + expect(overlay).not.toBeNull(); + + expect(overlay?.getAttribute("role")).toBe("dialog"); + expect(overlay?.getAttribute("aria-modal")).toBe("true"); + expect(overlay?.getAttribute("aria-label")).toBe("t:login.loggingIn"); + + const statusText = overlay?.querySelector('p[role="status"]'); + expect(statusText).not.toBeNull(); + expect(statusText?.getAttribute("aria-live")).toBe("polite"); + + const spinner = overlay?.querySelector(".animate-spin"); + expect(spinner?.classList.contains("motion-reduce:animate-none")).toBe(true); + + await act(async () => { + resolveFetch!({ + ok: true, + json: async () => ({ redirectTo: "/dashboard" }), + }); + }); + }); + + it("error state manages focus and announces alert", async () => { + (global.fetch as any).mockResolvedValue({ + ok: false, + json: async () => ({ error: "Invalid key" }), + }); + + await render(); + + const input = getApiKeyInput(); + const focusSpy = vi.spyOn(input, "focus"); + + await act(async () => { + setInputValue(input, "test-api-key"); + }); + + await act(async () => { + getSubmitButton().click(); + }); + + const alert = container.querySelector('[role="alert"]'); + expect(alert).not.toBeNull(); + expect(alert?.textContent).toContain("Invalid key"); + + expect(focusSpy).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/login/login-regression-matrix.test.tsx b/tests/unit/login/login-regression-matrix.test.tsx new file mode 100644 index 000000000..e46569cd3 --- /dev/null +++ b/tests/unit/login/login-regression-matrix.test.tsx @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mockValidateKey = vi.hoisted(() => vi.fn()); +const mockSetAuthCookie = vi.hoisted(() => vi.fn()); +const mockGetSessionTokenMode = vi.hoisted(() => vi.fn()); +const mockGetLoginRedirectTarget = vi.hoisted(() => vi.fn()); +const mockGetTranslations = vi.hoisted(() => vi.fn()); +const mockGetEnvConfig = vi.hoisted(() => vi.fn()); +const mockLogger = vi.hoisted(() => ({ + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + validateKey: mockValidateKey, + setAuthCookie: mockSetAuthCookie, + getSessionTokenMode: mockGetSessionTokenMode, + getLoginRedirectTarget: mockGetLoginRedirectTarget, + toKeyFingerprint: vi.fn().mockResolvedValue("sha256:mock"), + withNoStoreHeaders: (res: any) => { + (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate"); + (res as any).headers.set("Pragma", "no-cache"); + return res; + }, +})); + +vi.mock("next-intl/server", () => ({ + getTranslations: mockGetTranslations, +})); + +vi.mock("@/lib/config/env.schema", () => ({ + getEnvConfig: mockGetEnvConfig, +})); + +vi.mock("@/lib/logger", () => ({ + logger: mockLogger, +})); + +vi.mock("@/lib/security/auth-response-headers", () => ({ + withAuthResponseHeaders: (res: any) => { + (res as any).headers.set("Cache-Control", "no-store, no-cache, must-revalidate"); + (res as any).headers.set("Pragma", "no-cache"); + return res; + }, +})); + +function makeRequest(body: unknown, xForwardedProto = "https"): NextRequest { + return new NextRequest("http://localhost/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-forwarded-proto": xForwardedProto, + }, + body: JSON.stringify(body), + }); +} + +const adminSession = { + user: { + id: -1, + name: "Admin Token", + description: "Environment admin session", + role: "admin" as const, + }, + key: { canLoginWebUi: true }, +}; + +const dashboardUserSession = { + user: { + id: 1, + name: "Dashboard User", + description: "dashboard", + role: "user" as const, + }, + key: { canLoginWebUi: true }, +}; + +const readonlyUserSession = { + user: { + id: 2, + name: "Readonly User", + description: "readonly", + role: "user" as const, + }, + key: { canLoginWebUi: false }, +}; + +describe("Login Regression Matrix", () => { + let POST: (request: NextRequest) => Promise; + + beforeEach(async () => { + vi.clearAllMocks(); + + const mockT = vi.fn((key: string) => `translated:${key}`); + mockGetTranslations.mockResolvedValue(mockT); + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: false }); + mockSetAuthCookie.mockResolvedValue(undefined); + mockGetSessionTokenMode.mockReturnValue("legacy"); + + const mod = await import("@/app/api/auth/login/route"); + POST = mod.POST; + }); + + describe("Success Paths", () => { + it("admin user: redirectTo=/dashboard, loginType=admin", async () => { + mockValidateKey.mockResolvedValue(adminSession); + mockGetLoginRedirectTarget.mockReturnValue("/dashboard"); + + const res = await POST(makeRequest({ key: "admin-key" })); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + ok: true, + user: { + id: -1, + name: "Admin Token", + description: "Environment admin session", + role: "admin", + }, + redirectTo: "/dashboard", + loginType: "admin", + }); + expect(mockSetAuthCookie).toHaveBeenCalledWith("admin-key"); + expect(mockGetLoginRedirectTarget).toHaveBeenCalledWith(adminSession); + }); + + it("dashboard user: redirectTo=/dashboard, loginType=dashboard_user", async () => { + mockValidateKey.mockResolvedValue(dashboardUserSession); + mockGetLoginRedirectTarget.mockReturnValue("/dashboard"); + + const res = await POST(makeRequest({ key: "dashboard-user-key" })); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + ok: true, + user: { + id: 1, + name: "Dashboard User", + description: "dashboard", + role: "user", + }, + redirectTo: "/dashboard", + loginType: "dashboard_user", + }); + expect(mockSetAuthCookie).toHaveBeenCalledWith("dashboard-user-key"); + expect(mockGetLoginRedirectTarget).toHaveBeenCalledWith(dashboardUserSession); + }); + + it("readonly user: redirectTo=/my-usage, loginType=readonly_user", async () => { + mockValidateKey.mockResolvedValue(readonlyUserSession); + mockGetLoginRedirectTarget.mockReturnValue("/my-usage"); + + const res = await POST(makeRequest({ key: "readonly-user-key" })); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + ok: true, + user: { + id: 2, + name: "Readonly User", + description: "readonly", + role: "user", + }, + redirectTo: "/my-usage", + loginType: "readonly_user", + }); + expect(mockSetAuthCookie).toHaveBeenCalledWith("readonly-user-key"); + expect(mockGetLoginRedirectTarget).toHaveBeenCalledWith(readonlyUserSession); + }); + }); + + describe("Failure Paths", () => { + it("missing key: 400 + KEY_REQUIRED", async () => { + const res = await POST(makeRequest({})); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "translated:apiKeyRequired", + errorCode: "KEY_REQUIRED", + }); + expect(mockValidateKey).not.toHaveBeenCalled(); + expect(mockSetAuthCookie).not.toHaveBeenCalled(); + }); + + it("invalid key: 401 + KEY_INVALID", async () => { + mockValidateKey.mockResolvedValue(null); + + const res = await POST(makeRequest({ key: "invalid-key" })); + + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ + error: "translated:apiKeyInvalidOrExpired", + errorCode: "KEY_INVALID", + }); + expect(mockSetAuthCookie).not.toHaveBeenCalled(); + }); + + it("HTTP mismatch: 401 + httpMismatchGuidance", async () => { + mockGetEnvConfig.mockReturnValue({ ENABLE_SECURE_COOKIES: true }); + mockValidateKey.mockResolvedValue(null); + + const res = await POST(makeRequest({ key: "mismatch-key" }, "http")); + + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ + error: "translated:apiKeyInvalidOrExpired", + errorCode: "KEY_INVALID", + httpMismatchGuidance: "translated:cookieWarningDescription", + }); + expect(mockSetAuthCookie).not.toHaveBeenCalled(); + }); + + it("server error: 500 + SERVER_ERROR", async () => { + mockValidateKey.mockRejectedValue(new Error("DB connection failed")); + + const res = await POST(makeRequest({ key: "trigger-server-error" })); + + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + error: "translated:serverError", + errorCode: "SERVER_ERROR", + }); + expect(mockSetAuthCookie).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/login/login-ui-redesign.test.tsx b/tests/unit/login/login-ui-redesign.test.tsx new file mode 100644 index 000000000..d374a2aaf --- /dev/null +++ b/tests/unit/login/login-ui-redesign.test.tsx @@ -0,0 +1,147 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import LoginPage from "@/app/[locale]/login/page"; + +const mockPush = vi.hoisted(() => vi.fn()); +const mockRefresh = vi.hoisted(() => vi.fn()); +const mockUseRouter = vi.hoisted(() => vi.fn(() => ({ push: mockPush, refresh: mockRefresh }))); +const mockUseSearchParams = vi.hoisted(() => vi.fn(() => ({ get: vi.fn(() => null) }))); +const mockUseTranslations = vi.hoisted(() => vi.fn(() => (key: string) => `t:${key}`)); +const mockUseLocale = vi.hoisted(() => vi.fn(() => "en")); +const mockUsePathname = vi.hoisted(() => vi.fn(() => "/login")); + +vi.mock("next/navigation", () => ({ + useSearchParams: mockUseSearchParams, + useRouter: mockUseRouter, + usePathname: mockUsePathname, +})); + +vi.mock("next-intl", () => ({ + useTranslations: mockUseTranslations, + useLocale: mockUseLocale, +})); + +vi.mock("@/i18n/routing", () => ({ + Link: ({ children, ...props }: any) => {children}, + useRouter: mockUseRouter, + usePathname: mockUsePathname, +})); + +vi.mock("next-themes", () => ({ + useTheme: vi.fn(() => ({ theme: "system", setTheme: vi.fn() })), +})); + +describe("LoginPage UI Redesign", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + vi.clearAllMocks(); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + }); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + document.body.removeChild(container); + }); + + const render = async () => { + await act(async () => { + root.render(); + }); + }; + + it("password toggle changes input type between password and text", async () => { + await render(); + + const input = container.querySelector("input#apiKey") as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.type).toBe("password"); + + const toggleButton = container.querySelector( + 'button[aria-label="t:form.showPassword"]' + ) as HTMLButtonElement; + expect(toggleButton).not.toBeNull(); + + await act(async () => { + toggleButton.click(); + }); + + expect(input.type).toBe("text"); + + const hideButton = container.querySelector( + 'button[aria-label="t:form.hidePassword"]' + ) as HTMLButtonElement; + expect(hideButton).not.toBeNull(); + + await act(async () => { + hideButton.click(); + }); + + expect(input.type).toBe("password"); + }); + + it("ThemeSwitcher renders in the top-right control area", async () => { + await render(); + + const topRightArea = container.querySelector(".fixed.top-4.right-4"); + expect(topRightArea).not.toBeNull(); + + const buttons = topRightArea?.querySelectorAll("button"); + expect(buttons?.length).toBeGreaterThanOrEqual(2); + }); + + it("brand panel has data-testid login-brand-panel", async () => { + await render(); + + const brandPanel = container.querySelector('[data-testid="login-brand-panel"]'); + expect(brandPanel).not.toBeNull(); + }); + + it("brand panel is hidden on mobile (has hidden class without lg:flex)", async () => { + await render(); + + const brandPanel = container.querySelector('[data-testid="login-brand-panel"]'); + expect(brandPanel).not.toBeNull(); + expect(brandPanel?.className).toContain("hidden"); + expect(brandPanel?.className).toContain("lg:flex"); + }); + + it("mobile brand header is visible on mobile (has lg:hidden class)", async () => { + await render(); + + const formPanel = container.querySelector(".lg\\:w-\\[55\\%\\]"); + expect(formPanel).not.toBeNull(); + + const mobileHeader = formPanel?.querySelector(".lg\\:hidden"); + expect(mobileHeader).not.toBeNull(); + }); + + it("card header icon is hidden on desktop (has lg:hidden class)", async () => { + await render(); + + const card = container.querySelector('[data-slot="card"]'); + expect(card).not.toBeNull(); + + const headerIcon = card?.querySelector(".lg\\:hidden"); + expect(headerIcon).not.toBeNull(); + }); + + it("input has padding for both key icon and toggle button", async () => { + await render(); + + const input = container.querySelector("input#apiKey") as HTMLInputElement; + expect(input).not.toBeNull(); + expect(input.className).toContain("pl-9"); + expect(input.className).toContain("pr-10"); + }); +}); diff --git a/tests/unit/login/login-visual-regression.test.tsx b/tests/unit/login/login-visual-regression.test.tsx new file mode 100644 index 000000000..f9b3abff5 --- /dev/null +++ b/tests/unit/login/login-visual-regression.test.tsx @@ -0,0 +1,98 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import LoginPage from "@/app/[locale]/login/page"; + +// Mocks +const mockPush = vi.hoisted(() => vi.fn()); +const mockRefresh = vi.hoisted(() => vi.fn()); +const mockUseRouter = vi.hoisted(() => vi.fn(() => ({ push: mockPush, refresh: mockRefresh }))); +const mockUseSearchParams = vi.hoisted(() => vi.fn(() => ({ get: vi.fn(() => null) }))); +const mockUseTranslations = vi.hoisted(() => vi.fn(() => (key: string) => `t:${key}`)); +const mockUseLocale = vi.hoisted(() => vi.fn(() => "en")); +const mockUsePathname = vi.hoisted(() => vi.fn(() => "/login")); + +vi.mock("next/navigation", () => ({ + useSearchParams: mockUseSearchParams, + useRouter: mockUseRouter, + usePathname: mockUsePathname, +})); + +vi.mock("next-intl", () => ({ + useTranslations: mockUseTranslations, + useLocale: mockUseLocale, +})); + +vi.mock("@/i18n/routing", () => ({ + Link: ({ children, ...props }: any) => {children}, + useRouter: mockUseRouter, + usePathname: mockUsePathname, +})); + +vi.mock("next-themes", () => ({ + useTheme: vi.fn(() => ({ theme: "system", setTheme: vi.fn() })), +})); + +describe("LoginPage Visual Regression", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + vi.clearAllMocks(); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + }); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + document.body.removeChild(container); + }); + + const render = async () => { + await act(async () => { + root.render(); + }); + }; + + it("renders key structural elements", async () => { + await render(); + + const mainContainer = container.querySelector("div.min-h-screen"); + expect(mainContainer).not.toBeNull(); + const className = mainContainer?.className || ""; + expect(className).toContain("bg-gradient-to"); + + const langSwitcher = container.querySelector(".fixed.top-4.right-4"); + expect(langSwitcher).not.toBeNull(); + + const card = container.querySelector('[data-slot="card"]'); + expect(card).not.toBeNull(); + + const form = container.querySelector("form"); + expect(form).not.toBeNull(); + + const input = container.querySelector("input#apiKey"); + expect(input).not.toBeNull(); + + const button = container.querySelector('button[type="submit"]'); + expect(button).not.toBeNull(); + }); + + it("has mobile responsive classes", async () => { + await render(); + + const wrapper = container.querySelector(".max-w-lg"); + expect(wrapper).not.toBeNull(); + + const card = wrapper?.querySelector('[data-slot="card"]'); + expect(card).not.toBeNull(); + expect(card?.className).toContain("w-full"); + }); +}); diff --git a/tests/unit/proxy/proxy-auth-cookie-passthrough.test.ts b/tests/unit/proxy/proxy-auth-cookie-passthrough.test.ts new file mode 100644 index 000000000..6c3b5475b --- /dev/null +++ b/tests/unit/proxy/proxy-auth-cookie-passthrough.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; + +// Hoist mocks before imports -- mock transitive dependencies to avoid +// next-intl pulling in next/navigation (not resolvable in vitest) +const mockIntlMiddleware = vi.hoisted(() => vi.fn()); +vi.mock("next-intl/middleware", () => ({ + default: () => mockIntlMiddleware, +})); + +vi.mock("@/i18n/routing", () => ({ + routing: { + locales: ["zh-CN", "en"], + defaultLocale: "zh-CN", + }, +})); + +vi.mock("@/lib/config/env.schema", () => ({ + isDevelopment: () => false, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +function makeRequest(pathname: string, cookies: Record = {}) { + const url = new URL(`http://localhost:13500${pathname}`); + return { + method: "GET", + nextUrl: { pathname, clone: () => url }, + cookies: { + get: (name: string) => (name in cookies ? { name, value: cookies[name] } : undefined), + }, + headers: new Headers(), + } as unknown as import("next/server").NextRequest; +} + +describe("proxy auth cookie passthrough", () => { + it("redirects to login when no auth cookie is present", async () => { + const localeResponse = new Response(null, { status: 200 }); + mockIntlMiddleware.mockReturnValue(localeResponse); + + const { default: proxyHandler } = await import("@/proxy"); + const response = proxyHandler(makeRequest("/zh-CN/dashboard")); + + expect(response.status).toBeGreaterThanOrEqual(300); + expect(response.status).toBeLessThan(400); + const location = response.headers.get("location"); + expect(location).toContain("/login"); + expect(location).toContain("from="); + }); + + it("passes through when auth cookie exists without deleting it", async () => { + const localeResponse = new Response(null, { + status: 200, + headers: { "x-test": "locale-response" }, + }); + mockIntlMiddleware.mockReturnValue(localeResponse); + + const { default: proxyHandler } = await import("@/proxy"); + const response = proxyHandler( + makeRequest("/zh-CN/dashboard", { "auth-token": "sid_test-session-id" }) + ); + + // Should return the locale response, not a redirect + expect(response.headers.get("x-test")).toBe("locale-response"); + // Should NOT have a Set-Cookie header that deletes the auth cookie + const setCookie = response.headers.get("set-cookie"); + expect(setCookie).toBeNull(); + }); + + it("allows public paths without any cookie", async () => { + const localeResponse = new Response(null, { + status: 200, + headers: { "x-test": "public-ok" }, + }); + mockIntlMiddleware.mockReturnValue(localeResponse); + + const { default: proxyHandler } = await import("@/proxy"); + const response = proxyHandler(makeRequest("/zh-CN/login")); + + expect(response.headers.get("x-test")).toBe("public-ok"); + }); +}); diff --git a/tests/unit/repository/provider-batch-update-advanced-fields.test.ts b/tests/unit/repository/provider-batch-update-advanced-fields.test.ts new file mode 100644 index 000000000..21e3e1def --- /dev/null +++ b/tests/unit/repository/provider-batch-update-advanced-fields.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test, vi } from "vitest"; + +type BatchUpdateRow = { + id: number; + providerVendorId: number | null; + providerType: string; + url: string; +}; + +function createDbMock(updatedRows: BatchUpdateRow[]) { + const updateSetPayloads: Array> = []; + + const updateReturningMock = vi.fn(async () => updatedRows); + const updateWhereMock = vi.fn(() => ({ returning: updateReturningMock })); + const updateSetMock = vi.fn((payload: Record) => { + updateSetPayloads.push(payload); + return { where: updateWhereMock }; + }); + const updateMock = vi.fn(() => ({ set: updateSetMock })); + + const insertReturningMock = vi.fn(async () => []); + const insertOnConflictDoNothingMock = vi.fn(() => ({ returning: insertReturningMock })); + const insertValuesMock = vi.fn(() => ({ onConflictDoNothing: insertOnConflictDoNothingMock })); + const insertMock = vi.fn(() => ({ values: insertValuesMock })); + + return { + db: { + update: updateMock, + insert: insertMock, + }, + mocks: { + updateMock, + updateSetPayloads, + insertMock, + }, + }; +} + +async function arrange(updatedRows: BatchUpdateRow[] = []) { + vi.resetModules(); + + const dbMock = createDbMock(updatedRows); + + vi.doMock("@/drizzle/db", () => ({ db: dbMock.db })); + vi.doMock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + })); + + const { updateProvidersBatch } = await import("@/repository/provider"); + + return { + updateProvidersBatch, + ...dbMock.mocks, + }; +} + +describe("provider repository - updateProvidersBatch advanced fields", () => { + const updatedRows: BatchUpdateRow[] = [ + { + id: 11, + providerVendorId: 100, + providerType: "claude", + url: "https://api-one.example.com/v1/messages", + }, + { + id: 22, + providerVendorId: 100, + providerType: "claude", + url: "https://api-two.example.com/v1/messages", + }, + ]; + + test("updates modelRedirects for multiple providers", async () => { + const { updateProvidersBatch, updateSetPayloads, updateMock, insertMock } = + await arrange(updatedRows); + const modelRedirects = { + "claude-sonnet-4-5-20250929": "glm-4.6", + }; + + const result = await updateProvidersBatch([11, 22], { modelRedirects }); + + expect(result).toBe(2); + expect(updateMock).toHaveBeenCalledTimes(1); + expect(updateSetPayloads[0]).toEqual( + expect.objectContaining({ + updatedAt: expect.any(Date), + modelRedirects, + }) + ); + expect(insertMock).not.toHaveBeenCalled(); + }); + + test("updates allowedModels for multiple providers", async () => { + const { updateProvidersBatch, updateSetPayloads } = await arrange(updatedRows); + const allowedModels = ["claude-sonnet-4-5-20250929", "claude-opus-4-1-20250805"]; + + const result = await updateProvidersBatch([11, 22], { allowedModels }); + + expect(result).toBe(2); + expect(updateSetPayloads[0]).toEqual( + expect.objectContaining({ + updatedAt: expect.any(Date), + allowedModels, + }) + ); + }); + + test("updates anthropicThinkingBudgetPreference for multiple providers", async () => { + const { updateProvidersBatch, updateSetPayloads } = await arrange(updatedRows); + + const result = await updateProvidersBatch([11, 22], { + anthropicThinkingBudgetPreference: "4096", + }); + + expect(result).toBe(2); + expect(updateSetPayloads[0]).toEqual( + expect.objectContaining({ + updatedAt: expect.any(Date), + anthropicThinkingBudgetPreference: "4096", + }) + ); + }); + + test("updates anthropicAdaptiveThinking for multiple providers", async () => { + const { updateProvidersBatch, updateSetPayloads } = await arrange(updatedRows); + const anthropicAdaptiveThinking = { + effort: "high", + modelMatchMode: "specific", + models: ["claude-sonnet-4-5-20250929"], + }; + + const result = await updateProvidersBatch([11, 22], { + anthropicAdaptiveThinking, + }); + + expect(result).toBe(2); + expect(updateSetPayloads[0]).toEqual( + expect.objectContaining({ + updatedAt: expect.any(Date), + anthropicAdaptiveThinking, + }) + ); + }); + + test("does not include undefined advanced fields in set payload", async () => { + const { updateProvidersBatch, updateSetPayloads } = await arrange(updatedRows); + + const result = await updateProvidersBatch([11, 22], { + priority: 3, + modelRedirects: undefined, + allowedModels: undefined, + anthropicThinkingBudgetPreference: undefined, + anthropicAdaptiveThinking: undefined, + }); + + expect(result).toBe(2); + expect(updateSetPayloads[0]).toEqual( + expect.objectContaining({ + updatedAt: expect.any(Date), + priority: 3, + }) + ); + expect(updateSetPayloads[0]).not.toHaveProperty("modelRedirects"); + expect(updateSetPayloads[0]).not.toHaveProperty("allowedModels"); + expect(updateSetPayloads[0]).not.toHaveProperty("anthropicThinkingBudgetPreference"); + expect(updateSetPayloads[0]).not.toHaveProperty("anthropicAdaptiveThinking"); + }); + + test("writes null advanced values to clear fields", async () => { + const { updateProvidersBatch, updateSetPayloads } = await arrange(updatedRows); + + const result = await updateProvidersBatch([11, 22], { + modelRedirects: null, + allowedModels: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + }); + + expect(result).toBe(2); + expect(updateSetPayloads[0]).toEqual( + expect.objectContaining({ + updatedAt: expect.any(Date), + modelRedirects: null, + allowedModels: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + }) + ); + }); +}); diff --git a/tests/unit/repository/provider-restore.test.ts b/tests/unit/repository/provider-restore.test.ts new file mode 100644 index 000000000..d672b0dd4 --- /dev/null +++ b/tests/unit/repository/provider-restore.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, test, vi } from "vitest"; + +type SelectRow = Record; + +function createRestoreDbHarness(options: { + selectQueue: SelectRow[][]; + updateReturningQueue?: SelectRow[][]; +}) { + const selectQueue = [...options.selectQueue]; + const updateReturningQueue = [...(options.updateReturningQueue ?? [])]; + + const selectLimitMock = vi.fn(async () => selectQueue.shift() ?? []); + const selectOrderByMock = vi.fn(() => ({ limit: selectLimitMock })); + const selectWhereMock = vi.fn(() => ({ limit: selectLimitMock, orderBy: selectOrderByMock })); + const selectFromMock = vi.fn(() => ({ where: selectWhereMock })); + const selectMock = vi.fn(() => ({ from: selectFromMock })); + + const updateReturningMock = vi.fn(async () => updateReturningQueue.shift() ?? []); + const updateWhereMock = vi.fn(() => ({ returning: updateReturningMock })); + const updateSetMock = vi.fn(() => ({ where: updateWhereMock })); + const updateMock = vi.fn(() => ({ set: updateSetMock })); + + const tx = { + select: selectMock, + update: updateMock, + }; + + const transactionMock = vi.fn(async (runInTx: (trx: typeof tx) => Promise) => { + return runInTx(tx); + }); + + return { + db: { + transaction: transactionMock, + select: selectMock, + update: updateMock, + }, + mocks: { + transactionMock, + selectLimitMock, + updateMock, + updateSetMock, + }, + }; +} + +async function setupProviderRepository(options: { + selectQueue: SelectRow[][]; + updateReturningQueue?: SelectRow[][]; +}) { + vi.resetModules(); + + const harness = createRestoreDbHarness(options); + + vi.doMock("@/drizzle/db", () => ({ + db: harness.db, + })); + + vi.doMock("@/repository/provider-endpoints", () => ({ + ensureProviderEndpointExistsForUrl: vi.fn(), + getOrCreateProviderVendorIdFromUrls: vi.fn(), + syncProviderEndpointOnProviderEdit: vi.fn(), + tryDeleteProviderVendorIfEmpty: vi.fn(), + })); + + const repository = await import("../../../src/repository/provider"); + + return { + ...repository, + harness, + }; +} + +describe("provider repository restore", () => { + test("restoreProvider restores recent soft-deleted provider and clears deletedAt", async () => { + const deletedAt = new Date(Date.now() - 15_000); + const { restoreProvider, harness } = await setupProviderRepository({ + selectQueue: [ + [ + { + id: 1, + providerVendorId: null, + providerType: "claude", + url: "https://api.example.com/v1", + deletedAt, + }, + ], + ], + updateReturningQueue: [[{ id: 1 }]], + }); + + const restored = await restoreProvider(1); + + expect(restored).toBe(true); + expect(harness.mocks.transactionMock).toHaveBeenCalledTimes(1); + expect(harness.mocks.updateMock).toHaveBeenCalledTimes(1); + expect(harness.mocks.updateSetMock).toHaveBeenCalledWith( + expect.objectContaining({ + deletedAt: null, + updatedAt: expect.any(Date), + }) + ); + }); + + test("restoreProvider returns false when provider row is already restored concurrently", async () => { + const deletedAt = new Date(Date.now() - 5_000); + const { restoreProvider, harness } = await setupProviderRepository({ + selectQueue: [ + [ + { + id: 31, + providerVendorId: null, + providerType: "claude", + url: "https://api.example.com/v1", + deletedAt, + }, + ], + ], + updateReturningQueue: [[]], + }); + + const restored = await restoreProvider(31); + + expect(restored).toBe(false); + expect(harness.mocks.updateMock).toHaveBeenCalledTimes(1); + expect(harness.mocks.selectLimitMock).toHaveBeenCalledTimes(1); + }); + + test("restoreProvider rejects provider deleted more than 60 seconds ago", async () => { + const deletedAt = new Date(Date.now() - 61_000); + const { restoreProvider, harness } = await setupProviderRepository({ + selectQueue: [ + [ + { + id: 2, + providerVendorId: null, + providerType: "claude", + url: "https://api.example.com/v1", + deletedAt, + }, + ], + ], + updateReturningQueue: [[{ id: 2 }]], + }); + + const restored = await restoreProvider(2); + + expect(restored).toBe(false); + expect(harness.mocks.updateMock).not.toHaveBeenCalled(); + }); + + test("restoreProvidersBatch restores multiple providers in a single transaction", async () => { + const recent = new Date(Date.now() - 10_000); + const { restoreProvidersBatch, harness } = await setupProviderRepository({ + selectQueue: [ + [ + { + id: 11, + providerVendorId: null, + providerType: "claude", + url: "https://api.example.com/v1", + deletedAt: recent, + }, + ], + [ + { + id: 12, + providerVendorId: null, + providerType: "claude", + url: "https://api.example.com/v1", + deletedAt: recent, + }, + ], + [], + ], + updateReturningQueue: [[{ id: 11 }], [{ id: 12 }]], + }); + + const restoredCount = await restoreProvidersBatch([11, 12, 11, 13]); + + expect(restoredCount).toBe(2); + expect(harness.mocks.transactionMock).toHaveBeenCalledTimes(1); + expect(harness.mocks.selectLimitMock).toHaveBeenCalledTimes(3); + expect(harness.mocks.updateMock).toHaveBeenCalledTimes(2); + }); + + test("restoreProvidersBatch should short-circuit for empty id list", async () => { + const { restoreProvidersBatch, harness } = await setupProviderRepository({ + selectQueue: [], + updateReturningQueue: [], + }); + + const restoredCount = await restoreProvidersBatch([]); + + expect(restoredCount).toBe(0); + expect(harness.mocks.transactionMock).not.toHaveBeenCalled(); + }); + + test("restoreProvider skips endpoint restoration when provider url is blank", async () => { + const deletedAt = new Date(Date.now() - 8_000); + const { restoreProvider, harness } = await setupProviderRepository({ + selectQueue: [ + [ + { + id: 55, + providerVendorId: 5, + providerType: "claude", + url: " ", + deletedAt, + }, + ], + ], + updateReturningQueue: [[{ id: 55 }]], + }); + + const restored = await restoreProvider(55); + + expect(restored).toBe(true); + expect(harness.mocks.selectLimitMock).toHaveBeenCalledTimes(1); + expect(harness.mocks.updateMock).toHaveBeenCalledTimes(1); + }); + + test("restoreProvider skips endpoint restoration when active provider reference exists", async () => { + const deletedAt = new Date(Date.now() - 8_000); + const { restoreProvider, harness } = await setupProviderRepository({ + selectQueue: [ + [ + { + id: 66, + providerVendorId: 8, + providerType: "claude", + url: "https://api.example.com/v1/messages", + deletedAt, + }, + ], + [{ id: 999 }], + ], + updateReturningQueue: [[{ id: 66 }]], + }); + + const restored = await restoreProvider(66); + + expect(restored).toBe(true); + expect(harness.mocks.selectLimitMock).toHaveBeenCalledTimes(2); + expect(harness.mocks.updateMock).toHaveBeenCalledTimes(1); + }); + + test("restoreProvider skips endpoint restoration when no deleted endpoint can be matched", async () => { + const deletedAt = new Date(Date.now() - 8_000); + const { restoreProvider, harness } = await setupProviderRepository({ + selectQueue: [ + [ + { + id: 67, + providerVendorId: 8, + providerType: "claude", + url: "https://api.example.com/v1/messages", + deletedAt, + }, + ], + [], + [], + [], + ], + updateReturningQueue: [[{ id: 67 }]], + }); + + const restored = await restoreProvider(67); + + expect(restored).toBe(true); + expect(harness.mocks.selectLimitMock).toHaveBeenCalledTimes(4); + expect(harness.mocks.updateMock).toHaveBeenCalledTimes(1); + }); + + test("restoreProvider skips endpoint restoration when active endpoint already exists", async () => { + const deletedAt = new Date(Date.now() - 10_000); + const { restoreProvider, harness } = await setupProviderRepository({ + selectQueue: [ + [ + { + id: 77, + providerVendorId: 9, + providerType: "claude", + url: "https://api.example.com/v1/messages", + deletedAt, + }, + ], + [], + [{ id: 9001 }], + ], + updateReturningQueue: [[{ id: 77 }]], + }); + + const restored = await restoreProvider(77); + + expect(restored).toBe(true); + expect(harness.mocks.selectLimitMock).toHaveBeenCalledTimes(3); + expect(harness.mocks.updateMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/settings/providers/adaptive-thinking-editor.test.tsx b/tests/unit/settings/providers/adaptive-thinking-editor.test.tsx new file mode 100644 index 000000000..dba66edf0 --- /dev/null +++ b/tests/unit/settings/providers/adaptive-thinking-editor.test.tsx @@ -0,0 +1,336 @@ +/** + * @vitest-environment happy-dom + */ + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; +import { AdaptiveThinkingEditor } from "@/app/[locale]/settings/providers/_components/adaptive-thinking-editor"; +import type { AnthropicAdaptiveThinkingConfig } from "@/types/provider"; + +// Mock next-intl +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +// Mock UI components +vi.mock("@/components/ui/select", () => ({ + Select: ({ + children, + value, + onValueChange, + disabled, + }: { + children: React.ReactNode; + value: string; + onValueChange: (val: string) => void; + disabled?: boolean; + }) => ( +
+ +
+ ), + SelectTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => ( + + ), +})); + +vi.mock("@/components/ui/switch", () => ({ + Switch: ({ + checked, + onCheckedChange, + disabled, + }: { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; + }) => ( + + ), +})); + +vi.mock("@/components/ui/tag-input", () => ({ + TagInput: ({ + value, + onChange, + disabled, + placeholder, + }: { + value: string[]; + onChange: (tags: string[]) => void; + disabled?: boolean; + placeholder?: string; + }) => ( + onChange(e.target.value.split(",").filter(Boolean))} + disabled={disabled} + placeholder={placeholder} + /> + ), +})); + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("./forms/provider-form/components/section-card", () => ({ + SmartInputWrapper: ({ label, children }: { label: string; children: React.ReactNode }) => ( +
+ + {children} +
+ ), + ToggleRow: ({ label, children }: { label: string; children: React.ReactNode }) => ( +
+ + {children} +
+ ), +})); + +// Mock lucide-react +vi.mock("lucide-react", () => ({ + Info: () =>
, +})); + +function render(node: React.ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(node); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +describe("AdaptiveThinkingEditor", () => { + const defaultConfig: AnthropicAdaptiveThinkingConfig = { + effort: "medium", + modelMatchMode: "all", + models: [], + }; + + const mockOnEnabledChange = vi.fn(); + const mockOnConfigChange = vi.fn(); + + it("renders correctly in disabled state (switch off)", () => { + const { container, unmount } = render( + + ); + + const switchBtn = container.querySelector('[data-testid="switch"]'); + expect(switchBtn).toBeTruthy(); + expect(switchBtn?.textContent).toBe("Off"); + expect(container.querySelector('[data-testid="select-trigger"]')).toBeNull(); + + unmount(); + }); + + it("calls onEnabledChange when switch is clicked", () => { + const { container, unmount } = render( + + ); + + const switchBtn = container.querySelector('[data-testid="switch"]') as HTMLButtonElement; + act(() => { + switchBtn.click(); + }); + + expect(mockOnEnabledChange).toHaveBeenCalledWith(true); + + unmount(); + }); + + it("renders configuration options when enabled", () => { + const { container, unmount } = render( + + ); + + const switchBtn = container.querySelector('[data-testid="switch"]'); + expect(switchBtn?.textContent).toBe("On"); + + // Should have 2 selects: effort and mode (since mode is 'all') + const selects = container.querySelectorAll('[data-testid="select-trigger"]'); + expect(selects.length).toBe(2); + + unmount(); + }); + + it("calls onConfigChange when effort is changed", () => { + const { container, unmount } = render( + + ); + + const selects = container.querySelectorAll("select"); + // First select is effort + const effortSelect = selects[0]; + + act(() => { + effortSelect.value = "high"; + effortSelect.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(mockOnConfigChange).toHaveBeenCalledWith({ + ...defaultConfig, + effort: "high", + }); + + unmount(); + }); + + it("calls onConfigChange when model match mode is changed", () => { + const { container, unmount } = render( + + ); + + const selects = container.querySelectorAll("select"); + // Second select is model match mode + const modeSelect = selects[1]; + + act(() => { + modeSelect.value = "specific"; + modeSelect.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(mockOnConfigChange).toHaveBeenCalledWith({ + ...defaultConfig, + modelMatchMode: "specific", + }); + + unmount(); + }); + + it("renders model input when mode is specific", () => { + const specificConfig: AnthropicAdaptiveThinkingConfig = { + ...defaultConfig, + modelMatchMode: "specific", + }; + + const { container, unmount } = render( + + ); + + expect(container.querySelector('[data-testid="tag-input"]')).toBeTruthy(); + + unmount(); + }); + + it("calls onConfigChange when models are changed", () => { + const specificConfig: AnthropicAdaptiveThinkingConfig = { + ...defaultConfig, + modelMatchMode: "specific", + }; + + const { container, unmount } = render( + + ); + + const input = container.querySelector('[data-testid="tag-input"]') as HTMLInputElement; + + act(() => { + // Simulate typing a tag + // For standard HTML inputs, simply setting value and dispatching event works + // The Object.getOwnPropertyDescriptor trick is needed for React controlled inputs + // but here we are using a mocked input which might just need the event + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + nativeInputValueSetter?.call(input, "claude-3-5-sonnet"); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(mockOnConfigChange).toHaveBeenCalledWith({ + ...specificConfig, + models: ["claude-3-5-sonnet"], + }); + + unmount(); + }); + + it("passes disabled prop to children", () => { + const { container, unmount } = render( + + ); + + const switchBtn = container.querySelector('[data-testid="switch"]') as HTMLButtonElement; + expect(switchBtn.disabled).toBe(true); + + const selects = container.querySelectorAll("select"); + selects.forEach((select) => { + expect(select.disabled).toBe(true); + }); + + unmount(); + }); +}); diff --git a/tests/unit/settings/providers/build-patch-draft.test.ts b/tests/unit/settings/providers/build-patch-draft.test.ts new file mode 100644 index 000000000..c1421e6c3 --- /dev/null +++ b/tests/unit/settings/providers/build-patch-draft.test.ts @@ -0,0 +1,647 @@ +import { describe, expect, it } from "vitest"; +import { buildPatchDraftFromFormState } from "@/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft"; +import type { ProviderFormState } from "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createBatchState(): ProviderFormState { + return { + basic: { name: "", url: "", key: "", websiteUrl: "" }, + routing: { + providerType: "claude", + groupTag: [], + preserveClientIp: false, + modelRedirects: {}, + allowedModels: [], + priority: 0, + groupPriorities: {}, + weight: 1, + costMultiplier: 1.0, + cacheTtlPreference: "inherit", + swapCacheTtlBilling: false, + context1mPreference: "inherit", + codexReasoningEffortPreference: "inherit", + codexReasoningSummaryPreference: "inherit", + codexTextVerbosityPreference: "inherit", + codexParallelToolCallsPreference: "inherit", + anthropicMaxTokensPreference: "inherit", + anthropicThinkingBudgetPreference: "inherit", + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: "inherit", + }, + rateLimit: { + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: null, + }, + circuitBreaker: { + failureThreshold: undefined, + openDurationMinutes: undefined, + halfOpenSuccessThreshold: undefined, + maxRetryAttempts: null, + }, + network: { + proxyUrl: "", + proxyFallbackToDirect: false, + firstByteTimeoutStreamingSeconds: undefined, + streamingIdleTimeoutSeconds: undefined, + requestTimeoutNonStreamingSeconds: undefined, + }, + mcp: { + mcpPassthroughType: "none", + mcpPassthroughUrl: "", + }, + batch: { isEnabled: "no_change" }, + ui: { + activeTab: "basic", + isPending: false, + showFailureThresholdConfirm: false, + }, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("buildPatchDraftFromFormState", () => { + it("returns empty draft when no fields are dirty", () => { + const state = createBatchState(); + const dirty = new Set(); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft).toEqual({}); + }); + + it("includes isEnabled=true when dirty and set to true", () => { + const state = createBatchState(); + state.batch.isEnabled = "true"; + const dirty = new Set(["batch.isEnabled"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.is_enabled).toEqual({ set: true }); + }); + + it("includes isEnabled=false when dirty and set to false", () => { + const state = createBatchState(); + state.batch.isEnabled = "false"; + const dirty = new Set(["batch.isEnabled"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.is_enabled).toEqual({ set: false }); + }); + + it("skips isEnabled when dirty but value is no_change", () => { + const state = createBatchState(); + state.batch.isEnabled = "no_change"; + const dirty = new Set(["batch.isEnabled"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.is_enabled).toBeUndefined(); + }); + + it("sets priority when dirty", () => { + const state = createBatchState(); + state.routing.priority = 10; + const dirty = new Set(["routing.priority"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.priority).toEqual({ set: 10 }); + }); + + it("sets weight when dirty", () => { + const state = createBatchState(); + state.routing.weight = 5; + const dirty = new Set(["routing.weight"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.weight).toEqual({ set: 5 }); + }); + + it("sets costMultiplier when dirty", () => { + const state = createBatchState(); + state.routing.costMultiplier = 2.5; + const dirty = new Set(["routing.costMultiplier"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.cost_multiplier).toEqual({ set: 2.5 }); + }); + + it("clears groupTag when dirty and empty array", () => { + const state = createBatchState(); + state.routing.groupTag = []; + const dirty = new Set(["routing.groupTag"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.group_tag).toEqual({ clear: true }); + }); + + it("sets groupTag with joined value when dirty and non-empty", () => { + const state = createBatchState(); + state.routing.groupTag = ["tagA", "tagB"]; + const dirty = new Set(["routing.groupTag"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.group_tag).toEqual({ set: "tagA, tagB" }); + }); + + it("clears modelRedirects when dirty and empty object", () => { + const state = createBatchState(); + const dirty = new Set(["routing.modelRedirects"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.model_redirects).toEqual({ clear: true }); + }); + + it("sets modelRedirects when dirty and has entries", () => { + const state = createBatchState(); + state.routing.modelRedirects = { "model-a": "model-b" }; + const dirty = new Set(["routing.modelRedirects"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.model_redirects).toEqual({ set: { "model-a": "model-b" } }); + }); + + it("clears allowedModels when dirty and empty array", () => { + const state = createBatchState(); + const dirty = new Set(["routing.allowedModels"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.allowed_models).toEqual({ clear: true }); + }); + + it("sets allowedModels when dirty and non-empty", () => { + const state = createBatchState(); + state.routing.allowedModels = ["claude-opus-4-6"]; + const dirty = new Set(["routing.allowedModels"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.allowed_models).toEqual({ set: ["claude-opus-4-6"] }); + }); + + // --- inherit/clear pattern fields --- + + it("clears cacheTtlPreference when dirty and inherit", () => { + const state = createBatchState(); + const dirty = new Set(["routing.cacheTtlPreference"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.cache_ttl_preference).toEqual({ clear: true }); + }); + + it("sets cacheTtlPreference when dirty and not inherit", () => { + const state = createBatchState(); + state.routing.cacheTtlPreference = "5m"; + const dirty = new Set(["routing.cacheTtlPreference"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.cache_ttl_preference).toEqual({ set: "5m" }); + }); + + it("sets preserveClientIp when dirty", () => { + const state = createBatchState(); + state.routing.preserveClientIp = true; + const dirty = new Set(["routing.preserveClientIp"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.preserve_client_ip).toEqual({ set: true }); + }); + + it("sets swapCacheTtlBilling when dirty", () => { + const state = createBatchState(); + state.routing.swapCacheTtlBilling = true; + const dirty = new Set(["routing.swapCacheTtlBilling"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.swap_cache_ttl_billing).toEqual({ set: true }); + }); + + it("clears context1mPreference when dirty and inherit", () => { + const state = createBatchState(); + const dirty = new Set(["routing.context1mPreference"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.context_1m_preference).toEqual({ clear: true }); + }); + + it("sets context1mPreference when dirty and not inherit", () => { + const state = createBatchState(); + state.routing.context1mPreference = "force_enable"; + const dirty = new Set(["routing.context1mPreference"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.context_1m_preference).toEqual({ set: "force_enable" }); + }); + + it("clears codexReasoningEffortPreference when dirty and inherit", () => { + const state = createBatchState(); + const dirty = new Set(["routing.codexReasoningEffortPreference"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.codex_reasoning_effort_preference).toEqual({ clear: true }); + }); + + it("sets codexReasoningEffortPreference when dirty and not inherit", () => { + const state = createBatchState(); + state.routing.codexReasoningEffortPreference = "high"; + const dirty = new Set(["routing.codexReasoningEffortPreference"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.codex_reasoning_effort_preference).toEqual({ set: "high" }); + }); + + it("clears anthropicThinkingBudgetPreference when dirty and inherit", () => { + const state = createBatchState(); + const dirty = new Set(["routing.anthropicThinkingBudgetPreference"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.anthropic_thinking_budget_preference).toEqual({ clear: true }); + }); + + it("sets anthropicThinkingBudgetPreference when dirty and not inherit", () => { + const state = createBatchState(); + state.routing.anthropicThinkingBudgetPreference = "32000"; + const dirty = new Set(["routing.anthropicThinkingBudgetPreference"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.anthropic_thinking_budget_preference).toEqual({ set: "32000" }); + }); + + it("clears anthropicAdaptiveThinking when dirty and null", () => { + const state = createBatchState(); + const dirty = new Set(["routing.anthropicAdaptiveThinking"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.anthropic_adaptive_thinking).toEqual({ clear: true }); + }); + + it("sets anthropicAdaptiveThinking when dirty and configured", () => { + const state = createBatchState(); + state.routing.anthropicAdaptiveThinking = { + effort: "high", + modelMatchMode: "specific", + models: ["claude-opus-4-6"], + }; + const dirty = new Set(["routing.anthropicAdaptiveThinking"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.anthropic_adaptive_thinking).toEqual({ + set: { + effort: "high", + modelMatchMode: "specific", + models: ["claude-opus-4-6"], + }, + }); + }); + + it("clears geminiGoogleSearchPreference when dirty and inherit", () => { + const state = createBatchState(); + const dirty = new Set(["routing.geminiGoogleSearchPreference"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.gemini_google_search_preference).toEqual({ clear: true }); + }); + + it("sets geminiGoogleSearchPreference when dirty and not inherit", () => { + const state = createBatchState(); + state.routing.geminiGoogleSearchPreference = "enabled"; + const dirty = new Set(["routing.geminiGoogleSearchPreference"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.gemini_google_search_preference).toEqual({ set: "enabled" }); + }); + + // --- Rate limit fields --- + + it("clears limit5hUsd when dirty and null", () => { + const state = createBatchState(); + const dirty = new Set(["rateLimit.limit5hUsd"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.limit_5h_usd).toEqual({ clear: true }); + }); + + it("sets limit5hUsd when dirty and has value", () => { + const state = createBatchState(); + state.rateLimit.limit5hUsd = 50; + const dirty = new Set(["rateLimit.limit5hUsd"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.limit_5h_usd).toEqual({ set: 50 }); + }); + + it("sets dailyResetMode when dirty", () => { + const state = createBatchState(); + state.rateLimit.dailyResetMode = "rolling"; + const dirty = new Set(["rateLimit.dailyResetMode"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.daily_reset_mode).toEqual({ set: "rolling" }); + }); + + it("sets dailyResetTime when dirty", () => { + const state = createBatchState(); + state.rateLimit.dailyResetTime = "12:00"; + const dirty = new Set(["rateLimit.dailyResetTime"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.daily_reset_time).toEqual({ set: "12:00" }); + }); + + it("clears maxRetryAttempts when dirty and null", () => { + const state = createBatchState(); + const dirty = new Set(["circuitBreaker.maxRetryAttempts"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.max_retry_attempts).toEqual({ clear: true }); + }); + + it("sets maxRetryAttempts when dirty and has value", () => { + const state = createBatchState(); + state.circuitBreaker.maxRetryAttempts = 3; + const dirty = new Set(["circuitBreaker.maxRetryAttempts"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.max_retry_attempts).toEqual({ set: 3 }); + }); + + // --- Unit conversion: circuit breaker minutes -> ms --- + + it("converts openDurationMinutes to ms", () => { + const state = createBatchState(); + state.circuitBreaker.openDurationMinutes = 5; + const dirty = new Set(["circuitBreaker.openDurationMinutes"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.circuit_breaker_open_duration).toEqual({ set: 300000 }); + }); + + it("sets openDuration to 0 when dirty and undefined", () => { + const state = createBatchState(); + const dirty = new Set(["circuitBreaker.openDurationMinutes"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.circuit_breaker_open_duration).toEqual({ set: 0 }); + }); + + it("sets failureThreshold to 0 when dirty and undefined", () => { + const state = createBatchState(); + const dirty = new Set(["circuitBreaker.failureThreshold"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.circuit_breaker_failure_threshold).toEqual({ set: 0 }); + }); + + it("sets failureThreshold when dirty and has value", () => { + const state = createBatchState(); + state.circuitBreaker.failureThreshold = 10; + const dirty = new Set(["circuitBreaker.failureThreshold"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.circuit_breaker_failure_threshold).toEqual({ set: 10 }); + }); + + // --- Unit conversion: network seconds -> ms --- + + it("converts firstByteTimeoutStreamingSeconds to ms", () => { + const state = createBatchState(); + state.network.firstByteTimeoutStreamingSeconds = 30; + const dirty = new Set(["network.firstByteTimeoutStreamingSeconds"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.first_byte_timeout_streaming_ms).toEqual({ set: 30000 }); + }); + + it("skips firstByteTimeoutStreamingMs when dirty and undefined", () => { + const state = createBatchState(); + const dirty = new Set(["network.firstByteTimeoutStreamingSeconds"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.first_byte_timeout_streaming_ms).toBeUndefined(); + }); + + it("converts streamingIdleTimeoutSeconds to ms", () => { + const state = createBatchState(); + state.network.streamingIdleTimeoutSeconds = 120; + const dirty = new Set(["network.streamingIdleTimeoutSeconds"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.streaming_idle_timeout_ms).toEqual({ set: 120000 }); + }); + + it("converts requestTimeoutNonStreamingSeconds to ms", () => { + const state = createBatchState(); + state.network.requestTimeoutNonStreamingSeconds = 60; + const dirty = new Set(["network.requestTimeoutNonStreamingSeconds"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.request_timeout_non_streaming_ms).toEqual({ set: 60000 }); + }); + + // --- Network fields --- + + it("clears proxyUrl when dirty and empty string", () => { + const state = createBatchState(); + const dirty = new Set(["network.proxyUrl"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.proxy_url).toEqual({ clear: true }); + }); + + it("sets proxyUrl when dirty and has value", () => { + const state = createBatchState(); + state.network.proxyUrl = "socks5://proxy.example.com:1080"; + const dirty = new Set(["network.proxyUrl"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.proxy_url).toEqual({ set: "socks5://proxy.example.com:1080" }); + }); + + it("sets proxyFallbackToDirect when dirty", () => { + const state = createBatchState(); + state.network.proxyFallbackToDirect = true; + const dirty = new Set(["network.proxyFallbackToDirect"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.proxy_fallback_to_direct).toEqual({ set: true }); + }); + + // --- MCP fields --- + + it("sets mcpPassthroughType when dirty", () => { + const state = createBatchState(); + state.mcp.mcpPassthroughType = "minimax"; + const dirty = new Set(["mcp.mcpPassthroughType"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.mcp_passthrough_type).toEqual({ set: "minimax" }); + }); + + it("sets mcpPassthroughType to none when dirty", () => { + const state = createBatchState(); + const dirty = new Set(["mcp.mcpPassthroughType"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.mcp_passthrough_type).toEqual({ set: "none" }); + }); + + it("clears mcpPassthroughUrl when dirty and empty", () => { + const state = createBatchState(); + const dirty = new Set(["mcp.mcpPassthroughUrl"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.mcp_passthrough_url).toEqual({ clear: true }); + }); + + it("sets mcpPassthroughUrl when dirty and has value", () => { + const state = createBatchState(); + state.mcp.mcpPassthroughUrl = "https://mcp.example.com"; + const dirty = new Set(["mcp.mcpPassthroughUrl"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.mcp_passthrough_url).toEqual({ set: "https://mcp.example.com" }); + }); + + // --- Multi-field scenario --- + + it("only includes dirty fields in draft, ignoring non-dirty", () => { + const state = createBatchState(); + state.routing.priority = 10; + state.routing.weight = 5; + state.routing.costMultiplier = 2.0; + + // Only mark priority as dirty + const dirty = new Set(["routing.priority"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.priority).toEqual({ set: 10 }); + expect(draft.weight).toBeUndefined(); + expect(draft.cost_multiplier).toBeUndefined(); + }); + + it("handles multiple dirty fields correctly", () => { + const state = createBatchState(); + state.batch.isEnabled = "true"; + state.routing.priority = 5; + state.routing.weight = 3; + state.rateLimit.limit5hUsd = 100; + state.network.proxyUrl = "http://proxy:8080"; + + const dirty = new Set([ + "batch.isEnabled", + "routing.priority", + "routing.weight", + "rateLimit.limit5hUsd", + "network.proxyUrl", + ]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.is_enabled).toEqual({ set: true }); + expect(draft.priority).toEqual({ set: 5 }); + expect(draft.weight).toEqual({ set: 3 }); + expect(draft.limit_5h_usd).toEqual({ set: 100 }); + expect(draft.proxy_url).toEqual({ set: "http://proxy:8080" }); + // Non-dirty fields should be absent + expect(draft.cost_multiplier).toBeUndefined(); + expect(draft.group_tag).toBeUndefined(); + }); + + // --- groupPriorities --- + + it("clears groupPriorities when dirty and empty object", () => { + const state = createBatchState(); + const dirty = new Set(["routing.groupPriorities"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.group_priorities).toEqual({ clear: true }); + }); + + it("sets groupPriorities when dirty and has entries", () => { + const state = createBatchState(); + state.routing.groupPriorities = { groupA: 1, groupB: 2 }; + const dirty = new Set(["routing.groupPriorities"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.group_priorities).toEqual({ set: { groupA: 1, groupB: 2 } }); + }); + + // --- limitConcurrentSessions null -> 0 edge case --- + + it("sets limitConcurrentSessions to 0 when dirty and null", () => { + const state = createBatchState(); + const dirty = new Set(["rateLimit.limitConcurrentSessions"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.limit_concurrent_sessions).toEqual({ set: 0 }); + }); + + it("sets limitConcurrentSessions when dirty and has value", () => { + const state = createBatchState(); + state.rateLimit.limitConcurrentSessions = 20; + const dirty = new Set(["rateLimit.limitConcurrentSessions"]); + + const draft = buildPatchDraftFromFormState(state, dirty); + + expect(draft.limit_concurrent_sessions).toEqual({ set: 20 }); + }); +}); diff --git a/tests/unit/settings/providers/form-tab-nav.test.tsx b/tests/unit/settings/providers/form-tab-nav.test.tsx new file mode 100644 index 000000000..8dfeec4af --- /dev/null +++ b/tests/unit/settings/providers/form-tab-nav.test.tsx @@ -0,0 +1,213 @@ +/** + * @vitest-environment happy-dom + */ + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; + +// Mock next-intl +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +// Mock framer-motion -- render motion.div as a plain div +vi.mock("framer-motion", () => ({ + motion: { + div: ({ children, layoutId, ...rest }: any) => ( +
+ {children} +
+ ), + }, +})); + +// Mock lucide-react icons used by FormTabNav +vi.mock("lucide-react", () => { + const stub = ({ className }: any) => ; + return { + FileText: stub, + Route: stub, + Gauge: stub, + Network: stub, + FlaskConical: stub, + }; +}); + +import { FormTabNav } from "@/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav"; + +// --------------------------------------------------------------------------- +// Render helper (matches project convention) +// --------------------------------------------------------------------------- + +function render(node: React.ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(node); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("FormTabNav", () => { + const defaultProps = { + activeTab: "basic" as const, + onTabChange: vi.fn(), + }; + + // -- Default (vertical) layout ------------------------------------------- + + describe("default vertical layout", () => { + it("renders all 5 tabs across 3 responsive breakpoints (15 total)", () => { + const { container, unmount } = render(); + + // Desktop (5) + Tablet (5) + Mobile (5) = 15 + const buttons = container.querySelectorAll("button"); + expect(buttons.length).toBe(15); + + unmount(); + }); + + it("renders vertical sidebar nav with hidden lg:flex classes", () => { + const { container, unmount } = render(); + + const nav = container.querySelector("nav"); + expect(nav).toBeTruthy(); + expect(nav!.className).toContain("lg:flex"); + expect(nav!.className).toContain("flex-col"); + + unmount(); + }); + }); + + // -- Horizontal layout --------------------------------------------------- + + describe('layout="horizontal"', () => { + it("renders a horizontal nav bar", () => { + const { container, unmount } = render(); + + const nav = container.querySelector("nav"); + expect(nav).toBeTruthy(); + // Horizontal mode uses sticky top-0 nav with border-b + expect(nav!.className).toContain("sticky"); + expect(nav!.className).toContain("border-b"); + + unmount(); + }); + + it("has overflow-x-auto for horizontal scrolling", () => { + const { container, unmount } = render(); + + const scrollContainer = container.querySelector("nav > div"); + expect(scrollContainer).toBeTruthy(); + expect(scrollContainer!.className).toContain("overflow-x-auto"); + + unmount(); + }); + + it("highlights the active tab with text-primary", () => { + const { container, unmount } = render( + + ); + + const buttons = container.querySelectorAll("button"); + // "routing" is the second tab (index 1) + const routingBtn = buttons[1]; + expect(routingBtn.className).toContain("text-primary"); + + // Other tabs should have text-muted-foreground + const basicBtn = buttons[0]; + expect(basicBtn.className).toContain("text-muted-foreground"); + + unmount(); + }); + + it("renders motion indicator for active tab with horizontal layoutId", () => { + const { container, unmount } = render( + + ); + + const indicator = container.querySelector('[data-layout-id="activeTabIndicatorHorizontal"]'); + expect(indicator).toBeTruthy(); + + unmount(); + }); + + it("calls onTabChange when a tab is clicked", () => { + const onTabChange = vi.fn(); + const { container, unmount } = render( + + ); + + const buttons = container.querySelectorAll("button"); + // Click the "network" tab (index 3) + act(() => { + buttons[3].click(); + }); + + expect(onTabChange).toHaveBeenCalledWith("network"); + + unmount(); + }); + + it("disables all tabs when disabled prop is true", () => { + const onTabChange = vi.fn(); + const { container, unmount } = render( + + ); + + const buttons = container.querySelectorAll("button"); + for (const btn of buttons) { + expect(btn.disabled).toBe(true); + expect(btn.className).toContain("opacity-50"); + expect(btn.className).toContain("cursor-not-allowed"); + } + + // Click should not fire because button is disabled + act(() => { + buttons[2].click(); + }); + expect(onTabChange).not.toHaveBeenCalled(); + + unmount(); + }); + + it("shows status dot for tabs with warning or configured status", () => { + const { container, unmount } = render( + + ); + + const buttons = container.querySelectorAll("button"); + // routing (index 1) should have a yellow dot + const routingDot = buttons[1].querySelector(".bg-yellow-500"); + expect(routingDot).toBeTruthy(); + + // limits (index 2) should have a primary dot + const limitsDot = buttons[2].querySelector(".bg-primary"); + expect(limitsDot).toBeTruthy(); + + // basic (index 0) should have no status dot + const basicDot = buttons[0].querySelector(".rounded-full"); + expect(basicDot).toBeNull(); + + unmount(); + }); + }); +}); diff --git a/tests/unit/settings/providers/provider-batch-dialog-step1.test.tsx b/tests/unit/settings/providers/provider-batch-dialog-step1.test.tsx new file mode 100644 index 000000000..62dd1483d --- /dev/null +++ b/tests/unit/settings/providers/provider-batch-dialog-step1.test.tsx @@ -0,0 +1,482 @@ +/** + * @vitest-environment happy-dom + */ + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ProviderBatchDialog } from "@/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog"; +import type { ProviderDisplay } from "@/types/provider"; + +// --------------------------------------------------------------------------- +// Mutable mock state for useProviderForm +// --------------------------------------------------------------------------- + +let mockDirtyFields = new Set(); +const mockDispatch = vi.fn(); +let mockActiveTab = "basic"; +const mockState = { + ui: { activeTab: mockActiveTab, isPending: false, showFailureThresholdConfirm: false }, + basic: { name: "", url: "", key: "", websiteUrl: "" }, + routing: { + providerType: "claude" as const, + groupTag: [], + preserveClientIp: false, + modelRedirects: {}, + allowedModels: [], + priority: 0, + groupPriorities: {}, + weight: 1, + costMultiplier: 1, + cacheTtlPreference: "inherit" as const, + swapCacheTtlBilling: false, + context1mPreference: "inherit" as const, + codexReasoningEffortPreference: "inherit", + codexReasoningSummaryPreference: "inherit", + codexTextVerbosityPreference: "inherit", + codexParallelToolCallsPreference: "inherit", + anthropicMaxTokensPreference: "inherit", + anthropicThinkingBudgetPreference: "inherit", + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: "inherit", + }, + rateLimit: { + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed" as const, + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: null, + }, + circuitBreaker: { + failureThreshold: undefined, + openDurationMinutes: undefined, + halfOpenSuccessThreshold: undefined, + maxRetryAttempts: null, + }, + network: { + proxyUrl: "", + proxyFallbackToDirect: false, + firstByteTimeoutStreamingSeconds: undefined, + streamingIdleTimeoutSeconds: undefined, + requestTimeoutNonStreamingSeconds: undefined, + }, + mcp: { mcpPassthroughType: "none" as const, mcpPassthroughUrl: "" }, + batch: { isEnabled: "no_change" as const }, +}; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock("next-intl", () => ({ + useTranslations: () => { + const t = (key: string, params?: Record) => { + if (params) { + let result = key; + for (const [k, v] of Object.entries(params)) { + result = result.replace(`{${k}}`, String(v)); + } + return result; + } + return key; + }; + return t; + }, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ + invalidateQueries: vi.fn().mockResolvedValue(undefined), + }), +})); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/actions/providers", () => ({ + previewProviderBatchPatch: vi.fn().mockResolvedValue({ + ok: true, + data: { + previewToken: "tok-1", + previewRevision: "rev-1", + rows: [], + summary: { providerCount: 0, fieldCount: 0, skipCount: 0 }, + }, + }), + applyProviderBatchPatch: vi.fn().mockResolvedValue({ ok: true, data: { updatedCount: 2 } }), + undoProviderPatch: vi.fn().mockResolvedValue({ ok: true, data: { revertedCount: 2 } }), + batchDeleteProviders: vi.fn().mockResolvedValue({ ok: true, data: { deletedCount: 2 } }), + batchResetProviderCircuits: vi.fn().mockResolvedValue({ ok: true, data: { resetCount: 2 } }), +})); + +// Mock ProviderFormProvider + useProviderForm +vi.mock( + "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context", + () => ({ + ProviderFormProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + useProviderForm: () => ({ + state: mockState, + dispatch: mockDispatch, + dirtyFields: mockDirtyFields, + mode: "batch", + }), + }) +); + +// Mock all form section components as stubs +vi.mock( + "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section", + () => ({ + BasicInfoSection: () =>
BasicInfoSection
, + }) +); +vi.mock( + "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section", + () => ({ + RoutingSection: () =>
RoutingSection
, + }) +); +vi.mock( + "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section", + () => ({ + LimitsSection: () =>
LimitsSection
, + }) +); +vi.mock( + "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section", + () => ({ + NetworkSection: () =>
NetworkSection
, + }) +); +vi.mock( + "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/testing-section", + () => ({ + TestingSection: () =>
TestingSection
, + }) +); + +// Mock FormTabNav +vi.mock( + "@/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav", + () => ({ + FormTabNav: ({ activeTab }: { activeTab: string }) => ( +
+ FormTabNav +
+ ), + }) +); + +// Mock ProviderBatchPreviewStep +vi.mock( + "@/app/[locale]/settings/providers/_components/batch-edit/provider-batch-preview-step", + () => ({ + ProviderBatchPreviewStep: () =>
PreviewStep
, + }) +); + +// Mock buildPatchDraftFromFormState +vi.mock("@/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft", () => ({ + buildPatchDraftFromFormState: vi.fn().mockReturnValue({}), +})); + +// UI component mocks +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogDescription: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogFooter: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/components/ui/alert-dialog", () => ({ + AlertDialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + AlertDialogAction: ({ children, ...props }: any) => , + AlertDialogCancel: ({ children, ...props }: any) => , + AlertDialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: any) => , +})); + +vi.mock("lucide-react", () => ({ + Loader2: () =>
, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockProvider(id: number, name: string, maskedKey: string): ProviderDisplay { + return { + id, + name, + url: "https://api.example.com", + maskedKey, + isEnabled: true, + weight: 1, + priority: 0, + groupPriorities: null, + costMultiplier: 1, + groupTag: null, + providerType: "claude", + providerVendorId: null, + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: 10, + maxRetryAttempts: null, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 30000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 30000, + streamingIdleTimeoutMs: 120000, + requestTimeoutNonStreamingMs: 120000, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + swapCacheTtlBilling: false, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: null, + tpm: null, + rpm: null, + rpd: null, + cc: null, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; +} + +function render(node: React.ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + let root: ReturnType; + act(() => { + root = createRoot(container); + root.render(node); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const twoProviders = [ + createMockProvider(1, "Provider1", "aaaa****1111"), + createMockProvider(2, "Provider2", "bbbb****2222"), +]; + +const eightProviders = Array.from({ length: 8 }, (_, i) => + createMockProvider(i + 1, `Provider${i + 1}`, `key${i + 1}****tail${i + 1}`) +); + +function defaultProps(overrides: Record = {}) { + return { + open: true, + mode: "edit" as const, + onOpenChange: vi.fn(), + selectedProviderIds: new Set([1, 2]), + providers: twoProviders, + onSuccess: vi.fn(), + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("ProviderBatchDialog - Edit Mode Structure", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDirtyFields = new Set(); + mockActiveTab = "basic"; + mockState.ui.activeTab = "basic"; + }); + + it("renders edit mode with FormTabNav and basic section", () => { + const { container, unmount } = render(); + + expect(container.querySelector('[data-testid="dialog"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="form-tab-nav"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="basic-info-section"]')).toBeTruthy(); + + unmount(); + }); + + it("renders dialog title and description in edit step", () => { + const { container, unmount } = render(); + + const titleEl = container.querySelector('[data-testid="dialog-title"]'); + expect(titleEl?.textContent).toContain("dialog.editTitle"); + + const descEl = container.querySelector('[data-testid="dialog-description"]'); + expect(descEl?.textContent).toContain("dialog.editDesc"); + + unmount(); + }); + + it("next button is disabled when no dirty fields", () => { + const { container, unmount } = render(); + + const footer = container.querySelector('[data-testid="dialog-footer"]'); + const buttons = footer?.querySelectorAll("button") ?? []; + // Second button in footer is "Next" (first is "Cancel") + const nextButton = buttons[1] as HTMLButtonElement; + + expect(nextButton).toBeTruthy(); + expect(nextButton.disabled).toBe(true); + + unmount(); + }); + + it("next button is enabled when dirty fields exist", () => { + mockDirtyFields = new Set(["routing.priority"]); + + const { container, unmount } = render(); + + const footer = container.querySelector('[data-testid="dialog-footer"]'); + const buttons = footer?.querySelectorAll("button") ?? []; + const nextButton = buttons[1] as HTMLButtonElement; + + expect(nextButton).toBeTruthy(); + expect(nextButton.disabled).toBe(false); + + unmount(); + }); + + it("cancel button calls onOpenChange(false)", () => { + const onOpenChange = vi.fn(); + const { container, unmount } = render( + + ); + + const footer = container.querySelector('[data-testid="dialog-footer"]'); + const buttons = footer?.querySelectorAll("button") ?? []; + const cancelButton = buttons[0] as HTMLButtonElement; + + act(() => { + cancelButton.click(); + }); + + expect(onOpenChange).toHaveBeenCalledWith(false); + + unmount(); + }); + + it("next button calls preview when dirty fields exist", async () => { + mockDirtyFields = new Set(["routing.priority"]); + const { previewProviderBatchPatch } = await import("@/actions/providers"); + + const { container, unmount } = render(); + + const footer = container.querySelector('[data-testid="dialog-footer"]'); + const nextButton = (footer?.querySelectorAll("button") ?? [])[1] as HTMLButtonElement; + + await act(async () => { + nextButton.click(); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + expect(previewProviderBatchPatch).toHaveBeenCalledTimes(1); + + unmount(); + }); +}); + +describe("ProviderBatchDialog - Delete Mode", () => { + it("renders AlertDialog for delete mode", () => { + const { container, unmount } = render( + + ); + + expect(container.querySelector('[data-testid="alert-dialog"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="dialog"]')).toBeFalsy(); + + const text = container.textContent ?? ""; + expect(text).toContain("dialog.deleteTitle"); + + unmount(); + }); +}); + +describe("ProviderBatchDialog - Reset Circuit Mode", () => { + it("renders AlertDialog for resetCircuit mode", () => { + const { container, unmount } = render( + + ); + + expect(container.querySelector('[data-testid="alert-dialog"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="dialog"]')).toBeFalsy(); + + const text = container.textContent ?? ""; + expect(text).toContain("dialog.resetCircuitTitle"); + + unmount(); + }); +}); + +describe("ProviderBatchDialog - Closed State", () => { + it("renders nothing when open is false", () => { + const { container, unmount } = render( + + ); + + expect(container.querySelector('[data-testid="dialog"]')).toBeFalsy(); + expect(container.querySelector('[data-testid="alert-dialog"]')).toBeFalsy(); + + unmount(); + }); +}); diff --git a/tests/unit/settings/providers/provider-batch-preview-step.test.tsx b/tests/unit/settings/providers/provider-batch-preview-step.test.tsx new file mode 100644 index 000000000..5bdd09c5c --- /dev/null +++ b/tests/unit/settings/providers/provider-batch-preview-step.test.tsx @@ -0,0 +1,296 @@ +/** + * @vitest-environment happy-dom + */ + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProviderBatchPreviewRow } from "@/actions/providers"; +import { ProviderBatchPreviewStep } from "@/app/[locale]/settings/providers/_components/batch-edit/provider-batch-preview-step"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock("next-intl", () => ({ + useTranslations: () => { + const t = (key: string, params?: Record) => { + if (params) { + let result = key; + for (const [k, v] of Object.entries(params)) { + result = result.replace(`{${k}}`, String(v)); + } + return result; + } + return key; + }; + return t; + }, +})); + +vi.mock("@/components/ui/checkbox", () => ({ + Checkbox: ({ checked, onCheckedChange, ...props }: any) => ( + onCheckedChange?.(!checked)} + {...props} + /> + ), +})); + +vi.mock("lucide-react", () => ({ + Loader2: () =>
, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function render(ui: React.ReactElement) { + const container = document.createElement("div"); + document.body.appendChild(container); + let root: ReturnType; + act(() => { + root = createRoot(container); + root.render(ui); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function makeRow(overrides: Partial = {}): ProviderBatchPreviewRow { + return { + providerId: 1, + providerName: "TestProvider", + field: "priority", + status: "changed", + before: 0, + after: 10, + ...overrides, + }; +} + +const defaultSummary = { providerCount: 2, fieldCount: 3, skipCount: 1 }; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("ProviderBatchPreviewStep", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders changed rows with before/after values", () => { + const rows: ProviderBatchPreviewRow[] = [ + makeRow({ providerId: 1, providerName: "Alpha", field: "priority", before: 0, after: 5 }), + makeRow({ providerId: 1, providerName: "Alpha", field: "weight", before: 1, after: 10 }), + ]; + + const { container, unmount } = render( + {}} + /> + ); + + const changedRow1 = container.querySelector('[data-testid="preview-row-1-priority"]'); + expect(changedRow1).toBeTruthy(); + expect(changedRow1?.getAttribute("data-status")).toBe("changed"); + // Mock t() returns key with params substituted where {param} appears in key + // "preview.fieldChanged" does not contain {field} etc, so text is key with params inserted + expect(changedRow1?.textContent).toContain("preview.fieldChanged"); + + const changedRow2 = container.querySelector('[data-testid="preview-row-1-weight"]'); + expect(changedRow2).toBeTruthy(); + expect(changedRow2?.getAttribute("data-status")).toBe("changed"); + + unmount(); + }); + + it("renders skipped rows with skip reason", () => { + const rows: ProviderBatchPreviewRow[] = [ + makeRow({ + providerId: 2, + providerName: "Beta", + field: "anthropic_thinking_budget_preference", + status: "skipped", + before: null, + after: null, + skipReason: "not_applicable", + }), + ]; + + const { container, unmount } = render( + {}} + /> + ); + + const skippedRow = container.querySelector( + '[data-testid="preview-row-2-anthropic_thinking_budget_preference"]' + ); + expect(skippedRow).toBeTruthy(); + expect(skippedRow?.getAttribute("data-status")).toBe("skipped"); + expect(skippedRow?.textContent).toContain("preview.fieldSkipped"); + + unmount(); + }); + + it("groups rows by provider", () => { + const rows: ProviderBatchPreviewRow[] = [ + makeRow({ providerId: 1, providerName: "Alpha", field: "priority" }), + makeRow({ providerId: 2, providerName: "Beta", field: "weight" }), + makeRow({ providerId: 1, providerName: "Alpha", field: "is_enabled" }), + ]; + + const { container, unmount } = render( + {}} + /> + ); + + const provider1 = container.querySelector('[data-testid="preview-provider-1"]'); + const provider2 = container.querySelector('[data-testid="preview-provider-2"]'); + expect(provider1).toBeTruthy(); + expect(provider2).toBeTruthy(); + + // Provider 1 should have 2 rows + const p1Rows = provider1?.querySelectorAll("[data-status]"); + expect(p1Rows?.length).toBe(2); + + // Provider 2 should have 1 row + const p2Rows = provider2?.querySelectorAll("[data-status]"); + expect(p2Rows?.length).toBe(1); + + unmount(); + }); + + it("shows summary counts", () => { + const rows: ProviderBatchPreviewRow[] = [ + makeRow({ providerId: 1, providerName: "Alpha", field: "priority" }), + ]; + + const { container, unmount } = render( + {}} + /> + ); + + const summary = container.querySelector('[data-testid="preview-summary"]'); + expect(summary).toBeTruthy(); + // The mock t() substitutes {providerCount} -> 5, {fieldCount} -> 8, {skipCount} -> 2 + // into the key "preview.summary" which becomes "preview.summary" with params replaced + const text = summary?.textContent ?? ""; + expect(text).toContain("preview.summary"); + + unmount(); + }); + + it("exclusion checkbox toggles provider", () => { + const onToggle = vi.fn(); + const rows: ProviderBatchPreviewRow[] = [ + makeRow({ providerId: 3, providerName: "Gamma", field: "priority" }), + ]; + + const { container, unmount } = render( + + ); + + const checkbox = container.querySelector( + '[data-testid="exclude-checkbox-3"]' + ) as HTMLInputElement; + expect(checkbox).toBeTruthy(); + expect(checkbox.checked).toBe(true); // not excluded = checked + + act(() => { + checkbox.click(); + }); + + expect(onToggle).toHaveBeenCalledWith(3); + + unmount(); + }); + + it("loading state shows spinner", () => { + const { container, unmount } = render( + {}} + isLoading={true} + /> + ); + + const loading = container.querySelector('[data-testid="preview-loading"]'); + expect(loading).toBeTruthy(); + + // Should not show the empty state + const empty = container.querySelector('[data-testid="preview-empty"]'); + expect(empty).toBeNull(); + + unmount(); + }); + + it("shows empty state when no rows and not loading", () => { + const { container, unmount } = render( + {}} + /> + ); + + const empty = container.querySelector('[data-testid="preview-empty"]'); + expect(empty).toBeTruthy(); + + unmount(); + }); + + it("excluded provider checkbox shows unchecked", () => { + const rows: ProviderBatchPreviewRow[] = [ + makeRow({ providerId: 7, providerName: "Excluded", field: "weight" }), + ]; + + const { container, unmount } = render( + {}} + /> + ); + + const checkbox = container.querySelector( + '[data-testid="exclude-checkbox-7"]' + ) as HTMLInputElement; + expect(checkbox).toBeTruthy(); + expect(checkbox.checked).toBe(false); // excluded = unchecked + + unmount(); + }); +}); diff --git a/tests/unit/settings/providers/provider-batch-toolbar-selection.test.tsx b/tests/unit/settings/providers/provider-batch-toolbar-selection.test.tsx new file mode 100644 index 000000000..d6ba27294 --- /dev/null +++ b/tests/unit/settings/providers/provider-batch-toolbar-selection.test.tsx @@ -0,0 +1,246 @@ +/** + * @vitest-environment happy-dom + */ + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; +import type { ProviderDisplay, ProviderType } from "@/types/provider"; + +// Mock next-intl +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +// Mock UI components +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: any) => , +})); + +vi.mock("@/components/ui/checkbox", () => ({ + Checkbox: ({ checked, onCheckedChange, ...props }: any) => ( + onCheckedChange?.(e.target.checked)} + {...props} + /> + ), +})); + +vi.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: any) =>
{children}
, + DropdownMenuTrigger: ({ children }: any) => ( +
{children}
+ ), + DropdownMenuContent: ({ children }: any) => ( +
{children}
+ ), + DropdownMenuItem: ({ children, onClick, ...props }: any) => ( +
+ {children} +
+ ), +})); + +// Mock lucide-react +vi.mock("lucide-react", () => ({ + ChevronDown: () => , + Pencil: () => , + X: () => , +})); + +function createProvider( + id: number, + providerType: ProviderType, + groupTag: string | null = null +): ProviderDisplay { + return { id, providerType, groupTag } as ProviderDisplay; +} + +function render(node: React.ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(node); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +// Import after mocks +import { ProviderBatchToolbar } from "@/app/[locale]/settings/providers/_components/batch-edit/provider-batch-toolbar"; + +const defaultProps = { + isMultiSelectMode: false, + allSelected: false, + selectedCount: 0, + totalCount: 5, + onEnterMode: vi.fn(), + onExitMode: vi.fn(), + onSelectAll: vi.fn(), + onInvertSelection: vi.fn(), + onOpenBatchEdit: vi.fn(), + providers: [] as ProviderDisplay[], + onSelectByType: vi.fn(), + onSelectByGroup: vi.fn(), +}; + +describe("ProviderBatchToolbar - Selection enhancements", () => { + it("does NOT render type/group dropdowns when NOT in multi-select mode", () => { + const providers = [createProvider(1, "claude"), createProvider(2, "openai-compatible")]; + + const { container, unmount } = render( + + ); + + const dropdowns = container.querySelectorAll('[data-testid="dropdown-menu"]'); + expect(dropdowns.length).toBe(0); + + unmount(); + }); + + it("renders Select by Type dropdown in multi-select mode when providers have multiple types", () => { + const providers = [ + createProvider(1, "claude"), + createProvider(2, "claude"), + createProvider(3, "openai-compatible"), + ]; + + const { container, unmount } = render( + + ); + + const buttons = container.querySelectorAll("button"); + const typeButton = Array.from(buttons).find((b) => b.textContent?.includes("selectByType")); + expect(typeButton).toBeTruthy(); + + const items = container.querySelectorAll('[data-testid="dropdown-menu-item"]'); + const typeItems = Array.from(items).filter( + (item) => + item.getAttribute("data-value") === "claude" || + item.getAttribute("data-value") === "openai-compatible" + ); + expect(typeItems.length).toBe(2); + + unmount(); + }); + + it("calls onSelectByType with correct type when clicking a type option", () => { + const onSelectByType = vi.fn(); + const providers = [createProvider(1, "claude"), createProvider(2, "openai-compatible")]; + + const { container, unmount } = render( + + ); + + const claudeItem = container.querySelector('[data-value="claude"]'); + expect(claudeItem).toBeTruthy(); + + act(() => { + claudeItem!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onSelectByType).toHaveBeenCalledWith("claude"); + + unmount(); + }); + + it("renders Select by Group dropdown when providers have groups", () => { + const providers = [ + createProvider(1, "claude", "production"), + createProvider(2, "claude", "staging"), + createProvider(3, "claude", "production"), + ]; + + const { container, unmount } = render( + + ); + + const buttons = container.querySelectorAll("button"); + const groupButton = Array.from(buttons).find((b) => b.textContent?.includes("selectByGroup")); + expect(groupButton).toBeTruthy(); + + const items = container.querySelectorAll('[data-testid="dropdown-menu-item"]'); + const groupItems = Array.from(items).filter( + (item) => + item.getAttribute("data-value") === "production" || + item.getAttribute("data-value") === "staging" + ); + expect(groupItems.length).toBe(2); + + unmount(); + }); + + it("calls onSelectByGroup with correct group when clicking a group option", () => { + const onSelectByGroup = vi.fn(); + const providers = [ + createProvider(1, "claude", "production"), + createProvider(2, "claude", "staging"), + ]; + + const { container, unmount } = render( + + ); + + const productionItem = container.querySelector('[data-value="production"]'); + expect(productionItem).toBeTruthy(); + + act(() => { + productionItem!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onSelectByGroup).toHaveBeenCalledWith("production"); + + unmount(); + }); + + it("does NOT render type dropdown when all filtered providers have same type", () => { + const providers = [createProvider(1, "claude"), createProvider(2, "claude")]; + + const { container, unmount } = render( + + ); + + const buttons = container.querySelectorAll("button"); + const typeButton = Array.from(buttons).find((b) => b.textContent?.includes("selectByType")); + expect(typeButton).toBeFalsy(); + + unmount(); + }); + + it("does NOT render group dropdown when no groups exist", () => { + const providers = [ + createProvider(1, "claude", null), + createProvider(2, "openai-compatible", null), + ]; + + const { container, unmount } = render( + + ); + + const buttons = container.querySelectorAll("button"); + const groupButton = Array.from(buttons).find((b) => b.textContent?.includes("selectByGroup")); + expect(groupButton).toBeFalsy(); + + unmount(); + }); +}); diff --git a/tests/unit/settings/providers/provider-batch-toolbar.test.tsx b/tests/unit/settings/providers/provider-batch-toolbar.test.tsx new file mode 100644 index 000000000..c0967f6bd --- /dev/null +++ b/tests/unit/settings/providers/provider-batch-toolbar.test.tsx @@ -0,0 +1,215 @@ +/** + * @vitest-environment happy-dom + */ + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; +import type { ProviderDisplay, ProviderType } from "@/types/provider"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, params?: Record) => { + if (params) { + let result = key; + for (const [k, v] of Object.entries(params)) { + result = result.replace(`{${k}}`, String(v)); + } + return result; + } + return key; + }, +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: any) => , +})); + +vi.mock("@/components/ui/checkbox", () => ({ + Checkbox: ({ checked, onCheckedChange, ...props }: any) => ( + onCheckedChange?.(e.target.checked)} + {...props} + /> + ), +})); + +vi.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: any) =>
{children}
, + DropdownMenuTrigger: ({ children }: any) =>
{children}
, + DropdownMenuContent: ({ children }: any) =>
{children}
, + DropdownMenuItem: ({ children, onClick }: any) => ( +
+ {children} +
+ ), +})); + +vi.mock("lucide-react", () => ({ + ChevronDown: () => , + Pencil: () => , + X: () => , +})); + +import { + ProviderBatchToolbar, + type ProviderBatchToolbarProps, +} from "@/app/[locale]/settings/providers/_components/batch-edit/provider-batch-toolbar"; + +function createProvider( + id: number, + providerType: ProviderType, + groupTag: string | null = null +): ProviderDisplay { + return { id, providerType, groupTag } as ProviderDisplay; +} + +function render(node: React.ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(node); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function defaultProps( + overrides: Partial = {} +): ProviderBatchToolbarProps { + return { + isMultiSelectMode: false, + allSelected: false, + selectedCount: 0, + totalCount: 3, + onEnterMode: vi.fn(), + onExitMode: vi.fn(), + onSelectAll: vi.fn(), + onInvertSelection: vi.fn(), + onOpenBatchEdit: vi.fn(), + providers: [ + createProvider(1, "claude"), + createProvider(2, "openai"), + createProvider(3, "claude"), + ], + onSelectByType: vi.fn(), + onSelectByGroup: vi.fn(), + ...overrides, + }; +} + +describe("ProviderBatchToolbar - discoverability hint", () => { + describe("not in multi-select mode", () => { + it("shows enter-mode button and hint text when totalCount > 1", () => { + const props = defaultProps({ totalCount: 3 }); + const { container, unmount } = render(); + + const buttons = container.querySelectorAll("button"); + const enterBtn = Array.from(buttons).find((b) => b.textContent?.includes("enterMode")); + expect(enterBtn).toBeTruthy(); + + const hint = container.querySelector("span.text-xs"); + expect(hint).toBeTruthy(); + expect(hint!.textContent).toBe("selectionHint"); + + unmount(); + }); + + it("shows hint when totalCount is exactly 1 (totalCount > 0 condition)", () => { + const props = defaultProps({ + totalCount: 1, + providers: [createProvider(1, "claude")], + }); + const { container, unmount } = render(); + + const hint = container.querySelector("span.text-xs"); + expect(hint).toBeTruthy(); + + unmount(); + }); + + it("does NOT show hint when totalCount is 0", () => { + const props = defaultProps({ totalCount: 0, providers: [] }); + const { container, unmount } = render(); + + const hint = container.querySelector("span.text-xs"); + expect(hint).toBeNull(); + + unmount(); + }); + + it("hint uses i18n key selectionHint", () => { + const props = defaultProps({ totalCount: 5 }); + const { container, unmount } = render(); + + const hint = container.querySelector("span.text-xs"); + expect(hint).toBeTruthy(); + expect(hint!.textContent).toBe("selectionHint"); + + unmount(); + }); + + it("enter-mode button is disabled when totalCount is 0", () => { + const props = defaultProps({ totalCount: 0, providers: [] }); + const { container, unmount } = render(); + + const buttons = container.querySelectorAll("button"); + const enterBtn = Array.from(buttons).find((b) => b.textContent?.includes("enterMode")); + expect(enterBtn).toBeTruthy(); + expect(enterBtn!.disabled).toBe(true); + + unmount(); + }); + }); + + describe("in multi-select mode", () => { + it("does NOT show hint text", () => { + const props = defaultProps({ isMultiSelectMode: true, selectedCount: 1 }); + const { container, unmount } = render(); + + const allSpans = container.querySelectorAll("span"); + const hintSpan = Array.from(allSpans).find((s) => s.textContent === "selectionHint"); + expect(hintSpan).toBeFalsy(); + + unmount(); + }); + + it("renders select-all checkbox and selected count", () => { + const props = defaultProps({ isMultiSelectMode: true, selectedCount: 2 }); + const { container, unmount } = render(); + + const checkbox = container.querySelector('input[type="checkbox"]'); + expect(checkbox).toBeTruthy(); + + const countText = Array.from(container.querySelectorAll("span")).find((s) => + s.textContent?.includes("selectedCount") + ); + expect(countText).toBeTruthy(); + + unmount(); + }); + + it("renders invert, edit, and exit buttons", () => { + const props = defaultProps({ isMultiSelectMode: true, selectedCount: 1 }); + const { container, unmount } = render(); + + const buttons = container.querySelectorAll("button"); + const texts = Array.from(buttons).map((b) => b.textContent); + + expect(texts.some((t) => t?.includes("invertSelection"))).toBe(true); + expect(texts.some((t) => t?.includes("editSelected"))).toBe(true); + expect(texts.some((t) => t?.includes("exitMode"))).toBe(true); + + unmount(); + }); + }); +}); diff --git a/tests/unit/settings/providers/provider-form-batch-context.test.ts b/tests/unit/settings/providers/provider-form-batch-context.test.ts new file mode 100644 index 000000000..de0bd281d --- /dev/null +++ b/tests/unit/settings/providers/provider-form-batch-context.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; +import { + createInitialState, + providerFormReducer, +} from "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context"; + +// --------------------------------------------------------------------------- +// createInitialState("batch") +// --------------------------------------------------------------------------- + +describe("createInitialState - batch mode", () => { + it("returns batch state with isEnabled set to no_change", () => { + const state = createInitialState("batch"); + + expect(state.batch.isEnabled).toBe("no_change"); + }); + + it("returns neutral routing defaults (no provider source)", () => { + const state = createInitialState("batch"); + + expect(state.routing.priority).toBe(0); + expect(state.routing.weight).toBe(1); + expect(state.routing.costMultiplier).toBe(1.0); + expect(state.routing.groupTag).toEqual([]); + expect(state.routing.preserveClientIp).toBe(false); + expect(state.routing.modelRedirects).toEqual({}); + expect(state.routing.allowedModels).toEqual([]); + expect(state.routing.cacheTtlPreference).toBe("inherit"); + expect(state.routing.swapCacheTtlBilling).toBe(false); + expect(state.routing.anthropicAdaptiveThinking).toBeNull(); + }); + + it("returns neutral rate limit defaults", () => { + const state = createInitialState("batch"); + + expect(state.rateLimit.limit5hUsd).toBeNull(); + expect(state.rateLimit.limitDailyUsd).toBeNull(); + expect(state.rateLimit.dailyResetMode).toBe("fixed"); + expect(state.rateLimit.dailyResetTime).toBe("00:00"); + expect(state.rateLimit.limitWeeklyUsd).toBeNull(); + expect(state.rateLimit.limitMonthlyUsd).toBeNull(); + expect(state.rateLimit.limitTotalUsd).toBeNull(); + expect(state.rateLimit.limitConcurrentSessions).toBeNull(); + }); + + it("returns neutral circuit breaker defaults", () => { + const state = createInitialState("batch"); + + expect(state.circuitBreaker.failureThreshold).toBeUndefined(); + expect(state.circuitBreaker.openDurationMinutes).toBeUndefined(); + expect(state.circuitBreaker.halfOpenSuccessThreshold).toBeUndefined(); + expect(state.circuitBreaker.maxRetryAttempts).toBeNull(); + }); + + it("returns neutral network defaults", () => { + const state = createInitialState("batch"); + + expect(state.network.proxyUrl).toBe(""); + expect(state.network.proxyFallbackToDirect).toBe(false); + expect(state.network.firstByteTimeoutStreamingSeconds).toBeUndefined(); + expect(state.network.streamingIdleTimeoutSeconds).toBeUndefined(); + expect(state.network.requestTimeoutNonStreamingSeconds).toBeUndefined(); + }); + + it("returns neutral MCP defaults", () => { + const state = createInitialState("batch"); + + expect(state.mcp.mcpPassthroughType).toBe("none"); + expect(state.mcp.mcpPassthroughUrl).toBe(""); + }); + + it("ignores provider and cloneProvider arguments in batch mode", () => { + const fakeProvider = { + id: 99, + name: "Ignored", + url: "https://ignored.example.com", + maskedKey: "xxxx****xxxx", + isEnabled: false, + weight: 50, + priority: 99, + groupPriorities: null, + costMultiplier: 3.0, + groupTag: "prod", + providerType: "claude" as const, + providerVendorId: null, + preserveClientIp: true, + modelRedirects: null, + allowedModels: null, + mcpPassthroughType: "none" as const, + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed" as const, + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: 10, + maxRetryAttempts: null, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 30000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 30000, + streamingIdleTimeoutMs: 120000, + requestTimeoutNonStreamingMs: 120000, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + swapCacheTtlBilling: false, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: null, + tpm: null, + rpm: null, + rpd: null, + cc: null, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; + + const state = createInitialState("batch", fakeProvider, fakeProvider); + + // Should still be batch defaults, not the provider values + expect(state.routing.priority).toBe(0); + expect(state.routing.weight).toBe(1); + expect(state.routing.costMultiplier).toBe(1.0); + expect(state.batch.isEnabled).toBe("no_change"); + }); +}); + +// --------------------------------------------------------------------------- +// providerFormReducer - SET_BATCH_IS_ENABLED +// --------------------------------------------------------------------------- + +describe("providerFormReducer - SET_BATCH_IS_ENABLED", () => { + const baseState = createInitialState("batch"); + + it("sets isEnabled to true", () => { + const next = providerFormReducer(baseState, { + type: "SET_BATCH_IS_ENABLED", + payload: "true", + }); + + expect(next.batch.isEnabled).toBe("true"); + }); + + it("sets isEnabled to false", () => { + const next = providerFormReducer(baseState, { + type: "SET_BATCH_IS_ENABLED", + payload: "false", + }); + + expect(next.batch.isEnabled).toBe("false"); + }); + + it("sets isEnabled back to no_change", () => { + const modified = providerFormReducer(baseState, { + type: "SET_BATCH_IS_ENABLED", + payload: "true", + }); + const reverted = providerFormReducer(modified, { + type: "SET_BATCH_IS_ENABLED", + payload: "no_change", + }); + + expect(reverted.batch.isEnabled).toBe("no_change"); + }); + + it("does not mutate other state sections", () => { + const next = providerFormReducer(baseState, { + type: "SET_BATCH_IS_ENABLED", + payload: "true", + }); + + expect(next.routing).toEqual(baseState.routing); + expect(next.rateLimit).toEqual(baseState.rateLimit); + expect(next.circuitBreaker).toEqual(baseState.circuitBreaker); + expect(next.network).toEqual(baseState.network); + expect(next.mcp).toEqual(baseState.mcp); + expect(next.ui).toEqual(baseState.ui); + }); +}); diff --git a/tests/unit/settings/providers/provider-undo-toast.test.tsx b/tests/unit/settings/providers/provider-undo-toast.test.tsx new file mode 100644 index 000000000..239562e0c --- /dev/null +++ b/tests/unit/settings/providers/provider-undo-toast.test.tsx @@ -0,0 +1,595 @@ +/** + * @vitest-environment happy-dom + */ + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ProviderBatchDialog } from "@/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog"; +import type { ProviderDisplay } from "@/types/provider"; + +// --------------------------------------------------------------------------- +// Mutable mock state for useProviderForm +// --------------------------------------------------------------------------- + +let mockDirtyFields = new Set(); +const mockDispatch = vi.fn(); +const mockState = { + ui: { activeTab: "basic" as const, isPending: false, showFailureThresholdConfirm: false }, + basic: { name: "", url: "", key: "", websiteUrl: "" }, + routing: { + providerType: "claude" as const, + groupTag: [], + preserveClientIp: false, + modelRedirects: {}, + allowedModels: [], + priority: 5, + groupPriorities: {}, + weight: 1, + costMultiplier: 1, + cacheTtlPreference: "inherit" as const, + swapCacheTtlBilling: false, + context1mPreference: "inherit" as const, + codexReasoningEffortPreference: "inherit", + codexReasoningSummaryPreference: "inherit", + codexTextVerbosityPreference: "inherit", + codexParallelToolCallsPreference: "inherit", + anthropicMaxTokensPreference: "inherit", + anthropicThinkingBudgetPreference: "inherit", + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: "inherit", + }, + rateLimit: { + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed" as const, + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: null, + }, + circuitBreaker: { + failureThreshold: undefined, + openDurationMinutes: undefined, + halfOpenSuccessThreshold: undefined, + maxRetryAttempts: null, + }, + network: { + proxyUrl: "", + proxyFallbackToDirect: false, + firstByteTimeoutStreamingSeconds: undefined, + streamingIdleTimeoutSeconds: undefined, + requestTimeoutNonStreamingSeconds: undefined, + }, + mcp: { mcpPassthroughType: "none" as const, mcpPassthroughUrl: "" }, + batch: { isEnabled: "no_change" as const }, +}; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock("next-intl", () => ({ + useTranslations: () => { + const t = (key: string, params?: Record) => { + if (params) { + let result = key; + for (const [k, v] of Object.entries(params)) { + result = result.replace(`{${k}}`, String(v)); + } + return result; + } + return key; + }; + return t; + }, +})); + +const mockInvalidateQueries = vi.fn().mockResolvedValue(undefined); +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ + invalidateQueries: mockInvalidateQueries, + }), +})); + +const mockToastSuccess = vi.fn(); +const mockToastError = vi.fn(); +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => mockToastSuccess(...args), + error: (...args: unknown[]) => mockToastError(...args), + }, +})); + +const mockPreview = vi.fn(); +const mockApply = vi.fn(); +const mockUndo = vi.fn(); +vi.mock("@/actions/providers", () => ({ + previewProviderBatchPatch: (...args: unknown[]) => mockPreview(...args), + applyProviderBatchPatch: (...args: unknown[]) => mockApply(...args), + undoProviderPatch: (...args: unknown[]) => mockUndo(...args), + batchDeleteProviders: vi.fn().mockResolvedValue({ ok: true, data: { deletedCount: 1 } }), + batchResetProviderCircuits: vi.fn().mockResolvedValue({ ok: true, data: { resetCount: 1 } }), +})); + +// Mock ProviderFormProvider + useProviderForm +vi.mock( + "@/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context", + () => ({ + ProviderFormProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + useProviderForm: () => ({ + state: mockState, + dispatch: mockDispatch, + dirtyFields: mockDirtyFields, + mode: "batch", + }), + }) +); + +// Mock all form section components as stubs +vi.mock( + "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/basic-info-section", + () => ({ + BasicInfoSection: () =>
, + }) +); +vi.mock( + "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section", + () => ({ + RoutingSection: () =>
, + }) +); +vi.mock( + "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/limits-section", + () => ({ + LimitsSection: () =>
, + }) +); +vi.mock( + "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/network-section", + () => ({ + NetworkSection: () =>
, + }) +); +vi.mock( + "@/app/[locale]/settings/providers/_components/forms/provider-form/sections/testing-section", + () => ({ + TestingSection: () =>
, + }) +); + +// Mock FormTabNav +vi.mock( + "@/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav", + () => ({ + FormTabNav: () =>
, + }) +); + +// Mock ProviderBatchPreviewStep +vi.mock( + "@/app/[locale]/settings/providers/_components/batch-edit/provider-batch-preview-step", + () => ({ + ProviderBatchPreviewStep: () =>
, + }) +); + +// Mock buildPatchDraftFromFormState +vi.mock("@/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft", () => ({ + buildPatchDraftFromFormState: vi.fn().mockReturnValue({ priority: { set: 5 } }), +})); + +// UI component mocks +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogDescription: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogFooter: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DialogTitle: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/components/ui/alert-dialog", () => ({ + AlertDialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + AlertDialogAction: ({ children, ...props }: any) => , + AlertDialogCancel: ({ children, ...props }: any) => , + AlertDialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: any) => , +})); + +vi.mock("lucide-react", () => ({ + Loader2: () =>
, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function render(ui: React.ReactElement) { + const container = document.createElement("div"); + document.body.appendChild(container); + let root: ReturnType; + act(() => { + root = createRoot(container); + root.render(ui); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function createMockProvider(id: number, name: string): ProviderDisplay { + return { + id, + name, + url: "https://api.example.com", + maskedKey: "xxxx****1234", + isEnabled: true, + weight: 1, + priority: 0, + groupPriorities: null, + costMultiplier: 1, + groupTag: null, + providerType: "claude", + providerVendorId: null, + preserveClientIp: false, + modelRedirects: null, + allowedModels: null, + mcpPassthroughType: "none", + mcpPassthroughUrl: null, + limit5hUsd: null, + limitDailyUsd: null, + dailyResetMode: "fixed", + dailyResetTime: "00:00", + limitWeeklyUsd: null, + limitMonthlyUsd: null, + limitTotalUsd: null, + limitConcurrentSessions: 10, + maxRetryAttempts: null, + circuitBreakerFailureThreshold: 5, + circuitBreakerOpenDuration: 30000, + circuitBreakerHalfOpenSuccessThreshold: 2, + proxyUrl: null, + proxyFallbackToDirect: false, + firstByteTimeoutStreamingMs: 30000, + streamingIdleTimeoutMs: 120000, + requestTimeoutNonStreamingMs: 120000, + websiteUrl: null, + faviconUrl: null, + cacheTtlPreference: null, + swapCacheTtlBilling: false, + context1mPreference: null, + codexReasoningEffortPreference: null, + codexReasoningSummaryPreference: null, + codexTextVerbosityPreference: null, + codexParallelToolCallsPreference: null, + anthropicMaxTokensPreference: null, + anthropicThinkingBudgetPreference: null, + anthropicAdaptiveThinking: null, + geminiGoogleSearchPreference: null, + tpm: null, + rpm: null, + rpd: null, + cc: null, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }; +} + +function defaultProps(overrides: Partial> = {}) { + return { + open: true, + mode: "edit" as const, + onOpenChange: vi.fn(), + selectedProviderIds: new Set([1, 2]), + providers: [createMockProvider(1, "Provider1"), createMockProvider(2, "Provider2")], + onSuccess: vi.fn(), + ...overrides, + }; +} + +/** + * Drives the dialog from "edit" step through "preview" step to "apply": + * 1. Click "Next" (second button in edit-step footer) + * 2. Wait for preview to resolve + * 3. Click "Apply" (second button in preview-step footer) + * 4. Wait for apply to resolve + */ +async function driveToApply(container: HTMLElement) { + // Click Next (second button in footer) + const footer = container.querySelector('[data-testid="dialog-footer"]'); + const buttons = footer?.querySelectorAll("button") ?? []; + const nextButton = buttons[1] as HTMLButtonElement; + + await act(async () => { + nextButton.click(); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); + + // Click Apply (second button in preview-step footer) + const applyFooter = container.querySelector('[data-testid="dialog-footer"]'); + const applyButtons = applyFooter?.querySelectorAll("button") ?? []; + const applyButton = applyButtons[1] as HTMLButtonElement; + + await act(async () => { + applyButton.click(); + }); + await act(async () => { + await new Promise((r) => setTimeout(r, 10)); + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("Provider Undo Toast", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Make hasChanges true so the "Next" button is enabled + mockDirtyFields = new Set(["routing.priority"]); + }); + + it("shows undo toast after successful apply", async () => { + mockPreview.mockResolvedValue({ + ok: true, + data: { + previewToken: "tok-1", + previewRevision: "rev-1", + previewExpiresAt: new Date(Date.now() + 60000).toISOString(), + providerIds: [1, 2], + changedFields: ["priority"], + rows: [ + { + providerId: 1, + providerName: "Provider1", + field: "priority", + status: "changed", + before: 0, + after: 5, + }, + { + providerId: 2, + providerName: "Provider2", + field: "priority", + status: "changed", + before: 0, + after: 5, + }, + ], + summary: { providerCount: 2, fieldCount: 2, skipCount: 0 }, + }, + }); + + mockApply.mockResolvedValue({ + ok: true, + data: { + operationId: "op-1", + appliedAt: new Date().toISOString(), + updatedCount: 2, + undoToken: "undo-tok-1", + undoExpiresAt: new Date(Date.now() + 10000).toISOString(), + }, + }); + + const props = defaultProps(); + const { container, unmount } = render(); + + await driveToApply(container); + + expect(mockPreview).toHaveBeenCalledTimes(1); + expect(mockApply).toHaveBeenCalledTimes(1); + + // Verify toast.success was called with undo action + expect(mockToastSuccess).toHaveBeenCalledWith( + "toast.updated", + expect.objectContaining({ + duration: 10000, + action: expect.objectContaining({ + label: expect.any(String), + onClick: expect.any(Function), + }), + }) + ); + + unmount(); + }); + + it("undo action calls undoProviderPatch on success", async () => { + mockPreview.mockResolvedValue({ + ok: true, + data: { + previewToken: "tok-2", + previewRevision: "rev-2", + previewExpiresAt: new Date(Date.now() + 60000).toISOString(), + providerIds: [1], + changedFields: ["priority"], + rows: [ + { + providerId: 1, + providerName: "Provider1", + field: "priority", + status: "changed", + before: 0, + after: 5, + }, + ], + summary: { providerCount: 1, fieldCount: 1, skipCount: 0 }, + }, + }); + + mockApply.mockResolvedValue({ + ok: true, + data: { + operationId: "op-2", + appliedAt: new Date().toISOString(), + updatedCount: 1, + undoToken: "undo-tok-2", + undoExpiresAt: new Date(Date.now() + 10000).toISOString(), + }, + }); + + mockUndo.mockResolvedValue({ + ok: true, + data: { + operationId: "op-2", + revertedAt: new Date().toISOString(), + revertedCount: 1, + }, + }); + + const props = defaultProps({ selectedProviderIds: new Set([1]) }); + const { container, unmount } = render(); + + await driveToApply(container); + + // Extract the undo onClick from the toast call + const toastCall = mockToastSuccess.mock.calls[0]; + const toastOptions = toastCall[1] as { action: { onClick: () => Promise } }; + + // Call the undo action + await act(async () => { + await toastOptions.action.onClick(); + }); + + expect(mockUndo).toHaveBeenCalledWith({ + undoToken: "undo-tok-2", + operationId: "op-2", + }); + + // Should show success toast for undo + expect(mockToastSuccess).toHaveBeenCalledTimes(2); + expect(mockToastSuccess.mock.calls[1][0]).toBe("toast.undoSuccess"); + + unmount(); + }); + + it("undo failure shows error toast", async () => { + mockPreview.mockResolvedValue({ + ok: true, + data: { + previewToken: "tok-3", + previewRevision: "rev-3", + previewExpiresAt: new Date(Date.now() + 60000).toISOString(), + providerIds: [1], + changedFields: ["priority"], + rows: [ + { + providerId: 1, + providerName: "Provider1", + field: "priority", + status: "changed", + before: 0, + after: 5, + }, + ], + summary: { providerCount: 1, fieldCount: 1, skipCount: 0 }, + }, + }); + + mockApply.mockResolvedValue({ + ok: true, + data: { + operationId: "op-3", + appliedAt: new Date().toISOString(), + updatedCount: 1, + undoToken: "undo-tok-3", + undoExpiresAt: new Date(Date.now() + 10000).toISOString(), + }, + }); + + mockUndo.mockResolvedValue({ + ok: false, + error: "Undo window expired", + errorCode: "UNDO_EXPIRED", + }); + + const props = defaultProps({ selectedProviderIds: new Set([1]) }); + const { container, unmount } = render(); + + await driveToApply(container); + + // Extract undo onClick + const toastCall = mockToastSuccess.mock.calls[0]; + const toastOptions = toastCall[1] as { action: { onClick: () => Promise } }; + + // Call undo - should fail + await act(async () => { + await toastOptions.action.onClick(); + }); + + expect(mockUndo).toHaveBeenCalledTimes(1); + // After undo failure, error toast is shown via toast.error + expect(mockToastError).toHaveBeenCalled(); + + unmount(); + }); + + it("apply shows error toast on failure", async () => { + mockPreview.mockResolvedValue({ + ok: true, + data: { + previewToken: "tok-4", + previewRevision: "rev-4", + previewExpiresAt: new Date(Date.now() + 60000).toISOString(), + providerIds: [1], + changedFields: ["priority"], + rows: [ + { + providerId: 1, + providerName: "Provider1", + field: "priority", + status: "changed", + before: 0, + after: 5, + }, + ], + summary: { providerCount: 1, fieldCount: 1, skipCount: 0 }, + }, + }); + + mockApply.mockResolvedValue({ + ok: false, + error: "Preview expired", + errorCode: "PREVIEW_EXPIRED", + }); + + const props = defaultProps({ selectedProviderIds: new Set([1]) }); + const { container, unmount } = render(); + + await driveToApply(container); + + expect(mockApply).toHaveBeenCalledTimes(1); + // After apply failure, error toast is shown via toast.error + expect(mockToastError).toHaveBeenCalled(); + expect(mockToastSuccess).not.toHaveBeenCalled(); + + unmount(); + }); +}); diff --git a/tests/unit/settings/providers/thinking-budget-editor.test.tsx b/tests/unit/settings/providers/thinking-budget-editor.test.tsx new file mode 100644 index 000000000..3965822b4 --- /dev/null +++ b/tests/unit/settings/providers/thinking-budget-editor.test.tsx @@ -0,0 +1,233 @@ +/** + * @vitest-environment happy-dom + */ + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { ThinkingBudgetEditor } from "@/app/[locale]/settings/providers/_components/thinking-budget-editor"; + +// Mock next-intl +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +// Mock Select as native onValueChange(e.target.value)} + disabled={disabled} + > + {children} + +
+ ), + SelectTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => ( + + ), +})); + +// Mock Tooltip as passthrough +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +// Mock lucide-react +vi.mock("lucide-react", () => ({ + Info: () =>
, +})); + +function render(node: React.ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(node); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +describe("ThinkingBudgetEditor", () => { + const defaultProps = { + value: "inherit", + onChange: vi.fn(), + disabled: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders with inherit value - no numeric input or max button", () => { + const { container, unmount } = render(); + + const select = container.querySelector('[data-testid="select-trigger"]') as HTMLSelectElement; + expect(select).toBeTruthy(); + expect(select.value).toBe("inherit"); + + // No number input when inherit + expect(container.querySelector('input[type="number"]')).toBeNull(); + // No max-out button when inherit + expect(container.querySelector("button")).toBeNull(); + + unmount(); + }); + + it("renders with numeric value - shows custom select, input, and max button", () => { + const { container, unmount } = render(); + + const select = container.querySelector('[data-testid="select-trigger"]') as HTMLSelectElement; + expect(select.value).toBe("custom"); + + const input = container.querySelector('input[type="number"]') as HTMLInputElement; + expect(input).toBeTruthy(); + expect(input.value).toBe("15000"); + + const maxButton = container.querySelector("button"); + expect(maxButton).toBeTruthy(); + expect(maxButton?.textContent).toContain("maxOutButton"); + + unmount(); + }); + + it("switches from inherit to custom - calls onChange with 10240", () => { + const onChange = vi.fn(); + const { container, unmount } = render( + + ); + + const select = container.querySelector('[data-testid="select-trigger"]') as HTMLSelectElement; + + act(() => { + select.value = "custom"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(onChange).toHaveBeenCalledWith("10240"); + + unmount(); + }); + + it("switches from custom to inherit - calls onChange with inherit", () => { + const onChange = vi.fn(); + const { container, unmount } = render( + + ); + + const select = container.querySelector('[data-testid="select-trigger"]') as HTMLSelectElement; + + act(() => { + select.value = "inherit"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(onChange).toHaveBeenCalledWith("inherit"); + + unmount(); + }); + + it("clicking max-out button calls onChange with 32000", () => { + const onChange = vi.fn(); + const { container, unmount } = render( + + ); + + const maxButton = container.querySelector("button") as HTMLButtonElement; + + act(() => { + maxButton.click(); + }); + + expect(onChange).toHaveBeenCalledWith("32000"); + + unmount(); + }); + + it("typing a number calls onChange with that value", () => { + const onChange = vi.fn(); + const { container, unmount } = render( + + ); + + const input = container.querySelector('input[type="number"]') as HTMLInputElement; + + act(() => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + nativeInputValueSetter?.call(input, "12345"); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(onChange).toHaveBeenCalledWith("12345"); + + unmount(); + }); + + it("clearing input calls onChange with inherit", () => { + const onChange = vi.fn(); + const { container, unmount } = render( + + ); + + const input = container.querySelector('input[type="number"]') as HTMLInputElement; + + act(() => { + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + nativeInputValueSetter?.call(input, ""); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(onChange).toHaveBeenCalledWith(""); + + unmount(); + }); + + it("disabled prop disables all controls", () => { + const { container, unmount } = render( + + ); + + const select = container.querySelector('[data-testid="select-trigger"]') as HTMLSelectElement; + expect(select.disabled).toBe(true); + + const input = container.querySelector('input[type="number"]') as HTMLInputElement; + expect(input.disabled).toBe(true); + + const maxButton = container.querySelector("button") as HTMLButtonElement; + expect(maxButton.disabled).toBe(true); + + unmount(); + }); +}); diff --git a/tests/unit/usage-doc/usage-doc-auth-state.test.tsx b/tests/unit/usage-doc/usage-doc-auth-state.test.tsx new file mode 100644 index 000000000..e22fa99c0 --- /dev/null +++ b/tests/unit/usage-doc/usage-doc-auth-state.test.tsx @@ -0,0 +1,126 @@ +/** + * @vitest-environment happy-dom + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { describe, expect, test, vi } from "vitest"; +import { UsageDocAuthProvider } from "@/app/[locale]/usage-doc/_components/usage-doc-auth-context"; +import { QuickLinks } from "@/app/[locale]/usage-doc/_components/quick-links"; + +vi.mock("@/i18n/routing", () => ({ + Link: ({ + href, + children, + ...rest + }: { + href: string; + children: ReactNode; + className?: string; + }) => ( + + {children} + + ), +})); + +function loadUsageMessages() { + return JSON.parse( + fs.readFileSync(path.join(process.cwd(), "messages", "en", "usage.json"), "utf8") + ); +} + +function renderWithAuth(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const usageMessages = loadUsageMessages(); + + act(() => { + root.render( + + {node} + + ); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +describe("usage-doc auth state - HttpOnly cookie alignment", () => { + test("logged-in: QuickLinks renders dashboard link when isLoggedIn=true", () => { + Object.defineProperty(window, "scrollTo", { value: vi.fn(), writable: true }); + + const { container, unmount } = renderWithAuth( + + + + ); + + const dashboardLink = container.querySelector('a[href="/dashboard"]'); + expect(dashboardLink).not.toBeNull(); + + unmount(); + }); + + test("logged-out: QuickLinks does NOT render dashboard link when isLoggedIn=false", () => { + Object.defineProperty(window, "scrollTo", { value: vi.fn(), writable: true }); + + const { container, unmount } = renderWithAuth( + + + + ); + + const dashboardLink = container.querySelector('a[href="/dashboard"]'); + expect(dashboardLink).toBeNull(); + + unmount(); + }); + + test("default context value is isLoggedIn=false (no provider ancestor)", () => { + Object.defineProperty(window, "scrollTo", { value: vi.fn(), writable: true }); + + const { container, unmount } = renderWithAuth(); + + const dashboardLink = container.querySelector('a[href="/dashboard"]'); + expect(dashboardLink).toBeNull(); + + unmount(); + }); + + test("page.tsx no longer reads document.cookie for auth state", async () => { + const srcContent = fs.readFileSync( + path.join(process.cwd(), "src", "app", "[locale]", "usage-doc", "page.tsx"), + "utf8" + ); + expect(srcContent).not.toContain("document.cookie"); + }); + + test("page.tsx uses useUsageDocAuth hook for session state", async () => { + const srcContent = fs.readFileSync( + path.join(process.cwd(), "src", "app", "[locale]", "usage-doc", "page.tsx"), + "utf8" + ); + expect(srcContent).toContain("useUsageDocAuth"); + }); + + test("layout.tsx wraps children with UsageDocAuthProvider", async () => { + const srcContent = fs.readFileSync( + path.join(process.cwd(), "src", "app", "[locale]", "usage-doc", "layout.tsx"), + "utf8" + ); + expect(srcContent).toContain("UsageDocAuthProvider"); + expect(srcContent).toContain("isLoggedIn={!!session}"); + }); +}); diff --git a/tests/unit/usage-doc/usage-doc-page.test.tsx b/tests/unit/usage-doc/usage-doc-page.test.tsx index 284637e53..9801bda2c 100644 --- a/tests/unit/usage-doc/usage-doc-page.test.tsx +++ b/tests/unit/usage-doc/usage-doc-page.test.tsx @@ -10,6 +10,7 @@ import { createRoot } from "react-dom/client"; import { NextIntlClientProvider } from "next-intl"; import { describe, expect, test, vi } from "vitest"; import UsageDocPage from "@/app/[locale]/usage-doc/page"; +import { UsageDocAuthProvider } from "@/app/[locale]/usage-doc/_components/usage-doc-auth-context"; vi.mock("@/i18n/routing", () => ({ Link: ({ @@ -56,18 +57,18 @@ async function renderWithIntl(locale: string, node: ReactNode) { } describe("UsageDocPage - 目录/快速链接交互", () => { - test("应渲染 skip links,且登录态显示返回仪表盘链接", async () => { + test("should render skip links and show dashboard link when logged in", async () => { Object.defineProperty(window, "scrollTo", { value: vi.fn(), writable: true, }); - Object.defineProperty(document, "cookie", { - configurable: true, - get: () => "auth-token=test-token", - }); - - const { unmount } = await renderWithIntl("en", ); + const { unmount } = await renderWithIntl( + "en", + + + + ); expect(document.querySelector('a[href="#main-content"]')).not.toBeNull(); expect(document.querySelector('a[href="#toc-navigation"]')).not.toBeNull(); @@ -76,8 +77,6 @@ describe("UsageDocPage - 目录/快速链接交互", () => { expect(dashboardLink).not.toBeNull(); await unmount(); - - Reflect.deleteProperty(document, "cookie"); }); test("ru 语言不应显示中文占位符与代码块注释", async () => { diff --git a/vitest.config.ts b/vitest.config.ts index ec86290f7..0bee6eaad 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ environment: "happy-dom", include: [ "tests/unit/**/*.{test,spec}.tsx", + "tests/security/**/*.{test,spec}.{ts,tsx}", "tests/api/**/*.{test,spec}.tsx", "src/**/*.{test,spec}.tsx", ], @@ -89,6 +90,7 @@ export default defineConfig({ // ==================== 文件匹配 ==================== include: [ "tests/unit/**/*.{test,spec}.ts", // 单元测试 + "tests/security/**/*.{test,spec}.ts", "tests/api/**/*.{test,spec}.ts", // API 测试 "src/**/*.{test,spec}.ts", // 支持源码中的测试 ], From e8539541ea88802b00c7709f5ebd2a11652b6ec9 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:28:07 +0800 Subject: [PATCH 10/75] perf(dashboard): comprehensive homepage performance optimization (#808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(dashboard): add cache key types and builder utilities * perf(cache): add Redis overview cache with 10s TTL * perf(cache): add Redis statistics cache with 30s TTL * perf(db): rewrite statistics SQL to eliminate CROSS JOIN anti-pattern * perf(dashboard): wire overview action to Redis cache with 10s TTL * perf(dashboard): wire statistics action to Redis cache Replace direct DB calls (getUserStatisticsFromDB, getKeyStatisticsFromDB, getMixedStatisticsFromDB) with getStatisticsWithCache() in getUserStatistics(). The cache module handles Redis read-through with 30s TTL, distributed locking, and fail-open fallback to direct DB queries. * perf(dashboard): optimize client-side caching, polling, and lazy load charts - Remove cache: no-store from fetchLeaderboard to respect s-maxage=60 - Reduce overview polling from 5s to 15s with staleTime: 10_000 - Add staleTime: 30_000 and keepPreviousData to statistics query - Add staleTime: 60_000 to all 3 leaderboard queries - Lazy load StatisticsChartCard via next/dynamic with ssr: false * perf(db): add PG indexes for dashboard query optimization * perf(dashboard): expand SSR prefetch to include overview data * perf(db): commit migration artifacts for dashboard query indexes * test(dashboard): add unit tests for performance optimization modules * test(dashboard): add unit tests for performance optimization modules * test(actions): mock redis lifecycle in provider undo tests * fix(i18n): use fullwidth parentheses in zh-TW dashboard labels * fix(dashboard): address all bugbot comments from PR #808 - Replace O(N) redis.keys() with cursor-based scanPattern() in invalidateStatisticsCache (issue 1) - Fix lock not released when queryDatabase throws: move del(lockKey) to finally block in both statistics-cache and overview-cache (issues 2+4) - Wrap setex in inner try/catch so Redis write failure doesn't trigger double DB query via outer catch (issues 3+4) - Guard queryDatabase against undefined userId for keys/mixed modes (issue 5) - Remove duplicate buildCacheKey; use buildStatisticsCacheKey from dashboard-cache.ts throughout (issue 6) - Add TypeScript overloads to buildOverviewCacheKey preventing overview:user:undefined keys at compile time (issue 7) - Replace hardcoded Chinese sentinel "其他用户" with "__others__" and map it to i18n key othersAggregate in 5 locales (issue 8) - Extract duplicated Redis in-memory mock into shared tests/unit/actions/redis-mock-utils.ts (issue 9) * chore: format code (dashboard-perf-optimization-df4337e) --------- Co-authored-by: github-actions[bot] --- drizzle/0071_purple_captain_midlands.sql | 2 + drizzle/meta/0071_snapshot.json | 3301 +++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/en/dashboard.json | 3 +- messages/ja/dashboard.json | 3 +- messages/ru/dashboard.json | 3 +- messages/zh-CN/dashboard.json | 3 +- messages/zh-TW/dashboard.json | 7 +- src/actions/overview.ts | 4 +- src/actions/statistics.ts | 32 +- .../_components/bento/dashboard-bento.tsx | 21 +- .../bento/statistics-chart-card.tsx | 12 +- .../_components/dashboard-bento-sections.tsx | 5 +- .../_components/statistics/chart.tsx | 9 +- src/drizzle/schema.ts | 6 + src/lib/redis/index.ts | 2 + src/lib/redis/overview-cache.ts | 94 + src/lib/redis/statistics-cache.ts | 185 + src/repository/statistics.ts | 935 ++--- src/types/dashboard-cache.ts | 26 + .../unit/actions/provider-undo-delete.test.ts | 5 + tests/unit/actions/provider-undo-edit.test.ts | 5 + .../actions/providers-apply-engine.test.ts | 54 + .../providers-patch-actions-contract.test.ts | 5 + .../actions/providers-undo-engine.test.ts | 5 + tests/unit/actions/redis-mock-utils.ts | 48 + .../dashboard/dashboard-cache-keys.test.ts | 36 + tests/unit/redis/overview-cache.test.ts | 210 ++ tests/unit/redis/statistics-cache.test.ts | 369 ++ 29 files changed, 4727 insertions(+), 670 deletions(-) create mode 100644 drizzle/0071_purple_captain_midlands.sql create mode 100644 drizzle/meta/0071_snapshot.json create mode 100644 src/lib/redis/overview-cache.ts create mode 100644 src/lib/redis/statistics-cache.ts create mode 100644 src/types/dashboard-cache.ts create mode 100644 tests/unit/actions/redis-mock-utils.ts create mode 100644 tests/unit/dashboard/dashboard-cache-keys.test.ts create mode 100644 tests/unit/redis/overview-cache.test.ts create mode 100644 tests/unit/redis/statistics-cache.test.ts diff --git a/drizzle/0071_purple_captain_midlands.sql b/drizzle/0071_purple_captain_midlands.sql new file mode 100644 index 000000000..be3c1b765 --- /dev/null +++ b/drizzle/0071_purple_captain_midlands.sql @@ -0,0 +1,2 @@ +CREATE INDEX "idx_message_request_user_created_at_cost_stats" ON "message_request" USING btree ("user_id","created_at","cost_usd") WHERE "message_request"."deleted_at" IS NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> 'warmup');--> statement-breakpoint +CREATE INDEX "idx_message_request_provider_created_at_active" ON "message_request" USING btree ("provider_id","created_at") WHERE "message_request"."deleted_at" IS NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> 'warmup'); \ No newline at end of file diff --git a/drizzle/meta/0071_snapshot.json b/drizzle/meta/0071_snapshot.json new file mode 100644 index 000000000..d88abcdcf --- /dev/null +++ b/drizzle/meta/0071_snapshot.json @@ -0,0 +1,3301 @@ +{ + "id": "0612bb83-c19b-4507-a304-47fedf9d0b61", + "prevId": "36940835-849c-47f5-9cbf-15a6c250499a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9be36aa80..bc7baeeed 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -498,6 +498,13 @@ "when": 1771233193254, "tag": "0070_stormy_exiles", "breakpoints": true + }, + { + "idx": 71, + "version": "7", + "when": 1771491614848, + "tag": "0071_purple_captain_midlands", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 8b056aaf4..d95f954b6 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -706,7 +706,8 @@ "states": { "noData": "No statistics data available", "fetchFailed": "Failed to fetch statistics" - } + }, + "othersAggregate": "Other Users" }, "errors": { "fetchSystemSettingsFailed": "Failed to fetch system settings", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 1a6fada76..ff007d562 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -704,7 +704,8 @@ "states": { "noData": "統計データなし", "fetchFailed": "統計データの取得に失敗しました" - } + }, + "othersAggregate": "その他のユーザー" }, "errors": { "fetchSystemSettingsFailed": "システム設定の取得に失敗しました", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 1ea2d9cc7..3c2acb3a6 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -707,7 +707,8 @@ "states": { "noData": "Нет статистических данных", "fetchFailed": "Не удалось получить статистические данные" - } + }, + "othersAggregate": "Другие пользователи" }, "errors": { "fetchSystemSettingsFailed": "Не удалось получить параметры системы", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 924f895af..80f74db54 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -706,7 +706,8 @@ "states": { "noData": "暂无统计数据", "fetchFailed": "获取统计数据失败" - } + }, + "othersAggregate": "其他用户" }, "errors": { "fetchSystemSettingsFailed": "获取系统设置失败", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index abdafbccf..0d9008422 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -280,7 +280,7 @@ "cacheWrite1h": "快取寫入(1h)", "cacheRead": "快取讀取", "cacheTtl": "快取 TTL", - "cacheTtlSwapped": "計費 TTL (已互換)", + "cacheTtlSwapped": "計費 TTL(已互換)", "multiplier": "供應商倍率", "totalCost": "總費用", "context1m": "1M 上下文", @@ -366,7 +366,7 @@ "cacheWrite1h": "快取寫入(1h)", "cacheRead": "快取讀取", "cacheTtl": "快取 TTL", - "cacheTtlSwapped": "計費 TTL (已互換)", + "cacheTtlSwapped": "計費 TTL(已互換)", "multiplier": "供應商倍率", "totalCost": "總費用", "context1m": "1M 上下文長度", @@ -704,7 +704,8 @@ "states": { "noData": "暫無統計資料", "fetchFailed": "取得統計資料失敗" - } + }, + "othersAggregate": "其他使用者" }, "errors": { "fetchSystemSettingsFailed": "取得系統設定失敗", diff --git a/src/actions/overview.ts b/src/actions/overview.ts index b022c584d..a9cb71523 100644 --- a/src/actions/overview.ts +++ b/src/actions/overview.ts @@ -2,7 +2,7 @@ import { getSession } from "@/lib/auth"; import { logger } from "@/lib/logger"; -import { getOverviewMetricsWithComparison } from "@/repository/overview"; +import { getOverviewWithCache } from "@/lib/redis"; import { getSystemSettings } from "@/repository/system-config"; import { getConcurrentSessions as getConcurrentSessionsCount } from "./concurrent-sessions"; import type { ActionResult } from "./types"; @@ -59,7 +59,7 @@ export async function getOverviewData(): Promise> { const [concurrentResult, metricsData] = await Promise.all([ // 并发数只有管理员能看全站的 isAdmin ? getConcurrentSessionsCount() : Promise.resolve({ ok: true as const, data: 0 }), - getOverviewMetricsWithComparison(userId), + getOverviewWithCache(userId), ]); const concurrentSessions = concurrentResult.ok ? concurrentResult.data : 0; diff --git a/src/actions/statistics.ts b/src/actions/statistics.ts index 5913ee9d7..6bf3677ea 100644 --- a/src/actions/statistics.ts +++ b/src/actions/statistics.ts @@ -2,14 +2,9 @@ import { getSession } from "@/lib/auth"; import { logger } from "@/lib/logger"; +import { getStatisticsWithCache } from "@/lib/redis"; import { formatCostForStorage } from "@/lib/utils/currency"; -import { - getActiveKeysForUserFromDB, - getActiveUsersFromDB, - getKeyStatisticsFromDB, - getMixedStatisticsFromDB, - getUserStatisticsFromDB, -} from "@/repository/statistics"; +import { getActiveKeysForUserFromDB, getActiveUsersFromDB } from "@/repository/statistics"; import { getSystemSettings } from "@/repository/system-config"; import type { ChartDataItem, @@ -67,31 +62,36 @@ export async function getUserStatistics( if (mode === "users") { // Admin: 显示所有用户 - const [userStats, userList] = await Promise.all([ - getUserStatisticsFromDB(timeRange), + const [cachedData, userList] = await Promise.all([ + getStatisticsWithCache(timeRange, "users"), getActiveUsersFromDB(), ]); - statsData = userStats; + statsData = cachedData as DatabaseStatRow[]; entities = userList; } else if (mode === "mixed") { // 非 Admin + allowGlobalUsageView: 自己的密钥明细 + 其他用户汇总 - const [ownKeysList, mixedData] = await Promise.all([ + const [ownKeysList, cachedData] = await Promise.all([ getActiveKeysForUserFromDB(session.user.id), - getMixedStatisticsFromDB(session.user.id, timeRange), + getStatisticsWithCache(timeRange, "mixed", session.user.id), ]); + const mixedData = cachedData as { + ownKeys: DatabaseKeyStatRow[]; + othersAggregate: DatabaseStatRow[]; + }; + // 合并数据:自己的密钥 + 其他用户的虚拟条目 statsData = [...mixedData.ownKeys, ...mixedData.othersAggregate]; // 合并实体列表:自己的密钥 + 其他用户虚拟实体 - entities = [...ownKeysList, { id: -1, name: "其他用户" }]; + entities = [...ownKeysList, { id: -1, name: "__others__" }]; } else { // 非 Admin + !allowGlobalUsageView: 仅显示自己的密钥 - const [keyStats, keyList] = await Promise.all([ - getKeyStatisticsFromDB(session.user.id, timeRange), + const [cachedData, keyList] = await Promise.all([ + getStatisticsWithCache(timeRange, "keys", session.user.id), getActiveKeysForUserFromDB(session.user.id), ]); - statsData = keyStats; + statsData = cachedData as DatabaseKeyStatRow[]; entities = keyList; } diff --git a/src/app/[locale]/dashboard/_components/bento/dashboard-bento.tsx b/src/app/[locale]/dashboard/_components/bento/dashboard-bento.tsx index a813b725f..aefb1772e 100644 --- a/src/app/[locale]/dashboard/_components/bento/dashboard-bento.tsx +++ b/src/app/[locale]/dashboard/_components/bento/dashboard-bento.tsx @@ -1,7 +1,8 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Activity, Clock, DollarSign, TrendingUp } from "lucide-react"; +import dynamic from "next/dynamic"; import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; import { getActiveSessions } from "@/actions/active-sessions"; @@ -23,7 +24,11 @@ import { BentoGrid } from "./bento-grid"; import { LeaderboardCard } from "./leaderboard-card"; import { LiveSessionsPanel } from "./live-sessions-panel"; import { BentoMetricCard } from "./metric-card"; -import { StatisticsChartCard } from "./statistics-chart-card"; + +const StatisticsChartCard = dynamic( + () => import("./statistics-chart-card").then((mod) => ({ default: mod.StatisticsChartCard })), + { ssr: false } +); const REFRESH_INTERVAL = 5000; @@ -32,6 +37,7 @@ interface DashboardBentoProps { currencyCode: CurrencyCode; allowGlobalUsageView: boolean; initialStatistics?: UserStatisticsData; + initialOverview?: OverviewData; } interface LeaderboardData { @@ -62,7 +68,6 @@ async function fetchStatistics(timeRange: TimeRange): Promise { const res = await fetch(`/api/leaderboard?period=daily&scope=${scope}`, { - cache: "no-store", credentials: "include", }); if (!res.ok) throw new Error("Failed to fetch leaderboard"); @@ -110,6 +115,7 @@ export function DashboardBento({ currencyCode, allowGlobalUsageView, initialStatistics, + initialOverview, }: DashboardBentoProps) { const t = useTranslations("customs"); const tl = useTranslations("dashboard.leaderboard"); @@ -120,7 +126,9 @@ export function DashboardBento({ const { data: overview } = useQuery({ queryKey: ["overview-data"], queryFn: fetchOverviewData, - refetchInterval: REFRESH_INTERVAL, + refetchInterval: 15_000, + staleTime: 10_000, + initialData: initialOverview, }); // Active sessions @@ -136,6 +144,8 @@ export function DashboardBento({ queryKey: ["statistics", timeRange], queryFn: () => fetchStatistics(timeRange), initialData: timeRange === DEFAULT_TIME_RANGE ? initialStatistics : undefined, + staleTime: 30_000, + placeholderData: keepPreviousData, }); // Leaderboards @@ -145,6 +155,7 @@ export function DashboardBento({ queryKey: ["leaderboard", "user"], queryFn: () => fetchLeaderboard("user"), enabled: isAdmin || allowGlobalUsageView, + staleTime: 60_000, }); const { data: providerLeaderboard = [], isLoading: providerLeaderboardLoading } = useQuery< @@ -153,6 +164,7 @@ export function DashboardBento({ queryKey: ["leaderboard", "provider"], queryFn: () => fetchLeaderboard("provider"), enabled: isAdmin || allowGlobalUsageView, + staleTime: 60_000, }); const { data: modelLeaderboard = [], isLoading: modelLeaderboardLoading } = useQuery< @@ -161,6 +173,7 @@ export function DashboardBento({ queryKey: ["leaderboard", "model"], queryFn: () => fetchLeaderboard("model"), enabled: isAdmin || allowGlobalUsageView, + staleTime: 60_000, }); const metrics = overview || { diff --git a/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx b/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx index 756a6f45c..5c0bd11c8 100644 --- a/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx +++ b/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx @@ -79,7 +79,7 @@ export function StatisticsChartCard({ }; data.users.forEach((user, index) => { config[user.dataKey] = { - label: user.name, + label: user.name === "__others__" ? t("othersAggregate") : user.name, color: getUserColor(index), }; }); @@ -337,7 +337,11 @@ export function StatisticsChartCard({ className="h-2 w-2 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} /> - {displayUser?.name || baseKey} + + {displayUser?.name === "__others__" + ? t("othersAggregate") + : displayUser?.name || baseKey} +
{activeChart === "cost" @@ -444,7 +448,9 @@ export function StatisticsChartCard({ className="h-2 w-2 rounded-full flex-shrink-0" style={{ backgroundColor: color }} /> - {user.name} + + {user.name === "__others__" ? t("othersAggregate") : user.name} + {activeChart === "cost" ? formatCurrency(userTotal?.cost ?? 0, currencyCode) diff --git a/src/app/[locale]/dashboard/_components/dashboard-bento-sections.tsx b/src/app/[locale]/dashboard/_components/dashboard-bento-sections.tsx index 32ed3b193..4b4d99505 100644 --- a/src/app/[locale]/dashboard/_components/dashboard-bento-sections.tsx +++ b/src/app/[locale]/dashboard/_components/dashboard-bento-sections.tsx @@ -1,4 +1,5 @@ import { cache } from "react"; +import { getOverviewData } from "@/actions/overview"; import { getUserStatistics } from "@/actions/statistics"; import { getSystemSettings } from "@/repository/system-config"; import { DEFAULT_TIME_RANGE } from "@/types/statistics"; @@ -11,9 +12,10 @@ interface DashboardBentoSectionProps { } export async function DashboardBentoSection({ isAdmin }: DashboardBentoSectionProps) { - const [systemSettings, statistics] = await Promise.all([ + const [systemSettings, statistics, overviewResult] = await Promise.all([ getCachedSystemSettings(), getUserStatistics(DEFAULT_TIME_RANGE), + getOverviewData(), ]); return ( @@ -22,6 +24,7 @@ export async function DashboardBentoSection({ isAdmin }: DashboardBentoSectionPr currencyCode={systemSettings.currencyDisplay} allowGlobalUsageView={systemSettings.allowGlobalUsageView} initialStatistics={statistics.ok ? statistics.data : undefined} + initialOverview={overviewResult.ok ? overviewResult.data : undefined} /> ); } diff --git a/src/app/[locale]/dashboard/_components/statistics/chart.tsx b/src/app/[locale]/dashboard/_components/statistics/chart.tsx index 016d6ac58..491fd0634 100644 --- a/src/app/[locale]/dashboard/_components/statistics/chart.tsx +++ b/src/app/[locale]/dashboard/_components/statistics/chart.tsx @@ -117,7 +117,7 @@ export function UserStatisticsChart({ data.users.forEach((user, index) => { config[user.dataKey] = { - label: user.name, + label: user.name === "__others__" ? t("othersAggregate") : user.name, color: getUserColor(index), }; }); @@ -466,7 +466,10 @@ export function UserStatisticsChart({ style={{ backgroundColor: color }} /> - {displayUser?.name || baseKey}: + {displayUser?.name === "__others__" + ? t("othersAggregate") + : displayUser?.name || baseKey} + :
@@ -566,7 +569,7 @@ export function UserStatisticsChart({ aria-hidden="true" /> - {user.name} + {user.name === "__others__" ? t("othersAggregate") : user.name}
diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 3b4f31c75..7bcc207c9 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -483,8 +483,14 @@ export const messageRequest = pgTable('message_request', { }, (table) => ({ // 优化统计查询的复合索引(用户+时间+费用) messageRequestUserDateCostIdx: index('idx_message_request_user_date_cost').on(table.userId, table.createdAt, table.costUsd).where(sql`${table.deletedAt} IS NULL`), + messageRequestUserCreatedAtCostStatsIdx: index('idx_message_request_user_created_at_cost_stats') + .on(table.userId, table.createdAt, table.costUsd) + .where(sql`${table.deletedAt} IS NULL AND (${table.blockedBy} IS NULL OR ${table.blockedBy} <> 'warmup')`), // 优化用户查询的复合索引(按创建时间倒序) messageRequestUserQueryIdx: index('idx_message_request_user_query').on(table.userId, table.createdAt).where(sql`${table.deletedAt} IS NULL`), + messageRequestProviderCreatedAtActiveIdx: index('idx_message_request_provider_created_at_active') + .on(table.providerId, table.createdAt) + .where(sql`${table.deletedAt} IS NULL AND (${table.blockedBy} IS NULL OR ${table.blockedBy} <> 'warmup')`), // Session 查询索引(按 session 聚合查看对话) messageRequestSessionIdIdx: index('idx_message_request_session_id').on(table.sessionId).where(sql`${table.deletedAt} IS NULL`), // Session ID 前缀查询索引(LIKE 'prefix%',可稳定命中 B-tree) diff --git a/src/lib/redis/index.ts b/src/lib/redis/index.ts index 99ead0c72..1ab0b9213 100644 --- a/src/lib/redis/index.ts +++ b/src/lib/redis/index.ts @@ -2,5 +2,7 @@ import "server-only"; export { closeRedis, getRedisClient } from "./client"; export { getLeaderboardWithCache, invalidateLeaderboardCache } from "./leaderboard-cache"; +export { getOverviewWithCache, invalidateOverviewCache } from "./overview-cache"; export { scanPattern } from "./scan-helper"; export { getActiveConcurrentSessions } from "./session-stats"; +export { getStatisticsWithCache, invalidateStatisticsCache } from "./statistics-cache"; diff --git a/src/lib/redis/overview-cache.ts b/src/lib/redis/overview-cache.ts new file mode 100644 index 000000000..2226284e7 --- /dev/null +++ b/src/lib/redis/overview-cache.ts @@ -0,0 +1,94 @@ +import { logger } from "@/lib/logger"; +import { + getOverviewMetricsWithComparison, + type OverviewMetricsWithComparison, +} from "@/repository/overview"; +import { getRedisClient } from "./client"; + +const CACHE_TTL = 10; +const LOCK_TTL = 5; +const LOCK_WAIT_MS = 100; + +function buildCacheKey(userId?: number): string { + return userId !== undefined ? `overview:user:${userId}` : "overview:global"; +} + +/** + * Get overview metrics with Redis caching (10s TTL). + * Fail-open: Redis unavailable -> direct DB query. + * Thundering herd protection via lock key. + */ +export async function getOverviewWithCache( + userId?: number +): Promise { + const redis = getRedisClient(); + const cacheKey = buildCacheKey(userId); + const lockKey = `${cacheKey}:lock`; + + if (!redis) { + return await getOverviewMetricsWithComparison(userId); + } + + let lockAcquired = false; + let data: OverviewMetricsWithComparison | undefined; + + try { + // 1. Try cache hit + const cached = await redis.get(cacheKey); + if (cached) { + return JSON.parse(cached) as OverviewMetricsWithComparison; + } + + // 2. Acquire lock (prevent thundering herd) + const lockResult = await redis.set(lockKey, "1", "EX", LOCK_TTL, "NX"); + lockAcquired = lockResult === "OK"; + + if (!lockAcquired) { + // Another instance is computing -- wait briefly and retry cache + await new Promise((resolve) => setTimeout(resolve, LOCK_WAIT_MS)); + const retried = await redis.get(cacheKey); + if (retried) return JSON.parse(retried) as OverviewMetricsWithComparison; + // Still nothing -- fallback to direct query + return await getOverviewMetricsWithComparison(userId); + } + + // 3. Cache miss -- query DB + data = await getOverviewMetricsWithComparison(userId); + + // 4. Store in cache with TTL (best-effort) + try { + await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(data)); + } catch (writeErr) { + logger.warn("[OverviewCache] Failed to write cache", { cacheKey, error: writeErr }); + } + + return data; + } catch (error) { + logger.warn("[OverviewCache] Redis error, fallback to direct query", { userId, error }); + return data ?? (await getOverviewMetricsWithComparison(userId)); + } finally { + if (lockAcquired) { + await redis + .del(lockKey) + .catch((err) => + logger.warn("[OverviewCache] Failed to release lock", { lockKey, error: err }) + ); + } + } +} + +/** + * Invalidate overview cache for a specific user or global scope. + */ +export async function invalidateOverviewCache(userId?: number): Promise { + const redis = getRedisClient(); + if (!redis) return; + + const cacheKey = buildCacheKey(userId); + try { + await redis.del(cacheKey); + logger.info("[OverviewCache] Cache invalidated", { userId, cacheKey }); + } catch (error) { + logger.error("[OverviewCache] Failed to invalidate cache", { userId, error }); + } +} diff --git a/src/lib/redis/statistics-cache.ts b/src/lib/redis/statistics-cache.ts new file mode 100644 index 000000000..ab58810cc --- /dev/null +++ b/src/lib/redis/statistics-cache.ts @@ -0,0 +1,185 @@ +import { logger } from "@/lib/logger"; +import { + getKeyStatisticsFromDB, + getMixedStatisticsFromDB, + getUserStatisticsFromDB, +} from "@/repository/statistics"; +import { buildStatisticsCacheKey } from "@/types/dashboard-cache"; +import type { DatabaseKeyStatRow, DatabaseStatRow, TimeRange } from "@/types/statistics"; +import { getRedisClient } from "./client"; +import { scanPattern } from "./scan-helper"; + +const CACHE_TTL = 30; +const LOCK_TTL = 5; + +type MixedStatisticsResult = { + ownKeys: DatabaseKeyStatRow[]; + othersAggregate: DatabaseStatRow[]; +}; + +type StatisticsCacheData = DatabaseStatRow[] | DatabaseKeyStatRow[] | MixedStatisticsResult; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function queryDatabase( + timeRange: TimeRange, + mode: "users" | "keys" | "mixed", + userId?: number +): Promise { + if ((mode === "keys" || mode === "mixed") && userId === undefined) { + throw new Error(`queryDatabase: userId required for mode="${mode}"`); + } + switch (mode) { + case "users": + return await getUserStatisticsFromDB(timeRange); + case "keys": + return await getKeyStatisticsFromDB(userId!, timeRange); + case "mixed": + return await getMixedStatisticsFromDB(userId!, timeRange); + } +} + +/** + * Statistics data with Redis caching (30s TTL). + * + * Strategy: + * 1. Read from Redis cache first + * 2. On cache miss, acquire distributed lock to prevent thundering herd + * 3. Requests that fail to acquire lock wait and retry (up to 5s) + * 4. Fail-open: Redis unavailable -> direct DB query + */ +export async function getStatisticsWithCache( + timeRange: TimeRange, + mode: "users" | "keys" | "mixed", + userId?: number +): Promise { + const redis = getRedisClient(); + + if (!redis) { + logger.warn("[StatisticsCache] Redis not available, fallback to direct query", { + timeRange, + mode, + userId, + }); + return await queryDatabase(timeRange, mode, userId); + } + + const cacheKey = buildStatisticsCacheKey(timeRange, mode, userId); + const lockKey = `${cacheKey}:lock`; + + let locked = false; + let data: StatisticsCacheData | undefined; + + try { + // 1. Try cache + const cached = await redis.get(cacheKey); + if (cached) { + logger.debug("[StatisticsCache] Cache hit", { timeRange, mode, cacheKey }); + return JSON.parse(cached) as StatisticsCacheData; + } + + // 2. Cache miss - acquire lock (SET NX EX) + const lockResult = await redis.set(lockKey, "1", "EX", LOCK_TTL, "NX"); + locked = lockResult === "OK"; + + if (locked) { + logger.debug("[StatisticsCache] Acquired lock, computing", { timeRange, mode, lockKey }); + + data = await queryDatabase(timeRange, mode, userId); + + try { + await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(data)); + } catch (writeErr) { + logger.warn("[StatisticsCache] Failed to write cache", { cacheKey, error: writeErr }); + } + + logger.info("[StatisticsCache] Cache updated", { + timeRange, + mode, + userId, + cacheKey, + ttl: CACHE_TTL, + }); + + return data; + } + + // 3. Lock held by another request - wait and retry (up to 50 x 100ms = 5s) + logger.debug("[StatisticsCache] Lock held by another request, retrying", { timeRange, mode }); + + for (let i = 0; i < 50; i++) { + await sleep(100); + + const retried = await redis.get(cacheKey); + if (retried) { + logger.debug("[StatisticsCache] Cache hit after retry", { + timeRange, + mode, + retries: i + 1, + }); + return JSON.parse(retried) as StatisticsCacheData; + } + } + + // Retry timeout - fallback to direct DB + logger.warn("[StatisticsCache] Retry timeout, fallback to direct query", { timeRange, mode }); + return await queryDatabase(timeRange, mode, userId); + } catch (error) { + logger.error("[StatisticsCache] Redis error, fallback to direct query", { + timeRange, + mode, + error, + }); + return data ?? (await queryDatabase(timeRange, mode, userId)); + } finally { + if (locked) { + await redis + .del(lockKey) + .catch((err) => + logger.warn("[StatisticsCache] Failed to release lock", { lockKey, error: err }) + ); + } + } +} + +/** + * Invalidate statistics cache. + * + * - If timeRange provided: delete specific cache key + * - If timeRange undefined: delete all time ranges for the scope using pattern match + */ +export async function invalidateStatisticsCache( + timeRange?: TimeRange, + userId?: number +): Promise { + const redis = getRedisClient(); + if (!redis) { + return; + } + + const scope = userId !== undefined ? `${userId}` : "global"; + + try { + if (timeRange) { + const modes = ["users", "keys", "mixed"] as const; + const keysToDelete = modes.map((m) => buildStatisticsCacheKey(timeRange, m, userId)); + await redis.del(...keysToDelete); + logger.info("[StatisticsCache] Cache invalidated", { timeRange, scope, keysToDelete }); + } else { + const pattern = `statistics:*:*:${scope}`; + const matchedKeys = await scanPattern(redis, pattern); + if (matchedKeys.length > 0) { + await redis.del(...matchedKeys); + } + logger.info("[StatisticsCache] Cache invalidated (all timeRanges)", { + scope, + pattern, + deletedCount: matchedKeys.length, + }); + } + } catch (error) { + logger.error("[StatisticsCache] Failed to invalidate cache", { timeRange, scope, error }); + } +} diff --git a/src/repository/statistics.ts b/src/repository/statistics.ts index 4fecc985e..adc6f6b05 100644 --- a/src/repository/statistics.ts +++ b/src/repository/statistics.ts @@ -42,173 +42,257 @@ async function getKeyStringByIdCached(keyId: number): Promise { return keyString; } -/** - * 根据时间范围获取用户消费和API调用统计 - * 注意:这个函数使用原生SQL,因为涉及到PostgreSQL特定的generate_series函数 - */ -export async function getUserStatisticsFromDB(timeRange: TimeRange): Promise { - const timezone = await resolveSystemTimezone(); - let query; - +type SqlTimeRangeConfig = { + startTs: ReturnType; + endTs: ReturnType; + bucketExpr: ReturnType; + bucketSeriesQuery: ReturnType; +}; + +type TimeBucketValue = Date | string | null; + +type UserBucketStatsRow = { + user_id: number; + user_name: string; + bucket: TimeBucketValue; + api_calls: number | string | null; + total_cost: string | number | null; +}; + +type KeyBucketStatsRow = { + key_id: number; + key_name: string; + bucket: TimeBucketValue; + api_calls: number | string | null; + total_cost: string | number | null; +}; + +type MixedOthersBucketStatsRow = { + bucket: TimeBucketValue; + api_calls: number | string | null; + total_cost: string | number | null; +}; + +type RuntimeDatabaseStatRow = Omit & { date: Date }; +type RuntimeDatabaseKeyStatRow = Omit & { date: Date }; + +function getTimeRangeSqlConfig(timeRange: TimeRange, timezone: string): SqlTimeRangeConfig { switch (timeRange) { case "today": - // 今天(小时分辨率) - query = sql` - WITH hour_range AS ( + return { + startTs: sql`(DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) AT TIME ZONE ${timezone})`, + endTs: sql`((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone})`, + bucketExpr: sql`DATE_TRUNC('hour', message_request.created_at AT TIME ZONE ${timezone})`, + bucketSeriesQuery: sql` SELECT generate_series( - DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())), - DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) + INTERVAL '23 hours', + DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}), + DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '23 hours', '1 hour'::interval - ) AS hour - ), - hourly_stats AS ( - SELECT - u.id AS user_id, - u.name AS user_name, - hr.hour, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM users u - CROSS JOIN hour_range hr - LEFT JOIN message_request mr ON u.id = mr.user_id - AND mr.created_at >= (DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) AT TIME ZONE ${timezone}) - AND mr.created_at < ((DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) + INTERVAL '1 day') AT TIME ZONE ${timezone}) - AND DATE_TRUNC('hour', mr.created_at AT TIME ZONE ${timezone}) = hr.hour - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - WHERE u.deleted_at IS NULL - GROUP BY u.id, u.name, hr.hour - ) - SELECT - user_id, - user_name, - hour AS date, - api_calls::integer, - total_cost::numeric - FROM hourly_stats - ORDER BY hour ASC, user_name ASC - `; - break; - + ) AS bucket + `, + }; case "7days": - // 过去7天(天分辨率) - query = sql` - WITH date_range AS ( + return { + startTs: sql`((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '6 days') AT TIME ZONE ${timezone})`, + endTs: sql`((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone})`, + bucketExpr: sql`DATE_TRUNC('day', message_request.created_at AT TIME ZONE ${timezone})`, + bucketSeriesQuery: sql` SELECT generate_series( (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date - INTERVAL '6 days', (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date, '1 day'::interval - )::date AS date - ), - daily_stats AS ( - SELECT - u.id AS user_id, - u.name AS user_name, - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM users u - CROSS JOIN date_range dr - LEFT JOIN message_request mr ON u.id = mr.user_id - AND mr.created_at >= ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '6 days') AT TIME ZONE ${timezone}) - AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) - AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - WHERE u.deleted_at IS NULL - GROUP BY u.id, u.name, dr.date - ) - SELECT - user_id, - user_name, - date, - api_calls::integer, - total_cost::numeric - FROM daily_stats - ORDER BY date ASC, user_name ASC - `; - break; - + ) AS bucket + `, + }; case "30days": - // 过去 30 天(天分辨率) - query = sql` - WITH date_range AS ( + return { + startTs: sql`((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '29 days') AT TIME ZONE ${timezone})`, + endTs: sql`((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone})`, + bucketExpr: sql`DATE_TRUNC('day', message_request.created_at AT TIME ZONE ${timezone})`, + bucketSeriesQuery: sql` SELECT generate_series( (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date - INTERVAL '29 days', (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date, '1 day'::interval - )::date AS date - ), - daily_stats AS ( - SELECT - u.id AS user_id, - u.name AS user_name, - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM users u - CROSS JOIN date_range dr - LEFT JOIN message_request mr ON u.id = mr.user_id - AND mr.created_at >= ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '29 days') AT TIME ZONE ${timezone}) - AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) - AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - WHERE u.deleted_at IS NULL - GROUP BY u.id, u.name, dr.date - ) - SELECT - user_id, - user_name, - date, - api_calls::integer, - total_cost::numeric - FROM daily_stats - ORDER BY date ASC, user_name ASC - `; - break; - + ) AS bucket + `, + }; case "thisMonth": - // 本月(天分辨率,从本月第一天到今天) - query = sql` - WITH date_range AS ( + return { + startTs: sql`((DATE_TRUNC('month', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})) AT TIME ZONE ${timezone})`, + endTs: sql`((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone})`, + bucketExpr: sql`DATE_TRUNC('day', message_request.created_at AT TIME ZONE ${timezone})`, + bucketSeriesQuery: sql` SELECT generate_series( DATE_TRUNC('month', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date, (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date, '1 day'::interval - )::date AS date - ), - daily_stats AS ( - SELECT - u.id AS user_id, - u.name AS user_name, - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM users u - CROSS JOIN date_range dr - LEFT JOIN message_request mr ON u.id = mr.user_id - AND mr.created_at >= ((DATE_TRUNC('month', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})) AT TIME ZONE ${timezone}) - AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) - AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - WHERE u.deleted_at IS NULL - GROUP BY u.id, u.name, dr.date - ) - SELECT - user_id, - user_name, - date, - api_calls::integer, - total_cost::numeric - FROM daily_stats - ORDER BY date ASC, user_name ASC - `; - break; - + ) AS bucket + `, + }; default: throw new Error(`Unsupported time range: ${timeRange}`); } +} - const result = await db.execute(query); - return Array.from(result) as unknown as DatabaseStatRow[]; +function normalizeBucketDate(value: TimeBucketValue): Date | null { + if (!value) return null; + const parsed = value instanceof Date ? new Date(value.getTime()) : new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function normalizeApiCalls(value: number | string | null): number { + const normalized = Number(value ?? 0); + return Number.isFinite(normalized) ? normalized : 0; +} + +function normalizeTotalCost(value: string | number | null): string | number { + if (value === null || value === undefined) return 0; + if (typeof value === "number") return Number.isFinite(value) ? value : 0; + return value; +} + +async function getTimeBuckets(timeRange: TimeRange, timezone: string): Promise { + const { bucketSeriesQuery } = getTimeRangeSqlConfig(timeRange, timezone); + const result = await db.execute(bucketSeriesQuery); + return (Array.from(result) as Array<{ bucket: TimeBucketValue }>) + .map((row) => normalizeBucketDate(row.bucket)) + .filter((bucket): bucket is Date => bucket !== null) + .sort((a, b) => a.getTime() - b.getTime()); +} + +function zeroFillUserStats( + dbRows: UserBucketStatsRow[], + allUsers: Array<{ id: number; name: string }>, + buckets: Date[] +): RuntimeDatabaseStatRow[] { + const rowMap = new Map(); + for (const row of dbRows) { + const bucket = normalizeBucketDate(row.bucket); + if (!bucket) continue; + + rowMap.set(`${row.user_id}:${bucket.getTime()}`, { + api_calls: normalizeApiCalls(row.api_calls), + total_cost: normalizeTotalCost(row.total_cost), + }); + } + + const sortedUsers = [...allUsers].sort((a, b) => a.name.localeCompare(b.name)); + const filledRows: RuntimeDatabaseStatRow[] = []; + + for (const bucket of buckets) { + const bucketTime = bucket.getTime(); + for (const user of sortedUsers) { + const row = rowMap.get(`${user.id}:${bucketTime}`); + filledRows.push({ + user_id: user.id, + user_name: user.name, + date: new Date(bucketTime), + api_calls: row?.api_calls ?? 0, + total_cost: row?.total_cost ?? 0, + }); + } + } + + return filledRows; +} + +function zeroFillKeyStats( + dbRows: KeyBucketStatsRow[], + allKeys: Array<{ id: number; name: string }>, + buckets: Date[] +): RuntimeDatabaseKeyStatRow[] { + const rowMap = new Map(); + for (const row of dbRows) { + const bucket = normalizeBucketDate(row.bucket); + if (!bucket) continue; + + rowMap.set(`${row.key_id}:${bucket.getTime()}`, { + api_calls: normalizeApiCalls(row.api_calls), + total_cost: normalizeTotalCost(row.total_cost), + }); + } + + const sortedKeys = [...allKeys].sort((a, b) => a.name.localeCompare(b.name)); + const filledRows: RuntimeDatabaseKeyStatRow[] = []; + + for (const bucket of buckets) { + const bucketTime = bucket.getTime(); + for (const key of sortedKeys) { + const row = rowMap.get(`${key.id}:${bucketTime}`); + filledRows.push({ + key_id: key.id, + key_name: key.name, + date: new Date(bucketTime), + api_calls: row?.api_calls ?? 0, + total_cost: row?.total_cost ?? 0, + }); + } + } + + return filledRows; +} + +function zeroFillMixedOthersStats( + dbRows: MixedOthersBucketStatsRow[], + buckets: Date[] +): RuntimeDatabaseStatRow[] { + const rowMap = new Map(); + for (const row of dbRows) { + const bucket = normalizeBucketDate(row.bucket); + if (!bucket) continue; + + rowMap.set(bucket.getTime(), { + api_calls: normalizeApiCalls(row.api_calls), + total_cost: normalizeTotalCost(row.total_cost), + }); + } + + return buckets.map((bucket) => { + const row = rowMap.get(bucket.getTime()); + return { + user_id: -1, + user_name: "__others__", + date: new Date(bucket.getTime()), + api_calls: row?.api_calls ?? 0, + total_cost: row?.total_cost ?? 0, + }; + }); +} + +/** + * 根据时间范围获取用户消费和API调用统计 + */ +export async function getUserStatisticsFromDB(timeRange: TimeRange): Promise { + const timezone = await resolveSystemTimezone(); + const { startTs, endTs, bucketExpr } = getTimeRangeSqlConfig(timeRange, timezone); + + const statsQuery = sql` + SELECT + u.id AS user_id, + u.name AS user_name, + ${bucketExpr} AS bucket, + COUNT(message_request.id) AS api_calls, + COALESCE(SUM(message_request.cost_usd), 0) AS total_cost + FROM users u + LEFT JOIN message_request ON u.id = message_request.user_id + AND message_request.created_at >= ${startTs} + AND message_request.created_at < ${endTs} + AND message_request.deleted_at IS NULL + AND ${EXCLUDE_WARMUP_CONDITION} + WHERE u.deleted_at IS NULL + GROUP BY u.id, u.name, ${bucketExpr} + ORDER BY bucket ASC, u.name ASC + `; + + const [users, buckets, statsResult] = await Promise.all([ + getActiveUsersFromDB(), + getTimeBuckets(timeRange, timezone), + db.execute(statsQuery), + ]); + + const rows = Array.from(statsResult) as UserBucketStatsRow[]; + return zeroFillUserStats(rows, users, buckets) as unknown as DatabaseStatRow[]; } /** @@ -234,187 +318,36 @@ export async function getKeyStatisticsFromDB( timeRange: TimeRange ): Promise { const timezone = await resolveSystemTimezone(); - let query; + const { startTs, endTs, bucketExpr } = getTimeRangeSqlConfig(timeRange, timezone); - switch (timeRange) { - case "today": - query = sql` - WITH hour_range AS ( - SELECT generate_series( - DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())), - DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) + INTERVAL '23 hours', - '1 hour'::interval - ) AS hour - ), - user_keys AS ( - SELECT id, name, key - FROM keys - WHERE user_id = ${userId} - AND deleted_at IS NULL - ), - hourly_stats AS ( - SELECT - k.id AS key_id, - k.name AS key_name, - hr.hour, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM user_keys k - CROSS JOIN hour_range hr - LEFT JOIN message_request mr ON mr.key = k.key - AND mr.user_id = ${userId} - AND mr.created_at >= (DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) AT TIME ZONE ${timezone}) - AND mr.created_at < ((DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) + INTERVAL '1 day') AT TIME ZONE ${timezone}) - AND DATE_TRUNC('hour', mr.created_at AT TIME ZONE ${timezone}) = hr.hour - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY k.id, k.name, hr.hour - ) - SELECT - key_id, - key_name, - hour AS date, - api_calls::integer, - total_cost::numeric - FROM hourly_stats - ORDER BY hour ASC, key_name ASC - `; - break; - - case "7days": - query = sql` - WITH date_range AS ( - SELECT generate_series( - (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date - INTERVAL '6 days', - (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date, - '1 day'::interval - )::date AS date - ), - user_keys AS ( - SELECT id, name, key - FROM keys - WHERE user_id = ${userId} - AND deleted_at IS NULL - ), - daily_stats AS ( - SELECT - k.id AS key_id, - k.name AS key_name, - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM user_keys k - CROSS JOIN date_range dr - LEFT JOIN message_request mr ON mr.key = k.key - AND mr.user_id = ${userId} - AND mr.created_at >= ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '6 days') AT TIME ZONE ${timezone}) - AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) - AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY k.id, k.name, dr.date - ) - SELECT - key_id, - key_name, - date, - api_calls::integer, - total_cost::numeric - FROM daily_stats - ORDER BY date ASC, key_name ASC - `; - break; - - case "30days": - query = sql` - WITH date_range AS ( - SELECT generate_series( - (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date - INTERVAL '29 days', - (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date, - '1 day'::interval - )::date AS date - ), - user_keys AS ( - SELECT id, name, key - FROM keys - WHERE user_id = ${userId} - AND deleted_at IS NULL - ), - daily_stats AS ( - SELECT - k.id AS key_id, - k.name AS key_name, - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM user_keys k - CROSS JOIN date_range dr - LEFT JOIN message_request mr ON mr.key = k.key - AND mr.user_id = ${userId} - AND mr.created_at >= ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '29 days') AT TIME ZONE ${timezone}) - AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) - AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY k.id, k.name, dr.date - ) - SELECT - key_id, - key_name, - date, - api_calls::integer, - total_cost::numeric - FROM daily_stats - ORDER BY date ASC, key_name ASC - `; - break; - - case "thisMonth": - query = sql` - WITH date_range AS ( - SELECT generate_series( - DATE_TRUNC('month', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date, - (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date, - '1 day'::interval - )::date AS date - ), - user_keys AS ( - SELECT id, name, key - FROM keys - WHERE user_id = ${userId} - AND deleted_at IS NULL - ), - daily_stats AS ( - SELECT - k.id AS key_id, - k.name AS key_name, - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM user_keys k - CROSS JOIN date_range dr - LEFT JOIN message_request mr ON mr.key = k.key - AND mr.user_id = ${userId} - AND mr.created_at >= ((DATE_TRUNC('month', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})) AT TIME ZONE ${timezone}) - AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) - AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY k.id, k.name, dr.date - ) - SELECT - key_id, - key_name, - date, - api_calls::integer, - total_cost::numeric - FROM daily_stats - ORDER BY date ASC, key_name ASC - `; - break; + const statsQuery = sql` + SELECT + k.id AS key_id, + k.name AS key_name, + ${bucketExpr} AS bucket, + COUNT(message_request.id) AS api_calls, + COALESCE(SUM(message_request.cost_usd), 0) AS total_cost + FROM keys k + LEFT JOIN message_request ON message_request.key = k.key + AND message_request.user_id = ${userId} + AND message_request.created_at >= ${startTs} + AND message_request.created_at < ${endTs} + AND message_request.deleted_at IS NULL + AND ${EXCLUDE_WARMUP_CONDITION} + WHERE k.user_id = ${userId} + AND k.deleted_at IS NULL + GROUP BY k.id, k.name, ${bucketExpr} + ORDER BY bucket ASC, k.name ASC + `; - default: - throw new Error(`Unsupported time range: ${timeRange}`); - } + const [activeKeys, buckets, statsResult] = await Promise.all([ + getActiveKeysForUserFromDB(userId), + getTimeBuckets(timeRange, timezone), + db.execute(statsQuery), + ]); - const result = await db.execute(query); - return Array.from(result) as unknown as DatabaseKeyStatRow[]; + const rows = Array.from(statsResult) as KeyBucketStatsRow[]; + return zeroFillKeyStats(rows, activeKeys, buckets) as unknown as DatabaseKeyStatRow[]; } /** @@ -445,326 +378,60 @@ export async function getMixedStatisticsFromDB( othersAggregate: DatabaseStatRow[]; }> { const timezone = await resolveSystemTimezone(); - let ownKeysQuery; - let othersQuery; - - switch (timeRange) { - case "today": - // 自己的密钥明细(小时分辨率) - ownKeysQuery = sql` - WITH hour_range AS ( - SELECT generate_series( - DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())), - DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) + INTERVAL '23 hours', - '1 hour'::interval - ) AS hour - ), - user_keys AS ( - SELECT id, name, key - FROM keys - WHERE user_id = ${userId} - AND deleted_at IS NULL - ), - hourly_stats AS ( - SELECT - k.id AS key_id, - k.name AS key_name, - hr.hour, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM user_keys k - CROSS JOIN hour_range hr - LEFT JOIN message_request mr ON mr.key = k.key - AND mr.user_id = ${userId} - AND mr.created_at >= (DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) AT TIME ZONE ${timezone}) - AND mr.created_at < ((DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) + INTERVAL '1 day') AT TIME ZONE ${timezone}) - AND DATE_TRUNC('hour', mr.created_at AT TIME ZONE ${timezone}) = hr.hour - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY k.id, k.name, hr.hour - ) - SELECT - key_id, - key_name, - hour AS date, - api_calls::integer, - total_cost::numeric - FROM hourly_stats - ORDER BY hour ASC, key_name ASC - `; - - // 其他用户汇总(小时分辨率) - othersQuery = sql` - WITH hour_range AS ( - SELECT generate_series( - DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())), - DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) + INTERVAL '23 hours', - '1 hour'::interval - ) AS hour - ), - hourly_stats AS ( - SELECT - hr.hour, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM hour_range hr - LEFT JOIN message_request mr ON DATE_TRUNC('hour', mr.created_at AT TIME ZONE ${timezone}) = hr.hour - AND mr.created_at >= (DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) AT TIME ZONE ${timezone}) - AND mr.created_at < ((DATE_TRUNC('day', TIMEZONE(${timezone}, NOW())) + INTERVAL '1 day') AT TIME ZONE ${timezone}) - AND mr.user_id != ${userId} - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY hr.hour - ) - SELECT - -1 AS user_id, - '其他用户' AS user_name, - hour AS date, - api_calls::integer, - total_cost::numeric - FROM hourly_stats - ORDER BY hour ASC - `; - break; - - case "7days": - // 自己的密钥明细(天分辨率) - ownKeysQuery = sql` - WITH date_range AS ( - SELECT generate_series( - (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date - INTERVAL '6 days', - (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date, - '1 day'::interval - )::date AS date - ), - user_keys AS ( - SELECT id, name, key - FROM keys - WHERE user_id = ${userId} - AND deleted_at IS NULL - ), - daily_stats AS ( - SELECT - k.id AS key_id, - k.name AS key_name, - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM user_keys k - CROSS JOIN date_range dr - LEFT JOIN message_request mr ON mr.key = k.key - AND mr.user_id = ${userId} - AND mr.created_at >= ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '6 days') AT TIME ZONE ${timezone}) - AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) - AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY k.id, k.name, dr.date - ) - SELECT - key_id, - key_name, - date, - api_calls::integer, - total_cost::numeric - FROM daily_stats - ORDER BY date ASC, key_name ASC - `; - - // 其他用户汇总(天分辨率) - othersQuery = sql` - WITH date_range AS ( - SELECT generate_series( - (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date - INTERVAL '6 days', - (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date, - '1 day'::interval - )::date AS date - ), - daily_stats AS ( - SELECT - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM date_range dr - LEFT JOIN message_request mr ON (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.created_at >= ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '6 days') AT TIME ZONE ${timezone}) - AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) - AND mr.user_id != ${userId} - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY dr.date - ) - SELECT - -1 AS user_id, - '其他用户' AS user_name, - date, - api_calls::integer, - total_cost::numeric - FROM daily_stats - ORDER BY date ASC - `; - break; - - case "30days": - // 自己的密钥明细(天分辨率) - ownKeysQuery = sql` - WITH date_range AS ( - SELECT generate_series( - (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date - INTERVAL '29 days', - (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date, - '1 day'::interval - )::date AS date - ), - user_keys AS ( - SELECT id, name, key - FROM keys - WHERE user_id = ${userId} - AND deleted_at IS NULL - ), - daily_stats AS ( - SELECT - k.id AS key_id, - k.name AS key_name, - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM user_keys k - CROSS JOIN date_range dr - LEFT JOIN message_request mr ON mr.key = k.key - AND mr.user_id = ${userId} - AND mr.created_at >= ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '29 days') AT TIME ZONE ${timezone}) - AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) - AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY k.id, k.name, dr.date - ) - SELECT - key_id, - key_name, - date, - api_calls::integer, - total_cost::numeric - FROM daily_stats - ORDER BY date ASC, key_name ASC - `; - - // 其他用户汇总(天分辨率) - othersQuery = sql` - WITH date_range AS ( - SELECT generate_series( - (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date - INTERVAL '29 days', - (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date, - '1 day'::interval - )::date AS date - ), - daily_stats AS ( - SELECT - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM date_range dr - LEFT JOIN message_request mr ON (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.created_at >= ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '29 days') AT TIME ZONE ${timezone}) - AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) - AND mr.user_id != ${userId} - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY dr.date - ) - SELECT - -1 AS user_id, - '其他用户' AS user_name, - date, - api_calls::integer, - total_cost::numeric - FROM daily_stats - ORDER BY date ASC - `; - break; + const { startTs, endTs, bucketExpr } = getTimeRangeSqlConfig(timeRange, timezone); - case "thisMonth": - // 自己的密钥明细(天分辨率,本月) - ownKeysQuery = sql` - WITH date_range AS ( - SELECT generate_series( - DATE_TRUNC('month', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date, - (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date, - '1 day'::interval - )::date AS date - ), - user_keys AS ( - SELECT id, name, key - FROM keys - WHERE user_id = ${userId} - AND deleted_at IS NULL - ), - daily_stats AS ( - SELECT - k.id AS key_id, - k.name AS key_name, - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM user_keys k - CROSS JOIN date_range dr - LEFT JOIN message_request mr ON mr.key = k.key - AND mr.user_id = ${userId} - AND mr.created_at >= ((DATE_TRUNC('month', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})) AT TIME ZONE ${timezone}) - AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) - AND (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY k.id, k.name, dr.date - ) - SELECT - key_id, - key_name, - date, - api_calls::integer, - total_cost::numeric - FROM daily_stats - ORDER BY date ASC, key_name ASC - `; - - // 其他用户汇总(天分辨率,本月) - othersQuery = sql` - WITH date_range AS ( - SELECT generate_series( - DATE_TRUNC('month', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date, - (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date, - '1 day'::interval - )::date AS date - ), - daily_stats AS ( - SELECT - dr.date, - COUNT(mr.id) AS api_calls, - COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM date_range dr - LEFT JOIN message_request mr ON (mr.created_at AT TIME ZONE ${timezone})::date = dr.date - AND mr.created_at >= ((DATE_TRUNC('month', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})) AT TIME ZONE ${timezone}) - AND mr.created_at < ((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone}) - AND mr.user_id != ${userId} - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - GROUP BY dr.date - ) - SELECT - -1 AS user_id, - '其他用户' AS user_name, - date, - api_calls::integer, - total_cost::numeric - FROM daily_stats - ORDER BY date ASC - `; - break; + const ownKeysQuery = sql` + SELECT + k.id AS key_id, + k.name AS key_name, + ${bucketExpr} AS bucket, + COUNT(message_request.id) AS api_calls, + COALESCE(SUM(message_request.cost_usd), 0) AS total_cost + FROM keys k + LEFT JOIN message_request ON message_request.key = k.key + AND message_request.user_id = ${userId} + AND message_request.created_at >= ${startTs} + AND message_request.created_at < ${endTs} + AND message_request.deleted_at IS NULL + AND ${EXCLUDE_WARMUP_CONDITION} + WHERE k.user_id = ${userId} + AND k.deleted_at IS NULL + GROUP BY k.id, k.name, ${bucketExpr} + ORDER BY bucket ASC, k.name ASC + `; - default: - throw new Error(`Unsupported time range: ${timeRange}`); - } + const othersQuery = sql` + SELECT + ${bucketExpr} AS bucket, + COUNT(message_request.id) AS api_calls, + COALESCE(SUM(message_request.cost_usd), 0) AS total_cost + FROM message_request + WHERE message_request.user_id <> ${userId} + AND message_request.created_at >= ${startTs} + AND message_request.created_at < ${endTs} + AND message_request.deleted_at IS NULL + AND ${EXCLUDE_WARMUP_CONDITION} + GROUP BY ${bucketExpr} + ORDER BY bucket ASC + `; - const [ownKeysResult, othersResult] = await Promise.all([ + const [activeKeys, buckets, ownKeysResult, othersResult] = await Promise.all([ + getActiveKeysForUserFromDB(userId), + getTimeBuckets(timeRange, timezone), db.execute(ownKeysQuery), db.execute(othersQuery), ]); return { - ownKeys: Array.from(ownKeysResult) as unknown as DatabaseKeyStatRow[], - othersAggregate: Array.from(othersResult) as unknown as DatabaseStatRow[], + ownKeys: zeroFillKeyStats( + Array.from(ownKeysResult) as KeyBucketStatsRow[], + activeKeys, + buckets + ) as unknown as DatabaseKeyStatRow[], + othersAggregate: zeroFillMixedOthersStats( + Array.from(othersResult) as MixedOthersBucketStatsRow[], + buckets + ) as unknown as DatabaseStatRow[], }; } diff --git a/src/types/dashboard-cache.ts b/src/types/dashboard-cache.ts new file mode 100644 index 000000000..07731af33 --- /dev/null +++ b/src/types/dashboard-cache.ts @@ -0,0 +1,26 @@ +import type { TimeRange } from "@/types/statistics"; + +export type OverviewCacheKey = { + scope: "global" | "user"; + userId?: number; +}; + +export type StatisticsCacheKey = { + timeRange: TimeRange; + mode: "users" | "keys" | "mixed"; + userId?: number; +}; + +export function buildOverviewCacheKey(scope: "global"): string; +export function buildOverviewCacheKey(scope: "user", userId: number): string; +export function buildOverviewCacheKey(scope: "global" | "user", userId?: number): string { + return scope === "global" ? "overview:global" : `overview:user:${userId}`; +} + +export function buildStatisticsCacheKey( + timeRange: TimeRange, + mode: "users" | "keys" | "mixed", + userId?: number +): string { + return `statistics:${timeRange}:${mode}:${userId ?? "global"}`; +} diff --git a/tests/unit/actions/provider-undo-delete.test.ts b/tests/unit/actions/provider-undo-delete.test.ts index 6ab0f21d5..86c19a9c6 100644 --- a/tests/unit/actions/provider-undo-delete.test.ts +++ b/tests/unit/actions/provider-undo-delete.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "../../../src/lib/provider-batch-patch-error-codes"; +import { buildRedisMock, createRedisStore } from "./redis-mock-utils"; const getSessionMock = vi.fn(); const deleteProvidersBatchMock = vi.fn(); @@ -7,6 +8,7 @@ const restoreProvidersBatchMock = vi.fn(); const publishCacheInvalidationMock = vi.fn(); const clearProviderStateMock = vi.fn(); const clearConfigCacheMock = vi.fn(); +const { store: redisStore, mocks: redisMocks } = createRedisStore(); vi.mock("@/lib/auth", () => ({ getSession: getSessionMock, @@ -33,6 +35,8 @@ vi.mock("@/lib/circuit-breaker", () => ({ getAllHealthStatusAsync: vi.fn(), })); +vi.mock("@/lib/redis/client", () => buildRedisMock(redisMocks)); + vi.mock("@/lib/logger", () => ({ logger: { trace: vi.fn(), @@ -47,6 +51,7 @@ describe("Provider Delete Undo Actions", () => { beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); + redisStore.clear(); getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); deleteProvidersBatchMock.mockResolvedValue(2); restoreProvidersBatchMock.mockResolvedValue(2); diff --git a/tests/unit/actions/provider-undo-edit.test.ts b/tests/unit/actions/provider-undo-edit.test.ts index 4a0466346..112aff842 100644 --- a/tests/unit/actions/provider-undo-edit.test.ts +++ b/tests/unit/actions/provider-undo-edit.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "../../../src/lib/provider-batch-patch-error-codes"; +import { buildRedisMock, createRedisStore } from "./redis-mock-utils"; const getSessionMock = vi.fn(); const findProviderByIdMock = vi.fn(); @@ -10,6 +11,7 @@ const clearProviderStateMock = vi.fn(); const clearConfigCacheMock = vi.fn(); const saveProviderCircuitConfigMock = vi.fn(); const deleteProviderCircuitConfigMock = vi.fn(); +const { store: redisStore, mocks: redisMocks } = createRedisStore(); vi.mock("@/lib/auth", () => ({ getSession: getSessionMock, @@ -43,6 +45,8 @@ vi.mock("@/lib/redis/circuit-breaker-config", () => ({ deleteProviderCircuitConfig: deleteProviderCircuitConfigMock, })); +vi.mock("@/lib/redis/client", () => buildRedisMock(redisMocks)); + vi.mock("@/lib/logger", () => ({ logger: { trace: vi.fn(), @@ -118,6 +122,7 @@ describe("Provider Single Edit Undo Actions", () => { beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); + redisStore.clear(); getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); findProviderByIdMock.mockResolvedValue(makeProvider(1, { name: "Before Name", key: "sk-old" })); updateProviderMock.mockResolvedValue(makeProvider(1, { name: "After Name", key: "sk-new" })); diff --git a/tests/unit/actions/providers-apply-engine.test.ts b/tests/unit/actions/providers-apply-engine.test.ts index 559f250c9..6a9fd26e7 100644 --- a/tests/unit/actions/providers-apply-engine.test.ts +++ b/tests/unit/actions/providers-apply-engine.test.ts @@ -5,6 +5,45 @@ const getSessionMock = vi.fn(); const findAllProvidersFreshMock = vi.fn(); const updateProvidersBatchMock = vi.fn(); const publishCacheInvalidationMock = vi.fn(); +const redisStore = new Map(); + +function readRedisValue(key: string): string | null { + const entry = redisStore.get(key); + if (!entry) { + return null; + } + + if (entry.expiresAt <= Date.now()) { + redisStore.delete(key); + return null; + } + + return entry.value; +} + +const redisSetexMock = vi.fn(async (key: string, ttlSeconds: number, value: string) => { + redisStore.set(key, { + value, + expiresAt: Date.now() + ttlSeconds * 1000, + }); + return "OK"; +}); + +const redisGetMock = vi.fn(async (key: string) => readRedisValue(key)); + +const redisDelMock = vi.fn(async (key: string) => { + const existed = redisStore.delete(key); + return existed ? 1 : 0; +}); + +const redisEvalMock = vi.fn(async (_script: string, _numKeys: number, key: string) => { + const value = readRedisValue(key); + if (value === null) { + return null; + } + redisStore.delete(key); + return value; +}); vi.mock("@/lib/auth", () => ({ getSession: getSessionMock, @@ -20,6 +59,16 @@ vi.mock("@/lib/cache/provider-cache", () => ({ publishProviderCacheInvalidation: publishCacheInvalidationMock, })); +vi.mock("@/lib/redis/client", () => ({ + getRedisClient: () => ({ + status: "ready", + setex: redisSetexMock, + get: redisGetMock, + del: redisDelMock, + eval: redisEvalMock, + }), +})); + vi.mock("@/lib/circuit-breaker", () => ({ clearProviderState: vi.fn(), clearConfigCache: vi.fn(), @@ -102,6 +151,11 @@ describe("Apply Provider Batch Patch Engine", () => { beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); + redisStore.clear(); + redisSetexMock.mockClear(); + redisGetMock.mockClear(); + redisDelMock.mockClear(); + redisEvalMock.mockClear(); getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); findAllProvidersFreshMock.mockResolvedValue([]); updateProvidersBatchMock.mockResolvedValue(0); diff --git a/tests/unit/actions/providers-patch-actions-contract.test.ts b/tests/unit/actions/providers-patch-actions-contract.test.ts index a760b3513..a1ae9302c 100644 --- a/tests/unit/actions/providers-patch-actions-contract.test.ts +++ b/tests/unit/actions/providers-patch-actions-contract.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes"; +import { buildRedisMock, createRedisStore } from "./redis-mock-utils"; const getSessionMock = vi.fn(); const findAllProvidersFreshMock = vi.fn(); const updateProvidersBatchMock = vi.fn(); +const { store: redisStore, mocks: redisMocks } = createRedisStore(); vi.mock("@/lib/auth", () => ({ getSession: getSessionMock, @@ -19,6 +21,8 @@ vi.mock("@/lib/cache/provider-cache", () => ({ publishProviderCacheInvalidation: vi.fn(), })); +vi.mock("@/lib/redis/client", () => buildRedisMock(redisMocks)); + vi.mock("@/lib/circuit-breaker", () => ({ clearProviderState: vi.fn(), clearConfigCache: vi.fn(), @@ -100,6 +104,7 @@ describe("Provider Batch Patch Action Contracts", () => { beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); + redisStore.clear(); getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); findAllProvidersFreshMock.mockResolvedValue([]); updateProvidersBatchMock.mockResolvedValue(0); diff --git a/tests/unit/actions/providers-undo-engine.test.ts b/tests/unit/actions/providers-undo-engine.test.ts index b7f094da8..3ea2b5840 100644 --- a/tests/unit/actions/providers-undo-engine.test.ts +++ b/tests/unit/actions/providers-undo-engine.test.ts @@ -1,11 +1,13 @@ // @vitest-environment node import { beforeEach, describe, expect, it, vi } from "vitest"; import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes"; +import { buildRedisMock, createRedisStore } from "./redis-mock-utils"; const getSessionMock = vi.fn(); const findAllProvidersFreshMock = vi.fn(); const updateProvidersBatchMock = vi.fn(); const publishCacheInvalidationMock = vi.fn(); +const { store: redisStore, mocks: redisMocks } = createRedisStore(); vi.mock("@/lib/auth", () => ({ getSession: getSessionMock, @@ -21,6 +23,8 @@ vi.mock("@/lib/cache/provider-cache", () => ({ publishProviderCacheInvalidation: publishCacheInvalidationMock, })); +vi.mock("@/lib/redis/client", () => buildRedisMock(redisMocks)); + vi.mock("@/lib/circuit-breaker", () => ({ clearProviderState: vi.fn(), clearConfigCache: vi.fn(), @@ -103,6 +107,7 @@ describe("Undo Provider Batch Patch Engine", () => { beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); + redisStore.clear(); getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); findAllProvidersFreshMock.mockResolvedValue([]); updateProvidersBatchMock.mockResolvedValue(0); diff --git a/tests/unit/actions/redis-mock-utils.ts b/tests/unit/actions/redis-mock-utils.ts new file mode 100644 index 000000000..fbf44827c --- /dev/null +++ b/tests/unit/actions/redis-mock-utils.ts @@ -0,0 +1,48 @@ +import { vi } from "vitest"; + +export function createRedisStore() { + const store = new Map(); + + function readValue(key: string): string | null { + const entry = store.get(key); + if (!entry) return null; + if (entry.expiresAt <= Date.now()) { + store.delete(key); + return null; + } + return entry.value; + } + + const setex = vi.fn(async (key: string, ttlSeconds: number, value: string) => { + store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 }); + return "OK"; + }); + + const get = vi.fn(async (key: string) => readValue(key)); + + const del = vi.fn(async (key: string) => { + const existed = store.delete(key); + return existed ? 1 : 0; + }); + + const evalScript = vi.fn(async (_script: string, _numKeys: number, key: string) => { + const value = readValue(key); + if (value === null) return null; + store.delete(key); + return value; + }); + + return { store, mocks: { setex, get, del, eval: evalScript } }; +} + +export function buildRedisMock(mocks: ReturnType["mocks"]) { + return { + getRedisClient: () => ({ + status: "ready", + setex: mocks.setex, + get: mocks.get, + del: mocks.del, + eval: mocks.eval, + }), + }; +} diff --git a/tests/unit/dashboard/dashboard-cache-keys.test.ts b/tests/unit/dashboard/dashboard-cache-keys.test.ts new file mode 100644 index 000000000..24cc38f1a --- /dev/null +++ b/tests/unit/dashboard/dashboard-cache-keys.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { buildOverviewCacheKey, buildStatisticsCacheKey } from "@/types/dashboard-cache"; +import type { TimeRange } from "@/types/statistics"; + +describe("buildOverviewCacheKey", () => { + it("returns 'overview:global' for global scope", () => { + expect(buildOverviewCacheKey("global")).toBe("overview:global"); + }); + + it("returns 'overview:user:42' for user scope with userId=42", () => { + expect(buildOverviewCacheKey("user", 42)).toBe("overview:user:42"); + }); +}); + +describe("buildStatisticsCacheKey", () => { + it("returns correct key for today/users/global", () => { + expect(buildStatisticsCacheKey("today", "users")).toBe("statistics:today:users:global"); + }); + + it("returns correct key with userId", () => { + expect(buildStatisticsCacheKey("7days", "keys", 42)).toBe("statistics:7days:keys:42"); + }); + + it("handles all TimeRange values", () => { + const timeRanges: TimeRange[] = ["today", "7days", "30days", "thisMonth"]; + const keys = timeRanges.map((timeRange) => buildStatisticsCacheKey(timeRange, "users")); + + expect(keys).toEqual([ + "statistics:today:users:global", + "statistics:7days:users:global", + "statistics:30days:users:global", + "statistics:thisMonth:users:global", + ]); + expect(new Set(keys).size).toBe(timeRanges.length); + }); +}); diff --git a/tests/unit/redis/overview-cache.test.ts b/tests/unit/redis/overview-cache.test.ts new file mode 100644 index 000000000..4ac114b86 --- /dev/null +++ b/tests/unit/redis/overview-cache.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getRedisClient } from "@/lib/redis/client"; +import { getOverviewWithCache, invalidateOverviewCache } from "@/lib/redis/overview-cache"; +import { + getOverviewMetricsWithComparison, + type OverviewMetricsWithComparison, +} from "@/repository/overview"; + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/lib/redis/client", () => ({ + getRedisClient: vi.fn(), +})); + +vi.mock("@/repository/overview", () => ({ + getOverviewMetricsWithComparison: vi.fn(), +})); + +type RedisMock = { + get: ReturnType; + set: ReturnType; + setex: ReturnType; + del: ReturnType; +}; + +function createRedisMock(): RedisMock { + return { + get: vi.fn(), + set: vi.fn(), + setex: vi.fn(), + del: vi.fn(), + }; +} + +function createOverviewData(): OverviewMetricsWithComparison { + return { + todayRequests: 100, + todayCost: 12.34, + avgResponseTime: 210, + todayErrorRate: 1.25, + yesterdaySamePeriodRequests: 80, + yesterdaySamePeriodCost: 10.1, + yesterdaySamePeriodAvgResponseTime: 230, + recentMinuteRequests: 3, + }; +} + +describe("getOverviewWithCache", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns cached data on cache hit (no DB call)", async () => { + const data = createOverviewData(); + const redis = createRedisMock(); + redis.get.mockResolvedValueOnce(JSON.stringify(data)); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + + const result = await getOverviewWithCache(); + + expect(result).toEqual(data); + expect(redis.get).toHaveBeenCalledWith("overview:global"); + expect(getOverviewMetricsWithComparison).not.toHaveBeenCalled(); + }); + + it("calls DB on cache miss, stores in Redis with 10s TTL", async () => { + const data = createOverviewData(); + const redis = createRedisMock(); + redis.get.mockResolvedValueOnce(null); + redis.set.mockResolvedValueOnce("OK"); + redis.setex.mockResolvedValueOnce("OK"); + redis.del.mockResolvedValueOnce(1); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + vi.mocked(getOverviewMetricsWithComparison).mockResolvedValueOnce(data); + + const result = await getOverviewWithCache(42); + + expect(result).toEqual(data); + expect(getOverviewMetricsWithComparison).toHaveBeenCalledWith(42); + expect(redis.set).toHaveBeenCalledWith("overview:user:42:lock", "1", "EX", 5, "NX"); + expect(redis.setex).toHaveBeenCalledWith("overview:user:42", 10, JSON.stringify(data)); + expect(redis.del).toHaveBeenCalledWith("overview:user:42:lock"); + }); + + it("falls back to direct DB query when Redis is unavailable (null client)", async () => { + const data = createOverviewData(); + vi.mocked(getRedisClient).mockReturnValue(null); + vi.mocked(getOverviewMetricsWithComparison).mockResolvedValueOnce(data); + + const result = await getOverviewWithCache(7); + + expect(result).toEqual(data); + expect(getOverviewMetricsWithComparison).toHaveBeenCalledWith(7); + }); + + it("falls back to direct DB query on Redis error", async () => { + const data = createOverviewData(); + const redis = createRedisMock(); + redis.get.mockRejectedValueOnce(new Error("redis read failed")); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + vi.mocked(getOverviewMetricsWithComparison).mockResolvedValueOnce(data); + + const result = await getOverviewWithCache(); + + expect(result).toEqual(data); + expect(getOverviewMetricsWithComparison).toHaveBeenCalledWith(undefined); + }); + + it("falls back to direct DB query when lock is held and retry is still empty", async () => { + vi.useFakeTimers(); + try { + const data = createOverviewData(); + const redis = createRedisMock(); + redis.get.mockResolvedValueOnce(null).mockResolvedValueOnce(null); + redis.set.mockResolvedValueOnce(null); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + vi.mocked(getOverviewMetricsWithComparison).mockResolvedValueOnce(data); + + const pending = getOverviewWithCache(99); + await vi.advanceTimersByTimeAsync(100); + const result = await pending; + + expect(result).toEqual(data); + expect(redis.set).toHaveBeenCalledWith("overview:user:99:lock", "1", "EX", 5, "NX"); + expect(redis.get).toHaveBeenNthCalledWith(1, "overview:user:99"); + expect(redis.get).toHaveBeenNthCalledWith(2, "overview:user:99"); + expect(getOverviewMetricsWithComparison).toHaveBeenCalledWith(99); + } finally { + vi.useRealTimers(); + } + }); + + it("uses different cache keys for global vs user scope", async () => { + const redis = createRedisMock(); + const data = createOverviewData(); + + redis.get.mockResolvedValue(null); + redis.set.mockResolvedValue("OK"); + redis.setex.mockResolvedValue("OK"); + redis.del.mockResolvedValue(1); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + vi.mocked(getOverviewMetricsWithComparison).mockResolvedValue(data); + + await getOverviewWithCache(); + await getOverviewWithCache(42); + + expect(redis.get).toHaveBeenNthCalledWith(1, "overview:global"); + expect(redis.get).toHaveBeenNthCalledWith(2, "overview:user:42"); + expect(redis.setex).toHaveBeenNthCalledWith(1, "overview:global", 10, JSON.stringify(data)); + expect(redis.setex).toHaveBeenNthCalledWith(2, "overview:user:42", 10, JSON.stringify(data)); + }); +}); + +describe("invalidateOverviewCache", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("deletes the correct cache key", async () => { + const redis = createRedisMock(); + redis.del.mockResolvedValueOnce(1); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + + await invalidateOverviewCache(42); + + expect(redis.del).toHaveBeenCalledWith("overview:user:42"); + }); + + it("does nothing when Redis is unavailable", async () => { + vi.mocked(getRedisClient).mockReturnValue(null); + + await expect(invalidateOverviewCache(42)).resolves.toBeUndefined(); + }); + + it("swallows Redis errors during invalidation", async () => { + const redis = createRedisMock(); + redis.del.mockRejectedValueOnce(new Error("delete failed")); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + + await expect(invalidateOverviewCache()).resolves.toBeUndefined(); + }); +}); diff --git a/tests/unit/redis/statistics-cache.test.ts b/tests/unit/redis/statistics-cache.test.ts new file mode 100644 index 000000000..be4ff45a2 --- /dev/null +++ b/tests/unit/redis/statistics-cache.test.ts @@ -0,0 +1,369 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getRedisClient } from "@/lib/redis/client"; +import { getStatisticsWithCache, invalidateStatisticsCache } from "@/lib/redis/statistics-cache"; +import { + getKeyStatisticsFromDB, + getMixedStatisticsFromDB, + getUserStatisticsFromDB, +} from "@/repository/statistics"; +import type { DatabaseKeyStatRow, DatabaseStatRow } from "@/types/statistics"; + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/lib/redis/client", () => ({ + getRedisClient: vi.fn(), +})); + +vi.mock("@/repository/statistics", () => ({ + getUserStatisticsFromDB: vi.fn(), + getKeyStatisticsFromDB: vi.fn(), + getMixedStatisticsFromDB: vi.fn(), +})); + +type RedisMock = { + get: ReturnType; + set: ReturnType; + setex: ReturnType; + del: ReturnType; + scan: ReturnType; +}; + +function createRedisMock(): RedisMock { + return { + get: vi.fn(), + set: vi.fn(), + setex: vi.fn(), + del: vi.fn(), + scan: vi.fn(), + }; +} + +function createUserStats(): DatabaseStatRow[] { + return [ + { + user_id: 1, + user_name: "alice", + date: "2026-02-19", + api_calls: 10, + total_cost: "1.23", + }, + ]; +} + +function createKeyStats(): DatabaseKeyStatRow[] { + return [ + { + key_id: 100, + key_name: "test-key", + date: "2026-02-19", + api_calls: 6, + total_cost: "0.56", + }, + ]; +} + +describe("getStatisticsWithCache", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns cached data on cache hit", async () => { + const redis = createRedisMock(); + const cached = createUserStats(); + redis.get.mockResolvedValueOnce(JSON.stringify(cached)); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + + const result = await getStatisticsWithCache("today", "users"); + + expect(result).toEqual(cached); + expect(redis.get).toHaveBeenCalledWith("statistics:today:users:global"); + expect(getUserStatisticsFromDB).not.toHaveBeenCalled(); + expect(getKeyStatisticsFromDB).not.toHaveBeenCalled(); + expect(getMixedStatisticsFromDB).not.toHaveBeenCalled(); + }); + + it("calls getUserStatisticsFromDB for mode=users on cache miss", async () => { + const redis = createRedisMock(); + const rows = createUserStats(); + redis.get.mockResolvedValueOnce(null); + redis.set.mockResolvedValueOnce("OK"); + redis.setex.mockResolvedValueOnce("OK"); + redis.del.mockResolvedValueOnce(1); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + vi.mocked(getUserStatisticsFromDB).mockResolvedValueOnce(rows); + + const result = await getStatisticsWithCache("today", "users"); + + expect(result).toEqual(rows); + expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today"); + expect(getKeyStatisticsFromDB).not.toHaveBeenCalled(); + expect(getMixedStatisticsFromDB).not.toHaveBeenCalled(); + }); + + it("calls getKeyStatisticsFromDB for mode=keys on cache miss", async () => { + const redis = createRedisMock(); + const rows = createKeyStats(); + redis.get.mockResolvedValueOnce(null); + redis.set.mockResolvedValueOnce("OK"); + redis.setex.mockResolvedValueOnce("OK"); + redis.del.mockResolvedValueOnce(1); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + vi.mocked(getKeyStatisticsFromDB).mockResolvedValueOnce(rows); + + const result = await getStatisticsWithCache("7days", "keys", 42); + + expect(result).toEqual(rows); + expect(getKeyStatisticsFromDB).toHaveBeenCalledWith(42, "7days"); + expect(getUserStatisticsFromDB).not.toHaveBeenCalled(); + expect(getMixedStatisticsFromDB).not.toHaveBeenCalled(); + }); + + it("calls getMixedStatisticsFromDB for mode=mixed on cache miss", async () => { + const redis = createRedisMock(); + const mixedResult = { + ownKeys: createKeyStats(), + othersAggregate: createUserStats(), + }; + redis.get.mockResolvedValueOnce(null); + redis.set.mockResolvedValueOnce("OK"); + redis.setex.mockResolvedValueOnce("OK"); + redis.del.mockResolvedValueOnce(1); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + vi.mocked(getMixedStatisticsFromDB).mockResolvedValueOnce(mixedResult); + + const result = await getStatisticsWithCache("30days", "mixed", 42); + + expect(result).toEqual(mixedResult); + expect(getMixedStatisticsFromDB).toHaveBeenCalledWith(42, "30days"); + expect(getUserStatisticsFromDB).not.toHaveBeenCalled(); + expect(getKeyStatisticsFromDB).not.toHaveBeenCalled(); + }); + + it("stores result with 30s TTL", async () => { + const redis = createRedisMock(); + const rows = createUserStats(); + redis.get.mockResolvedValueOnce(null); + redis.set.mockResolvedValueOnce("OK"); + redis.setex.mockResolvedValueOnce("OK"); + redis.del.mockResolvedValueOnce(1); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + vi.mocked(getUserStatisticsFromDB).mockResolvedValueOnce(rows); + + await getStatisticsWithCache("today", "users"); + + expect(redis.setex).toHaveBeenCalledWith( + "statistics:today:users:global", + 30, + JSON.stringify(rows) + ); + }); + + it("falls back to direct DB on Redis unavailable", async () => { + const rows = createUserStats(); + vi.mocked(getRedisClient).mockReturnValue(null); + vi.mocked(getUserStatisticsFromDB).mockResolvedValueOnce(rows); + + const result = await getStatisticsWithCache("today", "users"); + + expect(result).toEqual(rows); + expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today"); + }); + + it("uses retry path and returns cached data when lock is held", async () => { + vi.useFakeTimers(); + try { + const redis = createRedisMock(); + const rows = createUserStats(); + redis.get.mockResolvedValueOnce(null).mockResolvedValueOnce(JSON.stringify(rows)); + redis.set.mockResolvedValueOnce(null); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + + const pending = getStatisticsWithCache("today", "users"); + await vi.advanceTimersByTimeAsync(100); + const result = await pending; + + expect(result).toEqual(rows); + expect(redis.set).toHaveBeenCalledWith( + "statistics:today:users:global:lock", + "1", + "EX", + 5, + "NX" + ); + expect(getUserStatisticsFromDB).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("falls back to direct DB when retry times out", async () => { + vi.useFakeTimers(); + try { + const redis = createRedisMock(); + const rows = createUserStats(); + redis.get.mockResolvedValue(null); + redis.set.mockResolvedValueOnce(null); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + vi.mocked(getUserStatisticsFromDB).mockResolvedValueOnce(rows); + + const pending = getStatisticsWithCache("today", "users"); + await vi.advanceTimersByTimeAsync(5100); + const result = await pending; + + expect(result).toEqual(rows); + expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today"); + } finally { + vi.useRealTimers(); + } + }); + + it("falls back to direct DB on Redis error", async () => { + const redis = createRedisMock(); + const rows = createUserStats(); + redis.get.mockRejectedValueOnce(new Error("redis get failed")); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + vi.mocked(getUserStatisticsFromDB).mockResolvedValueOnce(rows); + + const result = await getStatisticsWithCache("today", "users"); + + expect(result).toEqual(rows); + expect(getUserStatisticsFromDB).toHaveBeenCalledWith("today"); + }); + + it("uses different cache keys for different timeRanges", async () => { + const redis = createRedisMock(); + const rows = createUserStats(); + redis.get.mockResolvedValue(JSON.stringify(rows)); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + + await getStatisticsWithCache("today", "users"); + await getStatisticsWithCache("7days", "users"); + + expect(redis.get).toHaveBeenNthCalledWith(1, "statistics:today:users:global"); + expect(redis.get).toHaveBeenNthCalledWith(2, "statistics:7days:users:global"); + }); + + it("uses different cache keys for global vs user scope", async () => { + const redis = createRedisMock(); + const rows = createUserStats(); + redis.get.mockResolvedValue(JSON.stringify(rows)); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + + await getStatisticsWithCache("today", "users"); + await getStatisticsWithCache("today", "users", 42); + + expect(redis.get).toHaveBeenNthCalledWith(1, "statistics:today:users:global"); + expect(redis.get).toHaveBeenNthCalledWith(2, "statistics:today:users:42"); + }); +}); + +describe("invalidateStatisticsCache", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("deletes all mode keys for a given timeRange", async () => { + const redis = createRedisMock(); + redis.del.mockResolvedValueOnce(3); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + + await invalidateStatisticsCache("today", 42); + + expect(redis.del).toHaveBeenCalledWith( + "statistics:today:users:42", + "statistics:today:keys:42", + "statistics:today:mixed:42" + ); + }); + + it("deletes all keys for scope when timeRange is undefined", async () => { + const redis = createRedisMock(); + const matchedKeys = [ + "statistics:today:users:global", + "statistics:7days:keys:global", + "statistics:30days:mixed:global", + ]; + redis.scan.mockResolvedValueOnce(["0", matchedKeys]); + redis.del.mockResolvedValueOnce(matchedKeys.length); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + + await invalidateStatisticsCache(undefined, undefined); + + expect(redis.scan).toHaveBeenCalledWith("0", "MATCH", "statistics:*:*:global", "COUNT", 100); + expect(redis.del).toHaveBeenCalledWith(...matchedKeys); + }); + + it("does nothing when Redis is unavailable", async () => { + vi.mocked(getRedisClient).mockReturnValue(null); + + await expect(invalidateStatisticsCache("today", 42)).resolves.toBeUndefined(); + }); + + it("does not call del when wildcard query returns no key", async () => { + const redis = createRedisMock(); + redis.scan.mockResolvedValueOnce(["0", []]); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + + await invalidateStatisticsCache(undefined, 42); + + expect(redis.scan).toHaveBeenCalledWith("0", "MATCH", "statistics:*:*:42", "COUNT", 100); + expect(redis.del).not.toHaveBeenCalled(); + }); + + it("swallows Redis errors during invalidation", async () => { + const redis = createRedisMock(); + redis.del.mockRejectedValueOnce(new Error("delete failed")); + + vi.mocked(getRedisClient).mockReturnValue( + redis as unknown as NonNullable> + ); + + await expect(invalidateStatisticsCache("today", 42)).resolves.toBeUndefined(); + }); +}); From 477342509d7491465a69d11d6e3366d4851d7552 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 19 Feb 2026 19:34:06 +0800 Subject: [PATCH 11/75] fix(drizzle): add IF NOT EXISTS to index creation statements --- drizzle/0071_purple_captain_midlands.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/drizzle/0071_purple_captain_midlands.sql b/drizzle/0071_purple_captain_midlands.sql index be3c1b765..cb178abba 100644 --- a/drizzle/0071_purple_captain_midlands.sql +++ b/drizzle/0071_purple_captain_midlands.sql @@ -1,2 +1,2 @@ -CREATE INDEX "idx_message_request_user_created_at_cost_stats" ON "message_request" USING btree ("user_id","created_at","cost_usd") WHERE "message_request"."deleted_at" IS NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> 'warmup');--> statement-breakpoint -CREATE INDEX "idx_message_request_provider_created_at_active" ON "message_request" USING btree ("provider_id","created_at") WHERE "message_request"."deleted_at" IS NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> 'warmup'); \ No newline at end of file +CREATE INDEX IF NOT EXISTS "idx_message_request_user_created_at_cost_stats" ON "message_request" USING btree ("user_id","created_at","cost_usd") WHERE "message_request"."deleted_at" IS NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> 'warmup');--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_message_request_provider_created_at_active" ON "message_request" USING btree ("provider_id","created_at") WHERE "message_request"."deleted_at" IS NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> 'warmup'); From e2c5b5af77291e2b17e9ec52019a4d65050b3202 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 19 Feb 2026 20:07:46 +0800 Subject: [PATCH 12/75] refactor(security): replace timingSafeEqual with XOR loop for edge runtime compat --- src/lib/security/constant-time-compare.ts | 35 +++++++++++++++-------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/lib/security/constant-time-compare.ts b/src/lib/security/constant-time-compare.ts index d35b9aa61..7452358ea 100644 --- a/src/lib/security/constant-time-compare.ts +++ b/src/lib/security/constant-time-compare.ts @@ -1,27 +1,38 @@ -import { timingSafeEqual } from "node:crypto"; +const encoder = new TextEncoder(); /** * Constant-time string comparison to prevent timing attacks. * - * Uses crypto.timingSafeEqual internally. When lengths differ, a dummy + * Uses bitwise XOR accumulation instead of node:crypto.timingSafeEqual + * to remain compatible with Edge Runtime. When lengths differ, a dummy * comparison is still performed so the total CPU time does not leak * length information. */ export function constantTimeEqual(a: string, b: string): boolean { - const bufA = Buffer.from(a, "utf-8"); - const bufB = Buffer.from(b, "utf-8"); + const bufA = encoder.encode(a); + const bufB = encoder.encode(b); - if (bufA.length !== bufB.length) { + const lenA = bufA.byteLength; + const lenB = bufB.byteLength; + + if (lenA !== lenB) { // Pad both to the same length so the dummy comparison time does not // leak which side is shorter (attacker may control either one). - const padLen = Math.max(bufA.length, bufB.length); - const padA = Buffer.alloc(padLen); - const padB = Buffer.alloc(padLen); - bufA.copy(padA); - bufB.copy(padB); - timingSafeEqual(padA, padB); + const padLen = Math.max(lenA, lenB); + const padA = new Uint8Array(padLen); + const padB = new Uint8Array(padLen); + padA.set(bufA); + padB.set(bufB); + let dummy = 0; + for (let i = 0; i < padLen; i++) { + dummy |= padA[i] ^ padB[i]; + } return false; } - return timingSafeEqual(bufA, bufB); + let result = 0; + for (let i = 0; i < lenA; i++) { + result |= bufA[i] ^ bufB[i]; + } + return result === 0; } From 0ff04094fffe9dc318932971861c923121956340 Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 19 Feb 2026 20:07:46 +0800 Subject: [PATCH 13/75] fix(statistics): use bucket alias instead of expression in GROUP BY clauses --- src/repository/statistics.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/repository/statistics.ts b/src/repository/statistics.ts index adc6f6b05..da7d82ff3 100644 --- a/src/repository/statistics.ts +++ b/src/repository/statistics.ts @@ -281,7 +281,7 @@ export async function getUserStatisticsFromDB(timeRange: TimeRange): Promise Date: Thu, 19 Feb 2026 20:07:46 +0800 Subject: [PATCH 14/75] fix(dashboard): throw on statistics fetch failure and constrain chart height --- .../dashboard/_components/bento/dashboard-bento.tsx | 7 ++++--- .../dashboard/_components/bento/statistics-chart-card.tsx | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/app/[locale]/dashboard/_components/bento/dashboard-bento.tsx b/src/app/[locale]/dashboard/_components/bento/dashboard-bento.tsx index aefb1772e..ecaa13aeb 100644 --- a/src/app/[locale]/dashboard/_components/bento/dashboard-bento.tsx +++ b/src/app/[locale]/dashboard/_components/bento/dashboard-bento.tsx @@ -60,9 +60,9 @@ async function fetchActiveSessions(): Promise { return result.data; } -async function fetchStatistics(timeRange: TimeRange): Promise { +async function fetchStatistics(timeRange: TimeRange): Promise { const result = await getUserStatistics(timeRange); - if (!result.ok) return null; + if (!result.ok) throw new Error(result.error || "Failed to fetch statistics"); return result.data; } @@ -140,12 +140,13 @@ export function DashboardBento({ }); // Statistics - const { data: statistics } = useQuery({ + const { data: statistics } = useQuery({ queryKey: ["statistics", timeRange], queryFn: () => fetchStatistics(timeRange), initialData: timeRange === DEFAULT_TIME_RANGE ? initialStatistics : undefined, staleTime: 30_000, placeholderData: keepPreviousData, + retry: 3, }); // Leaderboards diff --git a/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx b/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx index 5c0bd11c8..7425d6d1c 100644 --- a/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx +++ b/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx @@ -168,7 +168,7 @@ export function StatisticsChartCard({ }; return ( - + {/* Header */}
From ab92a2dd8c9fb9ffabe6db35b3e19e49f5a1ff5b Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 19 Feb 2026 20:10:22 +0800 Subject: [PATCH 15/75] fix(dashboard): align leaderboard card heights by removing row-span-2 LiveSessionsPanel hardcoded rowSpan={2} which created an implicit second grid row, making it 24px taller than adjacent leaderboard cards. --- .../[locale]/dashboard/_components/bento/live-sessions-panel.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/[locale]/dashboard/_components/bento/live-sessions-panel.tsx b/src/app/[locale]/dashboard/_components/bento/live-sessions-panel.tsx index d4cc6d469..72c851b75 100644 --- a/src/app/[locale]/dashboard/_components/bento/live-sessions-panel.tsx +++ b/src/app/[locale]/dashboard/_components/bento/live-sessions-panel.tsx @@ -165,7 +165,6 @@ export function LiveSessionsPanel({ Date: Thu, 19 Feb 2026 22:05:03 +0800 Subject: [PATCH 16/75] feat(logs): add session reuse origin decision chain (#736) (#810) * feat(i18n): add session origin chain translation keys Add originDecisionTitle/Desc/Loading/Unavailable/Expand keys to logs.details.logicTrace in all 5 language dashboard.json files. Add originHint key to summary in all 5 language provider-chain.json files. * fix(test): fix sqlToString helper to handle drizzle column references * feat(repo): add findSessionOriginChain repository function * feat(ui): add origin context hint to ProviderChainPopover * feat(actions): add getSessionOriginChain server action * feat(ui): add collapsible origin chain to LogicTraceTab * test: add integration tests for session origin chain feature * fix(ui): replace hardcoded labels with i18n keys in origin chain collapsible * chore: format code (session-reuse-origin-chain-f1a2b5d) * fix(pr810): address coderabbit review comments - fix semantic mismatch: use providersCount key for enabledProviders display - fix probability formatting: use formatProbability() instead of Math.round - fix i18n: translate selectionMethod enum via selectionMethods namespace - add selectionMethods translations to all 5 language files - add JSDoc to findSessionOriginChain repository function - fix test: null providerChain mock now returns row with null providerChain - fix test: add assertion before trigger click in error-details-dialog test - add 2 missing test cases: non-admin unauthorized + exception path * chore: format code (session-reuse-origin-chain-152b428) --------- Co-authored-by: github-actions[bot] --- messages/en/dashboard.json | 23 +- messages/en/provider-chain.json | 9 +- messages/ja/dashboard.json | 140 ++++--------- messages/ja/provider-chain.json | 9 +- messages/ru/dashboard.json | 43 ++-- messages/ru/provider-chain.json | 9 +- messages/zh-CN/dashboard.json | 69 +++--- messages/zh-CN/provider-chain.json | 9 +- messages/zh-TW/dashboard.json | 29 ++- messages/zh-TW/provider-chain.json | 9 +- src/actions/session-origin-chain.ts | 57 +++++ .../_components/error-details-dialog.test.tsx | 150 +++++++++++++- .../components/LogicTraceTab.tsx | 110 ++++++++++ .../provider-chain-popover.test.tsx | 53 ++++- .../_components/provider-chain-popover.tsx | 7 + src/repository/message.ts | 29 ++- .../session-origin-chain-integration.test.ts | 87 ++++++++ .../unit/actions/session-origin-chain.test.ts | 131 ++++++++++++ .../repository/message-origin-chain.test.ts | 196 ++++++++++++++++++ 19 files changed, 981 insertions(+), 188 deletions(-) create mode 100644 src/actions/session-origin-chain.ts create mode 100644 tests/unit/actions/session-origin-chain-integration.test.ts create mode 100644 tests/unit/actions/session-origin-chain.test.ts create mode 100644 tests/unit/repository/message-origin-chain.test.ts diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index d95f954b6..1b21eca95 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -340,7 +340,12 @@ "sessionAge": "Session Age", "reusedProvider": "Reused Provider", "executeRequest": "Execute Request", - "cacheOptimizationHint": "Session reuse optimizes performance by maintaining provider affinity within the same conversation, reducing selection overhead and improving cache hit rates." + "cacheOptimizationHint": "Session reuse optimizes performance by maintaining provider affinity within the same conversation, reducing selection overhead and improving cache hit rates.", + "originDecisionTitle": "Original Selection Decision", + "originDecisionDesc": "How this provider was initially chosen for this session", + "originDecisionLoading": "Loading original decision...", + "originDecisionUnavailable": "Original decision record unavailable", + "originDecisionExpand": "View original selection" } }, "providerChain": { @@ -1870,14 +1875,14 @@ "descriptionEnabled": "When enabled, this key will access an independent personal usage page upon login. However, it cannot modify its own key's provider group.", "descriptionDisabled": "When disabled, the user cannot access the personal usage page UI. Instead, they will use the restricted Web UI." }, - "providerGroup": { - "label": "Provider Group", - "placeholder": "Default: default", - "selectHint": "Select the provider group(s) this key can use (default: default).", - "editHint": "Provider group cannot be changed for existing keys.", - "allGroups": "Use all groups", - "noGroupHint": "default includes providers without groupTag." - }, + "providerGroup": { + "label": "Provider Group", + "placeholder": "Default: default", + "selectHint": "Select the provider group(s) this key can use (default: default).", + "editHint": "Provider group cannot be changed for existing keys.", + "allGroups": "Use all groups", + "noGroupHint": "default includes providers without groupTag." + }, "cacheTtl": { "label": "Cache TTL Override", "description": "Force Anthropic prompt cache TTL for requests containing cache_control.", diff --git a/messages/en/provider-chain.json b/messages/en/provider-chain.json index bf9cb81a7..ba9d4c47f 100644 --- a/messages/en/provider-chain.json +++ b/messages/en/provider-chain.json @@ -19,7 +19,8 @@ }, "summary": { "singleSuccess": "{total} providers, {healthy} healthy → {provider} ✓", - "sessionReuse": "Session reuse → {provider} ✓" + "sessionReuse": "Session reuse → {provider} ✓", + "originHint": "Session reuse - originally selected via {method}" }, "description": { "noDecisionRecord": "No decision record", @@ -213,5 +214,11 @@ "strictBlockSelectorError": "Strict mode: endpoint selector encountered an error, provider skipped without fallback", "vendorTypeAllTimeout": "Vendor-Type All Endpoints Timeout (524)", "vendorTypeAllTimeoutNote": "All endpoints for this vendor-type timed out. Vendor-type circuit breaker triggered." + }, + "selectionMethods": { + "session_reuse": "Session Reuse", + "weighted_random": "Weighted Random", + "group_filtered": "Group Filtered", + "fail_open_fallback": "Fail-Open Fallback" } } diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index ff007d562..795ecc363 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -340,7 +340,12 @@ "sessionAge": "セッション経過時間", "reusedProvider": "再利用プロバイダー", "executeRequest": "リクエスト実行", - "cacheOptimizationHint": "セッション再利用は、同じ会話内でプロバイダーの親和性を維持することでパフォーマンスを最適化し、選択オーバーヘッドを削減してキャッシュヒット率を向上させます。" + "cacheOptimizationHint": "セッション再利用は、同じ会話内でプロバイダーの親和性を維持することでパフォーマンスを最適化し、選択オーバーヘッドを削減してキャッシュヒット率を向上させます。", + "originDecisionTitle": "元の選択決定", + "originDecisionDesc": "このセッションでプロバイダーが最初に選択された理由", + "originDecisionLoading": "元の決定を読み込み中...", + "originDecisionUnavailable": "元の決定記録は利用できません", + "originDecisionExpand": "元の選択を表示" } }, "providerChain": { @@ -1051,45 +1056,56 @@ "load": "負荷" }, "timeRange": { + "label": "時間範囲", "15min": "15分", "1h": "1時間", "6h": "6時間", "24h": "24時間", - "7d": "7日" + "7d": "7日間", + "last15min": "過去15分", + "last1h": "過去1時間", + "last6h": "過去6時間", + "last24h": "過去24時間", + "last7d": "過去7日間", + "custom": "カスタム" }, "laneChart": { "title": "プロバイダー可用性タイムライン", - "noData": "データがありません", + "noData": "データなし", "requests": "{count} リクエスト", - "availability": "{value}% 可用", - "noRequests": "リクエストなし" + "availability": "可用性 {value}%", + "noRequests": "リクエストなし", + "denseData": "高密度", + "sparseData": "低密度", + "latency": "レイテンシ" }, "latencyChart": { - "title": "遅延分布", + "title": "レイテンシ分布", "p50": "P50", "p95": "P95", "p99": "P99", - "noData": "遅延データがありません" + "noData": "レイテンシデータなし" }, "latencyCurve": { - "title": "遅延トレンド", - "noData": "遅延データがありません", + "title": "レイテンシトレンド", + "noData": "レイテンシデータなし", "avg": "平均", "min": "最小", "max": "最大", - "latency": "遅延" + "latency": "レイテンシ" }, "terminal": { "title": "プローブログ", - "live": "LIVE", + "live": "ライブ", "download": "ログをダウンロード", - "noLogs": "プローブログがありません", + "noLogs": "プローブログなし", "manual": "手動", "auto": "自動", "filterPlaceholder": "ログをフィルター..." }, "probeGrid": { - "noEndpoints": "エンドポイントが設定されていません", + "title": "エンドポイントステータス", + "noEndpoints": "エンドポイント未設定", "lastProbe": "最終プローブ", "status": { "unknown": "不明", @@ -1105,13 +1121,21 @@ "low": "低", "medium": "中", "high": "高", - "lowTooltip": "{count} 件未満のリクエスト。データが代表的でない可能性があります。", - "mediumTooltip": "中程度のリクエスト量。データは比較的信頼できます。", - "highTooltip": "高いリクエスト量。データは信頼できます。" + "lowTooltip": "リクエスト数が {count} 未満です。データが代表的でない可能性があります。", + "mediumTooltip": "リクエスト量は適度です。データは比較的信頼できます。", + "highTooltip": "リクエスト量が十分です。データは信頼できます。" }, "actions": { + "refresh": "更新", + "refreshing": "更新中...", + "autoRefresh": "自動更新", + "stopAutoRefresh": "自動更新を停止", + "viewDetails": "詳細を表示", + "testProvider": "プロバイダーをテスト", + "retry": "再試行", "probeNow": "今すぐプローブ", "probing": "プローブ中...", + "probeAll": "すべてプローブ", "probeSuccess": "プローブ成功", "probeFailed": "プローブ失敗" }, @@ -1136,20 +1160,6 @@ "lastRequest": "最終リクエスト", "requestCount": "リクエスト数" }, - "timeRange": { - "label": "時間範囲", - "15min": "15分", - "1h": "1時間", - "6h": "6時間", - "24h": "24時間", - "7d": "7日間", - "last15min": "過去15分", - "last1h": "過去1時間", - "last6h": "過去6時間", - "last24h": "過去24時間", - "last7d": "過去7日間", - "custom": "カスタム" - }, "filters": { "provider": "プロバイダー", "allProviders": "すべてのプロバイダー", @@ -1187,20 +1197,6 @@ "greenCount": "成功リクエスト", "redCount": "失敗リクエスト" }, - "actions": { - "refresh": "更新", - "refreshing": "更新中...", - "autoRefresh": "自動更新", - "stopAutoRefresh": "自動更新を停止", - "viewDetails": "詳細を表示", - "testProvider": "プロバイダーをテスト", - "retry": "再試行", - "probeNow": "今すぐプローブ", - "probing": "プローブ中...", - "probeAll": "すべてプローブ", - "probeSuccess": "プローブ成功", - "probeFailed": "プローブ失敗" - }, "states": { "loading": "読み込み中...", "error": "読み込み失敗", @@ -1251,62 +1247,6 @@ "probeSuccess": "プローブ成功", "probeFailed": "プローブ失敗" }, - "laneChart": { - "title": "プロバイダー可用性タイムライン", - "noData": "データなし", - "requests": "{count} リクエスト", - "availability": "可用性 {value}%", - "noRequests": "リクエストなし", - "denseData": "高密度", - "sparseData": "低密度", - "latency": "レイテンシ" - }, - "latencyChart": { - "title": "レイテンシ分布", - "p50": "P50", - "p95": "P95", - "p99": "P99", - "noData": "レイテンシデータなし" - }, - "latencyCurve": { - "title": "レイテンシトレンド", - "noData": "レイテンシデータなし", - "avg": "平均", - "min": "最小", - "max": "最大", - "latency": "レイテンシ" - }, - "terminal": { - "title": "プローブログ", - "live": "ライブ", - "download": "ログをダウンロード", - "noLogs": "プローブログなし", - "manual": "手動", - "auto": "自動", - "filterPlaceholder": "ログをフィルター..." - }, - "probeGrid": { - "title": "エンドポイントステータス", - "noEndpoints": "エンドポイント未設定", - "lastProbe": "最終プローブ", - "status": { - "unknown": "不明", - "healthy": "正常", - "unhealthy": "異常" - } - }, - "endpoint": { - "selectVendor": "ベンダーを選択", - "selectType": "タイプを選択" - }, - "confidence": { - "low": "低", - "medium": "中", - "high": "高", - "lowTooltip": "リクエスト数が {count} 未満です。データが代表的でない可能性があります。", - "mediumTooltip": "リクエスト量は適度です。データは比較的信頼できます。", - "highTooltip": "リクエスト量が十分です。データは信頼できます。" - }, "toast": { "refreshSuccess": "可用性データを更新しました", "refreshFailed": "更新に失敗しました。再試行してください" diff --git a/messages/ja/provider-chain.json b/messages/ja/provider-chain.json index d8e55285b..e8793c5fc 100644 --- a/messages/ja/provider-chain.json +++ b/messages/ja/provider-chain.json @@ -19,7 +19,8 @@ }, "summary": { "singleSuccess": "{total}個のプロバイダー、{healthy}個正常 → {provider} ✓", - "sessionReuse": "セッション再利用 → {provider} ✓" + "sessionReuse": "セッション再利用 → {provider} ✓", + "originHint": "セッション再利用 - 元は {method} で選択" }, "description": { "noDecisionRecord": "決定記録なし", @@ -213,5 +214,11 @@ "strictBlockSelectorError": "厳格モード:エンドポイントセレクターでエラーが発生したため、フォールバックなしでプロバイダーをスキップ", "vendorTypeAllTimeout": "ベンダータイプ全エンドポイントタイムアウト(524)", "vendorTypeAllTimeoutNote": "このベンダータイプの全エンドポイントがタイムアウトしました。ベンダータイプサーキットブレーカーが発動しました。" + }, + "selectionMethods": { + "session_reuse": "セッション再利用", + "weighted_random": "重み付きランダム", + "group_filtered": "グループフィルタ", + "fail_open_fallback": "フェイルオープンフォールバック" } } diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 3c2acb3a6..7634ae3fa 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -340,7 +340,12 @@ "sessionAge": "Возраст сессии", "reusedProvider": "Повторно используемый провайдер", "executeRequest": "Выполнить запрос", - "cacheOptimizationHint": "Повторное использование сессии оптимизирует производительность, поддерживая привязку к провайдеру в рамках одного разговора, снижая накладные расходы на выбор и повышая частоту попаданий в кэш." + "cacheOptimizationHint": "Повторное использование сессии оптимизирует производительность, поддерживая привязку к провайдеру в рамках одного разговора, снижая накладные расходы на выбор и повышая частоту попаданий в кэш.", + "originDecisionTitle": "Исходное решение выбора", + "originDecisionDesc": "Как провайдер был изначально выбран для этой сессии", + "originDecisionLoading": "Загрузка исходного решения...", + "originDecisionUnavailable": "Запись исходного решения недоступна", + "originDecisionExpand": "Просмотр исходного выбора" } }, "providerChain": { @@ -1123,12 +1128,12 @@ "highTooltip": "Высокий объём запросов. Данные надёжны." }, "actions": { - "retry": "Повторить", - "probeNow": "Проверить сейчас", - "probing": "Проверка...", - "probeAll": "Проверить все", - "probeSuccess": "Проверка успешна", - "probeFailed": "Проверка не удалась" + "refresh": "Обновить", + "refreshing": "Обновление...", + "autoRefresh": "Автообновление", + "stopAutoRefresh": "Остановить автообновление", + "viewDetails": "Подробнее", + "testProvider": "Тестировать провайдера" }, "status": { "green": "Здоров", @@ -1188,14 +1193,6 @@ "greenCount": "Успешные запросы", "redCount": "Неудачные запросы" }, - "actions": { - "refresh": "Обновить", - "refreshing": "Обновление...", - "autoRefresh": "Автообновление", - "stopAutoRefresh": "Остановить автообновление", - "viewDetails": "Подробнее", - "testProvider": "Тестировать провайдера" - }, "states": { "loading": "Загрузка...", "error": "Ошибка загрузки", @@ -1862,14 +1859,14 @@ "descriptionEnabled": "При включении этот ключ будет использовать независимую страницу личного использования при входе. Однако он не может изменять группу провайдеров собственного ключа.", "descriptionDisabled": "При отключении пользователь не сможет получить доступ к странице личного использования. Вместо этого будет использоваться ограниченный Web UI." }, - "providerGroup": { - "label": "Группа провайдеров", - "placeholder": "По умолчанию: default", - "selectHint": "Выберите группы провайдеров, доступные для этого ключа", - "editHint": "Группа провайдеров существующего ключа не может быть изменена", - "allGroups": "Использовать все группы", - "noGroupHint": "default включает провайдеров без groupTag." - }, + "providerGroup": { + "label": "Группа провайдеров", + "placeholder": "По умолчанию: default", + "selectHint": "Выберите группы провайдеров, доступные для этого ключа", + "editHint": "Группа провайдеров существующего ключа не может быть изменена", + "allGroups": "Использовать все группы", + "noGroupHint": "default включает провайдеров без groupTag." + }, "cacheTtl": { "label": "Переопределение Cache TTL", "description": "Принудительно установить Anthropic prompt cache TTL для запросов с cache_control.", diff --git a/messages/ru/provider-chain.json b/messages/ru/provider-chain.json index 10019665b..0e86f022f 100644 --- a/messages/ru/provider-chain.json +++ b/messages/ru/provider-chain.json @@ -19,7 +19,8 @@ }, "summary": { "singleSuccess": "{total} провайдеров, {healthy} работоспособных → {provider} ✓", - "sessionReuse": "Повторное использование сессии → {provider} ✓" + "sessionReuse": "Повторное использование сессии → {provider} ✓", + "originHint": "Повторное использование сессии - изначально выбрано через {method}" }, "description": { "noDecisionRecord": "Нет записей решений", @@ -213,5 +214,11 @@ "strictBlockSelectorError": "Строгий режим: ошибка селектора конечных точек, провайдер пропущен без отката", "vendorTypeAllTimeout": "Тайм-аут всех конечных точек типа поставщика (524)", "vendorTypeAllTimeoutNote": "Все конечные точки этого типа поставщика превысили тайм-аут. Активирован размыкатель типа поставщика." + }, + "selectionMethods": { + "session_reuse": "Повторное использование сессии", + "weighted_random": "Взвешенный случайный", + "group_filtered": "Фильтрация по группе", + "fail_open_fallback": "Резервный вариант при сбое" } } diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 80f74db54..a641a3aca 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -340,7 +340,12 @@ "sessionAge": "会话年龄", "reusedProvider": "复用的供应商", "executeRequest": "执行请求", - "cacheOptimizationHint": "会话复用通过在同一对话中保持供应商亲和性来优化性能,减少选择开销并提高缓存命中率。" + "cacheOptimizationHint": "会话复用通过在同一对话中保持供应商亲和性来优化性能,减少选择开销并提高缓存命中率。", + "originDecisionTitle": "原始选择决策", + "originDecisionDesc": "此会话中供应商最初被选择的原因", + "originDecisionLoading": "正在加载原始决策...", + "originDecisionUnavailable": "原始决策记录不可用", + "originDecisionExpand": "查看原始选择" } }, "providerChain": { @@ -388,13 +393,13 @@ "users": "用户排行", "keys": "密钥排行", "userRanking": "用户排行", - "providerRanking": "供应商排行", - "providerCacheHitRateRanking": "供应商缓存命中率排行", - "modelRanking": "模型排行", - "dailyRanking": "今日", - "weeklyRanking": "本周", - "monthlyRanking": "本月", - "allTimeRanking": "全部" + "providerRanking": "供应商排行", + "providerCacheHitRateRanking": "供应商缓存命中率排行", + "modelRanking": "模型排行", + "dailyRanking": "今日", + "weeklyRanking": "本周", + "monthlyRanking": "本月", + "allTimeRanking": "全部" }, "dateRange": { "to": "至", @@ -413,22 +418,22 @@ "requests": "请求数", "tokens": "Token 数", "consumedAmount": "消耗金额", - "provider": "供应商", - "model": "模型", - "cost": "成本", - "cacheHitRequests": "缓存触发请求数", - "cacheHitRate": "缓存命中率", - "cacheReadTokens": "缓存读取 Token 数", - "totalTokens": "总 Token 数", - "cacheCreationConsumedAmount": "缓存创建消耗金额", - "totalConsumedAmount": "总消耗金额", - "successRate": "成功率", - "avgResponseTime": "平均响应时间", - "avgTtfbMs": "平均 TTFB", - "avgTokensPerSecond": "平均输出速率", - "avgCostPerRequest": "平均单次请求成本", - "avgCostPerMillionTokens": "平均百万 Token 成本" - }, + "provider": "供应商", + "model": "模型", + "cost": "成本", + "cacheHitRequests": "缓存触发请求数", + "cacheHitRate": "缓存命中率", + "cacheReadTokens": "缓存读取 Token 数", + "totalTokens": "总 Token 数", + "cacheCreationConsumedAmount": "缓存创建消耗金额", + "totalConsumedAmount": "总消耗金额", + "successRate": "成功率", + "avgResponseTime": "平均响应时间", + "avgTtfbMs": "平均 TTFB", + "avgTokensPerSecond": "平均输出速率", + "avgCostPerRequest": "平均单次请求成本", + "avgCostPerMillionTokens": "平均百万 Token 成本" + }, "expandModelStats": "展开模型详情", "collapseModelStats": "收起模型详情", "states": { @@ -1829,14 +1834,14 @@ "descriptionEnabled": "启用后,此密钥在登录时将进入独立的个人用量页面。但不可修改自己密钥的供应商分组。", "descriptionDisabled": "关闭后,用户将无法进入个人独立用量页面 UI,而是进入受限的 Web UI。" }, - "providerGroup": { - "label": "供应商分组", - "placeholder": "默认:default", - "selectHint": "选择此 Key 可使用的供应商分组", - "editHint": "已有密钥的分组不可修改", - "allGroups": "使用全部分组", - "noGroupHint": "default 分组包含所有未设置 groupTag 的供应商" - }, + "providerGroup": { + "label": "供应商分组", + "placeholder": "默认:default", + "selectHint": "选择此 Key 可使用的供应商分组", + "editHint": "已有密钥的分组不可修改", + "allGroups": "使用全部分组", + "noGroupHint": "default 分组包含所有未设置 groupTag 的供应商" + }, "cacheTtl": { "label": "Cache TTL 覆写", "description": "强制为包含 cache_control 的请求设置 Anthropic prompt cache TTL。", diff --git a/messages/zh-CN/provider-chain.json b/messages/zh-CN/provider-chain.json index dfe3daad7..c1b287503 100644 --- a/messages/zh-CN/provider-chain.json +++ b/messages/zh-CN/provider-chain.json @@ -19,7 +19,8 @@ }, "summary": { "singleSuccess": "{total} 个供应商,{healthy} 个健康 → {provider} ✓", - "sessionReuse": "会话复用 → {provider} ✓" + "sessionReuse": "会话复用 → {provider} ✓", + "originHint": "会话复用 - 最初通过 {method} 选择" }, "description": { "noDecisionRecord": "无决策记录", @@ -213,5 +214,11 @@ "strictBlockSelectorError": "严格模式:端点选择器发生错误,跳过该供应商且不降级", "vendorTypeAllTimeout": "供应商类型全端点超时(524)", "vendorTypeAllTimeoutNote": "该供应商类型的所有端点均超时,已触发供应商类型临时熔断。" + }, + "selectionMethods": { + "session_reuse": "会话复用", + "weighted_random": "加权随机", + "group_filtered": "分组过滤", + "fail_open_fallback": "故障开放回退" } } diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 0d9008422..5bfccf874 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -340,7 +340,12 @@ "sessionAge": "會話年齡", "reusedProvider": "複用的供應商", "executeRequest": "執行請求", - "cacheOptimizationHint": "會話複用通過在同一對話中保持供應商親和性來優化效能,減少選擇開銷並提高快取命中率。" + "cacheOptimizationHint": "會話複用通過在同一對話中保持供應商親和性來優化效能,減少選擇開銷並提高快取命中率。", + "originDecisionTitle": "原始選擇決策", + "originDecisionDesc": "此會話中供應商最初被選擇的原因", + "originDecisionLoading": "正在載入原始決策...", + "originDecisionUnavailable": "原始決策記錄不可用", + "originDecisionExpand": "查看原始選擇" } }, "providerChain": { @@ -1120,8 +1125,16 @@ "highTooltip": "請求量充足,資料可靠。" }, "actions": { + "refresh": "重新整理", + "refreshing": "重新整理中...", + "autoRefresh": "自動重新整理", + "stopAutoRefresh": "停止自動重新整理", + "viewDetails": "檢視詳情", + "testProvider": "測試供應商", + "retry": "重試", "probeNow": "立即探測", "probing": "探測中...", + "probeAll": "探測全部", "probeSuccess": "探測成功", "probeFailed": "探測失敗" }, @@ -1183,20 +1196,6 @@ "greenCount": "成功請求", "redCount": "失敗請求" }, - "actions": { - "refresh": "重新整理", - "refreshing": "重新整理中...", - "autoRefresh": "自動重新整理", - "stopAutoRefresh": "停止自動重新整理", - "viewDetails": "檢視詳情", - "testProvider": "測試供應商", - "retry": "重試", - "probeNow": "立即探測", - "probing": "探測中...", - "probeAll": "探測全部", - "probeSuccess": "探測成功", - "probeFailed": "探測失敗" - }, "states": { "loading": "載入中...", "error": "載入失敗", diff --git a/messages/zh-TW/provider-chain.json b/messages/zh-TW/provider-chain.json index c9846479c..847d0bbd5 100644 --- a/messages/zh-TW/provider-chain.json +++ b/messages/zh-TW/provider-chain.json @@ -19,7 +19,8 @@ }, "summary": { "singleSuccess": "{total} 個供應商,{healthy} 個健康 → {provider} ✓", - "sessionReuse": "會話複用 → {provider} ✓" + "sessionReuse": "會話複用 → {provider} ✓", + "originHint": "會話複用 - 最初通過 {method} 選擇" }, "description": { "noDecisionRecord": "無決策記錄", @@ -213,5 +214,11 @@ "strictBlockSelectorError": "嚴格模式:端點選擇器發生錯誤,跳過該供應商且不降級", "vendorTypeAllTimeout": "供應商類型全端點逾時(524)", "vendorTypeAllTimeoutNote": "該供應商類型的所有端點均逾時,已觸發供應商類型臨時熔斷。" + }, + "selectionMethods": { + "session_reuse": "會話複用", + "weighted_random": "加權隨機", + "group_filtered": "分組過濾", + "fail_open_fallback": "故障開放回退" } } diff --git a/src/actions/session-origin-chain.ts b/src/actions/session-origin-chain.ts new file mode 100644 index 000000000..904b7b23d --- /dev/null +++ b/src/actions/session-origin-chain.ts @@ -0,0 +1,57 @@ +"use server"; + +import { and, eq, inArray, isNull, or } from "drizzle-orm"; +import { db } from "@/drizzle/db"; +import { messageRequest } from "@/drizzle/schema"; +import { getSession } from "@/lib/auth"; +import { logger } from "@/lib/logger"; +import { findKeyList } from "@/repository/key"; +import { findSessionOriginChain } from "@/repository/message"; +import type { ProviderChainItem } from "@/types/message"; +import type { ActionResult } from "./types"; + +export async function getSessionOriginChain( + sessionId: string +): Promise> { + try { + const session = await getSession(); + if (!session) { + return { ok: false, error: "未登录" }; + } + + if (session.user.role !== "admin") { + const userKeys = await findKeyList(session.user.id); + const userKeyValues = userKeys.map((key) => key.key); + + const ownershipCondition = + userKeyValues.length > 0 + ? or( + eq(messageRequest.userId, session.user.id), + inArray(messageRequest.key, userKeyValues) + ) + : eq(messageRequest.userId, session.user.id); + + const [ownedSession] = await db + .select({ id: messageRequest.id }) + .from(messageRequest) + .where( + and( + eq(messageRequest.sessionId, sessionId), + isNull(messageRequest.deletedAt), + ownershipCondition + ) + ) + .limit(1); + + if (!ownedSession) { + return { ok: false, error: "无权访问该 Session" }; + } + } + + const chain = await findSessionOriginChain(sessionId); + return { ok: true, data: chain ?? null }; + } catch (error) { + logger.error("获取会话来源链失败:", error); + return { ok: false, error: "获取会话来源链失败" }; + } +} diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx index 8fdcd5f7b..6a7ff6892 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx @@ -4,12 +4,26 @@ import { createRoot } from "react-dom/client"; import { act } from "react"; import { NextIntlClientProvider } from "next-intl"; import { Window } from "happy-dom"; -import { describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const hasSessionMessagesMock = vi.fn(); vi.mock("@/actions/active-sessions", () => ({ - hasSessionMessages: vi.fn().mockResolvedValue({ ok: true, data: false }), + hasSessionMessages: (...args: [string, number | undefined]) => hasSessionMessagesMock(...args), })); +const getSessionOriginChainMock = vi.fn(); + +vi.mock("@/actions/session-origin-chain", () => ({ + getSessionOriginChain: (...args: [string]) => getSessionOriginChainMock(...args), +})); + +beforeEach(() => { + hasSessionMessagesMock.mockResolvedValue({ ok: true, data: false }); + getSessionOriginChainMock.mockReset(); + getSessionOriginChainMock.mockResolvedValue({ ok: false, error: "mock" }); +}); + vi.mock("@/i18n/routing", () => ({ Link: ({ href, children }: { href: string; children: ReactNode }) => ( {children} @@ -246,6 +260,22 @@ const messages = { attemptProvider: "Attempt: {provider}", retryAttempt: "Retry #{number}", httpStatus: "HTTP {code}{inferredSuffix}", + sessionReuse: "Session Reuse", + sessionReuseSelection: "Session Reuse Selection", + sessionReuseSelectionDesc: "Provider selected from session cache", + sessionInfo: "Session Information", + sessionIdLabel: "Session ID", + requestSequence: "Request Sequence", + sessionAge: "Session Age", + reusedProvider: "Reused Provider", + executeRequest: "Execute Request", + cacheOptimizationHint: + "Session reuse optimizes performance by maintaining provider affinity within the same conversation, reducing selection overhead and improving cache hit rates.", + originDecisionTitle: "Original Selection Decision", + originDecisionDesc: "How this provider was initially chosen for this session", + originDecisionLoading: "Loading original decision...", + originDecisionUnavailable: "Original decision record unavailable", + originDecisionExpand: "View original selection", }, noError: { processing: "No error (processing)", @@ -335,6 +365,37 @@ function parseHtml(html: string) { return window.document; } +function renderClientWithIntl(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + + {node} + + ); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function click(element: Element | null) { + if (!element) return; + act(() => { + element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + element.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + describe("error-details-dialog layout", () => { test("renders fake-200 forwarded notice when errorMessage is a FAKE_200_* code", () => { const html = renderWithIntl( @@ -1028,3 +1089,88 @@ describe("error-details-dialog tabs", () => { expect(html).toContain("#5"); }); }); + +describe("error-details-dialog origin decision chain", () => { + test("shows origin chain trigger for session reuse flow with sessionId", () => { + const html = renderWithIntl( + + ); + + expect(html).toContain("View original selection"); + }); + + test("keeps origin chain content collapsed by default", () => { + const { container, unmount } = renderClientWithIntl( + + ); + + expect(container.textContent).not.toContain("Original decision record unavailable"); + unmount(); + }); + + test("shows unavailable text after expand when origin decision is null", async () => { + getSessionOriginChainMock.mockResolvedValue({ ok: true, data: null }); + + const { container, unmount } = renderClientWithIntl( + + ); + + const trigger = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("View original selection") + ); + + expect(trigger).toBeTruthy(); + click(trigger!); + + await act(async () => { + await Promise.resolve(); + }); + + expect(getSessionOriginChainMock).toHaveBeenCalledWith("sess-origin-3"); + expect(getSessionOriginChainMock).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain("Original decision record unavailable"); + unmount(); + }); +}); diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx index b928e8504..dd51b7ec8 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; +import { getSessionOriginChain } from "@/actions/session-origin-chain"; import { Badge } from "@/components/ui/badge"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { cn } from "@/lib/utils"; @@ -53,6 +54,7 @@ function getRequestStatus(item: ProviderChainItem): StepStatus { export function LogicTraceTab({ statusCode: _statusCode, providerChain, + sessionId, blockedBy, blockedReason, requestSequence, @@ -61,6 +63,9 @@ export function LogicTraceTab({ const t = useTranslations("dashboard.logs.details"); const tChain = useTranslations("provider-chain"); const [timelineCopied, setTimelineCopied] = useState(false); + const [originOpen, setOriginOpen] = useState(false); + const [originChain, setOriginChain] = useState(undefined); + const [originLoading, setOriginLoading] = useState(false); const handleCopyTimeline = async () => { if (!providerChain) return; @@ -295,6 +300,111 @@ export function LogicTraceTab({ /> )} + {isSessionReuseFlow && sessionId && ( + { + setOriginOpen(open); + if (open && originChain === undefined && !originLoading) { + setOriginLoading(true); + getSessionOriginChain(sessionId) + .then((result) => { + setOriginChain(result.ok ? result.data : null); + }) + .catch(() => { + setOriginChain(null); + }) + .finally(() => { + setOriginLoading(false); + }); + } + }} + > + + {t("logicTrace.originDecisionExpand")} + + + {originLoading && ( +
+ {t("logicTrace.originDecisionLoading")} +
+ )} + {!originLoading && originChain === null && ( +
+ {t("logicTrace.originDecisionUnavailable")} +
+ )} + {!originLoading && + originChain && + originChain.length > 0 && + (() => { + const originItem = + originChain.find((item) => item.reason === "initial_selection") ?? + originChain[0]; + const ctx = originItem?.decisionContext; + return ( +
+
+ {t("logicTrace.originDecisionTitle")} +
+ {ctx && ( +
+
+ + {t("logicTrace.providersCount", { count: ctx.totalProviders })} + +
+ {ctx.enabledProviders !== undefined && ( +
+ + {t("logicTrace.providersCount", { count: ctx.enabledProviders })} + +
+ )} + {ctx.afterHealthCheck !== undefined && ( +
+ + {tChain("details.afterHealthCheck")}: + {" "} + {ctx.afterHealthCheck} +
+ )} + {ctx.selectedPriority !== undefined && ( +
+ + {tChain("details.priority")}: + {" "} + P{ctx.selectedPriority} +
+ )} + {ctx.candidatesAtPriority && ctx.candidatesAtPriority.length > 0 && ( +
+ + {tChain("details.candidates")}: + {" "} + {ctx.candidatesAtPriority.map((c, i) => ( + + {i > 0 && ", "} + {c.name} + {c.probability !== undefined && ( + + {" "} + {formatProbability(c.probability)} + + )} + + ))} +
+ )} +
+ )} +
+ ); + })()} +
+
+ )} + {/* Step 1: Initial Selection (only for non-session-reuse flow) */} {decisionContext && ( { expect(truncateNode).not.toBeNull(); }); + test("session_reuse item with selectionMethod shows origin hint text", () => { + const html = renderWithIntl( + + ); + expect(html).toContain("weighted_random"); + expect(html).toContain("Session reuse - originally selected via"); + }); + + test("non-session-reuse item does NOT show origin hint", () => { + const html = renderWithIntl( + + ); + expect(html).not.toContain("Session reuse - originally selected via"); + }); + test("requestCount>1 branch uses w-full/min-w-0 button and flex-1 name container", () => { const html = renderWithIntl( )}
+ {sessionReuseItem?.selectionMethod && ( +
+ {tChain("summary.originHint", { + method: tChain(`selectionMethods.${sessionReuseItem.selectionMethod}`), + })} +
+ )}
)} diff --git a/src/repository/message.ts b/src/repository/message.ts index c22ace55a..5a8acf82f 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -5,7 +5,7 @@ import { db } from "@/drizzle/db"; import { keys as keysTable, messageRequest, providers, users } from "@/drizzle/schema"; import { getEnvConfig } from "@/lib/config/env.schema"; import { formatCostForStorage } from "@/lib/utils/currency"; -import type { CreateMessageRequestData, MessageRequest } from "@/types/message"; +import type { CreateMessageRequestData, MessageRequest, ProviderChainItem } from "@/types/message"; import type { SpecialSetting } from "@/types/special-settings"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; import { toMessageRequest } from "./_shared/transformers"; @@ -277,6 +277,33 @@ export async function findMessageRequestBySessionId( return toMessageRequest(result); } +/** + * 根据 sessionId 查询该 session 首条非 warmup 请求的 providerChain + * 用于展示会话来源链(原始选择决策) + */ +export async function findSessionOriginChain( + sessionId: string +): Promise { + const [row] = await db + .select({ + providerChain: messageRequest.providerChain, + }) + .from(messageRequest) + .where( + and( + eq(messageRequest.sessionId, sessionId), + isNull(messageRequest.deletedAt), + EXCLUDE_WARMUP_CONDITION, + sql`${messageRequest.providerChain} IS NOT NULL` + ) + ) + .orderBy(asc(messageRequest.requestSequence)) + .limit(1); + + if (!row?.providerChain) return null; + return row.providerChain as ProviderChainItem[]; +} + /** * 按 (sessionId, requestSequence) 获取请求的审计字段(用于 Session 详情页补齐特殊设置展示) */ diff --git a/tests/unit/actions/session-origin-chain-integration.test.ts b/tests/unit/actions/session-origin-chain-integration.test.ts new file mode 100644 index 000000000..a692405f6 --- /dev/null +++ b/tests/unit/actions/session-origin-chain-integration.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test, vi } from "vitest"; +import type { ProviderChainItem } from "../../../src/types/message"; + +type SessionRequestRow = { + requestSequence: number; + providerChain: ProviderChainItem[]; +}; + +describe("getSessionOriginChain integration", () => { + test("returns the first request origin chain for a multi-request session", async () => { + vi.resetModules(); + + const firstRequestChain: ProviderChainItem[] = [ + { + id: 101, + name: "provider-a", + reason: "initial_selection", + selectionMethod: "weighted_random", + }, + ]; + + const secondRequestChain: ProviderChainItem[] = [ + { + id: 101, + name: "provider-a", + reason: "session_reuse", + selectionMethod: "session_reuse", + }, + ]; + + const sessionRequests: SessionRequestRow[] = [ + { requestSequence: 1, providerChain: firstRequestChain }, + { requestSequence: 2, providerChain: secondRequestChain }, + ]; + + const limitMock = vi.fn((limit: number) => + Promise.resolve( + [...sessionRequests] + .sort((a, b) => a.requestSequence - b.requestSequence) + .slice(0, limit) + .map((row) => ({ providerChain: row.providerChain })) + ) + ); + const orderByMock = vi.fn(() => ({ limit: limitMock })); + const whereMock = vi.fn(() => ({ orderBy: orderByMock })); + const fromMock = vi.fn(() => ({ where: whereMock })); + const selectMock = vi.fn(() => ({ from: fromMock })); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + }, + })); + + vi.doMock("@/lib/auth", () => ({ + getSession: vi.fn().mockResolvedValue({ user: { id: 1, role: "admin" } }), + })); + + vi.doMock("@/repository/key", () => ({ + findKeyList: vi.fn(), + })); + + vi.doMock("@/lib/logger", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + }, + })); + + const { getSessionOriginChain } = await import("../../../src/actions/session-origin-chain"); + const result = await getSessionOriginChain("test-session"); + + expect(result).toEqual({ ok: true, data: firstRequestChain }); + expect(result.ok).toBe(true); + if (!result.ok || !result.data) { + throw new Error("Expected action to return origin chain data"); + } + + expect(result.data[0]?.reason).toBe("initial_selection"); + expect(result.data).not.toEqual(secondRequestChain); + expect(selectMock).toHaveBeenCalledTimes(1); + expect(limitMock).toHaveBeenCalledWith(1); + }); +}); diff --git a/tests/unit/actions/session-origin-chain.test.ts b/tests/unit/actions/session-origin-chain.test.ts new file mode 100644 index 000000000..3344e1bde --- /dev/null +++ b/tests/unit/actions/session-origin-chain.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { ProviderChainItem } from "@/types/message"; + +const getSessionMock = vi.fn(); +const findSessionOriginChainMock = vi.fn(); +const findKeyListMock = vi.fn(); + +const dbSelectMock = vi.fn(); +const dbFromMock = vi.fn(); +const dbWhereMock = vi.fn(); +const dbLimitMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/repository/message", () => ({ + findSessionOriginChain: findSessionOriginChainMock, +})); + +vi.mock("@/repository/key", () => ({ + findKeyList: findKeyListMock, +})); + +vi.mock("@/drizzle/db", () => ({ + db: { + select: dbSelectMock, + }, +})); + +describe("getSessionOriginChain", () => { + beforeEach(() => { + vi.clearAllMocks(); + + dbSelectMock.mockReturnValue({ from: dbFromMock }); + dbFromMock.mockReturnValue({ where: dbWhereMock }); + dbWhereMock.mockReturnValue({ limit: dbLimitMock }); + dbLimitMock.mockResolvedValue([{ id: 1 }]); + + findKeyListMock.mockResolvedValue([{ key: "user-key-1" }]); + }); + + test("admin happy path: returns provider chain", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + + const chain: ProviderChainItem[] = [ + { + id: 11, + name: "provider-a", + reason: "initial_selection", + }, + ]; + findSessionOriginChainMock.mockResolvedValue(chain); + + const { getSessionOriginChain } = await import("@/actions/session-origin-chain"); + const result = await getSessionOriginChain("sess-admin"); + + expect(result).toEqual({ ok: true, data: chain }); + expect(findSessionOriginChainMock).toHaveBeenCalledWith("sess-admin"); + expect(findKeyListMock).not.toHaveBeenCalled(); + expect(dbSelectMock).not.toHaveBeenCalled(); + }); + + test("non-admin happy path: returns provider chain after ownership check", async () => { + getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } }); + + const chain: ProviderChainItem[] = [ + { + id: 22, + name: "provider-b", + reason: "session_reuse", + }, + ]; + findSessionOriginChainMock.mockResolvedValue(chain); + + const { getSessionOriginChain } = await import("@/actions/session-origin-chain"); + const result = await getSessionOriginChain("sess-user"); + + expect(result).toEqual({ ok: true, data: chain }); + expect(findKeyListMock).toHaveBeenCalledWith(2); + expect(dbSelectMock).toHaveBeenCalledTimes(1); + expect(findSessionOriginChainMock).toHaveBeenCalledWith("sess-user"); + }); + + test("unauthenticated: returns not logged in", async () => { + getSessionMock.mockResolvedValue(null); + + const { getSessionOriginChain } = await import("@/actions/session-origin-chain"); + const result = await getSessionOriginChain("sess-no-auth"); + + expect(result).toEqual({ ok: false, error: "未登录" }); + expect(findSessionOriginChainMock).not.toHaveBeenCalled(); + expect(findKeyListMock).not.toHaveBeenCalled(); + expect(dbSelectMock).not.toHaveBeenCalled(); + }); + + test("non-admin without access: returns unauthorized error", async () => { + getSessionMock.mockResolvedValue({ user: { id: 3, role: "user" } }); + findKeyListMock.mockResolvedValue([{ key: "user-key-3" }]); + dbLimitMock.mockResolvedValue([]); + + const { getSessionOriginChain } = await import("@/actions/session-origin-chain"); + const result = await getSessionOriginChain("sess-other-user"); + + expect(result).toEqual({ ok: false, error: "无权访问该 Session" }); + expect(findSessionOriginChainMock).not.toHaveBeenCalled(); + }); + + test("exception path: returns error on unexpected throw", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findSessionOriginChainMock.mockRejectedValue(new Error("db error")); + + const { getSessionOriginChain } = await import("@/actions/session-origin-chain"); + const result = await getSessionOriginChain("sess-throws"); + + expect(result).toEqual({ ok: false, error: "获取会话来源链失败" }); + }); + + test("not found: returns ok with null data", async () => { + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findSessionOriginChainMock.mockResolvedValue(null); + + const { getSessionOriginChain } = await import("@/actions/session-origin-chain"); + const result = await getSessionOriginChain("sess-not-found"); + + expect(result).toEqual({ ok: true, data: null }); + expect(findSessionOriginChainMock).toHaveBeenCalledWith("sess-not-found"); + expect(findKeyListMock).not.toHaveBeenCalled(); + expect(dbSelectMock).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/repository/message-origin-chain.test.ts b/tests/unit/repository/message-origin-chain.test.ts new file mode 100644 index 000000000..646efbbd7 --- /dev/null +++ b/tests/unit/repository/message-origin-chain.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test, vi } from "vitest"; +import type { ProviderChainItem } from "@/types/message"; + +function sqlToString(sqlObj: unknown): string { + const visited = new Set(); + + const walk = (node: unknown): string => { + if (!node || visited.has(node)) return ""; + visited.add(node); + + if (typeof node === "string") return node; + + if (typeof node === "object") { + const anyNode = node as any; + if (Array.isArray(anyNode)) { + return anyNode.map(walk).join(""); + } + + if (anyNode.name && typeof anyNode.name === "string") { + return anyNode.name; + } + + if (anyNode.value) { + if (Array.isArray(anyNode.value)) { + return anyNode.value.map(String).join(""); + } + return String(anyNode.value); + } + + if (anyNode.queryChunks) { + return walk(anyNode.queryChunks); + } + } + + return ""; + }; + + return walk(sqlObj); +} + +function createThenableQuery( + result: T, + opts?: { + whereArgs?: unknown[]; + orderByArgs?: unknown[]; + limitArgs?: unknown[]; + } +) { + const query: any = Promise.resolve(result); + + query.from = vi.fn(() => query); + query.where = vi.fn((arg: unknown) => { + opts?.whereArgs?.push(arg); + return query; + }); + query.orderBy = vi.fn((...args: unknown[]) => { + opts?.orderByArgs?.push(args); + return query; + }); + query.limit = vi.fn((arg: unknown) => { + opts?.limitArgs?.push(arg); + return query; + }); + + return query; +} + +describe("repository/message findSessionOriginChain", () => { + test("happy path: 返回 session 首条非 warmup 的完整 providerChain", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + const orderByArgs: unknown[] = []; + const limitArgs: unknown[] = []; + + const chain: ProviderChainItem[] = [ + { + id: 101, + name: "provider-a", + reason: "initial_selection", + selectionMethod: "weighted_random", + attemptNumber: 1, + }, + ]; + + const selectMock = vi.fn(() => + createThenableQuery([{ providerChain: chain }], { whereArgs, orderByArgs, limitArgs }) + ); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findSessionOriginChain } = await import("@/repository/message"); + const result = await findSessionOriginChain("session-happy"); + + expect(result).toEqual(chain); + expect(whereArgs.length).toBeGreaterThan(0); + + const whereSql = sqlToString(whereArgs[0]).toLowerCase(); + expect(whereSql).toContain("warmup"); + expect(whereSql).toContain("is not null"); + + expect(orderByArgs.length).toBeGreaterThan(0); + const orderSql = sqlToString(orderByArgs[0]).toLowerCase(); + expect(orderSql).toContain("request_sequence"); + expect(orderSql).toContain("asc"); + + expect(limitArgs).toEqual([1]); + }); + + test("warmup skip: 第一条为 warmup 时应返回后续首条非 warmup 的 chain", async () => { + vi.resetModules(); + + const chain: ProviderChainItem[] = [ + { + id: 202, + name: "provider-b", + reason: "session_reuse", + selectionMethod: "session_reuse", + attemptNumber: 2, + }, + ]; + + const selectMock = vi.fn(() => createThenableQuery([{ providerChain: chain }])); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findSessionOriginChain } = await import("@/repository/message"); + const result = await findSessionOriginChain("session-warmup-first"); + + expect(result).toEqual(chain); + }); + + test("no data: session 不存在时返回 null", async () => { + vi.resetModules(); + + const selectMock = vi.fn(() => createThenableQuery([])); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findSessionOriginChain } = await import("@/repository/message"); + const result = await findSessionOriginChain("session-not-found"); + + expect(result).toBeNull(); + }); + + test("all warmup: 全部请求都被 warmup 拦截时返回 null", async () => { + vi.resetModules(); + + const selectMock = vi.fn(() => createThenableQuery([])); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findSessionOriginChain } = await import("@/repository/message"); + const result = await findSessionOriginChain("session-all-warmup"); + + expect(result).toBeNull(); + }); + + test("null providerChain: 首条非 warmup 记录 providerChain 为空时返回 null", async () => { + vi.resetModules(); + + const selectMock = vi.fn(() => createThenableQuery([{ providerChain: null }])); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findSessionOriginChain } = await import("@/repository/message"); + const result = await findSessionOriginChain("session-null-provider-chain"); + + expect(result).toBeNull(); + }); +}); From 9c0babae08d080eda88e41ebb6e8356d0b7a470a Mon Sep 17 00:00:00 2001 From: ding113 Date: Thu, 19 Feb 2026 22:10:36 +0800 Subject: [PATCH 17/75] fix(dashboard): prevent chart overflow with proper flex sizing --- .../dashboard/_components/bento/statistics-chart-card.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx b/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx index 7425d6d1c..4e69c29b2 100644 --- a/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx +++ b/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx @@ -254,8 +254,8 @@ export function StatisticsChartCard({
{/* Chart */} -
- +
+ {data.users.map((user, index) => { From c620c2920a025a15c4cf9c53121537c961562379 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 00:22:58 +0800 Subject: [PATCH 18/75] fix(dashboard): set fixed height for statistics chart container --- .../dashboard/_components/bento/statistics-chart-card.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx b/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx index 4e69c29b2..35c7ee3f6 100644 --- a/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx +++ b/src/app/[locale]/dashboard/_components/bento/statistics-chart-card.tsx @@ -254,8 +254,8 @@ export function StatisticsChartCard({
{/* Chart */} -
- +
+ {data.users.map((user, index) => { From 7d254d14493e61c6aa0583ee775e044d4f4094b6 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 00:36:11 +0800 Subject: [PATCH 19/75] fix(repository): filter origin chain query to require initial_selection reason --- src/repository/message.ts | 3 +- .../repository/message-origin-chain.test.ts | 56 ++++++++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/repository/message.ts b/src/repository/message.ts index 5a8acf82f..41bacde92 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -294,7 +294,8 @@ export async function findSessionOriginChain( eq(messageRequest.sessionId, sessionId), isNull(messageRequest.deletedAt), EXCLUDE_WARMUP_CONDITION, - sql`${messageRequest.providerChain} IS NOT NULL` + sql`${messageRequest.providerChain} IS NOT NULL`, + sql`${messageRequest.providerChain} @> '[{"reason": "initial_selection"}]'::jsonb` ) ) .orderBy(asc(messageRequest.requestSequence)) diff --git a/tests/unit/repository/message-origin-chain.test.ts b/tests/unit/repository/message-origin-chain.test.ts index 646efbbd7..be38e7628 100644 --- a/tests/unit/repository/message-origin-chain.test.ts +++ b/tests/unit/repository/message-origin-chain.test.ts @@ -103,6 +103,7 @@ describe("repository/message findSessionOriginChain", () => { const whereSql = sqlToString(whereArgs[0]).toLowerCase(); expect(whereSql).toContain("warmup"); expect(whereSql).toContain("is not null"); + expect(whereSql).toContain("initial_selection"); expect(orderByArgs.length).toBeGreaterThan(0); const orderSql = sqlToString(orderByArgs[0]).toLowerCase(); @@ -119,8 +120,8 @@ describe("repository/message findSessionOriginChain", () => { { id: 202, name: "provider-b", - reason: "session_reuse", - selectionMethod: "session_reuse", + reason: "initial_selection", + selectionMethod: "weighted_random", attemptNumber: 2, }, ]; @@ -193,4 +194,55 @@ describe("repository/message findSessionOriginChain", () => { expect(result).toBeNull(); }); + + test("all session_reuse: 全部请求都是 session_reuse 时 JSONB 过滤后返回 null", async () => { + vi.resetModules(); + + const selectMock = vi.fn(() => createThenableQuery([])); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findSessionOriginChain } = await import("@/repository/message"); + const result = await findSessionOriginChain("session-all-reuse"); + + expect(result).toBeNull(); + }); + + test("JSONB filter present: WHERE 子句包含 initial_selection 过滤条件", async () => { + vi.resetModules(); + + const whereArgs: unknown[] = []; + + const chain: ProviderChainItem[] = [ + { + id: 301, + name: "provider-c", + reason: "initial_selection", + selectionMethod: "weighted_random", + attemptNumber: 1, + }, + ]; + + const selectMock = vi.fn(() => createThenableQuery([{ providerChain: chain }], { whereArgs })); + + vi.doMock("@/drizzle/db", () => ({ + db: { + select: selectMock, + execute: vi.fn(async () => ({ count: 0 })), + }, + })); + + const { findSessionOriginChain } = await import("@/repository/message"); + await findSessionOriginChain("session-jsonb-filter"); + + expect(whereArgs.length).toBeGreaterThan(0); + const whereSql = sqlToString(whereArgs[0]).toLowerCase(); + expect(whereSql).toContain("initial_selection"); + expect(whereSql).toContain("@>"); + }); }); From dab5cd5ea65fc8827bb335c68bbcc24fca7015f6 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 00:36:11 +0800 Subject: [PATCH 20/75] refactor(logs): redesign origin decision section with StepCard layout --- .../components/LogicTraceTab.tsx | 176 +++++++++++++----- 1 file changed, 127 insertions(+), 49 deletions(-) diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx index dd51b7ec8..2b97e3952 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx @@ -338,65 +338,143 @@ export function LogicTraceTab({ originChain && originChain.length > 0 && (() => { - const originItem = - originChain.find((item) => item.reason === "initial_selection") ?? - originChain[0]; + const originItem = originChain.find( + (item) => item.reason === "initial_selection" + ); const ctx = originItem?.decisionContext; + const originFilteredProviders = originChain.flatMap( + (item) => item.decisionContext?.filteredProviders || [] + ); return ( -
-
+
+
{t("logicTrace.originDecisionTitle")}
{ctx && ( -
-
- - {t("logicTrace.providersCount", { count: ctx.totalProviders })} - -
- {ctx.enabledProviders !== undefined && ( -
- - {t("logicTrace.providersCount", { count: ctx.enabledProviders })} - -
- )} - {ctx.afterHealthCheck !== undefined && ( -
- - {tChain("details.afterHealthCheck")}: - {" "} - {ctx.afterHealthCheck} -
- )} - {ctx.selectedPriority !== undefined && ( -
- - {tChain("details.priority")}: - {" "} - P{ctx.selectedPriority} + ${ctx.afterModelFilter || ctx.afterHealthCheck}`} + status="success" + details={ +
+
+ Total:{" "} + {ctx.totalProviders} +
+
+ Enabled:{" "} + {ctx.enabledProviders} +
+ {ctx.afterGroupFilter !== undefined && ( +
+ After Group:{" "} + {ctx.afterGroupFilter} +
+ )} + {ctx.afterModelFilter !== undefined && ( +
+ After Model:{" "} + {ctx.afterModelFilter} +
+ )}
- )} - {ctx.candidatesAtPriority && ctx.candidatesAtPriority.length > 0 && ( -
- - {tChain("details.candidates")}: - {" "} - {ctx.candidatesAtPriority.map((c, i) => ( - - {i > 0 && ", "} - {c.name} - {c.probability !== undefined && ( - - {" "} - {formatProbability(c.probability)} + } + /> + )} + {originFilteredProviders.length > 0 && ( + + {originFilteredProviders.map((p, idx) => ( +
+ + {p.name} + + + {tChain(`filterReasons.${p.reason}`)} + + {p.details && ( + + ( + {tChain.has(`filterDetails.${p.details}`) + ? tChain(`filterDetails.${p.details}`) + : p.details} + ) )} - +
))}
- )} -
+ } + /> + )} + {ctx?.priorityLevels && ctx.priorityLevels.length > 0 && ( + 0 ? 3 : 2} + icon={Layers} + title={t("logicTrace.prioritySelection")} + subtitle={`Priority ${ctx.selectedPriority}`} + status="success" + details={ +
+
+ {ctx.priorityLevels.map((p) => ( + + P{p} + + ))} +
+ {ctx.candidatesAtPriority && + ctx.candidatesAtPriority.length > 0 && ( +
+ {ctx.candidatesAtPriority.map((c, idx) => { + const formattedProbability = formatProbability( + c.probability + ); + return ( +
+ {c.name} +
+ + W:{c.weight} + + + x{c.costMultiplier} + + {formattedProbability && ( + + {formattedProbability} + + )} +
+
+ ); + })} +
+ )} +
+ } + /> )}
); From d85e540d9b3c095866ea757a2765d30b5b67ad82 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 00:44:46 +0800 Subject: [PATCH 21/75] perf(keys): replace DISTINCT ON with LATERAL JOIN and add composite partial indexes for key lookups --- drizzle/0072_dark_gwen_stacy.sql | 3 + drizzle/meta/0072_snapshot.json | 3345 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/drizzle/schema.ts | 8 + src/repository/key.ts | 42 +- .../key-usage-token-overflow.test.ts | 6 +- 6 files changed, 3386 insertions(+), 25 deletions(-) create mode 100644 drizzle/0072_dark_gwen_stacy.sql create mode 100644 drizzle/meta/0072_snapshot.json diff --git a/drizzle/0072_dark_gwen_stacy.sql b/drizzle/0072_dark_gwen_stacy.sql new file mode 100644 index 000000000..93b4b1cc7 --- /dev/null +++ b/drizzle/0072_dark_gwen_stacy.sql @@ -0,0 +1,3 @@ +-- #slow-query: composite partial indexes for key-based lookups with EXCLUDE_WARMUP filter +CREATE INDEX IF NOT EXISTS "idx_message_request_key_last_active" ON "message_request" USING btree ("key","created_at" DESC NULLS LAST) WHERE "message_request"."deleted_at" IS NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> 'warmup');--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_message_request_key_cost_active" ON "message_request" USING btree ("key","cost_usd") WHERE "message_request"."deleted_at" IS NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> 'warmup'); diff --git a/drizzle/meta/0072_snapshot.json b/drizzle/meta/0072_snapshot.json new file mode 100644 index 000000000..0488af296 --- /dev/null +++ b/drizzle/meta/0072_snapshot.json @@ -0,0 +1,3345 @@ +{ + "id": "eee0f681-1760-44e9-a75b-63e92a19bdaf", + "prevId": "0612bb83-c19b-4507-a304-47fedf9d0b61", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index bc7baeeed..b37dca9a2 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -505,6 +505,13 @@ "when": 1771491614848, "tag": "0071_purple_captain_midlands", "breakpoints": true + }, + { + "idx": 72, + "version": "7", + "when": 1771519353657, + "tag": "0072_dark_gwen_stacy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 7bcc207c9..9f551957d 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -534,6 +534,14 @@ export const messageRequest = pgTable('message_request', { messageRequestStatusCodeActiveIdx: index('idx_message_request_status_code_active').on(table.statusCode).where(sql`${table.deletedAt} IS NULL AND ${table.statusCode} IS NOT NULL`), messageRequestCreatedAtIdx: index('idx_message_request_created_at').on(table.createdAt), messageRequestDeletedAtIdx: index('idx_message_request_deleted_at').on(table.deletedAt), + // #slow-query: DISTINCT ON / LATERAL last-provider lookup per key + messageRequestKeyLastActiveIdx: index('idx_message_request_key_last_active') + .on(table.key, table.createdAt.desc()) + .where(sql`${table.deletedAt} IS NULL AND (${table.blockedBy} IS NULL OR ${table.blockedBy} <> 'warmup')`), + // #slow-query: SUM(cost_usd) per key, enables index-only scan + messageRequestKeyCostActiveIdx: index('idx_message_request_key_cost_active') + .on(table.key, table.costUsd) + .where(sql`${table.deletedAt} IS NULL AND (${table.blockedBy} IS NULL OR ${table.blockedBy} <> 'warmup')`), })); // Model Prices table diff --git a/src/repository/key.ts b/src/repository/key.ts index 65ffdd6ff..9fb6375fd 100644 --- a/src/repository/key.ts +++ b/src/repository/key.ts @@ -874,30 +874,32 @@ export async function findKeysWithStatisticsBatch( } } - // Step 3: Query last usage for all keys at once using DISTINCT ON - const lastUsageRows = await db - .selectDistinctOn([messageRequest.key], { - key: messageRequest.key, - createdAt: messageRequest.createdAt, - providerName: providers.name, - }) - .from(messageRequest) - .innerJoin(providers, eq(messageRequest.providerId, providers.id)) - .where( - and( - inArray(messageRequest.key, keyStrings), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION - ) - ) - .orderBy(messageRequest.key, desc(messageRequest.createdAt)); + // Step 3: Query last usage for all keys via LATERAL JOIN (1 index probe per key) + const lastUsageResult = await db.execute(sql` + SELECT k.key_val AS key, lr.created_at, p.name AS provider_name + FROM unnest(${keyStrings}::varchar[]) AS k(key_val) + LEFT JOIN LATERAL ( + SELECT mr.created_at, mr.provider_id + FROM message_request mr + WHERE mr.key = k.key_val + AND mr.deleted_at IS NULL + AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') + ORDER BY mr.created_at DESC NULLS LAST + LIMIT 1 + ) lr ON true + LEFT JOIN providers p ON lr.provider_id = p.id + `); const lastUsageMap = new Map(); - for (const row of lastUsageRows) { + for (const row of Array.from(lastUsageResult) as Array<{ + key: string | null; + created_at: Date | null; + provider_name: string | null; + }>) { if (row.key) { lastUsageMap.set(row.key, { - createdAt: row.createdAt, - providerName: row.providerName, + createdAt: row.created_at ?? null, + providerName: row.provider_name ?? null, }); } } diff --git a/tests/unit/repository/key-usage-token-overflow.test.ts b/tests/unit/repository/key-usage-token-overflow.test.ts index e3bd6e215..db8f2d5f6 100644 --- a/tests/unit/repository/key-usage-token-overflow.test.ts +++ b/tests/unit/repository/key-usage-token-overflow.test.ts @@ -115,7 +115,6 @@ describe("Key usage token aggregation overflow", () => { ]) ); selectQueue.push(createThenableQuery([])); - selectQueue.push(createThenableQuery([])); const fallbackSelect = createThenableQuery([]); const selectMock = vi.fn((selection: unknown) => { @@ -123,13 +122,10 @@ describe("Key usage token aggregation overflow", () => { return selectQueue.shift() ?? fallbackSelect; }); - const selectDistinctOnMock = vi.fn(() => createThenableQuery([])); - vi.doMock("@/drizzle/db", () => ({ db: { select: selectMock, - selectDistinctOn: selectDistinctOnMock, - execute: vi.fn(async () => ({ count: 0 })), + execute: vi.fn(async () => []), }, })); From 1c549a6f150af6b180b88334a505085ccd88b34a Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 02:12:53 +0800 Subject: [PATCH 22/75] feat(repo): add shared ledger conditions and types --- src/repository/_shared/ledger-conditions.ts | 14 ++++++++++++++ src/types/usage-ledger.ts | 14 ++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 src/repository/_shared/ledger-conditions.ts create mode 100644 src/types/usage-ledger.ts diff --git a/src/repository/_shared/ledger-conditions.ts b/src/repository/_shared/ledger-conditions.ts new file mode 100644 index 000000000..1a192da70 --- /dev/null +++ b/src/repository/_shared/ledger-conditions.ts @@ -0,0 +1,14 @@ +import { sql } from "drizzle-orm"; +import { usageLedger } from "@/drizzle/schema"; + +/** + * 只统计未被阻断的请求。 + * Warmup 行在触发器层面已过滤,不会进入 usage_ledger, + * 因此此处只需排除 blocked_by IS NOT NULL 的记录。 + */ +export const LEDGER_BILLING_CONDITION = sql`(${usageLedger.blockedBy} IS NULL)`; + +/** + * 非计费查询中排除被阻断请求的别名条件(语义更清晰)。 + */ +export const LEDGER_ACTIVE_CONDITION = LEDGER_BILLING_CONDITION; diff --git a/src/types/usage-ledger.ts b/src/types/usage-ledger.ts new file mode 100644 index 000000000..2e9193a78 --- /dev/null +++ b/src/types/usage-ledger.ts @@ -0,0 +1,14 @@ +import type { InferSelectModel } from "drizzle-orm"; +import type { usageLedger } from "@/drizzle/schema"; +import type { TimeRange } from "@/types/statistics"; + +export type UsageLedgerRow = InferSelectModel; + +export interface LedgerAggregation { + totalCostUsd: string; + totalInputTokens: string; + totalOutputTokens: string; + requestCount: string; +} + +export type LedgerTimeRange = TimeRange; From 076684bc380b9fb358fe3abdf8a187a93f6bb584 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 02:13:29 +0800 Subject: [PATCH 23/75] feat(schema): add usage_ledger table definition --- src/drizzle/schema.ts | 58 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 9f551957d..06ad18358 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -828,6 +828,64 @@ export const notificationTargetBindings = pgTable( }) ); +// Usage Ledger table - immutable audit log, no FK constraints, no deletedAt/updatedAt +export const usageLedger = pgTable('usage_ledger', { + id: serial('id').primaryKey(), + requestId: integer('request_id').notNull(), + userId: integer('user_id').notNull(), + key: varchar('key').notNull(), + providerId: integer('provider_id').notNull(), + finalProviderId: integer('final_provider_id').notNull(), + model: varchar('model', { length: 128 }), + originalModel: varchar('original_model', { length: 128 }), + endpoint: varchar('endpoint', { length: 256 }), + apiType: varchar('api_type', { length: 20 }), + sessionId: varchar('session_id', { length: 64 }), + statusCode: integer('status_code'), + isSuccess: boolean('is_success').notNull().default(false), + blockedBy: varchar('blocked_by', { length: 50 }), + costUsd: numeric('cost_usd', { precision: 21, scale: 15 }).default('0'), + costMultiplier: numeric('cost_multiplier', { precision: 10, scale: 4 }), + inputTokens: bigint('input_tokens', { mode: 'number' }), + outputTokens: bigint('output_tokens', { mode: 'number' }), + cacheCreationInputTokens: bigint('cache_creation_input_tokens', { mode: 'number' }), + cacheReadInputTokens: bigint('cache_read_input_tokens', { mode: 'number' }), + cacheCreation5mInputTokens: bigint('cache_creation_5m_input_tokens', { mode: 'number' }), + cacheCreation1hInputTokens: bigint('cache_creation_1h_input_tokens', { mode: 'number' }), + cacheTtlApplied: varchar('cache_ttl_applied', { length: 10 }), + context1mApplied: boolean('context_1m_applied').default(false), + swapCacheTtlApplied: boolean('swap_cache_ttl_applied').default(false), + durationMs: integer('duration_ms'), + ttfbMs: integer('ttfb_ms'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), +}, (table) => ({ + // UNIQUE on requestId (survives message_request log deletion) + usageLedgerRequestIdIdx: uniqueIndex('idx_usage_ledger_request_id').on(table.requestId), + usageLedgerUserCreatedAtIdx: index('idx_usage_ledger_user_created_at') + .on(table.userId, table.createdAt) + .where(sql`${table.blockedBy} IS NULL`), + usageLedgerKeyCreatedAtIdx: index('idx_usage_ledger_key_created_at') + .on(table.key, table.createdAt) + .where(sql`${table.blockedBy} IS NULL`), + usageLedgerProviderCreatedAtIdx: index('idx_usage_ledger_provider_created_at') + .on(table.finalProviderId, table.createdAt) + .where(sql`${table.blockedBy} IS NULL`), + // Expression index on minute truncation - AT TIME ZONE 'UTC' makes date_trunc IMMUTABLE on timestamptz + usageLedgerCreatedAtMinuteIdx: index('idx_usage_ledger_created_at_minute') + .on(sql`date_trunc('minute', ${table.createdAt} AT TIME ZONE 'UTC')`), + usageLedgerCreatedAtDescIdIdx: index('idx_usage_ledger_created_at_desc_id') + .on(table.createdAt.desc(), table.id.desc()), + usageLedgerSessionIdIdx: index('idx_usage_ledger_session_id') + .on(table.sessionId) + .where(sql`${table.sessionId} IS NOT NULL`), + usageLedgerModelIdx: index('idx_usage_ledger_model') + .on(table.model) + .where(sql`${table.model} IS NOT NULL`), + usageLedgerKeyCostIdx: index('idx_usage_ledger_key_cost') + .on(table.key, table.costUsd) + .where(sql`${table.blockedBy} IS NULL`), +})); + // Relations export const usersRelations = relations(users, ({ many }) => ({ keys: many(keys), From 339c55681cb075a2440fb0b637de9bfc9b0901c4 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 02:15:04 +0800 Subject: [PATCH 24/75] feat(repo): add usage-ledger repository module --- src/repository/index.ts | 8 ++ src/repository/usage-ledger.ts | 160 +++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 src/repository/usage-ledger.ts diff --git a/src/repository/index.ts b/src/repository/index.ts index a4f28f4fa..299768236 100644 --- a/src/repository/index.ts +++ b/src/repository/index.ts @@ -76,5 +76,13 @@ export { } from "./statistics"; // System settings related exports export { getSystemSettings, updateSystemSettings } from "./system-config"; +// Usage ledger related exports +export { + countLedgerRequestsInTimeRange, + sumLedgerCostInTimeRange, + sumLedgerQuotaCosts, + sumLedgerTotalCost, + sumLedgerTotalCostBatch, +} from "./usage-ledger"; // User related exports export { createUser, deleteUser, findUserById, findUserList, updateUser } from "./user"; diff --git a/src/repository/usage-ledger.ts b/src/repository/usage-ledger.ts new file mode 100644 index 000000000..ed60cd4ec --- /dev/null +++ b/src/repository/usage-ledger.ts @@ -0,0 +1,160 @@ +import "server-only"; + +import { and, eq, gte, inArray, lt, sql } from "drizzle-orm"; +import { db } from "@/drizzle/db"; +import { usageLedger } from "@/drizzle/schema"; +import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; + +type EntityType = "user" | "key" | "provider"; + +function entityCondition(entityType: EntityType, entityId: number | string) { + if (entityType === "user") { + return eq(usageLedger.userId, entityId as number); + } else if (entityType === "key") { + return eq(usageLedger.key, entityId as string); + } else { + return eq(usageLedger.finalProviderId, entityId as number); + } +} + +/** + * Unified cost sum for quota checks within a time range. + */ +export async function sumLedgerCostInTimeRange( + entityType: EntityType, + entityId: number | string, + startTime: Date, + endTime: Date +): Promise { + const result = await db + .select({ total: sql`COALESCE(SUM(${usageLedger.costUsd}), '0')` }) + .from(usageLedger) + .where( + and( + entityCondition(entityType, entityId), + gte(usageLedger.createdAt, startTime), + lt(usageLedger.createdAt, endTime), + LEDGER_BILLING_CONDITION + ) + ); + return result[0]?.total ?? "0"; +} + +/** + * Total cost with optional resetAt support (for total-limit quota checks). + * resetAt=null means all-time; a valid Date means cumulative from that point. + */ +export async function sumLedgerTotalCost( + entityType: EntityType, + entityId: number | string, + resetAt?: Date | null +): Promise { + const effectiveStart = + resetAt instanceof Date && !Number.isNaN(resetAt.getTime()) ? resetAt : null; + + const result = await db + .select({ total: sql`COALESCE(SUM(${usageLedger.costUsd}), '0')` }) + .from(usageLedger) + .where( + and( + entityCondition(entityType, entityId), + LEDGER_BILLING_CONDITION, + ...(effectiveStart ? [gte(usageLedger.createdAt, effectiveStart)] : []) + ) + ); + return result[0]?.total ?? "0"; +} + +/** + * Batch total cost grouped by entity (single SQL query). + * Returns Map of entityId (as string) -> totalCost. + */ +export async function sumLedgerTotalCostBatch( + entityType: "user" | "key", + entityIds: number[] | string[] +): Promise> { + const result = new Map(); + if (entityIds.length === 0) return result; + + for (const id of entityIds) { + result.set(String(id), "0"); + } + + if (entityType === "user") { + const ids = entityIds as number[]; + const rows = await db + .select({ + entityId: usageLedger.userId, + total: sql`COALESCE(SUM(${usageLedger.costUsd}), '0')`, + }) + .from(usageLedger) + .where(and(inArray(usageLedger.userId, ids), LEDGER_BILLING_CONDITION)) + .groupBy(usageLedger.userId); + for (const row of rows) { + result.set(String(row.entityId), row.total ?? "0"); + } + } else { + const ids = entityIds as string[]; + const rows = await db + .select({ + entityId: usageLedger.key, + total: sql`COALESCE(SUM(${usageLedger.costUsd}), '0')`, + }) + .from(usageLedger) + .where(and(inArray(usageLedger.key, ids), LEDGER_BILLING_CONDITION)) + .groupBy(usageLedger.key); + for (const row of rows) { + result.set(row.entityId, row.total ?? "0"); + } + } + + return result; +} + +/** + * Multi-period quota costs in a single query via conditional aggregation. + * Returns costs in the same order as the input ranges array. + */ +export async function sumLedgerQuotaCosts( + entityType: "user" | "key", + entityId: number | string, + ranges: Array<{ start: Date; end: Date }> +): Promise { + if (ranges.length === 0) return []; + + const caseParts = ranges.map( + ({ start, end }, i) => + sql`COALESCE(SUM(CASE WHEN ${usageLedger.createdAt} >= ${start} AND ${usageLedger.createdAt} < ${end} THEN ${usageLedger.costUsd} ELSE 0 END), '0') AS ${sql.raw(`r${i}`)}` + ); + + const selectExpr = sql.join(caseParts, sql`, `); + const entityCond = entityCondition(entityType, entityId); + const query = sql`SELECT ${selectExpr} FROM ${usageLedger} WHERE ${and(entityCond, LEDGER_BILLING_CONDITION)}`; + + const rows = await db.execute(query); + const row = (rows as unknown as Record[])[0] ?? {}; + return ranges.map((_, i) => row[`r${i}`] ?? "0"); +} + +/** + * Request count within a time range for rate-limit checks. + */ +export async function countLedgerRequestsInTimeRange( + entityType: "user" | "key", + entityId: number | string, + startTime: Date, + endTime: Date +): Promise { + const result = await db + .select({ count: sql`COUNT(*)` }) + .from(usageLedger) + .where( + and( + entityCondition(entityType, entityId), + gte(usageLedger.createdAt, startTime), + lt(usageLedger.createdAt, endTime), + LEDGER_BILLING_CONDITION + ) + ); + return Number(result[0]?.count ?? 0); +} From 3bc82b420fc2a601fec2f5d4f98d0db348dc0331 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 02:17:36 +0800 Subject: [PATCH 25/75] feat(db): add fn_upsert_usage_ledger trigger function --- src/lib/ledger-backfill/trigger.sql | 83 +++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/lib/ledger-backfill/trigger.sql diff --git a/src/lib/ledger-backfill/trigger.sql b/src/lib/ledger-backfill/trigger.sql new file mode 100644 index 000000000..ef937df97 --- /dev/null +++ b/src/lib/ledger-backfill/trigger.sql @@ -0,0 +1,83 @@ +CREATE OR REPLACE FUNCTION fn_upsert_usage_ledger() +RETURNS TRIGGER AS $$ +DECLARE + v_final_provider_id integer; + v_is_success boolean; +BEGIN + IF NEW.blocked_by = 'warmup' THEN + RETURN NEW; + END IF; + + IF NEW.provider_chain IS NOT NULL + AND jsonb_typeof(NEW.provider_chain) = 'array' + AND jsonb_array_length(NEW.provider_chain) > 0 + AND jsonb_typeof(NEW.provider_chain -> -1) = 'object' + AND (NEW.provider_chain -> -1 ? 'id') + AND (NEW.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' THEN + v_final_provider_id := (NEW.provider_chain -> -1 ->> 'id')::integer; + ELSE + v_final_provider_id := NEW.provider_id; + END IF; + + v_is_success := (NEW.error_message IS NULL OR NEW.error_message = ''); + + INSERT INTO usage_ledger ( + request_id, user_id, key, provider_id, final_provider_id, + model, original_model, endpoint, api_type, session_id, + status_code, is_success, blocked_by, + cost_usd, cost_multiplier, + input_tokens, output_tokens, + cache_creation_input_tokens, cache_read_input_tokens, + cache_creation_5m_input_tokens, cache_creation_1h_input_tokens, + cache_ttl_applied, context_1m_applied, swap_cache_ttl_applied, + duration_ms, ttfb_ms, created_at + ) VALUES ( + NEW.id, NEW.user_id, NEW.key, NEW.provider_id, v_final_provider_id, + NEW.model, NEW.original_model, NEW.endpoint, NEW.api_type, NEW.session_id, + NEW.status_code, v_is_success, NEW.blocked_by, + NEW.cost_usd, NEW.cost_multiplier, + NEW.input_tokens, NEW.output_tokens, + NEW.cache_creation_input_tokens, NEW.cache_read_input_tokens, + NEW.cache_creation_5m_input_tokens, NEW.cache_creation_1h_input_tokens, + NEW.cache_ttl_applied, NEW.context_1m_applied, NEW.swap_cache_ttl_applied, + NEW.duration_ms, NEW.ttfb_ms, NEW.created_at + ) + ON CONFLICT (request_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + key = EXCLUDED.key, + provider_id = EXCLUDED.provider_id, + final_provider_id = EXCLUDED.final_provider_id, + model = EXCLUDED.model, + original_model = EXCLUDED.original_model, + endpoint = EXCLUDED.endpoint, + api_type = EXCLUDED.api_type, + session_id = EXCLUDED.session_id, + status_code = EXCLUDED.status_code, + is_success = EXCLUDED.is_success, + blocked_by = EXCLUDED.blocked_by, + cost_usd = EXCLUDED.cost_usd, + cost_multiplier = EXCLUDED.cost_multiplier, + input_tokens = EXCLUDED.input_tokens, + output_tokens = EXCLUDED.output_tokens, + cache_creation_input_tokens = EXCLUDED.cache_creation_input_tokens, + cache_read_input_tokens = EXCLUDED.cache_read_input_tokens, + cache_creation_5m_input_tokens = EXCLUDED.cache_creation_5m_input_tokens, + cache_creation_1h_input_tokens = EXCLUDED.cache_creation_1h_input_tokens, + cache_ttl_applied = EXCLUDED.cache_ttl_applied, + context_1m_applied = EXCLUDED.context_1m_applied, + swap_cache_ttl_applied = EXCLUDED.swap_cache_ttl_applied, + duration_ms = EXCLUDED.duration_ms, + ttfb_ms = EXCLUDED.ttfb_ms, + created_at = EXCLUDED.created_at; + + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + RAISE WARNING 'fn_upsert_usage_ledger failed for request_id=%: %', NEW.id, SQLERRM; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_upsert_usage_ledger +AFTER INSERT OR UPDATE ON message_request +FOR EACH ROW +EXECUTE FUNCTION fn_upsert_usage_ledger(); From 76c20cb392ff33799300d22c403b4faae1d724bb Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 02:19:49 +0800 Subject: [PATCH 26/75] test(ledger): add trigger verification test infrastructure --- src/lib/ledger-backfill/index.ts | 3 + src/lib/ledger-backfill/service.ts | 80 ++++++++++++++++++++++ tests/unit/usage-ledger/backfill.test.ts | 37 ++++++++++ tests/unit/usage-ledger/repository.test.ts | 57 +++++++++++++++ tests/unit/usage-ledger/trigger.test.ts | 31 +++++++++ 5 files changed, 208 insertions(+) create mode 100644 src/lib/ledger-backfill/index.ts create mode 100644 src/lib/ledger-backfill/service.ts create mode 100644 tests/unit/usage-ledger/backfill.test.ts create mode 100644 tests/unit/usage-ledger/repository.test.ts create mode 100644 tests/unit/usage-ledger/trigger.test.ts diff --git a/src/lib/ledger-backfill/index.ts b/src/lib/ledger-backfill/index.ts new file mode 100644 index 000000000..4e02ae67b --- /dev/null +++ b/src/lib/ledger-backfill/index.ts @@ -0,0 +1,3 @@ +import "server-only"; + +export { backfillUsageLedger } from "./service"; diff --git a/src/lib/ledger-backfill/service.ts b/src/lib/ledger-backfill/service.ts new file mode 100644 index 000000000..1ba1b0dee --- /dev/null +++ b/src/lib/ledger-backfill/service.ts @@ -0,0 +1,80 @@ +import "server-only"; + +import { sql } from "drizzle-orm"; +import { db } from "@/drizzle/db"; + +export async function backfillUsageLedger(): Promise<{ inserted: number }> { + const LOCK_KEY = 20260101; + + const result = await db.execute(sql` + SELECT pg_try_advisory_lock(${LOCK_KEY}) AS acquired + `); + + const acquired = (result as unknown as Array<{ acquired: boolean }>)[0]?.acquired; + if (!acquired) { + return { inserted: 0 }; + } + + try { + const insertResult = await db.execute(sql` + INSERT INTO usage_ledger ( + request_id, user_id, key, provider_id, final_provider_id, + model, original_model, endpoint, api_type, session_id, + status_code, is_success, blocked_by, + cost_usd, cost_multiplier, + input_tokens, output_tokens, + cache_creation_input_tokens, cache_read_input_tokens, + cache_creation_5m_input_tokens, cache_creation_1h_input_tokens, + cache_ttl_applied, context_1m_applied, swap_cache_ttl_applied, + duration_ms, ttfb_ms, created_at + ) + SELECT + mr.id, + mr.user_id, + mr.key, + mr.provider_id, + COALESCE( + CASE + WHEN mr.provider_chain IS NOT NULL + AND jsonb_typeof(mr.provider_chain) = 'array' + AND jsonb_array_length(mr.provider_chain) > 0 + AND jsonb_typeof(mr.provider_chain -> -1) = 'object' + AND (mr.provider_chain -> -1 ? 'id') + AND (mr.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' + THEN (mr.provider_chain -> -1 ->> 'id')::integer + END, + mr.provider_id + ), + mr.model, + mr.original_model, + mr.endpoint, + mr.api_type, + mr.session_id, + mr.status_code, + (mr.error_message IS NULL OR mr.error_message = ''), + mr.blocked_by, + mr.cost_usd, + mr.cost_multiplier, + mr.input_tokens, + mr.output_tokens, + mr.cache_creation_input_tokens, + mr.cache_read_input_tokens, + mr.cache_creation_5m_input_tokens, + mr.cache_creation_1h_input_tokens, + mr.cache_ttl_applied, + mr.context_1m_applied, + mr.swap_cache_ttl_applied, + mr.duration_ms, + mr.ttfb_ms, + mr.created_at + FROM message_request mr + WHERE mr.blocked_by IS DISTINCT FROM 'warmup' + ON CONFLICT (request_id) DO NOTHING + `); + + const inserted = Number((insertResult as unknown as { rowCount?: number }).rowCount ?? 0); + return { inserted }; + } finally { + await db.execute(sql`SELECT pg_advisory_unlock(${LOCK_KEY})`); + } +} diff --git a/tests/unit/usage-ledger/backfill.test.ts b/tests/unit/usage-ledger/backfill.test.ts new file mode 100644 index 000000000..f661e7adc --- /dev/null +++ b/tests/unit/usage-ledger/backfill.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +process.env.DSN = ""; + +vi.mock("@/drizzle/db", () => ({ + db: { + execute: vi.fn(), + }, +})); + +vi.mock("drizzle-orm", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, sql: actual.sql }; +}); + +const { backfillUsageLedger } = await import("@/lib/ledger-backfill"); + +const serviceSource = readFileSync( + resolve(process.cwd(), "src/lib/ledger-backfill/service.ts"), + "utf-8" +); + +describe("backfillUsageLedger", () => { + it("exports backfillUsageLedger function", () => { + expect(typeof backfillUsageLedger).toBe("function"); + }); + + it("uses ON CONFLICT in backfill SQL", () => { + expect(serviceSource).toContain("ON CONFLICT"); + }); + + it("uses DO NOTHING in backfill SQL", () => { + expect(serviceSource).toContain("DO NOTHING"); + }); +}); diff --git a/tests/unit/usage-ledger/repository.test.ts b/tests/unit/usage-ledger/repository.test.ts new file mode 100644 index 000000000..7b8c44fe6 --- /dev/null +++ b/tests/unit/usage-ledger/repository.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; + +process.env.DSN = ""; + +vi.mock("@/drizzle/db", () => ({ + db: { + select: vi.fn(() => ({ from: vi.fn(() => ({ where: vi.fn(async () => []) })) })), + execute: vi.fn(async () => []), + }, +})); + +vi.mock("drizzle-orm", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + and: vi.fn((...args: unknown[]) => args), + eq: vi.fn((...args: unknown[]) => args), + gte: vi.fn((...args: unknown[]) => args), + lt: vi.fn((...args: unknown[]) => args), + inArray: vi.fn((...args: unknown[]) => args), + }; +}); + +vi.mock("@/drizzle/schema", () => ({ + usageLedger: { + userId: "user_id", + key: "key", + finalProviderId: "final_provider_id", + costUsd: "cost_usd", + createdAt: "created_at", + blockedBy: "blocked_by", + }, +})); + +vi.mock("@/repository/_shared/ledger-conditions", () => ({ + LEDGER_BILLING_CONDITION: {}, +})); + +const repo = await import("@/repository/usage-ledger"); + +describe("usage-ledger repository", () => { + it("exports sumLedgerCostInTimeRange", () => { + expect(typeof repo.sumLedgerCostInTimeRange).toBe("function"); + }); + + it("exports sumLedgerTotalCost", () => { + expect(typeof repo.sumLedgerTotalCost).toBe("function"); + }); + + it("exports sumLedgerTotalCostBatch", () => { + expect(typeof repo.sumLedgerTotalCostBatch).toBe("function"); + }); + + it("exports countLedgerRequestsInTimeRange", () => { + expect(typeof repo.countLedgerRequestsInTimeRange).toBe("function"); + }); +}); diff --git a/tests/unit/usage-ledger/trigger.test.ts b/tests/unit/usage-ledger/trigger.test.ts new file mode 100644 index 000000000..6890dedcb --- /dev/null +++ b/tests/unit/usage-ledger/trigger.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const sql = readFileSync(resolve(process.cwd(), "src/lib/ledger-backfill/trigger.sql"), "utf-8"); + +describe("fn_upsert_usage_ledger trigger SQL", () => { + it("contains warmup exclusion check", () => { + expect(sql).toContain("blocked_by = 'warmup'"); + }); + + it("contains ON CONFLICT UPSERT", () => { + expect(sql).toContain("ON CONFLICT (request_id) DO UPDATE"); + }); + + it("contains EXCEPTION error handling", () => { + expect(sql).toContain("EXCEPTION WHEN OTHERS"); + }); + + it("pre-validates provider_chain before extraction", () => { + expect(sql).toContain("jsonb_typeof"); + }); + + it("computes is_success from error_message", () => { + expect(sql).toContain("error_message IS NULL"); + }); + + it("creates trigger binding", () => { + expect(sql).toContain("CREATE TRIGGER trg_upsert_usage_ledger"); + }); +}); From 399cf65518a588c128ffbd9b15e9843e833b0e2a Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 02:28:14 +0800 Subject: [PATCH 27/75] refactor(repo): migrate overview.ts read paths to usage_ledger - getOverviewMetrics: read from usageLedger instead of messageRequest - getOverviewMetricsWithComparison: same migration for all 3 parallel queries - Error rate: statusCode >= 400 -> NOT isSuccess (pre-computed boolean) - EXCLUDE_WARMUP_CONDITION -> LEDGER_BILLING_CONDITION - Removed deletedAt null checks (ledger has no deletedAt) --- .../evidence/task-9-overview-migrated.txt | 29 ++++++++++ src/repository/overview.ts | 58 +++++++++---------- 2 files changed, 56 insertions(+), 31 deletions(-) create mode 100644 .sisyphus/evidence/task-9-overview-migrated.txt diff --git a/.sisyphus/evidence/task-9-overview-migrated.txt b/.sisyphus/evidence/task-9-overview-migrated.txt new file mode 100644 index 000000000..dcd384f17 --- /dev/null +++ b/.sisyphus/evidence/task-9-overview-migrated.txt @@ -0,0 +1,29 @@ +Task 9: Migrate overview.ts read paths to usage_ledger +======================================================== + +File: src/repository/overview.ts + +Changes: +- Imports: messageRequest -> usageLedger, EXCLUDE_WARMUP_CONDITION -> LEDGER_BILLING_CONDITION +- Removed: isNull import (no deletedAt on ledger) + +getOverviewMetrics(): +- .from(messageRequest) -> .from(usageLedger) +- messageRequest.costUsd -> usageLedger.costUsd +- messageRequest.durationMs -> usageLedger.durationMs +- messageRequest.createdAt -> usageLedger.createdAt +- Error rate: statusCode >= 400 -> NOT isSuccess (pre-computed boolean) +- Removed: isNull(messageRequest.deletedAt) +- EXCLUDE_WARMUP_CONDITION -> LEDGER_BILLING_CONDITION + +getOverviewMetricsWithComparison(): +- Same pattern as above for all 3 parallel queries (today, yesterday, RPM) +- messageRequest.userId -> usageLedger.userId +- Error rate: statusCode >= 400 -> NOT isSuccess + +Interfaces unchanged: +- OverviewMetrics: todayRequests, todayCost, avgResponseTime, todayErrorRate +- OverviewMetricsWithComparison: extends OverviewMetrics + yesterday comparison + RPM + +Verification: +- bun run typecheck: EXIT_CODE:0 (clean) diff --git a/src/repository/overview.ts b/src/repository/overview.ts index c1cfaeb84..61f337087 100644 --- a/src/repository/overview.ts +++ b/src/repository/overview.ts @@ -1,11 +1,11 @@ "use server"; -import { and, avg, count, eq, gte, isNull, lt, sql, sum } from "drizzle-orm"; +import { and, avg, count, eq, gte, lt, sql, sum } from "drizzle-orm"; import { db } from "@/drizzle/db"; -import { messageRequest } from "@/drizzle/schema"; +import { usageLedger } from "@/drizzle/schema"; import { Decimal, toCostDecimal } from "@/lib/utils/currency"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; -import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; +import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; /** * 今日概览统计数据 @@ -50,17 +50,16 @@ export async function getOverviewMetrics(): Promise { const [result] = await db .select({ requestCount: count(), - totalCost: sum(messageRequest.costUsd), - avgDuration: avg(messageRequest.durationMs), - errorCount: sql`count(*) FILTER (WHERE ${messageRequest.statusCode} >= 400)`, + totalCost: sum(usageLedger.costUsd), + avgDuration: avg(usageLedger.durationMs), + errorCount: sql`count(*) FILTER (WHERE NOT ${usageLedger.isSuccess})`, }) - .from(messageRequest) + .from(usageLedger) .where( and( - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - gte(messageRequest.createdAt, todayStart), - lt(messageRequest.createdAt, tomorrowStart) + LEDGER_BILLING_CONDITION, + gte(usageLedger.createdAt, todayStart), + lt(usageLedger.createdAt, tomorrowStart) ) ); @@ -104,7 +103,7 @@ export async function getOverviewMetricsWithComparison( const yesterdayEnd = sql`(${yesterdayEndLocal} AT TIME ZONE ${timezone})`; // 用户过滤条件 - const userCondition = userId ? eq(messageRequest.userId, userId) : undefined; + const userCondition = userId ? eq(usageLedger.userId, userId) : undefined; // 并行查询今日数据、昨日同时段数据、最近1分钟数据 const [todayResult, yesterdayResult, rpmResult] = await Promise.all([ @@ -112,18 +111,17 @@ export async function getOverviewMetricsWithComparison( db .select({ requestCount: count(), - totalCost: sum(messageRequest.costUsd), - avgDuration: avg(messageRequest.durationMs), - errorCount: sql`count(*) FILTER (WHERE ${messageRequest.statusCode} >= 400)`, + totalCost: sum(usageLedger.costUsd), + avgDuration: avg(usageLedger.durationMs), + errorCount: sql`count(*) FILTER (WHERE NOT ${usageLedger.isSuccess})`, }) - .from(messageRequest) + .from(usageLedger) .where( and( - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, + LEDGER_BILLING_CONDITION, userCondition, - gte(messageRequest.createdAt, todayStart), - lt(messageRequest.createdAt, tomorrowStart) + gte(usageLedger.createdAt, todayStart), + lt(usageLedger.createdAt, tomorrowStart) ) ), @@ -131,17 +129,16 @@ export async function getOverviewMetricsWithComparison( db .select({ requestCount: count(), - totalCost: sum(messageRequest.costUsd), - avgDuration: avg(messageRequest.durationMs), + totalCost: sum(usageLedger.costUsd), + avgDuration: avg(usageLedger.durationMs), }) - .from(messageRequest) + .from(usageLedger) .where( and( - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, + LEDGER_BILLING_CONDITION, userCondition, - gte(messageRequest.createdAt, yesterdayStart), - lt(messageRequest.createdAt, yesterdayEnd) + gte(usageLedger.createdAt, yesterdayStart), + lt(usageLedger.createdAt, yesterdayEnd) ) ), @@ -150,13 +147,12 @@ export async function getOverviewMetricsWithComparison( .select({ requestCount: count(), }) - .from(messageRequest) + .from(usageLedger) .where( and( - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, + LEDGER_BILLING_CONDITION, userCondition, - gte(messageRequest.createdAt, sql`CURRENT_TIMESTAMP - INTERVAL '1 minute'`) + gte(usageLedger.createdAt, sql`CURRENT_TIMESTAMP - INTERVAL '1 minute'`) ) ), ]); From 9bf89df7e6504580bb54e5855b293f74316b4609 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 02:30:16 +0800 Subject: [PATCH 28/75] feat(migration): add idempotent ledger backfill service --- src/lib/ledger-backfill/service.ts | 208 +++++++++++++++++++++-------- 1 file changed, 150 insertions(+), 58 deletions(-) diff --git a/src/lib/ledger-backfill/service.ts b/src/lib/ledger-backfill/service.ts index 1ba1b0dee..48a4eaee4 100644 --- a/src/lib/ledger-backfill/service.ts +++ b/src/lib/ledger-backfill/service.ts @@ -2,8 +2,17 @@ import "server-only"; import { sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; +import { logger } from "@/lib/logger"; -export async function backfillUsageLedger(): Promise<{ inserted: number }> { +export interface BackfillUsageLedgerSummary { + totalProcessed: number; + totalInserted: number; + durationMs: number; + alreadyExisted: number; +} + +export async function backfillUsageLedger(): Promise { + const startTime = Date.now(); const LOCK_KEY = 20260101; const result = await db.execute(sql` @@ -12,68 +21,151 @@ export async function backfillUsageLedger(): Promise<{ inserted: number }> { const acquired = (result as unknown as Array<{ acquired: boolean }>)[0]?.acquired; if (!acquired) { - return { inserted: 0 }; + return { + totalProcessed: 0, + totalInserted: 0, + durationMs: Date.now() - startTime, + alreadyExisted: 0, + }; } try { - const insertResult = await db.execute(sql` - INSERT INTO usage_ledger ( - request_id, user_id, key, provider_id, final_provider_id, - model, original_model, endpoint, api_type, session_id, - status_code, is_success, blocked_by, - cost_usd, cost_multiplier, - input_tokens, output_tokens, - cache_creation_input_tokens, cache_read_input_tokens, - cache_creation_5m_input_tokens, cache_creation_1h_input_tokens, - cache_ttl_applied, context_1m_applied, swap_cache_ttl_applied, - duration_ms, ttfb_ms, created_at - ) - SELECT - mr.id, - mr.user_id, - mr.key, - mr.provider_id, - COALESCE( - CASE - WHEN mr.provider_chain IS NOT NULL - AND jsonb_typeof(mr.provider_chain) = 'array' - AND jsonb_array_length(mr.provider_chain) > 0 - AND jsonb_typeof(mr.provider_chain -> -1) = 'object' - AND (mr.provider_chain -> -1 ? 'id') - AND (mr.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' - THEN (mr.provider_chain -> -1 ->> 'id')::integer - END, - mr.provider_id + let totalProcessed = 0; + let totalInserted = 0; + let lastId = 0; + + while (true) { + const batchResult = await db.execute(sql` + WITH batch AS ( + SELECT + mr.id, + mr.user_id, + mr.key, + mr.provider_id, + COALESCE( + CASE + WHEN mr.provider_chain IS NOT NULL + AND jsonb_typeof(mr.provider_chain) = 'array' + AND jsonb_array_length(mr.provider_chain) > 0 + AND jsonb_typeof(mr.provider_chain -> -1) = 'object' + AND (mr.provider_chain -> -1 ? 'id') + AND (mr.provider_chain -> -1 ->> 'id') ~ '^[0-9]+$' + THEN (mr.provider_chain -> -1 ->> 'id')::integer + END, + mr.provider_id + ) AS final_provider_id, + mr.model, + mr.original_model, + mr.endpoint, + mr.api_type, + mr.session_id, + mr.status_code, + (mr.error_message IS NULL OR mr.error_message = '') AS is_success, + mr.blocked_by, + mr.cost_usd, + mr.cost_multiplier, + mr.input_tokens, + mr.output_tokens, + mr.cache_creation_input_tokens, + mr.cache_read_input_tokens, + mr.cache_creation_5m_input_tokens, + mr.cache_creation_1h_input_tokens, + mr.cache_ttl_applied, + mr.context_1m_applied, + mr.swap_cache_ttl_applied, + mr.duration_ms, + mr.ttfb_ms, + mr.created_at + FROM message_request mr + WHERE mr.id > ${lastId} + AND mr.blocked_by IS DISTINCT FROM 'warmup' + ORDER BY mr.id ASC + LIMIT 10000 ), - mr.model, - mr.original_model, - mr.endpoint, - mr.api_type, - mr.session_id, - mr.status_code, - (mr.error_message IS NULL OR mr.error_message = ''), - mr.blocked_by, - mr.cost_usd, - mr.cost_multiplier, - mr.input_tokens, - mr.output_tokens, - mr.cache_creation_input_tokens, - mr.cache_read_input_tokens, - mr.cache_creation_5m_input_tokens, - mr.cache_creation_1h_input_tokens, - mr.cache_ttl_applied, - mr.context_1m_applied, - mr.swap_cache_ttl_applied, - mr.duration_ms, - mr.ttfb_ms, - mr.created_at - FROM message_request mr - WHERE mr.blocked_by IS DISTINCT FROM 'warmup' - ON CONFLICT (request_id) DO NOTHING - `); + inserted_rows AS ( + INSERT INTO usage_ledger ( + request_id, user_id, key, provider_id, final_provider_id, + model, original_model, endpoint, api_type, session_id, + status_code, is_success, blocked_by, + cost_usd, cost_multiplier, + input_tokens, output_tokens, + cache_creation_input_tokens, cache_read_input_tokens, + cache_creation_5m_input_tokens, cache_creation_1h_input_tokens, + cache_ttl_applied, context_1m_applied, swap_cache_ttl_applied, + duration_ms, ttfb_ms, created_at + ) + SELECT + batch.id, + batch.user_id, + batch.key, + batch.provider_id, + batch.final_provider_id, + batch.model, + batch.original_model, + batch.endpoint, + batch.api_type, + batch.session_id, + batch.status_code, + batch.is_success, + batch.blocked_by, + batch.cost_usd, + batch.cost_multiplier, + batch.input_tokens, + batch.output_tokens, + batch.cache_creation_input_tokens, + batch.cache_read_input_tokens, + batch.cache_creation_5m_input_tokens, + batch.cache_creation_1h_input_tokens, + batch.cache_ttl_applied, + batch.context_1m_applied, + batch.swap_cache_ttl_applied, + batch.duration_ms, + batch.ttfb_ms, + batch.created_at + FROM batch + ON CONFLICT (request_id) DO NOTHING + RETURNING request_id + ) + SELECT + COALESCE((SELECT COUNT(*) FROM batch), 0)::integer AS processed, + COALESCE((SELECT COUNT(*) FROM inserted_rows), 0)::integer AS inserted, + COALESCE((SELECT MAX(id) FROM batch), 0)::integer AS max_id + `); + + const batchRow = ( + batchResult as unknown as Array<{ + processed?: number | string; + inserted?: number | string; + max_id?: number | string; + }> + )[0]; + + const processed = Number(batchRow?.processed ?? 0); + const inserted = Number(batchRow?.inserted ?? 0); + const maxId = Number(batchRow?.max_id ?? 0); + + if (processed === 0) { + break; + } + + totalProcessed += processed; + totalInserted += inserted; + lastId = maxId; + + logger.info("Backfill progress", { + processed: totalProcessed, + inserted: totalInserted, + elapsed: Date.now() - startTime, + }); + } - const inserted = Number((insertResult as unknown as { rowCount?: number }).rowCount ?? 0); - return { inserted }; + const durationMs = Date.now() - startTime; + return { + totalProcessed, + totalInserted, + durationMs, + alreadyExisted: totalProcessed - totalInserted, + }; } finally { await db.execute(sql`SELECT pg_advisory_unlock(${LOCK_KEY})`); } From 4c9a6921ede30a901abecb04338590dd64c3fa42 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 02:31:38 +0800 Subject: [PATCH 29/75] refactor(repo): migrate message.ts session aggregation to usage_ledger - aggregateSessionStats: read from usageLedger instead of messageRequest - aggregateMultipleSessionStats: read from usageLedger instead of messageRequest - Use LEDGER_BILLING_CONDITION instead of EXCLUDE_WARMUP_CONDITION + FILTER - Use finalProviderId for provider sub-queries - Remove deletedAt checks (ledger has no soft-delete) - Detail-view functions unchanged (still on messageRequest) --- .../evidence/task-11-session-agg-migrated.txt | 31 ++++ src/repository/message.ts | 138 +++++++++--------- 2 files changed, 96 insertions(+), 73 deletions(-) create mode 100644 .sisyphus/evidence/task-11-session-agg-migrated.txt diff --git a/.sisyphus/evidence/task-11-session-agg-migrated.txt b/.sisyphus/evidence/task-11-session-agg-migrated.txt new file mode 100644 index 000000000..1a5f63438 --- /dev/null +++ b/.sisyphus/evidence/task-11-session-agg-migrated.txt @@ -0,0 +1,31 @@ +Task 11: Migrate message.ts session aggregation to usage_ledger +================================================================ + +File changed: src/repository/message.ts + +Functions migrated: +1. aggregateSessionStats() - single session stats +2. aggregateMultipleSessionStats() - batch session stats + +Changes per function: +- .from(messageRequest) -> .from(usageLedger) +- messageRequest.X -> usageLedger.X for all aggregated columns +- EXCLUDE_WARMUP_CONDITION -> LEDGER_BILLING_CONDITION (warmup already excluded at trigger level) +- Removed isNull(messageRequest.deletedAt) (no deletedAt on ledger) +- Provider sub-query: messageRequest.providerId -> usageLedger.finalProviderId +- Cache TTL sub-query: messageRequest.cacheTtlApplied -> usageLedger.cacheTtlApplied +- Model sub-query: messageRequest.model -> usageLedger.model +- FILTER (WHERE EXCLUDE_WARMUP_CONDITION) removed from aggregates (ledger has no warmup rows) + +NOT migrated (detail-view, stays on messageRequest): +- userInfo sub-query (step 4) - needs userAgent, apiType, key join +- findMessageRequestById, findMessageRequestBySessionId, etc. + +Import changes: +- Added: usageLedger from schema +- Added: LEDGER_BILLING_CONDITION from ledger-conditions +- Kept: messageRequest, EXCLUDE_WARMUP_CONDITION (used by detail functions) + +Return types: UNCHANGED + +Typecheck: PASS (tsgo exit code 0, 0.51s) diff --git a/src/repository/message.ts b/src/repository/message.ts index 41bacde92..c1eb50d2a 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -2,11 +2,12 @@ import { and, asc, desc, eq, gt, inArray, isNull, lt, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; -import { keys as keysTable, messageRequest, providers, users } from "@/drizzle/schema"; +import { keys as keysTable, messageRequest, providers, usageLedger, users } from "@/drizzle/schema"; import { getEnvConfig } from "@/lib/config/env.schema"; import { formatCostForStorage } from "@/lib/utils/currency"; import type { CreateMessageRequestData, MessageRequest, ProviderChainItem } from "@/types/message"; import type { SpecialSetting } from "@/types/special-settings"; +import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; import { toMessageRequest } from "./_shared/transformers"; import { enqueueMessageRequestUpdate } from "./message-write-buffer"; @@ -382,69 +383,63 @@ export async function aggregateSessionStats(sessionId: string): Promise<{ apiType: string | null; cacheTtlApplied: string | null; } | null> { - // 1. 聚合统计 + // 1. 聚合统计(从 usageLedger 读取,warmup 已在触发器层面排除) const [stats] = await db .select({ - // Session 存在性:包含所有请求(含 warmup) - totalCount: sql`count(*)::double precision`, - // Session 统计:排除 warmup(不计入任何统计) - requestCount: sql`count(*) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision`, - totalCostUsd: sql`COALESCE(sum(${messageRequest.costUsd}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION}), 0)`, - totalInputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, - totalOutputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, - totalCacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, - totalCacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, - totalDurationMs: sql`COALESCE(sum(${messageRequest.durationMs}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, - firstRequestAt: sql`min(${messageRequest.createdAt}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})`, - lastRequestAt: sql`max(${messageRequest.createdAt}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})`, + requestCount: sql`count(*)::double precision`, + totalCostUsd: sql`COALESCE(sum(${usageLedger.costUsd}), 0)`, + totalInputTokens: sql`COALESCE(sum(${usageLedger.inputTokens})::double precision, 0::double precision)`, + totalOutputTokens: sql`COALESCE(sum(${usageLedger.outputTokens})::double precision, 0::double precision)`, + totalCacheCreationTokens: sql`COALESCE(sum(${usageLedger.cacheCreationInputTokens})::double precision, 0::double precision)`, + totalCacheReadTokens: sql`COALESCE(sum(${usageLedger.cacheReadInputTokens})::double precision, 0::double precision)`, + totalDurationMs: sql`COALESCE(sum(${usageLedger.durationMs})::double precision, 0::double precision)`, + firstRequestAt: sql`min(${usageLedger.createdAt})`, + lastRequestAt: sql`max(${usageLedger.createdAt})`, }) - .from(messageRequest) - .where(and(eq(messageRequest.sessionId, sessionId), isNull(messageRequest.deletedAt))); + .from(usageLedger) + .where(and(eq(usageLedger.sessionId, sessionId), LEDGER_BILLING_CONDITION)); - if (!stats || stats.totalCount === 0) { + if (!stats || stats.requestCount === 0) { return null; } // 2. 查询供应商列表(去重) const providerList = await db .selectDistinct({ - providerId: messageRequest.providerId, + providerId: usageLedger.finalProviderId, providerName: providers.name, }) - .from(messageRequest) - .leftJoin(providers, eq(messageRequest.providerId, providers.id)) + .from(usageLedger) + .leftJoin(providers, eq(usageLedger.finalProviderId, providers.id)) .where( and( - eq(messageRequest.sessionId, sessionId), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - sql`${messageRequest.providerId} IS NOT NULL` + eq(usageLedger.sessionId, sessionId), + LEDGER_BILLING_CONDITION, + sql`${usageLedger.finalProviderId} IS NOT NULL` ) ); // 3. 查询模型列表(去重) const modelList = await db - .selectDistinct({ model: messageRequest.model }) - .from(messageRequest) + .selectDistinct({ model: usageLedger.model }) + .from(usageLedger) .where( and( - eq(messageRequest.sessionId, sessionId), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - sql`${messageRequest.model} IS NOT NULL` + eq(usageLedger.sessionId, sessionId), + LEDGER_BILLING_CONDITION, + sql`${usageLedger.model} IS NOT NULL` ) ); - // 3.1 查询 Cache TTL 列表(去重,用于显示缓存时间信息) + // 3.1 查询 Cache TTL 列表(去重) const cacheTtlList = await db - .selectDistinct({ cacheTtl: messageRequest.cacheTtlApplied }) - .from(messageRequest) + .selectDistinct({ cacheTtl: usageLedger.cacheTtlApplied }) + .from(usageLedger) .where( and( - eq(messageRequest.sessionId, sessionId), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - sql`${messageRequest.cacheTtlApplied} IS NOT NULL` + eq(usageLedger.sessionId, sessionId), + LEDGER_BILLING_CONDITION, + sql`${usageLedger.cacheTtlApplied} IS NOT NULL` ) ); @@ -539,23 +534,23 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi return []; } - // 1. 批量聚合统计(单次查询) + // 1. 批量聚合统计(从 usageLedger,单次查询) const statsResults = await db .select({ - sessionId: messageRequest.sessionId, - requestCount: sql`count(*) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision`, - totalCostUsd: sql`COALESCE(sum(${messageRequest.costUsd}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION}), 0)`, - totalInputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, - totalOutputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, - totalCacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, - totalCacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, - totalDurationMs: sql`COALESCE(sum(${messageRequest.durationMs}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})::double precision, 0::double precision)`, - firstRequestAt: sql`min(${messageRequest.createdAt}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})`, - lastRequestAt: sql`max(${messageRequest.createdAt}) FILTER (WHERE ${EXCLUDE_WARMUP_CONDITION})`, + sessionId: usageLedger.sessionId, + requestCount: sql`count(*)::double precision`, + totalCostUsd: sql`COALESCE(sum(${usageLedger.costUsd}), 0)`, + totalInputTokens: sql`COALESCE(sum(${usageLedger.inputTokens})::double precision, 0::double precision)`, + totalOutputTokens: sql`COALESCE(sum(${usageLedger.outputTokens})::double precision, 0::double precision)`, + totalCacheCreationTokens: sql`COALESCE(sum(${usageLedger.cacheCreationInputTokens})::double precision, 0::double precision)`, + totalCacheReadTokens: sql`COALESCE(sum(${usageLedger.cacheReadInputTokens})::double precision, 0::double precision)`, + totalDurationMs: sql`COALESCE(sum(${usageLedger.durationMs})::double precision, 0::double precision)`, + firstRequestAt: sql`min(${usageLedger.createdAt})`, + lastRequestAt: sql`max(${usageLedger.createdAt})`, }) - .from(messageRequest) - .where(and(inArray(messageRequest.sessionId, sessionIds), isNull(messageRequest.deletedAt))) - .groupBy(messageRequest.sessionId); + .from(usageLedger) + .where(and(inArray(usageLedger.sessionId, sessionIds), LEDGER_BILLING_CONDITION)) + .groupBy(usageLedger.sessionId); // 创建 sessionId → stats 的 Map const statsMap = new Map(statsResults.map((s) => [s.sessionId, s])); @@ -563,18 +558,17 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi // 2. 批量查询供应商列表(按 session 分组) const providerResults = await db .selectDistinct({ - sessionId: messageRequest.sessionId, - providerId: messageRequest.providerId, + sessionId: usageLedger.sessionId, + providerId: usageLedger.finalProviderId, providerName: providers.name, }) - .from(messageRequest) - .leftJoin(providers, eq(messageRequest.providerId, providers.id)) + .from(usageLedger) + .leftJoin(providers, eq(usageLedger.finalProviderId, providers.id)) .where( and( - inArray(messageRequest.sessionId, sessionIds), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - sql`${messageRequest.providerId} IS NOT NULL` + inArray(usageLedger.sessionId, sessionIds), + LEDGER_BILLING_CONDITION, + sql`${usageLedger.finalProviderId} IS NOT NULL` ) ); @@ -596,16 +590,15 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi // 3. 批量查询模型列表(按 session 分组) const modelResults = await db .selectDistinct({ - sessionId: messageRequest.sessionId, - model: messageRequest.model, + sessionId: usageLedger.sessionId, + model: usageLedger.model, }) - .from(messageRequest) + .from(usageLedger) .where( and( - inArray(messageRequest.sessionId, sessionIds), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - sql`${messageRequest.model} IS NOT NULL` + inArray(usageLedger.sessionId, sessionIds), + LEDGER_BILLING_CONDITION, + sql`${usageLedger.model} IS NOT NULL` ) ); @@ -624,16 +617,15 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi // 3.1 批量查询 Cache TTL 列表(按 session 分组) const cacheTtlResults = await db .selectDistinct({ - sessionId: messageRequest.sessionId, - cacheTtl: messageRequest.cacheTtlApplied, + sessionId: usageLedger.sessionId, + cacheTtl: usageLedger.cacheTtlApplied, }) - .from(messageRequest) + .from(usageLedger) .where( and( - inArray(messageRequest.sessionId, sessionIds), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - sql`${messageRequest.cacheTtlApplied} IS NOT NULL` + inArray(usageLedger.sessionId, sessionIds), + LEDGER_BILLING_CONDITION, + sql`${usageLedger.cacheTtlApplied} IS NOT NULL` ) ); From 2111684c8725e564add1346ddde25f95c38e57e5 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 02:32:11 +0800 Subject: [PATCH 30/75] refactor(repo): migrate leaderboard.ts read paths to usage_ledger --- src/repository/leaderboard.ts | 145 ++++++++++++++++------------------ 1 file changed, 68 insertions(+), 77 deletions(-) diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index ed63b959f..451e6c440 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -2,10 +2,10 @@ import { and, desc, eq, isNull, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; -import { messageRequest, providers, users } from "@/drizzle/schema"; +import { providers, usageLedger, users } from "@/drizzle/schema"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; import type { ProviderType } from "@/types/provider"; -import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; +import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; import { getSystemSettings } from "./system-config"; /** @@ -166,7 +166,7 @@ function buildDateCondition( const endExclusiveLocal = sql`(${dateRange.endDate}::date + INTERVAL '1 day')`; const start = sql`(${startLocal} AT TIME ZONE ${timezone})`; const endExclusive = sql`(${endExclusiveLocal} AT TIME ZONE ${timezone})`; - return sql`${messageRequest.createdAt} >= ${start} AND ${messageRequest.createdAt} < ${endExclusive}`; + return sql`${usageLedger.createdAt} >= ${start} AND ${usageLedger.createdAt} < ${endExclusive}`; } switch (period) { @@ -177,23 +177,23 @@ function buildDateCondition( const endExclusiveLocal = sql`(${startLocal} + INTERVAL '1 day')`; const start = sql`(${startLocal} AT TIME ZONE ${timezone})`; const endExclusive = sql`(${endExclusiveLocal} AT TIME ZONE ${timezone})`; - return sql`${messageRequest.createdAt} >= ${start} AND ${messageRequest.createdAt} < ${endExclusive}`; + return sql`${usageLedger.createdAt} >= ${start} AND ${usageLedger.createdAt} < ${endExclusive}`; } case "last24h": - return sql`${messageRequest.createdAt} >= (CURRENT_TIMESTAMP - INTERVAL '24 hours')`; + return sql`${usageLedger.createdAt} >= (CURRENT_TIMESTAMP - INTERVAL '24 hours')`; case "weekly": { const startLocal = sql`DATE_TRUNC('week', ${nowLocal})`; const endExclusiveLocal = sql`(${startLocal} + INTERVAL '1 week')`; const start = sql`(${startLocal} AT TIME ZONE ${timezone})`; const endExclusive = sql`(${endExclusiveLocal} AT TIME ZONE ${timezone})`; - return sql`${messageRequest.createdAt} >= ${start} AND ${messageRequest.createdAt} < ${endExclusive}`; + return sql`${usageLedger.createdAt} >= ${start} AND ${usageLedger.createdAt} < ${endExclusive}`; } case "monthly": { const startLocal = sql`DATE_TRUNC('month', ${nowLocal})`; const endExclusiveLocal = sql`(${startLocal} + INTERVAL '1 month')`; const start = sql`(${startLocal} AT TIME ZONE ${timezone})`; const endExclusive = sql`(${endExclusiveLocal} AT TIME ZONE ${timezone})`; - return sql`${messageRequest.createdAt} >= ${start} AND ${messageRequest.createdAt} < ${endExclusive}`; + return sql`${usageLedger.createdAt} >= ${start} AND ${usageLedger.createdAt} < ${endExclusive}`; } default: return sql`1=1`; @@ -210,8 +210,7 @@ async function findLeaderboardWithTimezone( userFilters?: UserLeaderboardFilters ): Promise { const whereConditions = [ - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, + LEDGER_BILLING_CONDITION, buildDateCondition(period, timezone, dateRange), ]; @@ -242,25 +241,25 @@ async function findLeaderboardWithTimezone( const rankings = await db .select({ - userId: messageRequest.userId, + userId: usageLedger.userId, userName: users.name, totalRequests: sql`count(*)::double precision`, - totalCost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, + totalCost: sql`COALESCE(sum(${usageLedger.costUsd}), 0)`, totalTokens: sql`COALESCE( sum( - ${messageRequest.inputTokens} + - ${messageRequest.outputTokens} + - COALESCE(${messageRequest.cacheCreationInputTokens}, 0) + - COALESCE(${messageRequest.cacheReadInputTokens}, 0) + ${usageLedger.inputTokens} + + ${usageLedger.outputTokens} + + COALESCE(${usageLedger.cacheCreationInputTokens}, 0) + + COALESCE(${usageLedger.cacheReadInputTokens}, 0) )::double precision, 0::double precision )`, }) - .from(messageRequest) - .innerJoin(users, and(sql`${messageRequest.userId} = ${users.id}`, isNull(users.deletedAt))) + .from(usageLedger) + .innerJoin(users, and(sql`${usageLedger.userId} = ${users.id}`, isNull(users.deletedAt))) .where(and(...whereConditions)) - .groupBy(messageRequest.userId, users.name) - .orderBy(desc(sql`sum(${messageRequest.costUsd})`)); + .groupBy(usageLedger.userId, users.name) + .orderBy(desc(sql`sum(${usageLedger.costUsd})`)); return rankings.map((entry) => ({ userId: entry.userId, @@ -394,58 +393,57 @@ async function findProviderLeaderboardWithTimezone( providerType?: ProviderType ): Promise { const whereConditions = [ - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, + LEDGER_BILLING_CONDITION, buildDateCondition(period, timezone, dateRange), providerType ? eq(providers.providerType, providerType) : undefined, ]; const rankings = await db .select({ - providerId: messageRequest.providerId, + providerId: usageLedger.finalProviderId, providerName: providers.name, totalRequests: sql`count(*)::double precision`, - totalCost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, + totalCost: sql`COALESCE(sum(${usageLedger.costUsd}), 0)`, totalTokens: sql`COALESCE( sum( - ${messageRequest.inputTokens} + - ${messageRequest.outputTokens} + - COALESCE(${messageRequest.cacheCreationInputTokens}, 0) + - COALESCE(${messageRequest.cacheReadInputTokens}, 0) + ${usageLedger.inputTokens} + + ${usageLedger.outputTokens} + + COALESCE(${usageLedger.cacheCreationInputTokens}, 0) + + COALESCE(${usageLedger.cacheReadInputTokens}, 0) )::double precision, 0::double precision )`, successRate: sql`COALESCE( - count(CASE WHEN ${messageRequest.errorMessage} IS NULL OR ${messageRequest.errorMessage} = '' THEN 1 END)::double precision + count(CASE WHEN ${usageLedger.isSuccess} THEN 1 END)::double precision / NULLIF(count(*)::double precision, 0), 0::double precision )`, - avgTtfbMs: sql`COALESCE(avg(${messageRequest.ttfbMs})::double precision, 0::double precision)`, + avgTtfbMs: sql`COALESCE(avg(${usageLedger.ttfbMs})::double precision, 0::double precision)`, avgTokensPerSecond: sql`COALESCE( avg( CASE - WHEN ${messageRequest.outputTokens} > 0 - AND ${messageRequest.durationMs} IS NOT NULL - AND ${messageRequest.ttfbMs} IS NOT NULL - AND ${messageRequest.ttfbMs} < ${messageRequest.durationMs} - AND (${messageRequest.durationMs} - ${messageRequest.ttfbMs}) >= 100 - THEN (${messageRequest.outputTokens}::double precision) - / ((${messageRequest.durationMs} - ${messageRequest.ttfbMs}) / 1000.0) + WHEN ${usageLedger.outputTokens} > 0 + AND ${usageLedger.durationMs} IS NOT NULL + AND ${usageLedger.ttfbMs} IS NOT NULL + AND ${usageLedger.ttfbMs} < ${usageLedger.durationMs} + AND (${usageLedger.durationMs} - ${usageLedger.ttfbMs}) >= 100 + THEN (${usageLedger.outputTokens}::double precision) + / ((${usageLedger.durationMs} - ${usageLedger.ttfbMs}) / 1000.0) END )::double precision, 0::double precision )`, }) - .from(messageRequest) + .from(usageLedger) .innerJoin( providers, - and(sql`${messageRequest.providerId} = ${providers.id}`, isNull(providers.deletedAt)) + and(sql`${usageLedger.finalProviderId} = ${providers.id}`, isNull(providers.deletedAt)) ) .where( and(...whereConditions.filter((c): c is NonNullable<(typeof whereConditions)[number]> => !!c)) ) - .groupBy(messageRequest.providerId, providers.name) - .orderBy(desc(sql`sum(${messageRequest.costUsd})`)); + .groupBy(usageLedger.finalProviderId, providers.name) + .orderBy(desc(sql`sum(${usageLedger.costUsd})`)); return rankings.map((entry) => { const totalCost = parseFloat(entry.totalCost); @@ -480,19 +478,19 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( providerType?: ProviderType ): Promise { const totalInputTokensExpr = sql`( - COALESCE(${messageRequest.inputTokens}, 0)::double precision + - COALESCE(${messageRequest.cacheCreationInputTokens}, 0)::double precision + - COALESCE(${messageRequest.cacheReadInputTokens}, 0)::double precision + COALESCE(${usageLedger.inputTokens}, 0)::double precision + + COALESCE(${usageLedger.cacheCreationInputTokens}, 0)::double precision + + COALESCE(${usageLedger.cacheReadInputTokens}, 0)::double precision )`; const cacheRequiredCondition = sql`( - COALESCE(${messageRequest.cacheCreationInputTokens}, 0) > 0 - OR COALESCE(${messageRequest.cacheReadInputTokens}, 0) > 0 + COALESCE(${usageLedger.cacheCreationInputTokens}, 0) > 0 + OR COALESCE(${usageLedger.cacheReadInputTokens}, 0) > 0 )`; const sumTotalInputTokens = sql`COALESCE(sum(${totalInputTokensExpr})::double precision, 0::double precision)`; - const sumCacheReadTokens = sql`COALESCE(sum(COALESCE(${messageRequest.cacheReadInputTokens}, 0))::double precision, 0::double precision)`; - const sumCacheCreationCost = sql`COALESCE(sum(CASE WHEN COALESCE(${messageRequest.cacheCreationInputTokens}, 0) > 0 THEN ${messageRequest.costUsd} ELSE 0 END), 0)`; + const sumCacheReadTokens = sql`COALESCE(sum(COALESCE(${usageLedger.cacheReadInputTokens}, 0))::double precision, 0::double precision)`; + const sumCacheCreationCost = sql`COALESCE(sum(CASE WHEN COALESCE(${usageLedger.cacheCreationInputTokens}, 0) > 0 THEN ${usageLedger.costUsd} ELSE 0 END), 0)`; const cacheHitRateExpr = sql`COALESCE( ${sumCacheReadTokens} / NULLIF(${sumTotalInputTokens}, 0::double precision), @@ -500,8 +498,7 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( )`; const whereConditions = [ - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, + LEDGER_BILLING_CONDITION, buildDateCondition(period, timezone, dateRange), cacheRequiredCondition, providerType ? eq(providers.providerType, providerType) : undefined, @@ -509,24 +506,24 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( const rankings = await db .select({ - providerId: messageRequest.providerId, + providerId: usageLedger.finalProviderId, providerName: providers.name, totalRequests: sql`count(*)::double precision`, - totalCost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, + totalCost: sql`COALESCE(sum(${usageLedger.costUsd}), 0)`, cacheReadTokens: sumCacheReadTokens, cacheCreationCost: sumCacheCreationCost, totalInputTokens: sumTotalInputTokens, cacheHitRate: cacheHitRateExpr, }) - .from(messageRequest) + .from(usageLedger) .innerJoin( providers, - and(sql`${messageRequest.providerId} = ${providers.id}`, isNull(providers.deletedAt)) + and(sql`${usageLedger.finalProviderId} = ${providers.id}`, isNull(providers.deletedAt)) ) .where( and(...whereConditions.filter((c): c is NonNullable<(typeof whereConditions)[number]> => !!c)) ) - .groupBy(messageRequest.providerId, providers.name) + .groupBy(usageLedger.finalProviderId, providers.name) .orderBy(desc(cacheHitRateExpr), desc(sql`count(*)`)); // Model-level cache hit breakdown per provider @@ -534,11 +531,11 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( const billingModelSource = systemSettings.billingModelSource; const modelField = billingModelSource === "original" - ? sql`COALESCE(${messageRequest.originalModel}, ${messageRequest.model})` - : sql`COALESCE(${messageRequest.model}, ${messageRequest.originalModel})`; + ? sql`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})` + : sql`COALESCE(${usageLedger.model}, ${usageLedger.originalModel})`; const modelTotalInput = sql`COALESCE(sum(${totalInputTokensExpr})::double precision, 0::double precision)`; - const modelCacheRead = sql`COALESCE(sum(COALESCE(${messageRequest.cacheReadInputTokens}, 0))::double precision, 0::double precision)`; + const modelCacheRead = sql`COALESCE(sum(COALESCE(${usageLedger.cacheReadInputTokens}, 0))::double precision, 0::double precision)`; const modelCacheHitRate = sql`COALESCE( ${modelCacheRead} / NULLIF(${modelTotalInput}, 0::double precision), 0::double precision @@ -546,22 +543,22 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( const modelRows = await db .select({ - providerId: messageRequest.providerId, + providerId: usageLedger.finalProviderId, model: modelField, totalRequests: sql`count(*)::double precision`, cacheReadTokens: modelCacheRead, totalInputTokens: modelTotalInput, cacheHitRate: modelCacheHitRate, }) - .from(messageRequest) + .from(usageLedger) .innerJoin( providers, - and(sql`${messageRequest.providerId} = ${providers.id}`, isNull(providers.deletedAt)) + and(sql`${usageLedger.finalProviderId} = ${providers.id}`, isNull(providers.deletedAt)) ) .where( and(...whereConditions.filter((c): c is NonNullable<(typeof whereConditions)[number]> => !!c)) ) - .groupBy(messageRequest.providerId, modelField) + .groupBy(usageLedger.finalProviderId, modelField) .orderBy(desc(modelCacheHitRate), desc(sql`count(*)`)); // Group model stats by providerId @@ -672,37 +669,31 @@ async function findModelLeaderboardWithTimezone( // redirected: 优先使用 model(重定向后的实际模型),回退到 originalModel const modelField = billingModelSource === "original" - ? sql`COALESCE(${messageRequest.originalModel}, ${messageRequest.model})` - : sql`COALESCE(${messageRequest.model}, ${messageRequest.originalModel})`; + ? sql`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})` + : sql`COALESCE(${usageLedger.model}, ${usageLedger.originalModel})`; const rankings = await db .select({ model: modelField, totalRequests: sql`count(*)::double precision`, - totalCost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, + totalCost: sql`COALESCE(sum(${usageLedger.costUsd}), 0)`, totalTokens: sql`COALESCE( sum( - ${messageRequest.inputTokens} + - ${messageRequest.outputTokens} + - COALESCE(${messageRequest.cacheCreationInputTokens}, 0) + - COALESCE(${messageRequest.cacheReadInputTokens}, 0) + ${usageLedger.inputTokens} + + ${usageLedger.outputTokens} + + COALESCE(${usageLedger.cacheCreationInputTokens}, 0) + + COALESCE(${usageLedger.cacheReadInputTokens}, 0) )::double precision, 0::double precision )`, successRate: sql`COALESCE( - count(CASE WHEN ${messageRequest.errorMessage} IS NULL OR ${messageRequest.errorMessage} = '' THEN 1 END)::double precision + count(CASE WHEN ${usageLedger.isSuccess} THEN 1 END)::double precision / NULLIF(count(*)::double precision, 0), 0::double precision )`, }) - .from(messageRequest) - .where( - and( - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - buildDateCondition(period, timezone, dateRange) - ) - ) + .from(usageLedger) + .where(and(LEDGER_BILLING_CONDITION, buildDateCondition(period, timezone, dateRange))) .groupBy(modelField) .orderBy(desc(sql`count(*)`)); // 按请求数排序 From d40b570a54bd0f406b67627e7293b52e332ae266 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 02:32:37 +0800 Subject: [PATCH 31/75] refactor(repo): migrate statistics.ts read paths to usage_ledger --- src/repository/statistics.ts | 228 +++++++++++++++-------------------- 1 file changed, 98 insertions(+), 130 deletions(-) diff --git a/src/repository/statistics.ts b/src/repository/statistics.ts index da7d82ff3..1420fa297 100644 --- a/src/repository/statistics.ts +++ b/src/repository/statistics.ts @@ -2,7 +2,7 @@ import "server-only"; import { and, eq, gte, inArray, isNull, lt, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; -import { keys, messageRequest } from "@/drizzle/schema"; +import { keys, messageRequest, usageLedger } from "@/drizzle/schema"; import { TTLMap } from "@/lib/cache/ttl-map"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; import type { @@ -15,6 +15,7 @@ import type { RateLimitType, TimeRange, } from "@/types/statistics"; +import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; /** @@ -82,7 +83,7 @@ function getTimeRangeSqlConfig(timeRange: TimeRange, timezone: string): SqlTimeR return { startTs: sql`(DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) AT TIME ZONE ${timezone})`, endTs: sql`((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone})`, - bucketExpr: sql`DATE_TRUNC('hour', message_request.created_at AT TIME ZONE ${timezone})`, + bucketExpr: sql`DATE_TRUNC('hour', usage_ledger.created_at AT TIME ZONE ${timezone})`, bucketSeriesQuery: sql` SELECT generate_series( DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}), @@ -95,7 +96,7 @@ function getTimeRangeSqlConfig(timeRange: TimeRange, timezone: string): SqlTimeR return { startTs: sql`((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '6 days') AT TIME ZONE ${timezone})`, endTs: sql`((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone})`, - bucketExpr: sql`DATE_TRUNC('day', message_request.created_at AT TIME ZONE ${timezone})`, + bucketExpr: sql`DATE_TRUNC('day', usage_ledger.created_at AT TIME ZONE ${timezone})`, bucketSeriesQuery: sql` SELECT generate_series( (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date - INTERVAL '6 days', @@ -108,7 +109,7 @@ function getTimeRangeSqlConfig(timeRange: TimeRange, timezone: string): SqlTimeR return { startTs: sql`((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) - INTERVAL '29 days') AT TIME ZONE ${timezone})`, endTs: sql`((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone})`, - bucketExpr: sql`DATE_TRUNC('day', message_request.created_at AT TIME ZONE ${timezone})`, + bucketExpr: sql`DATE_TRUNC('day', usage_ledger.created_at AT TIME ZONE ${timezone})`, bucketSeriesQuery: sql` SELECT generate_series( (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date - INTERVAL '29 days', @@ -121,7 +122,7 @@ function getTimeRangeSqlConfig(timeRange: TimeRange, timezone: string): SqlTimeR return { startTs: sql`((DATE_TRUNC('month', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})) AT TIME ZONE ${timezone})`, endTs: sql`((DATE_TRUNC('day', CURRENT_TIMESTAMP AT TIME ZONE ${timezone}) + INTERVAL '1 day') AT TIME ZONE ${timezone})`, - bucketExpr: sql`DATE_TRUNC('day', message_request.created_at AT TIME ZONE ${timezone})`, + bucketExpr: sql`DATE_TRUNC('day', usage_ledger.created_at AT TIME ZONE ${timezone})`, bucketSeriesQuery: sql` SELECT generate_series( DATE_TRUNC('month', CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date, @@ -272,14 +273,13 @@ export async function getUserStatisticsFromDB(timeRange: TimeRange): Promise= ${startTs} - AND message_request.created_at < ${endTs} - AND message_request.deleted_at IS NULL - AND ${EXCLUDE_WARMUP_CONDITION} + LEFT JOIN usage_ledger ON u.id = usage_ledger.user_id + AND usage_ledger.created_at >= ${startTs} + AND usage_ledger.created_at < ${endTs} + AND ${LEDGER_BILLING_CONDITION} WHERE u.deleted_at IS NULL GROUP BY u.id, u.name, bucket ORDER BY bucket ASC, u.name ASC @@ -325,15 +325,14 @@ export async function getKeyStatisticsFromDB( k.id AS key_id, k.name AS key_name, ${bucketExpr} AS bucket, - COUNT(message_request.id) AS api_calls, - COALESCE(SUM(message_request.cost_usd), 0) AS total_cost + COUNT(usage_ledger.id) AS api_calls, + COALESCE(SUM(usage_ledger.cost_usd), 0) AS total_cost FROM keys k - LEFT JOIN message_request ON message_request.key = k.key - AND message_request.user_id = ${userId} - AND message_request.created_at >= ${startTs} - AND message_request.created_at < ${endTs} - AND message_request.deleted_at IS NULL - AND ${EXCLUDE_WARMUP_CONDITION} + LEFT JOIN usage_ledger ON usage_ledger.key = k.key + AND usage_ledger.user_id = ${userId} + AND usage_ledger.created_at >= ${startTs} + AND usage_ledger.created_at < ${endTs} + AND ${LEDGER_BILLING_CONDITION} WHERE k.user_id = ${userId} AND k.deleted_at IS NULL GROUP BY k.id, k.name, bucket @@ -385,15 +384,14 @@ export async function getMixedStatisticsFromDB( k.id AS key_id, k.name AS key_name, ${bucketExpr} AS bucket, - COUNT(message_request.id) AS api_calls, - COALESCE(SUM(message_request.cost_usd), 0) AS total_cost + COUNT(usage_ledger.id) AS api_calls, + COALESCE(SUM(usage_ledger.cost_usd), 0) AS total_cost FROM keys k - LEFT JOIN message_request ON message_request.key = k.key - AND message_request.user_id = ${userId} - AND message_request.created_at >= ${startTs} - AND message_request.created_at < ${endTs} - AND message_request.deleted_at IS NULL - AND ${EXCLUDE_WARMUP_CONDITION} + LEFT JOIN usage_ledger ON usage_ledger.key = k.key + AND usage_ledger.user_id = ${userId} + AND usage_ledger.created_at >= ${startTs} + AND usage_ledger.created_at < ${endTs} + AND ${LEDGER_BILLING_CONDITION} WHERE k.user_id = ${userId} AND k.deleted_at IS NULL GROUP BY k.id, k.name, bucket @@ -403,14 +401,13 @@ export async function getMixedStatisticsFromDB( const othersQuery = sql` SELECT ${bucketExpr} AS bucket, - COUNT(message_request.id) AS api_calls, - COALESCE(SUM(message_request.cost_usd), 0) AS total_cost - FROM message_request - WHERE message_request.user_id <> ${userId} - AND message_request.created_at >= ${startTs} - AND message_request.created_at < ${endTs} - AND message_request.deleted_at IS NULL - AND ${EXCLUDE_WARMUP_CONDITION} + COUNT(usage_ledger.id) AS api_calls, + COALESCE(SUM(usage_ledger.cost_usd), 0) AS total_cost + FROM usage_ledger + WHERE usage_ledger.user_id <> ${userId} + AND usage_ledger.created_at >= ${startTs} + AND usage_ledger.created_at < ${endTs} + AND ${LEDGER_BILLING_CONDITION} GROUP BY bucket ORDER BY bucket ASC `; @@ -448,13 +445,11 @@ export async function sumUserCostToday(userId: number): Promise { const timezone = await resolveSystemTimezone(); const query = sql` - SELECT COALESCE(SUM(mr.cost_usd), 0) AS total_cost - FROM message_request mr - INNER JOIN keys k ON mr.key = k.key - WHERE k.user_id = ${userId} - AND (mr.created_at AT TIME ZONE ${timezone})::date = (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date - AND mr.deleted_at IS NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - AND k.deleted_at IS NULL + SELECT COALESCE(SUM(usage_ledger.cost_usd), 0) AS total_cost + FROM usage_ledger + WHERE usage_ledger.user_id = ${userId} + AND (usage_ledger.created_at AT TIME ZONE ${timezone})::date = (CURRENT_TIMESTAMP AT TIME ZONE ${timezone})::date + AND ${LEDGER_BILLING_CONDITION} `; const result = await db.execute(query); @@ -468,21 +463,17 @@ export async function sumUserCostToday(userId: number): Promise { * @param maxAgeDays - Max query days (default 365). Use Infinity for all-time. */ export async function sumKeyTotalCost(keyHash: string, maxAgeDays: number = 365): Promise { - const conditions = [ - eq(messageRequest.key, keyHash), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - ]; + const conditions = [eq(usageLedger.key, keyHash), LEDGER_BILLING_CONDITION]; // Finite positive maxAgeDays adds a date filter; Infinity/0/negative means all-time if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); - conditions.push(gte(messageRequest.createdAt, cutoffDate)); + conditions.push(gte(usageLedger.createdAt, cutoffDate)); } const result = await db - .select({ total: sql`COALESCE(SUM(${messageRequest.costUsd}), 0)` }) - .from(messageRequest) + .select({ total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)` }) + .from(usageLedger) .where(and(...conditions)); return Number(result[0]?.total || 0); @@ -494,21 +485,17 @@ export async function sumKeyTotalCost(keyHash: string, maxAgeDays: number = 365) * @param maxAgeDays - Max query days (default 365). Use Infinity for all-time. */ export async function sumUserTotalCost(userId: number, maxAgeDays: number = 365): Promise { - const conditions = [ - eq(messageRequest.userId, userId), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - ]; + const conditions = [eq(usageLedger.userId, userId), LEDGER_BILLING_CONDITION]; // Finite positive maxAgeDays adds a date filter; Infinity/0/negative means all-time if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); - conditions.push(gte(messageRequest.createdAt, cutoffDate)); + conditions.push(gte(usageLedger.createdAt, cutoffDate)); } const result = await db - .select({ total: sql`COALESCE(SUM(${messageRequest.costUsd}), 0)` }) - .from(messageRequest) + .select({ total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)` }) + .from(usageLedger) .where(and(...conditions)); return Number(result[0]?.total || 0); @@ -525,18 +512,12 @@ export async function sumUserTotalCostBatch(userIds: number[]): Promise`COALESCE(SUM(${messageRequest.costUsd}), 0)`, + userId: usageLedger.userId, + total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`, }) - .from(messageRequest) - .where( - and( - inArray(messageRequest.userId, userIds), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION - ) - ) - .groupBy(messageRequest.userId); + .from(usageLedger) + .where(and(inArray(usageLedger.userId, userIds), LEDGER_BILLING_CONDITION)) + .groupBy(usageLedger.userId); for (const id of userIds) { result.set(id, 0); @@ -559,17 +540,10 @@ export async function sumKeyTotalCostBatchByIds(keyIds: number[]): Promise`COALESCE(SUM(${messageRequest.costUsd}), 0)`, + total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`, }) .from(keys) - .leftJoin( - messageRequest, - and( - eq(messageRequest.key, keys.key), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION - ) - ) + .leftJoin(usageLedger, and(eq(usageLedger.key, keys.key), LEDGER_BILLING_CONDITION)) .where(inArray(keys.id, keyIds)) .groupBy(keys.id); @@ -600,14 +574,13 @@ export async function sumProviderTotalCost( resetAt instanceof Date && !Number.isNaN(resetAt.getTime()) ? resetAt : null; const result = await db - .select({ total: sql`COALESCE(SUM(${messageRequest.costUsd}), 0)` }) - .from(messageRequest) + .select({ total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)` }) + .from(usageLedger) .where( and( - eq(messageRequest.providerId, providerId), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - ...(effectiveStart ? [gte(messageRequest.createdAt, effectiveStart)] : []) + eq(usageLedger.finalProviderId, providerId), + LEDGER_BILLING_CONDITION, + ...(effectiveStart ? [gte(usageLedger.createdAt, effectiveStart)] : []) ) ); @@ -624,15 +597,14 @@ export async function sumUserCostInTimeRange( endTime: Date ): Promise { const result = await db - .select({ total: sql`COALESCE(SUM(${messageRequest.costUsd}), 0)` }) - .from(messageRequest) + .select({ total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)` }) + .from(usageLedger) .where( and( - eq(messageRequest.userId, userId), - gte(messageRequest.createdAt, startTime), - lt(messageRequest.createdAt, endTime), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION + eq(usageLedger.userId, userId), + gte(usageLedger.createdAt, startTime), + lt(usageLedger.createdAt, endTime), + LEDGER_BILLING_CONDITION ) ); @@ -652,15 +624,14 @@ export async function sumKeyCostInTimeRange( if (!keyString) return 0; const result = await db - .select({ total: sql`COALESCE(SUM(${messageRequest.costUsd}), 0)` }) - .from(messageRequest) + .select({ total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)` }) + .from(usageLedger) .where( and( - eq(messageRequest.key, keyString), // 使用 key 字符串而非 ID - gte(messageRequest.createdAt, startTime), - lt(messageRequest.createdAt, endTime), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION + eq(usageLedger.key, keyString), // 使用 key 字符串而非 ID + gte(usageLedger.createdAt, startTime), + lt(usageLedger.createdAt, endTime), + LEDGER_BILLING_CONDITION ) ); @@ -722,25 +693,24 @@ export async function sumUserQuotaCosts( ); const costTotal = cutoffDate - ? sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${cutoffDate}), 0)` - : sql`COALESCE(SUM(${messageRequest.costUsd}), 0)`; + ? sql`COALESCE(SUM(${usageLedger.costUsd}) FILTER (WHERE ${usageLedger.createdAt} >= ${cutoffDate}), 0)` + : sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`; const [row] = await db .select({ - cost5h: sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${ranges.range5h.startTime} AND ${messageRequest.createdAt} < ${ranges.range5h.endTime}), 0)`, - costDaily: sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${ranges.rangeDaily.startTime} AND ${messageRequest.createdAt} < ${ranges.rangeDaily.endTime}), 0)`, - costWeekly: sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${ranges.rangeWeekly.startTime} AND ${messageRequest.createdAt} < ${ranges.rangeWeekly.endTime}), 0)`, - costMonthly: sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${ranges.rangeMonthly.startTime} AND ${messageRequest.createdAt} < ${ranges.rangeMonthly.endTime}), 0)`, + cost5h: sql`COALESCE(SUM(${usageLedger.costUsd}) FILTER (WHERE ${usageLedger.createdAt} >= ${ranges.range5h.startTime} AND ${usageLedger.createdAt} < ${ranges.range5h.endTime}), 0)`, + costDaily: sql`COALESCE(SUM(${usageLedger.costUsd}) FILTER (WHERE ${usageLedger.createdAt} >= ${ranges.rangeDaily.startTime} AND ${usageLedger.createdAt} < ${ranges.rangeDaily.endTime}), 0)`, + costWeekly: sql`COALESCE(SUM(${usageLedger.costUsd}) FILTER (WHERE ${usageLedger.createdAt} >= ${ranges.rangeWeekly.startTime} AND ${usageLedger.createdAt} < ${ranges.rangeWeekly.endTime}), 0)`, + costMonthly: sql`COALESCE(SUM(${usageLedger.costUsd}) FILTER (WHERE ${usageLedger.createdAt} >= ${ranges.rangeMonthly.startTime} AND ${usageLedger.createdAt} < ${ranges.rangeMonthly.endTime}), 0)`, costTotal, }) - .from(messageRequest) + .from(usageLedger) .where( and( - eq(messageRequest.userId, userId), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - ...(scanStart ? [gte(messageRequest.createdAt, scanStart)] : []), - lt(messageRequest.createdAt, scanEnd) + eq(usageLedger.userId, userId), + LEDGER_BILLING_CONDITION, + ...(scanStart ? [gte(usageLedger.createdAt, scanStart)] : []), + lt(usageLedger.createdAt, scanEnd) ) ); @@ -793,25 +763,24 @@ export async function sumKeyQuotaCostsById( ); const costTotal = cutoffDate - ? sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${cutoffDate}), 0)` - : sql`COALESCE(SUM(${messageRequest.costUsd}), 0)`; + ? sql`COALESCE(SUM(${usageLedger.costUsd}) FILTER (WHERE ${usageLedger.createdAt} >= ${cutoffDate}), 0)` + : sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`; const [row] = await db .select({ - cost5h: sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${ranges.range5h.startTime} AND ${messageRequest.createdAt} < ${ranges.range5h.endTime}), 0)`, - costDaily: sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${ranges.rangeDaily.startTime} AND ${messageRequest.createdAt} < ${ranges.rangeDaily.endTime}), 0)`, - costWeekly: sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${ranges.rangeWeekly.startTime} AND ${messageRequest.createdAt} < ${ranges.rangeWeekly.endTime}), 0)`, - costMonthly: sql`COALESCE(SUM(${messageRequest.costUsd}) FILTER (WHERE ${messageRequest.createdAt} >= ${ranges.rangeMonthly.startTime} AND ${messageRequest.createdAt} < ${ranges.rangeMonthly.endTime}), 0)`, + cost5h: sql`COALESCE(SUM(${usageLedger.costUsd}) FILTER (WHERE ${usageLedger.createdAt} >= ${ranges.range5h.startTime} AND ${usageLedger.createdAt} < ${ranges.range5h.endTime}), 0)`, + costDaily: sql`COALESCE(SUM(${usageLedger.costUsd}) FILTER (WHERE ${usageLedger.createdAt} >= ${ranges.rangeDaily.startTime} AND ${usageLedger.createdAt} < ${ranges.rangeDaily.endTime}), 0)`, + costWeekly: sql`COALESCE(SUM(${usageLedger.costUsd}) FILTER (WHERE ${usageLedger.createdAt} >= ${ranges.rangeWeekly.startTime} AND ${usageLedger.createdAt} < ${ranges.rangeWeekly.endTime}), 0)`, + costMonthly: sql`COALESCE(SUM(${usageLedger.costUsd}) FILTER (WHERE ${usageLedger.createdAt} >= ${ranges.rangeMonthly.startTime} AND ${usageLedger.createdAt} < ${ranges.rangeMonthly.endTime}), 0)`, costTotal, }) - .from(messageRequest) + .from(usageLedger) .where( and( - eq(messageRequest.key, keyString), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - ...(scanStart ? [gte(messageRequest.createdAt, scanStart)] : []), - lt(messageRequest.createdAt, scanEnd) + eq(usageLedger.key, keyString), + LEDGER_BILLING_CONDITION, + ...(scanStart ? [gte(usageLedger.createdAt, scanStart)] : []), + lt(usageLedger.createdAt, scanEnd) ) ); @@ -1103,15 +1072,14 @@ export async function sumProviderCostInTimeRange( endTime: Date ): Promise { const result = await db - .select({ total: sql`COALESCE(SUM(${messageRequest.costUsd}), 0)` }) - .from(messageRequest) + .select({ total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)` }) + .from(usageLedger) .where( and( - eq(messageRequest.providerId, providerId), - gte(messageRequest.createdAt, startTime), - lt(messageRequest.createdAt, endTime), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION + eq(usageLedger.finalProviderId, providerId), + gte(usageLedger.createdAt, startTime), + lt(usageLedger.createdAt, endTime), + LEDGER_BILLING_CONDITION ) ); From 44be131a4532986f3428290da67a1985e1faeab6 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 02:33:51 +0800 Subject: [PATCH 32/75] refactor(repo): migrate usage-logs summary + my-usage to usage_ledger --- src/actions/my-usage.ts | 26 ++++++------- src/repository/usage-logs.ts | 71 +++++++++++++++++++++++++++--------- 2 files changed, 66 insertions(+), 31 deletions(-) diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index a1dfdcb09..a450f7603 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -3,7 +3,7 @@ import { fromZonedTime } from "date-fns-tz"; import { and, eq, gte, isNull, lt, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; -import { messageRequest } from "@/drizzle/schema"; +import { messageRequest, usageLedger } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; import { logger } from "@/lib/logger"; import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit"; @@ -11,6 +11,7 @@ import type { DailyResetMode } from "@/lib/rate-limit/time-utils"; import { SessionTracker } from "@/lib/session-tracker"; import type { CurrencyCode } from "@/lib/utils"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; +import { LEDGER_BILLING_CONDITION } from "@/repository/_shared/ledger-conditions"; import { EXCLUDE_WARMUP_CONDITION } from "@/repository/_shared/message-request-conditions"; import { getSystemSettings } from "@/repository/system-config"; import { @@ -359,24 +360,23 @@ export async function getMyTodayStats(): Promise> { const breakdown = await db .select({ - model: messageRequest.model, - originalModel: messageRequest.originalModel, + model: usageLedger.model, + originalModel: usageLedger.originalModel, calls: sql`count(*)::int`, - costUsd: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, - inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::double precision`, - outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::double precision`, + costUsd: sql`COALESCE(sum(${usageLedger.costUsd}), 0)`, + inputTokens: sql`COALESCE(sum(${usageLedger.inputTokens}), 0)::double precision`, + outputTokens: sql`COALESCE(sum(${usageLedger.outputTokens}), 0)::double precision`, }) - .from(messageRequest) + .from(usageLedger) .where( and( - eq(messageRequest.key, session.key.key), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - gte(messageRequest.createdAt, timeRange.startTime), - lt(messageRequest.createdAt, timeRange.endTime) + eq(usageLedger.key, session.key.key), + LEDGER_BILLING_CONDITION, + gte(usageLedger.createdAt, timeRange.startTime), + lt(usageLedger.createdAt, timeRange.endTime) ) ) - .groupBy(messageRequest.model, messageRequest.originalModel); + .groupBy(usageLedger.model, usageLedger.originalModel); let totalCalls = 0; let totalInputTokens = 0; diff --git a/src/repository/usage-logs.ts b/src/repository/usage-logs.ts index 410025958..bd0f7001c 100644 --- a/src/repository/usage-logs.ts +++ b/src/repository/usage-logs.ts @@ -1,12 +1,13 @@ import "server-only"; -import { and, desc, eq, isNull, sql } from "drizzle-orm"; +import { and, desc, eq, gte, isNull, lt, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; -import { keys as keysTable, messageRequest, providers, users } from "@/drizzle/schema"; +import { keys as keysTable, messageRequest, providers, usageLedger, users } from "@/drizzle/schema"; import { TTLMap } from "@/lib/cache/ttl-map"; import { buildUnifiedSpecialSettings } from "@/lib/utils/special-settings"; import type { ProviderChainItem } from "@/types/message"; import type { SpecialSetting } from "@/types/special-settings"; +import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; import { escapeLike } from "./_shared/like"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; import { buildUsageLogConditions } from "./_shared/usage-log-filters"; @@ -716,10 +717,10 @@ export async function findUsageLogsStats( ): Promise { const { userId, keyId, providerId } = filters; - const conditions = [isNull(messageRequest.deletedAt)]; + const conditions = [LEDGER_BILLING_CONDITION]; if (userId !== undefined) { - conditions.push(eq(messageRequest.userId, userId)); + conditions.push(eq(usageLedger.userId, userId)); } if (keyId !== undefined) { @@ -727,32 +728,66 @@ export async function findUsageLogsStats( } if (providerId !== undefined) { - conditions.push(eq(messageRequest.providerId, providerId)); + conditions.push(eq(usageLedger.providerId, providerId)); } - conditions.push(...buildUsageLogConditions(filters)); + const trimmedSessionId = filters.sessionId?.trim(); + if (trimmedSessionId) { + conditions.push(eq(usageLedger.sessionId, trimmedSessionId)); + } + + if (filters.startTime !== undefined) { + conditions.push(gte(usageLedger.createdAt, new Date(filters.startTime))); + } + + if (filters.endTime !== undefined) { + conditions.push(lt(usageLedger.createdAt, new Date(filters.endTime))); + } - const statsConditions = [...conditions, EXCLUDE_WARMUP_CONDITION]; + if (filters.statusCode !== undefined) { + conditions.push(eq(usageLedger.statusCode, filters.statusCode)); + } else if (filters.excludeStatusCode200) { + conditions.push(sql`(${usageLedger.statusCode} IS NULL OR ${usageLedger.statusCode} <> 200)`); + } + + if (filters.model) { + conditions.push(eq(usageLedger.model, filters.model)); + } + + if (filters.endpoint) { + conditions.push(eq(usageLedger.endpoint, filters.endpoint)); + } + + if (filters.minRetryCount !== undefined) { + conditions.push( + sql`GREATEST(COALESCE(jsonb_array_length(${messageRequest.providerChain}) - 1, 0), 0) >= ${filters.minRetryCount}` + ); + } const baseQuery = db .select({ totalRequests: sql`count(*)::double precision`, - totalCost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, - totalInputTokens: sql`COALESCE(sum(${messageRequest.inputTokens})::double precision, 0::double precision)`, - totalOutputTokens: sql`COALESCE(sum(${messageRequest.outputTokens})::double precision, 0::double precision)`, - totalCacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens})::double precision, 0::double precision)`, - totalCacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens})::double precision, 0::double precision)`, - totalCacheCreation5mTokens: sql`COALESCE(sum(${messageRequest.cacheCreation5mInputTokens})::double precision, 0::double precision)`, - totalCacheCreation1hTokens: sql`COALESCE(sum(${messageRequest.cacheCreation1hInputTokens})::double precision, 0::double precision)`, + totalCost: sql`COALESCE(sum(${usageLedger.costUsd}), 0)`, + totalInputTokens: sql`COALESCE(sum(${usageLedger.inputTokens})::double precision, 0::double precision)`, + totalOutputTokens: sql`COALESCE(sum(${usageLedger.outputTokens})::double precision, 0::double precision)`, + totalCacheCreationTokens: sql`COALESCE(sum(${usageLedger.cacheCreationInputTokens})::double precision, 0::double precision)`, + totalCacheReadTokens: sql`COALESCE(sum(${usageLedger.cacheReadInputTokens})::double precision, 0::double precision)`, + totalCacheCreation5mTokens: sql`COALESCE(sum(${usageLedger.cacheCreation5mInputTokens})::double precision, 0::double precision)`, + totalCacheCreation1hTokens: sql`COALESCE(sum(${usageLedger.cacheCreation1hInputTokens})::double precision, 0::double precision)`, }) - .from(messageRequest); + .from(usageLedger); - const query = + const queryByKey = keyId !== undefined - ? baseQuery.innerJoin(keysTable, eq(messageRequest.key, keysTable.key)) + ? baseQuery.innerJoin(keysTable, eq(usageLedger.key, keysTable.key)) : baseQuery; - const [summaryResult] = await query.where(and(...statsConditions)); + const query = + filters.minRetryCount !== undefined + ? queryByKey.innerJoin(messageRequest, eq(usageLedger.requestId, messageRequest.id)) + : queryByKey; + + const [summaryResult] = await query.where(and(...conditions)); const totalRequests = summaryResult?.totalRequests ?? 0; const totalCost = parseFloat(summaryResult?.totalCost ?? "0"); From 3437a95fec9ae12f347309224cea6fc2c181b54c Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 02:44:58 +0800 Subject: [PATCH 33/75] refactor(repo): migrate key.ts + provider.ts read paths to usage_ledger --- src/repository/key.ts | 151 +++++++++++++++++-------------------- src/repository/provider.ts | 46 +++-------- 2 files changed, 80 insertions(+), 117 deletions(-) diff --git a/src/repository/key.ts b/src/repository/key.ts index 9fb6375fd..baf5b22b3 100644 --- a/src/repository/key.ts +++ b/src/repository/key.ts @@ -2,7 +2,7 @@ import { and, count, desc, eq, gt, gte, inArray, isNull, lt, or, sql, sum } from "drizzle-orm"; import { db } from "@/drizzle/db"; -import { keys, messageRequest, providers, users } from "@/drizzle/schema"; +import { keys, providers, usageLedger, users } from "@/drizzle/schema"; import { CHANNEL_API_KEYS_UPDATED, publishCacheInvalidation } from "@/lib/redis/pubsub"; import { cacheActiveKey, @@ -16,7 +16,7 @@ import { apiKeyVacuumFilter } from "@/lib/security/api-key-vacuum-filter"; import { Decimal, toCostDecimal } from "@/lib/utils/currency"; import type { CreateKeyData, Key, UpdateKeyData } from "@/types/key"; import type { User } from "@/types/user"; -import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; +import { LEDGER_BILLING_CONDITION } from "./_shared/ledger-conditions"; import { toKey, toUser } from "./_shared/transformers"; export async function findKeyById(id: number): Promise { @@ -337,17 +337,16 @@ export async function findKeyUsageToday( const rows = await db .select({ keyId: keys.id, - totalCost: sum(messageRequest.costUsd), + totalCost: sum(usageLedger.costUsd), }) .from(keys) .leftJoin( - messageRequest, + usageLedger, and( - eq(messageRequest.key, keys.key), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - gte(messageRequest.createdAt, today), - lt(messageRequest.createdAt, tomorrow) + eq(usageLedger.key, keys.key), + LEDGER_BILLING_CONDITION, + gte(usageLedger.createdAt, today), + lt(usageLedger.createdAt, tomorrow) ) ) .where(and(eq(keys.userId, userId), isNull(keys.deletedAt))) @@ -382,23 +381,22 @@ export async function findKeyUsageTodayBatch( .select({ userId: keys.userId, keyId: keys.id, - totalCost: sum(messageRequest.costUsd), + totalCost: sum(usageLedger.costUsd), totalTokens: sql`COALESCE(SUM( - COALESCE(${messageRequest.inputTokens}, 0)::double precision + - COALESCE(${messageRequest.outputTokens}, 0)::double precision + - COALESCE(${messageRequest.cacheCreationInputTokens}, 0)::double precision + - COALESCE(${messageRequest.cacheReadInputTokens}, 0)::double precision + COALESCE(${usageLedger.inputTokens}, 0)::double precision + + COALESCE(${usageLedger.outputTokens}, 0)::double precision + + COALESCE(${usageLedger.cacheCreationInputTokens}, 0)::double precision + + COALESCE(${usageLedger.cacheReadInputTokens}, 0)::double precision ), 0::double precision)`, }) .from(keys) .leftJoin( - messageRequest, + usageLedger, and( - eq(messageRequest.key, keys.key), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - gte(messageRequest.createdAt, today), - lt(messageRequest.createdAt, tomorrow) + eq(usageLedger.key, keys.key), + LEDGER_BILLING_CONDITION, + gte(usageLedger.createdAt, today), + lt(usageLedger.createdAt, tomorrow) ) ) .where(and(inArray(keys.userId, userIds), isNull(keys.deletedAt))) @@ -725,58 +723,50 @@ export async function findKeysWithStatistics(userId: number): Promise`count(*)::int`, - totalCost: sum(messageRequest.costUsd), - inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::double precision`, - outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::double precision`, - cacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::double precision`, - cacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::double precision`, + totalCost: sum(usageLedger.costUsd), + inputTokens: sql`COALESCE(sum(${usageLedger.inputTokens}), 0)::double precision`, + outputTokens: sql`COALESCE(sum(${usageLedger.outputTokens}), 0)::double precision`, + cacheCreationTokens: sql`COALESCE(sum(${usageLedger.cacheCreationInputTokens}), 0)::double precision`, + cacheReadTokens: sql`COALESCE(sum(${usageLedger.cacheReadInputTokens}), 0)::double precision`, }) - .from(messageRequest) + .from(usageLedger) .where( and( - eq(messageRequest.key, key.key), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - gte(messageRequest.createdAt, today), - lt(messageRequest.createdAt, tomorrow), - sql`${messageRequest.model} IS NOT NULL` + eq(usageLedger.key, key.key), + LEDGER_BILLING_CONDITION, + gte(usageLedger.createdAt, today), + lt(usageLedger.createdAt, tomorrow), + sql`${usageLedger.model} IS NOT NULL` ) ) - .groupBy(messageRequest.model) + .groupBy(usageLedger.model) .orderBy(desc(sql`count(*)`)); const modelStats = modelStatsRows.map((row) => ({ @@ -852,20 +842,19 @@ export async function findKeysWithStatisticsBatch( // Step 2: Query today's call counts for all keys at once const todayCountRows = await db .select({ - key: messageRequest.key, + key: usageLedger.key, count: count(), }) - .from(messageRequest) + .from(usageLedger) .where( and( - inArray(messageRequest.key, keyStrings), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - gte(messageRequest.createdAt, today), - lt(messageRequest.createdAt, tomorrow) + inArray(usageLedger.key, keyStrings), + LEDGER_BILLING_CONDITION, + gte(usageLedger.createdAt, today), + lt(usageLedger.createdAt, tomorrow) ) ) - .groupBy(messageRequest.key); + .groupBy(usageLedger.key); const todayCountMap = new Map(); for (const row of todayCountRows) { @@ -879,15 +868,14 @@ export async function findKeysWithStatisticsBatch( SELECT k.key_val AS key, lr.created_at, p.name AS provider_name FROM unnest(${keyStrings}::varchar[]) AS k(key_val) LEFT JOIN LATERAL ( - SELECT mr.created_at, mr.provider_id - FROM message_request mr - WHERE mr.key = k.key_val - AND mr.deleted_at IS NULL - AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - ORDER BY mr.created_at DESC NULLS LAST + SELECT ul.created_at, ul.final_provider_id + FROM usage_ledger ul + WHERE ul.key = k.key_val + AND ul.blocked_by IS NULL + ORDER BY ul.created_at DESC NULLS LAST LIMIT 1 ) lr ON true - LEFT JOIN providers p ON lr.provider_id = p.id + LEFT JOIN providers p ON lr.final_provider_id = p.id `); const lastUsageMap = new Map(); @@ -907,28 +895,27 @@ export async function findKeysWithStatisticsBatch( // Step 4: Query model statistics for all keys at once const modelStatsRows = await db .select({ - key: messageRequest.key, - model: messageRequest.model, + key: usageLedger.key, + model: usageLedger.model, callCount: sql`count(*)::int`, - totalCost: sum(messageRequest.costUsd), - inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::double precision`, - outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::double precision`, - cacheCreationTokens: sql`COALESCE(sum(${messageRequest.cacheCreationInputTokens}), 0)::double precision`, - cacheReadTokens: sql`COALESCE(sum(${messageRequest.cacheReadInputTokens}), 0)::double precision`, + totalCost: sum(usageLedger.costUsd), + inputTokens: sql`COALESCE(sum(${usageLedger.inputTokens}), 0)::double precision`, + outputTokens: sql`COALESCE(sum(${usageLedger.outputTokens}), 0)::double precision`, + cacheCreationTokens: sql`COALESCE(sum(${usageLedger.cacheCreationInputTokens}), 0)::double precision`, + cacheReadTokens: sql`COALESCE(sum(${usageLedger.cacheReadInputTokens}), 0)::double precision`, }) - .from(messageRequest) + .from(usageLedger) .where( and( - inArray(messageRequest.key, keyStrings), - isNull(messageRequest.deletedAt), - EXCLUDE_WARMUP_CONDITION, - gte(messageRequest.createdAt, today), - lt(messageRequest.createdAt, tomorrow), - sql`${messageRequest.model} IS NOT NULL` + inArray(usageLedger.key, keyStrings), + LEDGER_BILLING_CONDITION, + gte(usageLedger.createdAt, today), + lt(usageLedger.createdAt, tomorrow), + sql`${usageLedger.model} IS NOT NULL` ) ) - .groupBy(messageRequest.key, messageRequest.model) - .orderBy(messageRequest.key, desc(sql`count(*)`)); + .groupBy(usageLedger.key, usageLedger.model) + .orderBy(usageLedger.key, desc(sql`count(*)`)); // Group model stats by key const modelStatsMap = new Map< diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 5c24866e2..5204aab56 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -1437,7 +1437,7 @@ export async function getDistinctProviderGroups(): Promise { * 包括:今天的总金额、今天的调用次数、最近一次调用时间和模型 * * 性能优化: - * - provider_stats: 先按最终供应商聚合,再与 providers 做 LEFT JOIN,避免 providers × message_request 的笛卡尔积 + * - provider_stats: 先按最终供应商聚合,再与 providers 做 LEFT JOIN,避免 providers × usage_ledger 的笛卡尔积 * - bounds: 用“按时区计算的时间范围”过滤 created_at,便于命中 created_at 索引 * - DST 兼容:对“本地日界/近 7 日”先在 timestamp 上做 +interval,再 AT TIME ZONE 回到 timestamptz,避免夏令时跨日偏移 * - latest_call: 限制近 7 天范围,避免扫描历史数据 @@ -1483,8 +1483,6 @@ export async function getProviderStatistics(): Promise } const promise: Promise = (async () => { - // 使用 providerChain 最后一项的 providerId 来确定最终供应商(兼容重试切换) - // 如果 provider_chain 为空(无重试),则使用 provider_id 字段 const query = sql` WITH bounds AS ( SELECT @@ -1495,45 +1493,23 @@ export async function getProviderStatistics(): Promise provider_stats AS ( -- 先按最终供应商聚合,再与 providers 做 LEFT JOIN,避免 providers × 今日请求 的笛卡尔积 SELECT - mr.final_provider_id, - COALESCE(SUM(mr.cost_usd), 0) AS today_cost, + final_provider_id, + COALESCE(SUM(cost_usd), 0) AS today_cost, COUNT(*)::integer AS today_calls - FROM ( - SELECT - CASE - WHEN provider_chain IS NULL OR provider_chain = '[]'::jsonb THEN provider_id - WHEN (provider_chain->-1->>'id') ~ '^[0-9]+$' THEN (provider_chain->-1->>'id')::int - ELSE provider_id - END AS final_provider_id, - cost_usd - FROM message_request - WHERE deleted_at IS NULL - AND (blocked_by IS NULL OR blocked_by <> 'warmup') - AND created_at >= (SELECT today_start FROM bounds) - AND created_at < (SELECT tomorrow_start FROM bounds) - ) mr - GROUP BY mr.final_provider_id + FROM usage_ledger + WHERE blocked_by IS NULL + AND created_at >= (SELECT today_start FROM bounds) + AND created_at < (SELECT tomorrow_start FROM bounds) + GROUP BY final_provider_id ), latest_call AS ( SELECT DISTINCT ON (final_provider_id) final_provider_id, created_at AS last_call_time, model AS last_call_model - FROM ( - SELECT - CASE - WHEN provider_chain IS NULL OR provider_chain = '[]'::jsonb THEN provider_id - WHEN (provider_chain->-1->>'id') ~ '^[0-9]+$' THEN (provider_chain->-1->>'id')::int - ELSE provider_id - END AS final_provider_id, - id, - created_at, - model - FROM message_request - WHERE deleted_at IS NULL - AND (blocked_by IS NULL OR blocked_by <> 'warmup') - AND created_at >= (SELECT last7_start FROM bounds) - ) mr + FROM usage_ledger + WHERE blocked_by IS NULL + AND created_at >= (SELECT last7_start FROM bounds) -- 性能优化:添加 7 天时间范围限制(避免扫描历史数据) ORDER BY final_provider_id, created_at DESC, id DESC ) From d83d140da09847568acb3196b48175f374e67c4c Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 02:52:49 +0800 Subject: [PATCH 34/75] fix(users): update resetUserAllStatistics to clear usage_ledger + verify cleanup immunity - Add usageLedger delete in resetUserAllStatistics (the ONLY legitimate DELETE path) - Add architectural invariant comments on removeUser and cleanupLogs - Create cleanup-immunity.test.ts (4 assertions verifying ledger safety) --- src/actions/users.ts | 6 ++- src/lib/log-cleanup/service.ts | 2 + .../usage-ledger/cleanup-immunity.test.ts | 40 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 tests/unit/usage-ledger/cleanup-immunity.test.ts diff --git a/src/actions/users.ts b/src/actions/users.ts index e972e54d6..02eadd0bc 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -5,7 +5,7 @@ import { and, eq, inArray, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { getLocale, getTranslations } from "next-intl/server"; import { db } from "@/drizzle/db"; -import { messageRequest, users as usersTable } from "@/drizzle/schema"; +import { messageRequest, usageLedger, users as usersTable } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; @@ -1231,6 +1231,7 @@ export async function editUser( } // 删除用户 +// Ledger rows intentionally survive user deletion (billing audit trail) export async function removeUser(userId: number): Promise { try { // Get translations for error messages @@ -1572,6 +1573,9 @@ export async function resetUserAllStatistics(userId: number): Promise { + it("log cleanup service never imports or queries usageLedger", () => { + expect(serviceTs).not.toContain("import.*usageLedger"); + expect(serviceTs).not.toMatch(/from.*schema.*usageLedger/); + expect(serviceTs).not.toContain("db.delete(usageLedger)"); + expect(serviceTs).not.toContain('from("usage_ledger")'); + expect(serviceTs).not.toContain("FROM usage_ledger"); + }); + + it("removeUser does not delete from usageLedger", () => { + const removeUserMatch = usersTs.match(/export async function removeUser[\s\S]*?^}/m); + expect(removeUserMatch).not.toBeNull(); + const removeUserBody = removeUserMatch![0]; + expect(removeUserBody).not.toContain("db.delete(usageLedger)"); + }); + + it("resetUserAllStatistics deletes from both tables", () => { + const resetMatch = usersTs.match(/export async function resetUserAllStatistics[\s\S]*?^}/m); + expect(resetMatch).not.toBeNull(); + const resetBody = resetMatch![0]; + expect(resetBody).toContain("db.delete(messageRequest)"); + expect(resetBody).toContain("db.delete(usageLedger)"); + }); + + it("resetUserAllStatistics is the only usageLedger delete path in users.ts", () => { + const allDeleteMatches = [...usersTs.matchAll(/db\.delete\(usageLedger\)/g)]; + expect(allDeleteMatches).toHaveLength(1); + + const deleteIndex = usersTs.indexOf("db.delete(usageLedger)"); + const precedingChunk = usersTs.slice(Math.max(0, deleteIndex - 2000), deleteIndex); + expect(precedingChunk).toContain("resetUserAllStatistics"); + }); +}); From 543a3ce33d33905599a75d6109f5334c38ab94a4 Mon Sep 17 00:00:00 2001 From: ding113 Date: Fri, 20 Feb 2026 02:55:03 +0800 Subject: [PATCH 35/75] feat(export): add ledger-only export option - Add ExportMode type ('full' | 'excludeLogs' | 'ledgerOnly') to executePgDump() - full: complete backup (default, backward compatible) - excludeLogs: --exclude-table-data=message_request (keeps schema) - ledgerOnly: --exclude-table=message_request (excludes schema+data) - Update export API route to accept ?mode= query param with validation - Add Select component to export UI for mode selection - Add i18n strings for all 5 languages (en, zh-CN, zh-TW, ja, ru) --- messages/en/settings/data.json | 10 +++++ messages/ja/settings/data.json | 10 +++++ messages/ru/settings/data.json | 10 +++++ messages/zh-CN/settings/data.json | 12 +++++- messages/zh-TW/settings/data.json | 10 +++++ .../data/_components/database-export.tsx | 37 +++++++++++++++++-- src/app/api/admin/database/export/route.ts | 34 ++++++++++------- src/lib/database-backup/docker-executor.ts | 18 ++++++--- 8 files changed, 117 insertions(+), 24 deletions(-) diff --git a/messages/en/settings/data.json b/messages/en/settings/data.json index 45dc7b584..1b09fd9a4 100644 --- a/messages/en/settings/data.json +++ b/messages/en/settings/data.json @@ -39,6 +39,16 @@ "error": "Failed to export database", "exporting": "Exporting...", "failed": "Export failed", + "mode": { + "excludeLogs": "Exclude Logs", + "full": "Full Backup", + "ledgerOnly": "Ledger Only" + }, + "modeDescription": { + "excludeLogs": "Export without log data (keeps structure)", + "full": "Export all data including request logs", + "ledgerOnly": "Export only billing ledger (minimal size)" + }, "successMessage": "Database exported successfully!" }, "guide": { diff --git a/messages/ja/settings/data.json b/messages/ja/settings/data.json index 5bce34914..e531ebece 100644 --- a/messages/ja/settings/data.json +++ b/messages/ja/settings/data.json @@ -39,6 +39,16 @@ "error": "データベースのエクスポートに失敗しました", "exporting": "エクスポート中...", "failed": "エクスポート失敗", + "mode": { + "excludeLogs": "ログを除外", + "full": "完全バックアップ", + "ledgerOnly": "課金データのみ" + }, + "modeDescription": { + "excludeLogs": "ログデータなしでエクスポート(テーブル構造は保持)", + "full": "リクエストログを含む全データをエクスポート", + "ledgerOnly": "課金台帳のみエクスポート(最小サイズ)" + }, "successMessage": "データベースのエクスポートに成功しました!" }, "guide": { diff --git a/messages/ru/settings/data.json b/messages/ru/settings/data.json index 1a2b10505..ea16ef84c 100644 --- a/messages/ru/settings/data.json +++ b/messages/ru/settings/data.json @@ -39,6 +39,16 @@ "error": "Не удалось экспортировать базу данных", "exporting": "Экспорт...", "failed": "Экспорт не удался", + "mode": { + "excludeLogs": "Без логов", + "full": "Полная резервная копия", + "ledgerOnly": "Только биллинг" + }, + "modeDescription": { + "excludeLogs": "Экспорт без данных логов (структура сохраняется)", + "full": "Экспорт всех данных, включая логи запросов", + "ledgerOnly": "Экспорт только биллинговых записей (минимальный размер)" + }, "successMessage": "База данных успешно экспортирована!" }, "guide": { diff --git a/messages/zh-CN/settings/data.json b/messages/zh-CN/settings/data.json index ef8dcc158..443941d9b 100644 --- a/messages/zh-CN/settings/data.json +++ b/messages/zh-CN/settings/data.json @@ -49,7 +49,17 @@ "successMessage": "数据库导出成功!", "failed": "导出失败", "error": "导出数据库失败", - "descriptionFull": "导出完整的数据库备份文件(.dump 格式),可用于数据迁移或恢复。备份文件使用 PostgreSQL custom format,自动压缩且兼容不同版本的数据库结构。" + "descriptionFull": "导出完整的数据库备份文件(.dump 格式),可用于数据迁移或恢复。备份文件使用 PostgreSQL custom format,自动压缩且兼容不同版本的数据库结构。", + "mode": { + "full": "完整备份", + "excludeLogs": "排除日志", + "ledgerOnly": "仅账单数据" + }, + "modeDescription": { + "full": "导出所有数据,包括请求日志", + "excludeLogs": "不导出日志数据(保留表结构)", + "ledgerOnly": "仅导出账单记录(最小体积)" + } }, "import": { "selectFileLabel": "选择备份文件", diff --git a/messages/zh-TW/settings/data.json b/messages/zh-TW/settings/data.json index 992039f0c..1ce25620b 100644 --- a/messages/zh-TW/settings/data.json +++ b/messages/zh-TW/settings/data.json @@ -39,6 +39,16 @@ "error": "匯出資料庫失敗", "exporting": "正在匯出...", "failed": "匯出失敗", + "mode": { + "excludeLogs": "排除日誌", + "full": "完整備份", + "ledgerOnly": "僅帳單資料" + }, + "modeDescription": { + "excludeLogs": "不匯出日誌資料(保留表結構)", + "full": "匯出所有資料,包括請求日誌", + "ledgerOnly": "僅匯出帳單記錄(最小體積)" + }, "successMessage": "資料庫匯出成功!" }, "guide": { diff --git a/src/app/[locale]/settings/data/_components/database-export.tsx b/src/app/[locale]/settings/data/_components/database-export.tsx index dc0997e7f..4a4117c31 100644 --- a/src/app/[locale]/settings/data/_components/database-export.tsx +++ b/src/app/[locale]/settings/data/_components/database-export.tsx @@ -5,17 +5,28 @@ import { useTranslations } from "next-intl"; import { useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +type ExportMode = "full" | "excludeLogs" | "ledgerOnly"; + +const EXPORT_MODES: ExportMode[] = ["full", "excludeLogs", "ledgerOnly"]; export function DatabaseExport() { const t = useTranslations("settings.data.export"); const [isExporting, setIsExporting] = useState(false); + const [exportMode, setExportMode] = useState("full"); const handleExport = async () => { setIsExporting(true); try { - // Call export API (auto includes cookie) - const response = await fetch("/api/admin/database/export", { + const response = await fetch(`/api/admin/database/export?mode=${exportMode}`, { method: "GET", credentials: "include", }); @@ -25,12 +36,10 @@ export function DatabaseExport() { throw new Error(error.error || t("failed")); } - // Get filename (from Content-Disposition header) const contentDisposition = response.headers.get("Content-Disposition"); const filenameMatch = contentDisposition?.match(/filename="(.+)"/); const filename = filenameMatch?.[1] || `backup_${new Date().toISOString()}.dump`; - // Download file const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); @@ -54,6 +63,26 @@ export function DatabaseExport() {

{t("descriptionFull")}

+
+ +
+
diff --git a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx index 59f5c9642..585b7836b 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx @@ -6,6 +6,7 @@ import { ChevronRight, InfoIcon, Link2, + MinusCircle, RefreshCw, XCircle, Zap, @@ -33,6 +34,8 @@ interface ProviderChainPopoverProps { * Determine if this is an actual request record (excluding intermediate states) */ function isActualRequest(item: ProviderChainItem): boolean { + if (item.reason === "client_restriction_filtered") return false; + if (item.reason === "concurrent_limit_failed") return true; if (item.reason === "retry_failed" || item.reason === "system_error") return true; @@ -101,6 +104,13 @@ function getItemStatus(item: ProviderChainItem): { bgColor: "bg-orange-50 dark:bg-orange-950/30", }; } + if (item.reason === "client_restriction_filtered") { + return { + icon: MinusCircle, + color: "text-muted-foreground", + bgColor: "bg-muted/30", + }; + } return { icon: RefreshCw, color: "text-slate-500", diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx index 907ba3ff4..33430a398 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx @@ -318,6 +318,8 @@ function ProviderFormContent({ model_redirects: state.routing.modelRedirects, allowed_models: state.routing.allowedModels.length > 0 ? state.routing.allowedModels : null, + allowed_clients: state.routing.allowedClients, + blocked_clients: state.routing.blockedClients, priority: state.routing.priority, group_priorities: Object.keys(state.routing.groupPriorities).length > 0 diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx index a8f79bc67..4b2272168 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx @@ -28,6 +28,8 @@ const ACTION_TO_FIELD_PATH: Partial> SET_PRESERVE_CLIENT_IP: "routing.preserveClientIp", SET_MODEL_REDIRECTS: "routing.modelRedirects", SET_ALLOWED_MODELS: "routing.allowedModels", + SET_ALLOWED_CLIENTS: "routing.allowedClients", + SET_BLOCKED_CLIENTS: "routing.blockedClients", SET_GROUP_PRIORITIES: "routing.groupPriorities", SET_CACHE_TTL_PREFERENCE: "routing.cacheTtlPreference", SET_SWAP_CACHE_TTL_BILLING: "routing.swapCacheTtlBilling", @@ -91,6 +93,8 @@ export function createInitialState( preserveClientIp: false, modelRedirects: {}, allowedModels: [], + allowedClients: [], + blockedClients: [], priority: 0, groupPriorities: {}, weight: 1, @@ -165,6 +169,8 @@ export function createInitialState( preserveClientIp: sourceProvider?.preserveClientIp ?? false, modelRedirects: sourceProvider?.modelRedirects ?? {}, allowedModels: sourceProvider?.allowedModels ?? [], + allowedClients: sourceProvider?.allowedClients ?? [], + blockedClients: sourceProvider?.blockedClients ?? [], priority: sourceProvider?.priority ?? 0, groupPriorities: sourceProvider?.groupPriorities ?? {}, weight: sourceProvider?.weight ?? 1, @@ -262,6 +268,10 @@ export function providerFormReducer( return { ...state, routing: { ...state.routing, modelRedirects: action.payload } }; case "SET_ALLOWED_MODELS": return { ...state, routing: { ...state.routing, allowedModels: action.payload } }; + case "SET_ALLOWED_CLIENTS": + return { ...state, routing: { ...state.routing, allowedClients: action.payload } }; + case "SET_BLOCKED_CLIENTS": + return { ...state, routing: { ...state.routing, blockedClients: action.payload } }; case "SET_PRIORITY": return { ...state, routing: { ...state.routing, priority: action.payload } }; case "SET_GROUP_PRIORITIES": diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts index 4bec44463..cd7d3dfcc 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts @@ -42,6 +42,8 @@ export interface RoutingState { preserveClientIp: boolean; modelRedirects: Record; allowedModels: string[]; + allowedClients: string[]; + blockedClients: string[]; priority: number; groupPriorities: Record; weight: number; @@ -128,6 +130,8 @@ export type ProviderFormAction = | { type: "SET_PRESERVE_CLIENT_IP"; payload: boolean } | { type: "SET_MODEL_REDIRECTS"; payload: Record } | { type: "SET_ALLOWED_MODELS"; payload: string[] } + | { type: "SET_ALLOWED_CLIENTS"; payload: string[] } + | { type: "SET_BLOCKED_CLIENTS"; payload: string[] } | { type: "SET_PRIORITY"; payload: number } | { type: "SET_GROUP_PRIORITIES"; payload: Record } | { type: "SET_WEIGHT"; payload: number } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx index 59c0f7c4a..6b5458774 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx @@ -5,7 +5,9 @@ import { Info, Layers, Route, Scale, Settings, Timer } from "lucide-react"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Select, SelectContent, @@ -16,6 +18,14 @@ import { import { Switch } from "@/components/ui/switch"; import { TagInput } from "@/components/ui/tag-input"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { + CLIENT_RESTRICTION_PRESET_OPTIONS, + isPresetSelected, + mergePresetAndCustomClients, + removePresetValues, + splitPresetAndCustomClients, + togglePresetSelection, +} from "@/lib/client-restrictions/client-presets"; import { getProviderTypeConfig } from "@/lib/provider-type-utils"; import type { CodexParallelToolCallsPreference, @@ -69,6 +79,44 @@ export function RoutingSection() { }; const providerTypes: ProviderType[] = ["claude", "codex", "gemini", "openai-compatible"]; + const allowedClients = state.routing.allowedClients; + const blockedClients = state.routing.blockedClients; + const { customValues: customAllowedClients } = splitPresetAndCustomClients(allowedClients); + const { customValues: customBlockedClients } = splitPresetAndCustomClients(blockedClients); + + const handleAllowToggle = (presetValue: string, checked: boolean) => { + const nextAllowed = togglePresetSelection(allowedClients, presetValue, checked); + dispatch({ type: "SET_ALLOWED_CLIENTS", payload: nextAllowed }); + + if (checked) { + const nextBlocked = removePresetValues(blockedClients, presetValue); + dispatch({ type: "SET_BLOCKED_CLIENTS", payload: nextBlocked }); + } + }; + + const handleBlockToggle = (presetValue: string, checked: boolean) => { + const nextBlocked = togglePresetSelection(blockedClients, presetValue, checked); + dispatch({ type: "SET_BLOCKED_CLIENTS", payload: nextBlocked }); + + if (checked) { + const nextAllowed = removePresetValues(allowedClients, presetValue); + dispatch({ type: "SET_ALLOWED_CLIENTS", payload: nextAllowed }); + } + }; + + const handleCustomAllowedChange = (customValues: string[]) => { + dispatch({ + type: "SET_ALLOWED_CLIENTS", + payload: mergePresetAndCustomClients(allowedClients, customValues), + }); + }; + + const handleCustomBlockedChange = (customValues: string[]) => { + dispatch({ + type: "SET_BLOCKED_CLIENTS", + payload: mergePresetAndCustomClients(blockedClients, customValues), + }); + }; return ( @@ -219,6 +267,85 @@ export function RoutingSection() {

+ + {/* Client Restrictions */} + +
+ {CLIENT_RESTRICTION_PRESET_OPTIONS.map((option) => { + const isAllowed = isPresetSelected(allowedClients, option.value); + const isBlocked = isPresetSelected(blockedClients, option.value); + return ( +
+ + {t(`sections.routing.clientRestrictions.presetClients.${option.value}`)} + +
+
+ + handleAllowToggle(option.value, checked === true) + } + /> + +
+
+ + handleBlockToggle(option.value, checked === true) + } + /> + +
+
+
+ ); + })} +
+
+ + + +

+ {t("sections.routing.clientRestrictions.customHelp")} +

+
+ + + +

+ {t("sections.routing.clientRestrictions.customHelp")} +

+
{/* Scheduling Parameters */} diff --git a/src/app/v1/_lib/proxy/client-detector.ts b/src/app/v1/_lib/proxy/client-detector.ts new file mode 100644 index 000000000..b11889fa0 --- /dev/null +++ b/src/app/v1/_lib/proxy/client-detector.ts @@ -0,0 +1,236 @@ +import type { ProxySession } from "./session"; + +export const CLAUDE_CODE_KEYWORD_PREFIX = "claude-code"; + +export const BUILTIN_CLIENT_KEYWORDS = new Set([ + "claude-code", + "claude-code-cli", + "claude-code-cli-sdk", + "claude-code-vscode", + "claude-code-sdk-ts", + "claude-code-sdk-py", + "claude-code-gh-action", +]); + +export interface ClientDetectionResult { + matched: boolean; + hubConfirmed: boolean; + subClient: string | null; + signals: string[]; + supplementary: string[]; +} + +export interface ClientRestrictionResult { + allowed: boolean; + matchType: "no_restriction" | "allowed" | "blocklist_hit" | "allowlist_miss"; + matchedPattern?: string; + detectedClient?: string; + checkedAllowlist: string[]; + checkedBlocklist: string[]; +} + +const normalize = (s: string) => s.toLowerCase().replace(/[-_]/g, ""); + +const ENTRYPOINT_MAP: Record = { + cli: "claude-code-cli", + "sdk-cli": "claude-code-cli-sdk", + "claude-vscode": "claude-code-vscode", + "sdk-ts": "claude-code-sdk-ts", + "sdk-py": "claude-code-sdk-py", + "claude-code-github-action": "claude-code-gh-action", +}; + +function confirmClaudeCodeSignals(session: ProxySession): { + confirmed: boolean; + signals: string[]; + supplementary: string[]; +} { + const signals: string[] = []; + const supplementary: string[] = []; + + if (session.headers.get("x-app") === "cli") { + signals.push("x-app-cli"); + } + + if (/^claude-cli\//i.test(session.userAgent ?? "")) { + signals.push("ua-prefix"); + } + + const betas = session.request.message["betas"]; + if ( + Array.isArray(betas) && + betas.some((beta) => typeof beta === "string" && /^claude-code-/i.test(beta)) + ) { + signals.push("betas-claude-code"); + } + + if (session.headers.get("anthropic-dangerous-direct-browser-access") === "true") { + supplementary.push("dangerous-browser-access"); + } + + return { + confirmed: signals.length === 3, + signals, + supplementary, + }; +} + +function extractSubClient(ua: string): string | null { + const match = /^claude-cli\/\S+\s+\(external,\s*([^,)]+)/i.exec(ua); + if (!match?.[1]) { + return null; + } + + const entrypoint = match[1].trim(); + return ENTRYPOINT_MAP[entrypoint] ?? null; +} + +export function isBuiltinKeyword(pattern: string): boolean { + return BUILTIN_CLIENT_KEYWORDS.has(pattern); +} + +export function matchClientPattern(session: ProxySession, pattern: string): boolean { + if (!isBuiltinKeyword(pattern)) { + const ua = session.userAgent?.trim(); + if (!ua) { + return false; + } + + const normalizedPattern = normalize(pattern); + if (normalizedPattern === "") { + return false; + } + + return normalize(ua).includes(normalizedPattern); + } + + const claudeCode = confirmClaudeCodeSignals(session); + if (!claudeCode.confirmed) { + return false; + } + + if (pattern === CLAUDE_CODE_KEYWORD_PREFIX) { + return true; + } + + const subClient = extractSubClient(session.userAgent ?? ""); + return subClient === pattern; +} + +export function detectClientFull(session: ProxySession, pattern: string): ClientDetectionResult { + const claudeCode = confirmClaudeCodeSignals(session); + const subClient = claudeCode.confirmed ? extractSubClient(session.userAgent ?? "") : null; + + let matched = false; + if (isBuiltinKeyword(pattern)) { + if (claudeCode.confirmed) { + matched = + pattern === CLAUDE_CODE_KEYWORD_PREFIX || (subClient !== null && subClient === pattern); + } + } else { + const ua = session.userAgent?.trim(); + if (ua) { + const normalizedPattern = normalize(pattern); + if (normalizedPattern !== "") { + matched = normalize(ua).includes(normalizedPattern); + } + } + } + + return { + matched, + hubConfirmed: claudeCode.confirmed, + subClient, + signals: claudeCode.signals, + supplementary: claudeCode.supplementary, + }; +} + +export function isClientAllowed( + session: ProxySession, + allowedClients: string[], + blockedClients?: string[] +): boolean { + return isClientAllowedDetailed(session, allowedClients, blockedClients).allowed; +} + +export function isClientAllowedDetailed( + session: ProxySession, + allowedClients: string[], + blockedClients?: string[] +): ClientRestrictionResult { + const checkedAllowlist = allowedClients; + const checkedBlocklist = blockedClients ?? []; + + const hasBlockList = checkedBlocklist.length > 0; + if (!hasBlockList && allowedClients.length === 0) { + return { + allowed: true, + matchType: "no_restriction", + checkedAllowlist, + checkedBlocklist, + }; + } + + // Pre-compute once to avoid repeated signal checks per pattern + const claudeCode = confirmClaudeCodeSignals(session); + const ua = session.userAgent?.trim() ?? ""; + const normalizedUa = normalize(ua); + const subClient = claudeCode.confirmed ? extractSubClient(ua) : null; + const detectedClient = subClient || ua || undefined; + + const matches = (pattern: string): boolean => { + if (!isBuiltinKeyword(pattern)) { + if (!ua) return false; + const normalizedPattern = normalize(pattern); + return normalizedPattern !== "" && normalizedUa.includes(normalizedPattern); + } + if (!claudeCode.confirmed) return false; + if (pattern === CLAUDE_CODE_KEYWORD_PREFIX) return true; + return subClient === pattern; + }; + + if (checkedBlocklist.length > 0) { + const blockedPattern = checkedBlocklist.find(matches); + if (blockedPattern) { + return { + allowed: false, + matchType: "blocklist_hit", + matchedPattern: blockedPattern, + detectedClient, + checkedAllowlist, + checkedBlocklist, + }; + } + } + + if (allowedClients.length === 0) { + return { + allowed: true, + matchType: "allowed", + detectedClient, + checkedAllowlist, + checkedBlocklist, + }; + } + + const allowedPattern = allowedClients.find(matches); + if (allowedPattern) { + return { + allowed: true, + matchType: "allowed", + matchedPattern: allowedPattern, + detectedClient, + checkedAllowlist, + checkedBlocklist, + }; + } + + return { + allowed: false, + matchType: "allowlist_miss", + detectedClient, + checkedAllowlist, + checkedBlocklist, + }; +} diff --git a/src/app/v1/_lib/proxy/client-guard.ts b/src/app/v1/_lib/proxy/client-guard.ts index f227132e6..7ffadb63a 100644 --- a/src/app/v1/_lib/proxy/client-guard.ts +++ b/src/app/v1/_lib/proxy/client-guard.ts @@ -1,21 +1,7 @@ +import { isClientAllowedDetailed } from "./client-detector"; import { ProxyResponses } from "./responses"; import type { ProxySession } from "./session"; -/** - * Client (CLI/IDE) restriction guard - * - * Validates that the client making the request is allowed based on User-Agent header matching. - * This check is ONLY performed when the user has configured client restrictions (allowedClients). - * - * Logic: - * - If allowedClients is empty or undefined: skip all checks, allow request - * - If allowedClients is non-empty: - * - Missing or empty User-Agent → 400 error - * - User-Agent doesn't match any allowed pattern → 400 error - * - User-Agent matches at least one pattern → allow request - * - * Matching: case-insensitive substring match - */ export class ProxyClientGuard { static async ensure(session: ProxySession): Promise { const user = session.authState?.user; @@ -24,18 +10,17 @@ export class ProxyClientGuard { return null; } - // Check if client restrictions are configured const allowedClients = user.allowedClients ?? []; - if (allowedClients.length === 0) { - // No restrictions configured - skip all checks + const blockedClients = user.blockedClients ?? []; + + if (allowedClients.length === 0 && blockedClients.length === 0) { return null; } - // Restrictions exist - now User-Agent is required - const userAgent = session.userAgent; - - // Missing or empty User-Agent when restrictions exist - if (!userAgent || userAgent.trim() === "") { + // User-Agent is only required when an allowlist is configured. + // Blocklist-only: no UA can't match any block pattern, so the request passes through. + const userAgent = session.userAgent?.trim(); + if (!userAgent && allowedClients.length > 0) { return ProxyResponses.buildError( 400, "Client not allowed. User-Agent header is required when client restrictions are configured.", @@ -43,23 +28,17 @@ export class ProxyClientGuard { ); } - // Case-insensitive substring match with hyphen/underscore normalization - // This handles variations like "gemini-cli" matching "GeminiCLI" or "gemini_cli" - const normalize = (s: string) => s.toLowerCase().replace(/[-_]/g, ""); - const userAgentNorm = normalize(userAgent); - const isAllowed = allowedClients.some((pattern) => { - const normalizedPattern = normalize(pattern); - // Skip empty patterns to prevent includes("") matching everything - if (normalizedPattern === "") return false; - return userAgentNorm.includes(normalizedPattern); - }); + const result = isClientAllowedDetailed(session, allowedClients, blockedClients); - if (!isAllowed) { - return ProxyResponses.buildError( - 400, - `Client not allowed. Your client is not in the allowed list.`, - "invalid_request_error" - ); + if (!result.allowed) { + const detected = result.detectedClient ? ` (detected: ${result.detectedClient})` : ""; + let message: string; + if (result.matchType === "blocklist_hit") { + message = `Client blocked by pattern: ${result.matchedPattern}${detected}`; + } else { + message = `Client not in allowed list: [${allowedClients.join(", ")}]${detected}`; + } + return ProxyResponses.buildError(400, message, "invalid_request_error"); } // Client is allowed diff --git a/src/app/v1/_lib/proxy/provider-selector.ts b/src/app/v1/_lib/proxy/provider-selector.ts index 9f108cd80..617ac4349 100644 --- a/src/app/v1/_lib/proxy/provider-selector.ts +++ b/src/app/v1/_lib/proxy/provider-selector.ts @@ -8,6 +8,7 @@ import { findAllProviders, findProviderById } from "@/repository/provider"; import { getSystemSettings } from "@/repository/system-config"; import type { ProviderChainItem } from "@/types/message"; import type { Provider } from "@/types/provider"; +import { isClientAllowedDetailed } from "./client-detector"; import type { ClientFormat } from "./format-mapper"; import { ProxyResponses } from "./responses"; import type { ProxySession } from "./session"; @@ -423,10 +424,15 @@ export class ProxyProviderResolver { const circuitOpen = filteredProviders.filter((p) => p.reason === "circuit_open"); const disabled = filteredProviders.filter((p) => p.reason === "disabled"); const modelNotAllowed = filteredProviders.filter((p) => p.reason === "model_not_allowed"); + const clientRestricted = filteredProviders.filter((p) => p.reason === "client_restriction"); // 计算可用供应商数量(排除禁用和模型不支持的) const unavailableCount = rateLimited.length + circuitOpen.length; - const totalEnabled = filteredProviders.length - disabled.length - modelNotAllowed.length; + const totalEnabled = + filteredProviders.length - + disabled.length - + modelNotAllowed.length - + clientRestricted.length; if ( rateLimited.length > 0 && @@ -473,11 +479,20 @@ export class ProxyProviderResolver { const filteredProviders = session.getLastSelectionContext()?.filteredProviders; if (filteredProviders) { + const clientRestricted = filteredProviders.filter((p) => p.reason === "client_restriction"); + // C-001: 脱敏供应商名称,仅暴露 id 和 reason details.filteredProviders = filteredProviders.map((p) => ({ id: p.id, reason: p.reason, })); + + if (clientRestricted.length > 0) { + details.clientRestrictedProviders = clientRestricted.map((p) => ({ + id: p.id, + reason: p.reason, + })); + } } return ProxyResponses.buildError(status, message, errorType, details); @@ -573,6 +588,55 @@ export class ProxyProviderResolver { return null; } + // Check provider-level client restrictions on session reuse + const providerAllowed = provider.allowedClients ?? []; + const providerBlocked = provider.blockedClients ?? []; + const clientResult = isClientAllowedDetailed(session, providerAllowed, providerBlocked); + if (!clientResult.allowed) { + logger.debug("ProviderSelector: Session provider blocked by client restrictions", { + sessionId: session.sessionId, + providerId: provider.id, + matchType: clientResult.matchType, + matchedPattern: clientResult.matchedPattern, + detectedClient: clientResult.detectedClient, + }); + session.addProviderToChain(provider, { + reason: "client_restriction_filtered", + decisionContext: { + totalProviders: 0, + enabledProviders: 0, + targetType: provider.providerType as NonNullable< + ProviderChainItem["decisionContext"] + >["targetType"], + requestedModel: session.getOriginalModel() || "", + groupFilterApplied: false, + beforeHealthCheck: 0, + afterHealthCheck: 0, + priorityLevels: [], + selectedPriority: 0, + candidatesAtPriority: [], + filteredProviders: [ + { + id: provider.id, + name: provider.name, + reason: "client_restriction", + details: + clientResult.matchType === "blocklist_hit" ? "blocklist_hit" : "allowlist_miss", + clientRestrictionContext: { + matchType: clientResult.matchType as "blocklist_hit" | "allowlist_miss", + matchedPattern: clientResult.matchedPattern, + detectedClient: clientResult.detectedClient, + providerAllowlist: clientResult.checkedAllowlist, + providerBlocklist: clientResult.checkedBlocklist, + }, + }, + ], + }, + }); + await SessionManager.clearSessionProvider(session.sessionId); + return null; + } + // 修复:检查用户分组权限(严格分组隔离 + 支持多分组) // Check if session provider matches user's group // Priority: key.providerGroup > user.providerGroup @@ -749,6 +813,37 @@ export class ProxyProviderResolver { excludedProviderIds: excludeIds.length > 0 ? excludeIds : undefined, }; + if (session) { + const clientFilteredProviders: typeof visibleProviders = []; + for (const p of visibleProviders) { + const providerAllowed = p.allowedClients ?? []; + const providerBlocked = p.blockedClients ?? []; + if (providerAllowed.length === 0 && providerBlocked.length === 0) { + clientFilteredProviders.push(p); + continue; + } + const result = isClientAllowedDetailed(session, providerAllowed, providerBlocked); + if (!result.allowed) { + context.filteredProviders?.push({ + id: p.id, + name: p.name, + reason: "client_restriction", + details: result.matchType === "blocklist_hit" ? "blocklist_hit" : "allowlist_miss", + clientRestrictionContext: { + matchType: result.matchType as "blocklist_hit" | "allowlist_miss", + matchedPattern: result.matchedPattern, + detectedClient: result.detectedClient, + providerAllowlist: result.checkedAllowlist, + providerBlocklist: result.checkedBlocklist, + }, + }); + continue; + } + clientFilteredProviders.push(p); + } + visibleProviders = clientFilteredProviders; + } + // Step 2: 基础过滤 + 格式/模型匹配(使用 visibleProviders) const enabledProviders = visibleProviders.filter((provider) => { // 2a. 基础过滤 diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index bebf1ba9e..74afe9400 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -449,7 +449,8 @@ export class ProxySession { | "client_error_non_retryable" // 不可重试的客户端错误(Prompt 超限、内容过滤、PDF 限制、Thinking 格式) | "http2_fallback" // HTTP/2 协议错误,回退到 HTTP/1.1(不切换供应商、不计入熔断器) | "endpoint_pool_exhausted" // 端点池耗尽(strict endpoint policy 阻止了 fallback) - | "vendor_type_all_timeout"; // 供应商类型全端点超时(524),触发 vendor-type 临时熔断 + | "vendor_type_all_timeout" // 供应商类型全端点超时(524),触发 vendor-type 临时熔断 + | "client_restriction_filtered"; // 供应商因客户端限制被跳过(会话复用路径) selectionMethod?: | "session_reuse" | "weighted_random" diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index 06ad18358..4c34b5a60 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -72,6 +72,10 @@ export const users = pgTable('users', { // Empty array = no restrictions, non-empty = only listed models allowed allowedModels: jsonb('allowed_models').$type().default([]), + // Blocked clients (CLI/IDE blocklist) + // Non-empty = listed patterns are denied even if allowedClients permits them + blockedClients: jsonb('blocked_clients').$type().notNull().default([]), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), deletedAt: timestamp('deleted_at', { withTimezone: true }), @@ -193,6 +197,12 @@ export const providers = pgTable('providers', { // - null 或空数组:Anthropic 允许所有 claude 模型,非 Anthropic 允许任意模型 allowedModels: jsonb('allowed_models').$type().default(null), + // Client restrictions for this provider + // allowedClients: empty = no restriction; non-empty = only listed patterns allowed + // blockedClients: non-empty = listed patterns are denied + allowedClients: jsonb('allowed_clients').$type().notNull().default([]), + blockedClients: jsonb('blocked_clients').$type().notNull().default([]), + // 加入 Claude 调度池:仅对非 Anthropic 提供商有效 // 启用后,如果该提供商配置了重定向到 claude-* 模型,可以加入 claude 调度池 joinClaudePool: boolean('join_claude_pool').default(false), @@ -542,6 +552,13 @@ export const messageRequest = pgTable('message_request', { messageRequestKeyCostActiveIdx: index('idx_message_request_key_cost_active') .on(table.key, table.costUsd) .where(sql`${table.deletedAt} IS NULL AND (${table.blockedBy} IS NULL OR ${table.blockedBy} <> 'warmup')`), + // #slow-query: composite index for session user-info LATERAL lookup + // Query: WHERE session_id = $1 AND deleted_at IS NULL ORDER BY created_at LIMIT 1 + // Provides seek + pre-sorted scan; user_id, key in index reduce heap columns to fetch. + // user_agent/api_type still require one heap fetch per session (LIMIT 1, negligible). + messageRequestSessionUserInfoIdx: index('idx_message_request_session_user_info') + .on(table.sessionId, table.createdAt, table.userId, table.key) + .where(sql`${table.deletedAt} IS NULL`), })); // Model Prices table @@ -881,8 +898,18 @@ export const usageLedger = pgTable('usage_ledger', { usageLedgerModelIdx: index('idx_usage_ledger_model') .on(table.model) .where(sql`${table.model} IS NOT NULL`), + // #slow-query: covering index for SUM(cost_usd) per key (replaces old key+cost, adds created_at for time range) usageLedgerKeyCostIdx: index('idx_usage_ledger_key_cost') - .on(table.key, table.costUsd) + .on(table.key, table.createdAt, table.costUsd) + .where(sql`${table.blockedBy} IS NULL`), + // #slow-query: covering index for SUM(cost_usd) per user (Quotas page + rate-limit total) + // Keys: user_id (equality), created_at (range filter), cost_usd (aggregation, index-only scan) + usageLedgerUserCostCoverIdx: index('idx_usage_ledger_user_cost_cover') + .on(table.userId, table.createdAt, table.costUsd) + .where(sql`${table.blockedBy} IS NULL`), + // #slow-query: covering index for SUM(cost_usd) per provider (rate-limit total) + usageLedgerProviderCostCoverIdx: index('idx_usage_ledger_provider_cost_cover') + .on(table.finalProviderId, table.createdAt, table.costUsd) .where(sql`${table.blockedBy} IS NULL`), })); diff --git a/src/lib/client-restrictions/client-presets.test.ts b/src/lib/client-restrictions/client-presets.test.ts new file mode 100644 index 000000000..041974ca7 --- /dev/null +++ b/src/lib/client-restrictions/client-presets.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vitest"; +import { + isPresetClientValue, + isPresetSelected, + mergePresetAndCustomClients, + removePresetValues, + splitPresetAndCustomClients, + togglePresetSelection, +} from "./client-presets"; + +describe("client restriction presets", () => { + test("treats Claude Code sub-client values as preset-compatible aliases", () => { + expect(isPresetClientValue("claude-code")).toBe(true); + expect(isPresetClientValue("claude-code-cli")).toBe(true); + expect(isPresetSelected(["claude-code-cli-sdk"], "claude-code")).toBe(true); + }); + + test("splitPresetAndCustomClients keeps legacy aliases in presetValues", () => { + const result = splitPresetAndCustomClients(["claude-code-vscode", "my-ide"]); + expect(result).toEqual({ + presetValues: ["claude-code-vscode"], + customValues: ["my-ide"], + }); + }); + + test("togglePresetSelection adds canonical value for newly enabled preset", () => { + expect(togglePresetSelection(["gemini-cli"], "claude-code", true)).toEqual([ + "gemini-cli", + "claude-code", + ]); + }); + + test("togglePresetSelection removes canonical value and aliases when disabled", () => { + expect( + togglePresetSelection(["claude-code", "claude-code-cli", "my-ide"], "claude-code", false) + ).toEqual(["my-ide"]); + }); + + test("removePresetValues clears the whole preset group", () => { + expect(removePresetValues(["claude-code-gh-action", "codex-cli"], "claude-code")).toEqual([ + "codex-cli", + ]); + }); + + test("mergePresetAndCustomClients preserves legacy preset values without forcing migration", () => { + expect( + mergePresetAndCustomClients(["claude-code-sdk-ts", "codex-cli"], ["my-ide", "codex-cli"]) + ).toEqual(["claude-code-sdk-ts", "codex-cli", "my-ide"]); + }); +}); diff --git a/src/lib/client-restrictions/client-presets.ts b/src/lib/client-restrictions/client-presets.ts new file mode 100644 index 000000000..825186b10 --- /dev/null +++ b/src/lib/client-restrictions/client-presets.ts @@ -0,0 +1,89 @@ +export interface ClientRestrictionPresetOption { + value: string; + aliases: readonly string[]; +} + +const CLAUDE_CODE_ALIAS_VALUES = [ + "claude-code", + "claude-code-cli", + "claude-code-cli-sdk", + "claude-code-vscode", + "claude-code-sdk-ts", + "claude-code-sdk-py", + "claude-code-gh-action", +] as const; + +export const CLIENT_RESTRICTION_PRESET_OPTIONS: readonly ClientRestrictionPresetOption[] = [ + { value: "claude-code", aliases: CLAUDE_CODE_ALIAS_VALUES }, + { value: "gemini-cli", aliases: ["gemini-cli"] }, + { value: "factory-cli", aliases: ["factory-cli"] }, + { value: "codex-cli", aliases: ["codex-cli"] }, +]; + +const PRESET_OPTION_MAP = new Map( + CLIENT_RESTRICTION_PRESET_OPTIONS.map((option) => [option.value, option] as const) +); + +const PRESET_ALIAS_SET = new Set( + CLIENT_RESTRICTION_PRESET_OPTIONS.flatMap((option) => [...option.aliases]) +); + +function uniqueOrdered(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + if (seen.has(value)) continue; + seen.add(value); + result.push(value); + } + return result; +} + +function getPresetAliases(presetValue: string): readonly string[] { + return PRESET_OPTION_MAP.get(presetValue)?.aliases ?? [presetValue]; +} + +export function isPresetClientValue(value: string): boolean { + return PRESET_ALIAS_SET.has(value); +} + +export function isPresetSelected(values: string[], presetValue: string): boolean { + const aliases = getPresetAliases(presetValue); + return values.some((value) => aliases.includes(value)); +} + +export function removePresetValues(values: string[], presetValue: string): string[] { + const aliases = new Set(getPresetAliases(presetValue)); + return values.filter((value) => !aliases.has(value)); +} + +export function togglePresetSelection( + values: string[], + presetValue: string, + checked: boolean +): string[] { + if (!checked) { + return removePresetValues(values, presetValue); + } + + if (isPresetSelected(values, presetValue)) { + return uniqueOrdered(values); + } + + return uniqueOrdered([...values, presetValue]); +} + +export function splitPresetAndCustomClients(values: string[]): { + presetValues: string[]; + customValues: string[]; +} { + const presetValues = values.filter((value) => PRESET_ALIAS_SET.has(value)); + const customValues = values.filter((value) => !PRESET_ALIAS_SET.has(value)); + return { presetValues, customValues }; +} + +export function mergePresetAndCustomClients(values: string[], customValues: string[]): string[] { + const { presetValues } = splitPresetAndCustomClients(values); + const filteredCustomValues = customValues.filter((value) => !PRESET_ALIAS_SET.has(value)); + return uniqueOrdered([...presetValues, ...filteredCustomValues]); +} diff --git a/src/lib/database-backup/docker-executor.ts b/src/lib/database-backup/docker-executor.ts index 33095e934..6ebccbf5d 100644 --- a/src/lib/database-backup/docker-executor.ts +++ b/src/lib/database-backup/docker-executor.ts @@ -1,7 +1,7 @@ import { spawn } from "node:child_process"; import { createReadStream } from "node:fs"; -import { db } from "@/drizzle/db"; import { sql } from "drizzle-orm"; +import { db } from "@/drizzle/db"; import { logger } from "@/lib/logger"; import { getDatabaseConfig } from "./db-config"; diff --git a/src/lib/permissions/user-field-permissions.ts b/src/lib/permissions/user-field-permissions.ts index 2ce287023..2d0d7fb32 100644 --- a/src/lib/permissions/user-field-permissions.ts +++ b/src/lib/permissions/user-field-permissions.ts @@ -28,6 +28,7 @@ export const USER_FIELD_PERMISSIONS = { // Admin-only field (client restrictions) allowedClients: { requiredRole: "admin" }, + blockedClients: { requiredRole: "admin" }, // Admin-only field (model restrictions) allowedModels: { requiredRole: "admin" }, diff --git a/src/lib/provider-patch-contract.ts b/src/lib/provider-patch-contract.ts index 659176713..1e3c876d0 100644 --- a/src/lib/provider-patch-contract.ts +++ b/src/lib/provider-patch-contract.ts @@ -31,6 +31,8 @@ const PATCH_FIELDS: ProviderBatchPatchField[] = [ "group_tag", "model_redirects", "allowed_models", + "allowed_clients", + "blocked_clients", "anthropic_thinking_budget_preference", "anthropic_adaptive_thinking", // Routing @@ -79,6 +81,8 @@ const CLEARABLE_FIELDS: Record = { group_tag: true, model_redirects: true, allowed_models: true, + allowed_clients: true, + blocked_clients: true, anthropic_thinking_budget_preference: true, anthropic_adaptive_thinking: true, // Routing @@ -395,6 +399,12 @@ export function normalizeProviderBatchPatchDraft( const allowedModels = normalizePatchField("allowed_models", typedDraft.allowed_models); if (!allowedModels.ok) return allowedModels; + const allowedClients = normalizePatchField("allowed_clients", typedDraft.allowed_clients); + if (!allowedClients.ok) return allowedClients; + + const blockedClients = normalizePatchField("blocked_clients", typedDraft.blocked_clients); + if (!blockedClients.ok) return blockedClients; + const thinkingBudget = normalizePatchField( "anthropic_thinking_budget_preference", typedDraft.anthropic_thinking_budget_preference @@ -566,6 +576,8 @@ export function normalizeProviderBatchPatchDraft( group_tag: groupTag.data, model_redirects: modelRedirects.data, allowed_models: allowedModels.data, + allowed_clients: allowedClients.data, + blocked_clients: blockedClients.data, anthropic_thinking_budget_preference: thinkingBudget.data, anthropic_adaptive_thinking: adaptiveThinking.data, // Routing @@ -642,6 +654,12 @@ function applyPatchField( ? (patch.value as ProviderBatchApplyUpdates["allowed_models"]) : null; return { ok: true, data: undefined }; + case "allowed_clients": + updates.allowed_clients = patch.value as ProviderBatchApplyUpdates["allowed_clients"]; + return { ok: true, data: undefined }; + case "blocked_clients": + updates.blocked_clients = patch.value as ProviderBatchApplyUpdates["blocked_clients"]; + return { ok: true, data: undefined }; case "anthropic_thinking_budget_preference": updates.anthropic_thinking_budget_preference = patch.value as ProviderBatchApplyUpdates["anthropic_thinking_budget_preference"]; @@ -780,6 +798,12 @@ function applyPatchField( case "allowed_models": updates.allowed_models = null; return { ok: true, data: undefined }; + case "allowed_clients": + updates.allowed_clients = []; + return { ok: true, data: undefined }; + case "blocked_clients": + updates.blocked_clients = []; + return { ok: true, data: undefined }; case "anthropic_thinking_budget_preference": updates.anthropic_thinking_budget_preference = "inherit"; return { ok: true, data: undefined }; @@ -861,6 +885,8 @@ export function buildProviderBatchApplyUpdates( ["group_tag", patch.group_tag], ["model_redirects", patch.model_redirects], ["allowed_models", patch.allowed_models], + ["allowed_clients", patch.allowed_clients], + ["blocked_clients", patch.blocked_clients], ["anthropic_thinking_budget_preference", patch.anthropic_thinking_budget_preference], ["anthropic_adaptive_thinking", patch.anthropic_adaptive_thinking], // Routing @@ -922,6 +948,8 @@ export function hasProviderBatchPatchChanges(patch: ProviderBatchPatch): boolean patch.group_tag.mode !== "no_change" || patch.model_redirects.mode !== "no_change" || patch.allowed_models.mode !== "no_change" || + patch.allowed_clients.mode !== "no_change" || + patch.blocked_clients.mode !== "no_change" || patch.anthropic_thinking_budget_preference.mode !== "no_change" || patch.anthropic_adaptive_thinking.mode !== "no_change" || // Routing diff --git a/src/lib/utils/provider-chain-formatter.ts b/src/lib/utils/provider-chain-formatter.ts index 98e3188f5..1f7c72150 100644 --- a/src/lib/utils/provider-chain-formatter.ts +++ b/src/lib/utils/provider-chain-formatter.ts @@ -400,6 +400,27 @@ export function formatProviderTimeline( continue; } + // === Session reuse client restriction === + if (item.reason === "client_restriction_filtered" && ctx) { + timeline += `${t("filterDetails.session_reuse_client_restriction")}\n\n`; + timeline += `${t("timeline.provider", { provider: item.name })}\n`; + if (ctx.filteredProviders && ctx.filteredProviders.length > 0) { + const f = ctx.filteredProviders[0]; + if (f.clientRestrictionContext) { + const crc = f.clientRestrictionContext; + const detailKey = `filterDetails.${crc.matchType}`; + const detailsText = crc.matchedPattern + ? t(detailKey, { pattern: crc.matchedPattern }) + : t(detailKey); + timeline += `${detailsText}\n`; + if (crc.detectedClient) { + timeline += `${t("filterDetails.detectedClient", { client: crc.detectedClient })}\n`; + } + } + } + continue; + } + // === 首次选择 === if (item.reason === "initial_selection" && ctx) { timeline += `${t("timeline.initialSelectionTitle")}\n\n`; @@ -425,13 +446,28 @@ export function formatProviderTimeline( if (ctx.filteredProviders && ctx.filteredProviders.length > 0) { timeline += `\n${t("timeline.filtered")}:\n`; for (const f of ctx.filteredProviders) { - const icon = f.reason === "circuit_open" ? "⚡" : "💰"; + const icon = + f.reason === "circuit_open" ? "⚡" : f.reason === "client_restriction" ? "🚫" : "💰"; const detailsText = f.details ? t(`filterDetails.${f.details}`) !== `filterDetails.${f.details}` ? t(`filterDetails.${f.details}`) : f.details : f.reason; timeline += ` ${icon} ${f.name} (${detailsText})\n`; + + // Client restriction context details + if (f.clientRestrictionContext) { + const crc = f.clientRestrictionContext; + if (crc.detectedClient) { + timeline += ` ${t("filterDetails.detectedClient", { client: crc.detectedClient })}\n`; + } + if (crc.providerAllowlist.length > 0) { + timeline += ` ${t("filterDetails.providerAllowlist", { list: crc.providerAllowlist.join(", ") })}\n`; + } + if (crc.providerBlocklist.length > 0) { + timeline += ` ${t("filterDetails.providerBlocklist", { list: crc.providerBlocklist.join(", ") })}\n`; + } + } } } diff --git a/src/lib/validation/schemas.test.ts b/src/lib/validation/schemas.test.ts index f13fabbcd..ed63cd48d 100644 --- a/src/lib/validation/schemas.test.ts +++ b/src/lib/validation/schemas.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "vitest"; -import { CreateProviderSchema, UpdateProviderSchema } from "./schemas"; +import { + CreateProviderSchema, + CreateUserSchema, + UpdateProviderSchema, + UpdateUserSchema, +} from "./schemas"; describe("Provider schemas - priority/weight/costMultiplier 规则对齐", () => { describe("UpdateProviderSchema", () => { @@ -99,5 +104,55 @@ describe("Provider schemas - priority/weight/costMultiplier 规则对齐", () => ); // 注意: null 会被 coerce 转为 0 (Number(null) === 0),所以会通过 }); + + test("allowed_clients/blocked_clients 支持 null 并归一化为空数组", () => { + const base = { + name: "测试供应商", + url: "https://api.example.com", + key: "sk-test", + }; + + const parsed = CreateProviderSchema.parse({ + ...base, + allowed_clients: null, + blocked_clients: null, + }); + + expect(parsed.allowed_clients).toEqual([]); + expect(parsed.blocked_clients).toEqual([]); + }); + }); + + describe("client restrictions null normalization", () => { + test("UpdateProviderSchema 将 null 归一化为空数组", () => { + const parsed = UpdateProviderSchema.parse({ + allowed_clients: null, + blocked_clients: null, + }); + + expect(parsed.allowed_clients).toEqual([]); + expect(parsed.blocked_clients).toEqual([]); + }); + + test("CreateUserSchema 将 null 归一化为空数组", () => { + const parsed = CreateUserSchema.parse({ + name: "test-user", + allowedClients: null, + blockedClients: null, + }); + + expect(parsed.allowedClients).toEqual([]); + expect(parsed.blockedClients).toEqual([]); + }); + + test("UpdateUserSchema 将 null 归一化为空数组", () => { + const parsed = UpdateUserSchema.parse({ + allowedClients: null, + blockedClients: null, + }); + + expect(parsed.allowedClients).toEqual([]); + expect(parsed.blockedClients).toEqual([]); + }); }); }); diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index ebfbf1321..97dcae39a 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -74,6 +74,21 @@ const ANTHROPIC_ADAPTIVE_THINKING_CONFIG = z // - 'disabled': force remove googleSearch tool from request const GEMINI_GOOGLE_SEARCH_PREFERENCE = z.enum(["inherit", "enabled", "disabled"]); +const CLIENT_PATTERN_SCHEMA = z + .string() + .trim() + .min(1, "客户端模式不能为空") + .max(64, "客户端模式长度不能超过64个字符"); +const CLIENT_PATTERN_ARRAY_SCHEMA = z + .array(CLIENT_PATTERN_SCHEMA) + .max(50, "客户端模式数量不能超过50个"); +const OPTIONAL_CLIENT_PATTERN_ARRAY_SCHEMA = z.preprocess( + (value) => (value === null ? [] : value), + CLIENT_PATTERN_ARRAY_SCHEMA.optional() +); +const OPTIONAL_CLIENT_PATTERN_ARRAY_WITH_DEFAULT_SCHEMA = + OPTIONAL_CLIENT_PATTERN_ARRAY_SCHEMA.default([]); + /** * 用户创建数据验证schema */ @@ -197,11 +212,9 @@ export const CreateUserSchema = z.object({ .optional() .default("00:00"), // Allowed clients (CLI/IDE restrictions) - allowedClients: z - .array(z.string().max(64, "客户端模式长度不能超过64个字符")) - .max(50, "客户端模式数量不能超过50个") - .optional() - .default([]), + allowedClients: OPTIONAL_CLIENT_PATTERN_ARRAY_WITH_DEFAULT_SCHEMA, + // Blocked clients (CLI/IDE restrictions) + blockedClients: OPTIONAL_CLIENT_PATTERN_ARRAY_WITH_DEFAULT_SCHEMA, // Allowed models (AI model restrictions) allowedModels: z .array(z.string().max(64, "模型名称长度不能超过64个字符")) @@ -322,10 +335,9 @@ export const UpdateUserSchema = z.object({ .regex(/^([01]\d|2[0-3]):[0-5]\d$/, "重置时间格式必须为 HH:mm") .optional(), // Allowed clients (CLI/IDE restrictions) - allowedClients: z - .array(z.string().max(64, "客户端模式长度不能超过64个字符")) - .max(50, "客户端模式数量不能超过50个") - .optional(), + allowedClients: OPTIONAL_CLIENT_PATTERN_ARRAY_SCHEMA, + // Blocked clients (CLI/IDE restrictions) + blockedClients: OPTIONAL_CLIENT_PATTERN_ARRAY_SCHEMA, // Allowed models (AI model restrictions) allowedModels: z .array(z.string().max(64, "模型名称长度不能超过64个字符")) @@ -437,6 +449,8 @@ export const CreateProviderSchema = z preserve_client_ip: z.boolean().optional().default(false), model_redirects: z.record(z.string(), z.string()).nullable().optional(), allowed_models: z.array(z.string()).nullable().optional(), + allowed_clients: OPTIONAL_CLIENT_PATTERN_ARRAY_WITH_DEFAULT_SCHEMA, + blocked_clients: OPTIONAL_CLIENT_PATTERN_ARRAY_WITH_DEFAULT_SCHEMA, // MCP 透传配置 mcp_passthrough_type: z.enum(["none", "minimax", "glm", "custom"]).optional().default("none"), mcp_passthrough_url: z @@ -643,6 +657,8 @@ export const UpdateProviderSchema = z preserve_client_ip: z.boolean().optional(), model_redirects: z.record(z.string(), z.string()).nullable().optional(), allowed_models: z.array(z.string()).nullable().optional(), + allowed_clients: OPTIONAL_CLIENT_PATTERN_ARRAY_SCHEMA, + blocked_clients: OPTIONAL_CLIENT_PATTERN_ARRAY_SCHEMA, // MCP 透传配置 mcp_passthrough_type: z.enum(["none", "minimax", "glm", "custom"]).optional(), mcp_passthrough_url: z diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index d3773b713..d06cd86ed 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -48,6 +48,7 @@ export function toUser(dbUser: any): User { isEnabled: dbUser?.isEnabled ?? true, expiresAt: dbUser?.expiresAt ? new Date(dbUser.expiresAt) : null, allowedClients: dbUser?.allowedClients ?? [], + blockedClients: dbUser?.blockedClients ?? [], allowedModels: dbUser?.allowedModels ?? [], createdAt: dbUser?.createdAt ? new Date(dbUser.createdAt) : new Date(), updatedAt: dbUser?.updatedAt ? new Date(dbUser.updatedAt) : new Date(), diff --git a/src/repository/message.ts b/src/repository/message.ts index d0baa0314..b5d2527ed 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -837,33 +837,63 @@ export async function aggregateMultipleSessionStats(sessionIds: string[]): Promi } // 4. 批量获取用户信息(每个 session 的第一条请求) - // 使用 DISTINCT ON + ORDER BY 优化 - const userInfoResults = await db - .select({ - sessionId: messageRequest.sessionId, - userName: users.name, - userId: users.id, - keyName: keysTable.name, - keyId: keysTable.id, - userAgent: messageRequest.userAgent, - apiType: messageRequest.apiType, - createdAt: messageRequest.createdAt, - }) - .from(messageRequest) - .innerJoin(users, eq(messageRequest.userId, users.id)) - .innerJoin(keysTable, eq(messageRequest.key, keysTable.key)) - .where(and(inArray(messageRequest.sessionId, sessionIds), isNull(messageRequest.deletedAt))) - .orderBy(messageRequest.sessionId, messageRequest.createdAt); - - // 创建 sessionId → userInfo 的 Map(取每个 session 最早的记录) - const userInfoMap = new Map(); - for (const info of userInfoResults) { - // 跳过 null sessionId(虽然 WHERE 条件已过滤,但需要满足 TypeScript 类型检查) - if (!info.sessionId) continue; - - if (!userInfoMap.has(info.sessionId)) { - userInfoMap.set(info.sessionId, info); + // LATERAL JOIN: 每个 session_id 做 1 次索引探测,无全局排序 + const sessionIdParams = sql.join( + sessionIds.map((id) => sql`${id}`), + sql.raw(", ") + ); + const userInfoRows = await db.execute(sql` + SELECT + sid AS session_id, + u.name AS user_name, + u.id AS user_id, + k.name AS key_name, + k.id AS key_id, + mr.user_agent, + mr.api_type + FROM unnest(ARRAY[${sessionIdParams}]::varchar[]) AS sid + CROSS JOIN LATERAL ( + SELECT user_id, key, user_agent, api_type + FROM message_request + WHERE session_id = sid AND deleted_at IS NULL + ORDER BY created_at + LIMIT 1 + ) mr + INNER JOIN users u ON mr.user_id = u.id + INNER JOIN keys k ON mr.key = k.key + `); + + // 创建 sessionId → userInfo 的 Map + const userInfoMap = new Map< + string, + { + sessionId: string; + userName: string; + userId: number; + keyName: string; + keyId: number; + userAgent: string | null; + apiType: string | null; } + >(); + for (const row of Array.from(userInfoRows) as Array<{ + session_id: string; + user_name: string; + user_id: number; + key_name: string; + key_id: number; + user_agent: string | null; + api_type: string | null; + }>) { + userInfoMap.set(row.session_id, { + sessionId: row.session_id, + userName: row.user_name, + userId: row.user_id, + keyName: row.key_name, + keyId: row.key_id, + userAgent: row.user_agent, + apiType: row.api_type, + }); } // 5. 组装最终结果 diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 5204aab56..f9b6c32f3 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -181,6 +181,8 @@ export async function createProvider(providerData: CreateProviderData): Promise< preserveClientIp: providerData.preserve_client_ip ?? false, modelRedirects: providerData.model_redirects, allowedModels: providerData.allowed_models, + allowedClients: providerData.allowed_clients ?? [], + blockedClients: providerData.blocked_clients ?? [], mcpPassthroughType: providerData.mcp_passthrough_type ?? "none", mcpPassthroughUrl: providerData.mcp_passthrough_url ?? null, limit5hUsd: providerData.limit_5h_usd != null ? providerData.limit_5h_usd.toString() : null, @@ -256,6 +258,8 @@ export async function createProvider(providerData: CreateProviderData): Promise< preserveClientIp: providers.preserveClientIp, modelRedirects: providers.modelRedirects, allowedModels: providers.allowedModels, + allowedClients: providers.allowedClients, + blockedClients: providers.blockedClients, mcpPassthroughType: providers.mcpPassthroughType, mcpPassthroughUrl: providers.mcpPassthroughUrl, limit5hUsd: providers.limit5hUsd, @@ -336,6 +340,8 @@ export async function findProviderList( preserveClientIp: providers.preserveClientIp, modelRedirects: providers.modelRedirects, allowedModels: providers.allowedModels, + allowedClients: providers.allowedClients, + blockedClients: providers.blockedClients, mcpPassthroughType: providers.mcpPassthroughType, mcpPassthroughUrl: providers.mcpPassthroughUrl, limit5hUsd: providers.limit5hUsd, @@ -416,6 +422,8 @@ export async function findAllProvidersFresh(): Promise { preserveClientIp: providers.preserveClientIp, modelRedirects: providers.modelRedirects, allowedModels: providers.allowedModels, + allowedClients: providers.allowedClients, + blockedClients: providers.blockedClients, mcpPassthroughType: providers.mcpPassthroughType, mcpPassthroughUrl: providers.mcpPassthroughUrl, limit5hUsd: providers.limit5hUsd, @@ -500,6 +508,8 @@ export async function findProviderById(id: number): Promise { preserveClientIp: providers.preserveClientIp, modelRedirects: providers.modelRedirects, allowedModels: providers.allowedModels, + allowedClients: providers.allowedClients, + blockedClients: providers.blockedClients, mcpPassthroughType: providers.mcpPassthroughType, mcpPassthroughUrl: providers.mcpPassthroughUrl, limit5hUsd: providers.limit5hUsd, @@ -578,6 +588,10 @@ export async function updateProvider( if (providerData.model_redirects !== undefined) dbData.modelRedirects = providerData.model_redirects; if (providerData.allowed_models !== undefined) dbData.allowedModels = providerData.allowed_models; + if (providerData.allowed_clients !== undefined) + dbData.allowedClients = providerData.allowed_clients ?? []; + if (providerData.blocked_clients !== undefined) + dbData.blockedClients = providerData.blocked_clients ?? []; if (providerData.mcp_passthrough_type !== undefined) dbData.mcpPassthroughType = providerData.mcp_passthrough_type; if (providerData.mcp_passthrough_url !== undefined) @@ -723,6 +737,8 @@ export async function updateProvider( preserveClientIp: providers.preserveClientIp, modelRedirects: providers.modelRedirects, allowedModels: providers.allowedModels, + allowedClients: providers.allowedClients, + blockedClients: providers.blockedClients, mcpPassthroughType: providers.mcpPassthroughType, mcpPassthroughUrl: providers.mcpPassthroughUrl, limit5hUsd: providers.limit5hUsd, @@ -973,6 +989,8 @@ export interface BatchProviderUpdates { groupTag?: string | null; modelRedirects?: Record | null; allowedModels?: string[] | null; + allowedClients?: string[] | null; + blockedClients?: string[] | null; anthropicThinkingBudgetPreference?: string | null; anthropicAdaptiveThinking?: AnthropicAdaptiveThinkingConfig | null; // Routing @@ -1045,6 +1063,12 @@ export async function updateProvidersBatch( if (updates.allowedModels !== undefined) { setClauses.allowedModels = updates.allowedModels; } + if (updates.allowedClients !== undefined) { + setClauses.allowedClients = updates.allowedClients; + } + if (updates.blockedClients !== undefined) { + setClauses.blockedClients = updates.blockedClients; + } if (updates.anthropicThinkingBudgetPreference !== undefined) { setClauses.anthropicThinkingBudgetPreference = updates.anthropicThinkingBudgetPreference; } diff --git a/src/repository/statistics.ts b/src/repository/statistics.ts index fa00d0f50..7df64e5d0 100644 --- a/src/repository/statistics.ts +++ b/src/repository/statistics.ts @@ -1,5 +1,6 @@ import "server-only"; +import type { SQL } from "drizzle-orm"; import { and, eq, gte, inArray, isNull, lt, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { keys, messageRequest, usageLedger } from "@/drizzle/schema"; @@ -502,56 +503,83 @@ export async function sumUserTotalCost(userId: number, maxAgeDays: number = 365) } /** - * Batch query: all-time total cost grouped by user_id (single SQL query) + * Batch query: total cost grouped by user_id (single SQL query) * @param userIds - Array of user IDs + * @param maxAgeDays - Only include records newer than this many days (default 365, use Infinity to include all) * @returns Map of userId -> totalCost */ -export async function sumUserTotalCostBatch(userIds: number[]): Promise> { +export async function sumUserTotalCostBatch( + userIds: number[], + maxAgeDays: number = 365 +): Promise> { const result = new Map(); if (userIds.length === 0) return result; + const conditions: SQL[] = [inArray(usageLedger.userId, userIds), LEDGER_BILLING_CONDITION]; + if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { + const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); + conditions.push(gte(usageLedger.createdAt, cutoffDate)); + } + const rows = await db .select({ userId: usageLedger.userId, total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`, }) .from(usageLedger) - .where(and(inArray(usageLedger.userId, userIds), LEDGER_BILLING_CONDITION)) + .where(and(...conditions)) .groupBy(usageLedger.userId); - for (const id of userIds) { - result.set(id, 0); - } - for (const row of rows) { - result.set(row.userId, Number(row.total || 0)); - } + for (const id of userIds) result.set(id, 0); + for (const row of rows) result.set(row.userId, Number(row.total || 0)); return result; } /** - * Batch query: all-time total cost grouped by key_id (single SQL query via JOIN) + * Batch query: total cost grouped by key_id using a two-step PK lookup then aggregate. + * Avoids varchar LEFT JOIN by first resolving key strings via PK, then aggregating on + * usage_ledger directly (hits idx_usage_ledger_key_cost index). * @param keyIds - Array of key IDs + * @param maxAgeDays - Only include records newer than this many days (default 365, use Infinity to include all) * @returns Map of keyId -> totalCost */ -export async function sumKeyTotalCostBatchByIds(keyIds: number[]): Promise> { +export async function sumKeyTotalCostBatchByIds( + keyIds: number[], + maxAgeDays: number = 365 +): Promise> { const result = new Map(); if (keyIds.length === 0) return result; + for (const id of keyIds) result.set(id, 0); + + // Step 1: PK lookup -> key strings + const keyMappings = await db + .select({ id: keys.id, key: keys.key }) + .from(keys) + .where(inArray(keys.id, keyIds)); + + const keyStringToId = new Map(keyMappings.map((k) => [k.key, k.id])); + const keyStrings = keyMappings.map((k) => k.key); + if (keyStrings.length === 0) return result; + + // Step 2: Aggregate on usage_ledger directly (hits idx_usage_ledger_key_cost) + const conditions: SQL[] = [inArray(usageLedger.key, keyStrings), LEDGER_BILLING_CONDITION]; + if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { + const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); + conditions.push(gte(usageLedger.createdAt, cutoffDate)); + } const rows = await db .select({ - keyId: keys.id, + key: usageLedger.key, total: sql`COALESCE(SUM(${usageLedger.costUsd}), 0)`, }) - .from(keys) - .leftJoin(usageLedger, and(eq(usageLedger.key, keys.key), LEDGER_BILLING_CONDITION)) - .where(inArray(keys.id, keyIds)) - .groupBy(keys.id); + .from(usageLedger) + .where(and(...conditions)) + .groupBy(usageLedger.key); - for (const id of keyIds) { - result.set(id, 0); - } for (const row of rows) { - result.set(row.keyId, Number(row.total || 0)); + const keyId = keyStringToId.get(row.key); + if (keyId !== undefined) result.set(keyId, Number(row.total || 0)); } return result; } diff --git a/src/repository/usage-ledger.ts b/src/repository/usage-ledger.ts index ed60cd4ec..8655c59aa 100644 --- a/src/repository/usage-ledger.ts +++ b/src/repository/usage-ledger.ts @@ -68,10 +68,13 @@ export async function sumLedgerTotalCost( /** * Batch total cost grouped by entity (single SQL query). * Returns Map of entityId (as string) -> totalCost. + * @param maxAgeDays - Only include ledger rows created within this many days (default 365). + * Pass Infinity or a non-positive number to include all-time records. */ export async function sumLedgerTotalCostBatch( entityType: "user" | "key", - entityIds: number[] | string[] + entityIds: number[] | string[], + maxAgeDays: number = 365 ): Promise> { const result = new Map(); if (entityIds.length === 0) return result; @@ -80,6 +83,12 @@ export async function sumLedgerTotalCostBatch( result.set(String(id), "0"); } + const timeConditions: ReturnType[] = []; + if (Number.isFinite(maxAgeDays) && maxAgeDays > 0) { + const cutoffDate = new Date(Date.now() - Math.floor(maxAgeDays) * 24 * 60 * 60 * 1000); + timeConditions.push(gte(usageLedger.createdAt, cutoffDate)); + } + if (entityType === "user") { const ids = entityIds as number[]; const rows = await db @@ -88,7 +97,7 @@ export async function sumLedgerTotalCostBatch( total: sql`COALESCE(SUM(${usageLedger.costUsd}), '0')`, }) .from(usageLedger) - .where(and(inArray(usageLedger.userId, ids), LEDGER_BILLING_CONDITION)) + .where(and(inArray(usageLedger.userId, ids), LEDGER_BILLING_CONDITION, ...timeConditions)) .groupBy(usageLedger.userId); for (const row of rows) { result.set(String(row.entityId), row.total ?? "0"); @@ -101,7 +110,7 @@ export async function sumLedgerTotalCostBatch( total: sql`COALESCE(SUM(${usageLedger.costUsd}), '0')`, }) .from(usageLedger) - .where(and(inArray(usageLedger.key, ids), LEDGER_BILLING_CONDITION)) + .where(and(inArray(usageLedger.key, ids), LEDGER_BILLING_CONDITION, ...timeConditions)) .groupBy(usageLedger.key); for (const row of rows) { result.set(row.entityId, row.total ?? "0"); diff --git a/src/repository/user.ts b/src/repository/user.ts index 350ccbf6c..a7de95da2 100644 --- a/src/repository/user.ts +++ b/src/repository/user.ts @@ -59,6 +59,7 @@ export async function createUser(userData: CreateUserData): Promise { isEnabled: userData.isEnabled ?? true, expiresAt: userData.expiresAt ?? null, allowedClients: userData.allowedClients ?? [], + blockedClients: userData.blockedClients ?? [], allowedModels: userData.allowedModels ?? [], }; @@ -84,6 +85,7 @@ export async function createUser(userData: CreateUserData): Promise { isEnabled: users.isEnabled, expiresAt: users.expiresAt, allowedClients: users.allowedClients, + blockedClients: users.blockedClients, allowedModels: users.allowedModels, }); @@ -116,6 +118,7 @@ export async function findUserList(limit: number = 50, offset: number = 0): Prom isEnabled: users.isEnabled, expiresAt: users.expiresAt, allowedClients: users.allowedClients, + blockedClients: users.blockedClients, allowedModels: users.allowedModels, }) .from(users) @@ -294,6 +297,7 @@ export async function findUserListBatch( isEnabled: users.isEnabled, expiresAt: users.expiresAt, allowedClients: users.allowedClients, + blockedClients: users.blockedClients, allowedModels: users.allowedModels, }) .from(users) @@ -338,6 +342,7 @@ export async function findUserById(id: number): Promise { isEnabled: users.isEnabled, expiresAt: users.expiresAt, allowedClients: users.allowedClients, + blockedClients: users.blockedClients, allowedModels: users.allowedModels, }) .from(users) @@ -371,6 +376,7 @@ export async function updateUser(id: number, userData: UpdateUserData): Promise< isEnabled?: boolean; expiresAt?: Date | null; allowedClients?: string[]; + blockedClients?: string[]; allowedModels?: string[]; } @@ -402,6 +408,7 @@ export async function updateUser(id: number, userData: UpdateUserData): Promise< if (userData.isEnabled !== undefined) dbData.isEnabled = userData.isEnabled; if (userData.expiresAt !== undefined) dbData.expiresAt = userData.expiresAt; if (userData.allowedClients !== undefined) dbData.allowedClients = userData.allowedClients; + if (userData.blockedClients !== undefined) dbData.blockedClients = userData.blockedClients; if (userData.allowedModels !== undefined) dbData.allowedModels = userData.allowedModels; const [user] = await db @@ -430,6 +437,7 @@ export async function updateUser(id: number, userData: UpdateUserData): Promise< isEnabled: users.isEnabled, expiresAt: users.expiresAt, allowedClients: users.allowedClients, + blockedClients: users.blockedClients, allowedModels: users.allowedModels, }); diff --git a/src/types/message.ts b/src/types/message.ts index c6833d290..f99ca47b9 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -34,7 +34,8 @@ export interface ProviderChainItem { | "client_error_non_retryable" // 不可重试的客户端错误(Prompt 超限、内容过滤、PDF 限制、Thinking 格式) | "http2_fallback" // HTTP/2 协议错误,回退到 HTTP/1.1(不切换供应商、不计入熔断器) | "endpoint_pool_exhausted" // 端点池耗尽(所有端点熔断或不可用,严格模式阻止降级) - | "vendor_type_all_timeout"; // 供应商类型全端点超时(524),触发 vendor-type 临时熔断 + | "vendor_type_all_timeout" // 供应商类型全端点超时(524),触发 vendor-type 临时熔断 + | "client_restriction_filtered"; // Provider skipped due to client restriction (neutral, no circuit breaker) // === 选择方法(细化) === selectionMethod?: @@ -171,8 +172,16 @@ export interface ProviderChainItem { | "type_mismatch" | "model_not_allowed" | "context_1m_disabled" // 供应商禁用了 1M 上下文功能 - | "disabled"; + | "disabled" + | "client_restriction"; // Provider filtered due to client restriction details?: string; // 额外信息(如费用:$15.2/$15) + clientRestrictionContext?: { + matchType: "blocklist_hit" | "allowlist_miss"; + matchedPattern?: string; + detectedClient?: string; + providerAllowlist: string[]; + providerBlocklist: string[]; + }; }>; // --- 优先级分层 --- diff --git a/src/types/provider.ts b/src/types/provider.ts index 94480e6d0..875341206 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -65,6 +65,8 @@ export type ProviderBatchPatchField = | "group_tag" | "model_redirects" | "allowed_models" + | "allowed_clients" + | "blocked_clients" | "anthropic_thinking_budget_preference" | "anthropic_adaptive_thinking" // Routing @@ -112,6 +114,8 @@ export interface ProviderBatchPatchDraft { group_tag?: ProviderPatchDraftInput; model_redirects?: ProviderPatchDraftInput>; allowed_models?: ProviderPatchDraftInput; + allowed_clients?: ProviderPatchDraftInput; + blocked_clients?: ProviderPatchDraftInput; anthropic_thinking_budget_preference?: ProviderPatchDraftInput; anthropic_adaptive_thinking?: ProviderPatchDraftInput; // Routing @@ -160,6 +164,8 @@ export interface ProviderBatchPatch { group_tag: ProviderPatchOperation; model_redirects: ProviderPatchOperation>; allowed_models: ProviderPatchOperation; + allowed_clients: ProviderPatchOperation; + blocked_clients: ProviderPatchOperation; anthropic_thinking_budget_preference: ProviderPatchOperation; anthropic_adaptive_thinking: ProviderPatchOperation; // Routing @@ -208,6 +214,8 @@ export interface ProviderBatchApplyUpdates { group_tag?: string | null; model_redirects?: Record | null; allowed_models?: string[] | null; + allowed_clients?: string[]; + blocked_clients?: string[]; anthropic_thinking_budget_preference?: AnthropicThinkingBudgetPreference | null; anthropic_adaptive_thinking?: AnthropicAdaptiveThinkingConfig | null; // Routing @@ -285,6 +293,8 @@ export interface Provider { // - 非 Anthropic 提供商:声明列表(提供商声称支持的模型,可选) // - null 或空数组:Anthropic 允许所有 claude 模型,非 Anthropic 允许任意模型 allowedModels: string[] | null; + allowedClients: string[]; // Allowed client patterns (empty = no restriction) + blockedClients: string[]; // Blocked client patterns (blacklist, checked before allowedClients) // MCP 透传类型:控制是否启用 MCP 透传功能 // 'none': 不启用(默认) @@ -390,6 +400,8 @@ export interface ProviderDisplay { modelRedirects: Record | null; // 模型列表(双重语义) allowedModels: string[] | null; + allowedClients: string[]; // Allowed client patterns (empty = no restriction) + blockedClients: string[]; // Blocked client patterns (blacklist, checked before allowedClients) // MCP 透传类型 mcpPassthroughType: McpPassthroughType; // MCP 透传 URL @@ -479,6 +491,8 @@ export interface CreateProviderData { preserve_client_ip?: boolean; model_redirects?: Record | null; allowed_models?: string[] | null; + allowed_clients?: string[] | null; + blocked_clients?: string[] | null; mcp_passthrough_type?: McpPassthroughType; mcp_passthrough_url?: string | null; @@ -553,6 +567,8 @@ export interface UpdateProviderData { preserve_client_ip?: boolean; model_redirects?: Record | null; allowed_models?: string[] | null; + allowed_clients?: string[] | null; + blocked_clients?: string[] | null; mcp_passthrough_type?: McpPassthroughType; mcp_passthrough_url?: string | null; diff --git a/src/types/user.ts b/src/types/user.ts index 1efcad8e3..7a1307c76 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -27,6 +27,7 @@ export interface User { expiresAt?: Date | null; // 用户过期时间 // Allowed clients (CLI/IDE restrictions) allowedClients?: string[]; // 允许的客户端模式(空数组=无限制) + blockedClients?: string[]; // Blocked client patterns (blacklist, checked before allowedClients) // Allowed models (AI model restrictions) allowedModels?: string[]; // 允许的AI模型(空数组=无限制) } @@ -55,6 +56,7 @@ export interface CreateUserData { expiresAt?: Date | null; // Allowed clients (CLI/IDE restrictions) allowedClients?: string[]; + blockedClients?: string[]; // Blocked client patterns (blacklist, checked before allowedClients) // Allowed models (AI model restrictions) allowedModels?: string[]; } @@ -83,6 +85,7 @@ export interface UpdateUserData { expiresAt?: Date | null; // Allowed clients (CLI/IDE restrictions) allowedClients?: string[]; + blockedClients?: string[]; // Blocked client patterns (blacklist, checked before allowedClients) // Allowed models (AI model restrictions) allowedModels?: string[]; } @@ -156,6 +159,7 @@ export interface UserDisplay { expiresAt?: Date | null; // 用户过期时间 // Allowed clients (CLI/IDE restrictions) allowedClients?: string[]; // 允许的客户端模式(空数组=无限制) + blockedClients?: string[]; // Blocked client patterns (blacklist, checked before allowedClients) // Allowed models (AI model restrictions) allowedModels?: string[]; // 允许的AI模型(空数组=无限制) } @@ -173,6 +177,7 @@ export interface KeyDialogUserContext { limitTotalUsd?: number | null; limitConcurrentSessions?: number; allowedClients?: string[]; + blockedClients?: string[]; // Blocked client patterns (blacklist, checked before allowedClients) allowedModels?: string[]; } diff --git a/tests/unit/proxy/client-detector.test.ts b/tests/unit/proxy/client-detector.test.ts new file mode 100644 index 000000000..818cd607d --- /dev/null +++ b/tests/unit/proxy/client-detector.test.ts @@ -0,0 +1,428 @@ +import { describe, expect, test } from "vitest"; +import { + BUILTIN_CLIENT_KEYWORDS, + CLAUDE_CODE_KEYWORD_PREFIX, + detectClientFull, + isBuiltinKeyword, + isClientAllowed, + isClientAllowedDetailed, + matchClientPattern, +} from "@/app/v1/_lib/proxy/client-detector"; +import type { ProxySession } from "@/app/v1/_lib/proxy/session"; + +type SessionOptions = { + userAgent?: string | null; + xApp?: string | null; + dangerousBrowserAccess?: string | null; + betas?: unknown; +}; + +function createMockSession(options: SessionOptions = {}): ProxySession { + const headers = new Headers(); + if (options.xApp !== undefined && options.xApp !== null) { + headers.set("x-app", options.xApp); + } + if (options.dangerousBrowserAccess !== undefined && options.dangerousBrowserAccess !== null) { + headers.set("anthropic-dangerous-direct-browser-access", options.dangerousBrowserAccess); + } + + const message: Record = {}; + if ("betas" in options) { + message.betas = options.betas; + } + + return { + userAgent: options.userAgent ?? null, + headers, + request: { + message, + }, + } as unknown as ProxySession; +} + +function createConfirmedClaudeCodeSession(userAgent: string): ProxySession { + return createMockSession({ + userAgent, + xApp: "cli", + betas: ["claude-code-test"], + }); +} + +describe("client-detector", () => { + describe("constants", () => { + test("CLAUDE_CODE_KEYWORD_PREFIX should be claude-code", () => { + expect(CLAUDE_CODE_KEYWORD_PREFIX).toBe("claude-code"); + }); + + test("BUILTIN_CLIENT_KEYWORDS should contain 7 items", () => { + expect(BUILTIN_CLIENT_KEYWORDS.size).toBe(7); + }); + }); + + describe("isBuiltinKeyword", () => { + test.each([ + "claude-code", + "claude-code-cli", + "claude-code-cli-sdk", + "claude-code-vscode", + "claude-code-sdk-ts", + "claude-code-sdk-py", + "claude-code-gh-action", + ])("should return true for builtin keyword: %s", (pattern) => { + expect(isBuiltinKeyword(pattern)).toBe(true); + }); + + test.each([ + "gemini-cli", + "codex-cli", + "custom-pattern", + ])("should return false for non-builtin keyword: %s", (pattern) => { + expect(isBuiltinKeyword(pattern)).toBe(false); + }); + }); + + describe("confirmClaudeCodeSignals via detectClientFull", () => { + test("should confirm when all 3 strong signals are present", () => { + const session = createMockSession({ + userAgent: "claude-cli/1.0.0 (external, cli)", + xApp: "cli", + betas: ["claude-code-cache-control-20260101"], + }); + + const result = detectClientFull(session, "claude-code"); + expect(result.hubConfirmed).toBe(true); + expect(result.signals).toEqual(["x-app-cli", "ua-prefix", "betas-claude-code"]); + expect(result.supplementary).toEqual([]); + }); + + test.each([ + { + name: "missing x-app", + options: { + userAgent: "claude-cli/1.0.0 (external, cli)", + betas: ["claude-code-foo"], + }, + }, + { + name: "missing ua-prefix", + options: { + userAgent: "GeminiCLI/1.0", + xApp: "cli", + betas: ["claude-code-foo"], + }, + }, + { + name: "missing betas-claude-code", + options: { + userAgent: "claude-cli/1.0.0 (external, cli)", + xApp: "cli", + betas: ["not-claude-code"], + }, + }, + ])("should not confirm with only 2-of-3 signals: $name", ({ options }) => { + const session = createMockSession(options); + const result = detectClientFull(session, "claude-code"); + expect(result.hubConfirmed).toBe(false); + expect(result.signals.length).toBe(2); + }); + + test("should not confirm with 0 strong signals", () => { + const session = createMockSession({ userAgent: "GeminiCLI/1.0", betas: "not-array" }); + const result = detectClientFull(session, "claude-code"); + + expect(result.hubConfirmed).toBe(false); + expect(result.signals).toEqual([]); + }); + + test("should collect supplementary signal without counting it", () => { + const session = createMockSession({ + userAgent: "claude-cli/1.0.0 (external, cli)", + xApp: "cli", + betas: ["not-claude-code"], + dangerousBrowserAccess: "true", + }); + + const result = detectClientFull(session, "claude-code"); + expect(result.hubConfirmed).toBe(false); + expect(result.signals).toEqual(["x-app-cli", "ua-prefix"]); + expect(result.supplementary).toEqual(["dangerous-browser-access"]); + }); + }); + + describe("extractSubClient via detectClientFull", () => { + test.each([ + ["cli", "claude-code-cli"], + ["sdk-cli", "claude-code-cli-sdk"], + ["claude-vscode", "claude-code-vscode"], + ["sdk-ts", "claude-code-sdk-ts"], + ["sdk-py", "claude-code-sdk-py"], + ["claude-code-github-action", "claude-code-gh-action"], + ])("should map entrypoint %s to %s", (entrypoint, expectedSubClient) => { + const session = createConfirmedClaudeCodeSession( + `claude-cli/1.2.3 (external, ${entrypoint})` + ); + const result = detectClientFull(session, "claude-code"); + + expect(result.hubConfirmed).toBe(true); + expect(result.subClient).toBe(expectedSubClient); + }); + + test("should return null for unknown entrypoint", () => { + const session = createConfirmedClaudeCodeSession( + "claude-cli/1.2.3 (external, unknown-entry)" + ); + const result = detectClientFull(session, "claude-code"); + + expect(result.hubConfirmed).toBe(true); + expect(result.subClient).toBeNull(); + }); + + test("should return null for malformed UA", () => { + const session = createConfirmedClaudeCodeSession("claude-cli 1.2.3 (external, cli)"); + const result = detectClientFull(session, "claude-code"); + + expect(result.hubConfirmed).toBe(false); + expect(result.subClient).toBeNull(); + }); + + test("should return null when UA has no parentheses section", () => { + const session = createMockSession({ + userAgent: "claude-cli/1.2.3 external, cli", + xApp: "cli", + betas: ["claude-code-a"], + }); + const result = detectClientFull(session, "claude-code"); + + expect(result.hubConfirmed).toBe(true); + expect(result.subClient).toBeNull(); + }); + }); + + describe("matchClientPattern builtin keyword path", () => { + test("should match wildcard claude-code when 3-of-3 is confirmed", () => { + const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)"); + expect(matchClientPattern(session, "claude-code")).toBe(true); + }); + + test("should match claude-code-cli for cli entrypoint", () => { + const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)"); + expect(matchClientPattern(session, "claude-code-cli")).toBe(true); + }); + + test("should match claude-code-vscode for claude-vscode entrypoint", () => { + const session = createConfirmedClaudeCodeSession( + "claude-cli/1.2.3 (external, claude-vscode, agent-sdk/0.1.0)" + ); + expect(matchClientPattern(session, "claude-code-vscode")).toBe(true); + }); + + test("should return false when sub-client does not match", () => { + const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, sdk-py)"); + expect(matchClientPattern(session, "claude-code-sdk-ts")).toBe(false); + }); + + test("should return false when only 2-of-3 signals are present", () => { + const session = createMockSession({ + userAgent: "claude-cli/1.2.3 (external, cli)", + xApp: "cli", + betas: ["non-claude-code"], + }); + expect(matchClientPattern(session, "claude-code")).toBe(false); + }); + }); + + describe("matchClientPattern custom substring path", () => { + test("should match gemini-cli against GeminiCLI", () => { + const session = createMockSession({ userAgent: "GeminiCLI/1.0" }); + expect(matchClientPattern(session, "gemini-cli")).toBe(true); + }); + + test("should match codex-cli against codex_cli", () => { + const session = createMockSession({ userAgent: "codex_cli/2.0" }); + expect(matchClientPattern(session, "codex-cli")).toBe(true); + }); + + test("should return false when User-Agent is empty", () => { + const session = createMockSession({ userAgent: " " }); + expect(matchClientPattern(session, "gemini-cli")).toBe(false); + }); + + test("should return false when custom pattern is not found", () => { + const session = createMockSession({ userAgent: "Mozilla/5.0 Compatible" }); + expect(matchClientPattern(session, "gemini-cli")).toBe(false); + }); + + test("should return false when pattern normalizes to empty", () => { + const session = createMockSession({ userAgent: "AnyClient/1.0" }); + expect(matchClientPattern(session, "-_-")).toBe(false); + }); + }); + + describe("isClientAllowed", () => { + test("should reject when blocked matches even if allowed also matches", () => { + const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)"); + expect(isClientAllowed(session, ["claude-code"], ["claude-code"])).toBe(false); + }); + + test("should allow when allowedClients and blockedClients are both empty", () => { + const session = createMockSession({ userAgent: "AnyClient/1.0" }); + expect(isClientAllowed(session, [], [])).toBe(true); + }); + + test("should allow when allowedClients match", () => { + const session = createMockSession({ userAgent: "GeminiCLI/1.0" }); + expect(isClientAllowed(session, ["gemini-cli"])).toBe(true); + }); + + test("should reject when allowedClients are set but none match", () => { + const session = createMockSession({ userAgent: "UnknownClient/1.0" }); + expect(isClientAllowed(session, ["gemini-cli"])).toBe(false); + }); + + test("should reject when only blockedClients are set and blocked matches", () => { + const session = createMockSession({ userAgent: "GeminiCLI/1.0" }); + expect(isClientAllowed(session, [], ["gemini-cli"])).toBe(false); + }); + + test("should allow when only blockedClients are set and blocked does not match", () => { + const session = createMockSession({ userAgent: "GeminiCLI/1.0" }); + expect(isClientAllowed(session, [], ["codex-cli"])).toBe(true); + }); + + test("should allow when blocked does not match and allowed matches", () => { + const session = createMockSession({ userAgent: "codex_cli/2.0" }); + expect(isClientAllowed(session, ["codex-cli"], ["gemini-cli"])).toBe(true); + }); + }); + + describe("isClientAllowedDetailed", () => { + test("should return no_restriction when both lists are empty", () => { + const session = createMockSession({ userAgent: "AnyClient/1.0" }); + const result = isClientAllowedDetailed(session, [], []); + expect(result).toEqual({ + allowed: true, + matchType: "no_restriction", + matchedPattern: undefined, + detectedClient: undefined, + checkedAllowlist: [], + checkedBlocklist: [], + }); + }); + + test("should return blocklist_hit with matched pattern", () => { + const session = createMockSession({ userAgent: "GeminiCLI/1.0" }); + const result = isClientAllowedDetailed(session, [], ["gemini-cli"]); + expect(result.allowed).toBe(false); + expect(result.matchType).toBe("blocklist_hit"); + expect(result.matchedPattern).toBe("gemini-cli"); + expect(result.detectedClient).toBe("GeminiCLI/1.0"); + expect(result.checkedBlocklist).toEqual(["gemini-cli"]); + }); + + test("should return allowlist_miss when no allowlist pattern matches", () => { + const session = createMockSession({ userAgent: "UnknownClient/1.0" }); + const result = isClientAllowedDetailed(session, ["gemini-cli", "codex-cli"], []); + expect(result.allowed).toBe(false); + expect(result.matchType).toBe("allowlist_miss"); + expect(result.matchedPattern).toBeUndefined(); + expect(result.detectedClient).toBe("UnknownClient/1.0"); + expect(result.checkedAllowlist).toEqual(["gemini-cli", "codex-cli"]); + }); + + test("should return allowed when allowlist matches", () => { + const session = createMockSession({ userAgent: "GeminiCLI/1.0" }); + const result = isClientAllowedDetailed(session, ["gemini-cli"], []); + expect(result.allowed).toBe(true); + expect(result.matchType).toBe("allowed"); + expect(result.matchedPattern).toBe("gemini-cli"); + expect(result.detectedClient).toBe("GeminiCLI/1.0"); + }); + + test("blocklist takes precedence over allowlist", () => { + const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, cli)"); + const result = isClientAllowedDetailed(session, ["claude-code"], ["claude-code"]); + expect(result.allowed).toBe(false); + expect(result.matchType).toBe("blocklist_hit"); + expect(result.matchedPattern).toBe("claude-code"); + }); + + test("should detect sub-client for builtin keywords", () => { + const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, sdk-ts)"); + const result = isClientAllowedDetailed(session, ["claude-code"], []); + expect(result.allowed).toBe(true); + expect(result.matchType).toBe("allowed"); + expect(result.detectedClient).toBe("claude-code-sdk-ts"); + expect(result.matchedPattern).toBe("claude-code"); + }); + + test("should return allowed when only blocklist set and no match", () => { + const session = createMockSession({ userAgent: "CodexCLI/1.0" }); + const result = isClientAllowedDetailed(session, [], ["gemini-cli"]); + expect(result.allowed).toBe(true); + expect(result.matchType).toBe("allowed"); + expect(result.detectedClient).toBe("CodexCLI/1.0"); + }); + + test("should return no_restriction when blockedClients is undefined and allowlist empty", () => { + const session = createMockSession({ userAgent: "AnyClient/1.0" }); + const result = isClientAllowedDetailed(session, []); + expect(result.allowed).toBe(true); + expect(result.matchType).toBe("no_restriction"); + }); + + test("should capture first matching blocked pattern", () => { + const session = createMockSession({ userAgent: "GeminiCLI/1.0" }); + const result = isClientAllowedDetailed( + session, + [], + ["codex-cli", "gemini-cli", "factory-cli"] + ); + expect(result.allowed).toBe(false); + expect(result.matchType).toBe("blocklist_hit"); + expect(result.matchedPattern).toBe("gemini-cli"); + }); + }); + + describe("detectClientFull", () => { + test("should return matched=true for confirmed claude-code wildcard", () => { + const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, sdk-ts)"); + const result = detectClientFull(session, "claude-code"); + + expect(result).toEqual({ + matched: true, + hubConfirmed: true, + subClient: "claude-code-sdk-ts", + signals: ["x-app-cli", "ua-prefix", "betas-claude-code"], + supplementary: [], + }); + }); + + test("should return matched=false for confirmed but different builtin sub-client", () => { + const session = createConfirmedClaudeCodeSession("claude-cli/1.2.3 (external, sdk-ts)"); + const result = detectClientFull(session, "claude-code-cli"); + + expect(result.hubConfirmed).toBe(true); + expect(result.subClient).toBe("claude-code-sdk-ts"); + expect(result.matched).toBe(false); + }); + + test("should use custom normalization path for non-builtin patterns", () => { + const session = createMockSession({ userAgent: "GeminiCLI/0.22.5" }); + const result = detectClientFull(session, "gemini-cli"); + + expect(result.matched).toBe(true); + expect(result.hubConfirmed).toBe(false); + expect(result.subClient).toBeNull(); + }); + + test("should return matched=false for custom pattern when User-Agent is missing", () => { + const session = createMockSession({ userAgent: null }); + const result = detectClientFull(session, "gemini-cli"); + + expect(result.matched).toBe(false); + expect(result.hubConfirmed).toBe(false); + expect(result.signals).toEqual([]); + expect(result.supplementary).toEqual([]); + }); + }); +}); diff --git a/tests/unit/proxy/client-guard.test.ts b/tests/unit/proxy/client-guard.test.ts index 83fda1b18..89b1633fb 100644 --- a/tests/unit/proxy/client-guard.test.ts +++ b/tests/unit/proxy/client-guard.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi, beforeEach } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { ProxyClientGuard } from "@/app/v1/_lib/proxy/client-guard"; import type { ProxySession } from "@/app/v1/_lib/proxy/session"; @@ -13,13 +13,17 @@ vi.mock("@/app/v1/_lib/proxy/responses", () => ({ // Helper to create mock session function createMockSession( userAgent: string | undefined, - allowedClients: string[] = [] + allowedClients: string[] = [], + blockedClients: string[] = [] ): ProxySession { return { userAgent, + headers: new Headers(), + request: { message: {} }, authState: { user: { allowedClients, + blockedClients, }, }, } as unknown as ProxySession; @@ -57,6 +61,14 @@ describe("ProxyClientGuard", () => { }); }); + describe("when both allowedClients and blockedClients are empty", () => { + test("should allow request", async () => { + const session = createMockSession("AnyClient/1.0", [], []); + const result = await ProxyClientGuard.ensure(session); + expect(result).toBeNull(); + }); + }); + describe("when restrictions are configured", () => { test("should reject when User-Agent is missing", async () => { const session = createMockSession(undefined, ["claude-cli"]); @@ -196,4 +208,41 @@ describe("ProxyClientGuard", () => { expect(result).toBeNull(); }); }); + + describe("when blockedClients is configured", () => { + test("should reject when client matches blocked pattern", async () => { + const session = createMockSession("GeminiCLI/1.0", [], ["gemini-cli"]); + const result = await ProxyClientGuard.ensure(session); + expect(result).not.toBeNull(); + expect(result?.status).toBe(400); + }); + + test("should allow when client does not match blocked pattern", async () => { + const session = createMockSession("CodexCLI/1.0", [], ["gemini-cli"]); + const result = await ProxyClientGuard.ensure(session); + expect(result).toBeNull(); + }); + + test("should reject even when allowedClients matches", async () => { + const session = createMockSession("gemini-cli/1.0", ["gemini-cli"], ["gemini-cli"]); + const result = await ProxyClientGuard.ensure(session); + expect(result).not.toBeNull(); + expect(result?.status).toBe(400); + }); + }); + + describe("when only blockedClients is configured (no allowedClients)", () => { + test("should reject matching client", async () => { + const session = createMockSession("codex-cli/2.0", [], ["codex-cli"]); + const result = await ProxyClientGuard.ensure(session); + expect(result).not.toBeNull(); + expect(result?.status).toBe(400); + }); + + test("should allow non-matching client", async () => { + const session = createMockSession("claude-cli/1.0", [], ["codex-cli"]); + const result = await ProxyClientGuard.ensure(session); + expect(result).toBeNull(); + }); + }); }); diff --git a/tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx b/tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx index 18e8bb5d3..edf4f19c4 100644 --- a/tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx +++ b/tests/unit/settings/providers/provider-form-endpoint-pool.test.tsx @@ -304,6 +304,12 @@ describe("ProviderForm: endpoint pool integration", () => { } expect(providersActionMocks.addProvider).toHaveBeenCalledTimes(1); + expect(providersActionMocks.addProvider).toHaveBeenCalledWith( + expect.objectContaining({ + allowed_clients: [], + blocked_clients: [], + }) + ); await flushTicks(3); expect(providerEndpointsActionMocks.addProviderEndpoint).toHaveBeenCalledTimes(0); diff --git a/tests/unit/user-dialogs.test.tsx b/tests/unit/user-dialogs.test.tsx index 32aa965c2..772947529 100644 --- a/tests/unit/user-dialogs.test.tsx +++ b/tests/unit/user-dialogs.test.tsx @@ -256,6 +256,14 @@ const messages = { description: "Restrict clients", customLabel: "Custom", customPlaceholder: "Custom client", + customHelp: "Custom help", + }, + blockedClients: { + label: "Blocked Clients", + description: "Blocked description", + customLabel: "Custom blocked", + customPlaceholder: "Blocked client", + customHelp: "Blocked help", }, allowedModels: { label: "Allowed Models", @@ -263,8 +271,12 @@ const messages = { description: "Restrict models", }, }, + actions: { + allow: "Allow", + block: "Block", + }, presetClients: { - "claude-cli": "Claude CLI", + "claude-code": "Claude Code", "gemini-cli": "Gemini CLI", "factory-cli": "Factory CLI", "codex-cli": "Codex CLI", From bd788619f26ecb22a835c4a763c703185f8a71ba Mon Sep 17 00:00:00 2001 From: tesgth032 Date: Sun, 22 Feb 2026 10:15:06 +0800 Subject: [PATCH 53/75] fix: Removed a vertical separator between quick period buttons and date navigation controls (#813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 修复:移除日期范围筛选多余分隔符 * chore: format code (fix-my-usage-date-range-separator-3233cab) --------- Co-authored-by: github-actions[bot] --- .../dashboard/logs/_components/logs-date-range-picker.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/app/[locale]/dashboard/logs/_components/logs-date-range-picker.tsx b/src/app/[locale]/dashboard/logs/_components/logs-date-range-picker.tsx index 3a18857f0..5bf10791c 100644 --- a/src/app/[locale]/dashboard/logs/_components/logs-date-range-picker.tsx +++ b/src/app/[locale]/dashboard/logs/_components/logs-date-range-picker.tsx @@ -179,9 +179,6 @@ export function LogsDateRangePicker({ ))}
- {/* Separator */} -
- {/* Navigation and date display */}
- + setOpen(false)} currentUser={currentUser} /> diff --git a/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx b/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx index 22f7e2667..d466a2dde 100644 --- a/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/batch-edit/batch-edit-dialog.tsx @@ -443,7 +443,7 @@ function BatchEditDialogInner({ -
+
{selectedUsersCount > 0 ? ( +
diff --git a/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx index ea543338f..0a9d6827d 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-key-dialog.tsx @@ -52,7 +52,7 @@ export function EditKeyDialog({ return ( - + {t("title")} {t("description")} diff --git a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx index 2a73c239b..5a0fcf9bb 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx @@ -244,7 +244,7 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr }; return ( - +
diff --git a/src/app/[locale]/dashboard/_components/user/key-actions.tsx b/src/app/[locale]/dashboard/_components/user/key-actions.tsx index 610ae7b65..ade599c7c 100644 --- a/src/app/[locale]/dashboard/_components/user/key-actions.tsx +++ b/src/app/[locale]/dashboard/_components/user/key-actions.tsx @@ -50,7 +50,7 @@ export function KeyActions({ - + - + setOpenDelete(false)} /> diff --git a/src/app/[locale]/dashboard/_components/user/key-list-header.tsx b/src/app/[locale]/dashboard/_components/user/key-list-header.tsx index 84efc15b1..f40ffce9e 100644 --- a/src/app/[locale]/dashboard/_components/user/key-list-header.tsx +++ b/src/app/[locale]/dashboard/_components/user/key-list-header.tsx @@ -27,6 +27,15 @@ import { UserActions } from "./user-actions"; const PROXY_STATUS_REFRESH_INTERVAL = 2000; +type UserStatusCode = "disabled" | "expired" | "expiringSoon" | "active"; + +const USER_STATUS_LABEL_KEYS: Record = { + disabled: "userStatus.disabled", + expired: "userStatus.expired", + expiringSoon: "userStatus.expiringSoon", + active: "userStatus.active", +}; + async function fetchProxyStatus(): Promise { const result = await getProxyStatus(); if (result.ok) { @@ -123,19 +132,18 @@ export function KeyListHeader({ const exp = activeUser.expiresAt ? new Date(activeUser.expiresAt).getTime() : null; let status: { - code: string; - badge: string; + code: UserStatusCode; variant: "default" | "secondary" | "destructive" | "outline"; }; if (!activeUser.isEnabled) { - status = { code: "disabled", badge: "已禁用", variant: "secondary" }; + status = { code: "disabled", variant: "secondary" }; } else if (exp && exp <= now) { - status = { code: "expired", badge: "已过期", variant: "destructive" }; + status = { code: "expired", variant: "destructive" }; } else if (exp && exp - now <= 72 * 60 * 60 * 1000) { - status = { code: "expiringSoon", badge: "即将过期", variant: "outline" }; + status = { code: "expiringSoon", variant: "outline" }; } else { - status = { code: "active", badge: "已启用", variant: "default" }; + status = { code: "active", variant: "default" }; } const expiryText = activeUser.expiresAt @@ -255,7 +263,7 @@ export function KeyListHeader({ {activeUser ? activeUser.name : "-"} {activeUser && userStatusInfo && ( - {userStatusInfo.status.badge} + {t(USER_STATUS_LABEL_KEYS[userStatusInfo.status.code])} )} {activeUser && } @@ -318,7 +326,7 @@ export function KeyListHeader({ {t("addKey")} - + - + setOpenEdit(false)} currentUser={currentUser} /> diff --git a/src/app/[locale]/dashboard/_components/user/user-list.tsx b/src/app/[locale]/dashboard/_components/user/user-list.tsx index fa6da22d2..cb64c5bd6 100644 --- a/src/app/[locale]/dashboard/_components/user/user-list.tsx +++ b/src/app/[locale]/dashboard/_components/user/user-list.tsx @@ -388,7 +388,7 @@ export function UserList({ users, activeUserId, onUserSelect, currentUser }: Use if (!open) setEditUser(null); }} > - + {editUser ? ( +
{children} diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx index 1d64e0e61..ffaadca05 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-view-virtualized.tsx @@ -476,7 +476,7 @@ function UsageLogsViewContent({ hideStatusBar={true} hideScrollToTop={true} hiddenColumns={hideProviderColumn ? ["provider"] : undefined} - bodyClassName="h-[calc(100vh-56px-32px-40px)]" + bodyClassName="h-[calc(var(--cch-viewport-height,100vh)_-_56px_-_32px_-_40px)]" />
diff --git a/src/app/[locale]/dashboard/quotas/keys/_components/edit-key-quota-dialog.tsx b/src/app/[locale]/dashboard/quotas/keys/_components/edit-key-quota-dialog.tsx index abe61ebbc..1d8335c46 100644 --- a/src/app/[locale]/dashboard/quotas/keys/_components/edit-key-quota-dialog.tsx +++ b/src/app/[locale]/dashboard/quotas/keys/_components/edit-key-quota-dialog.tsx @@ -149,7 +149,7 @@ export function EditKeyQuotaDialog({ )} - + {t("title")} {t("description", { keyName, userName })} diff --git a/src/app/[locale]/dashboard/quotas/keys/_components/edit-user-quota-dialog.tsx b/src/app/[locale]/dashboard/quotas/keys/_components/edit-user-quota-dialog.tsx index be54a92d0..3967c2f97 100644 --- a/src/app/[locale]/dashboard/quotas/keys/_components/edit-user-quota-dialog.tsx +++ b/src/app/[locale]/dashboard/quotas/keys/_components/edit-user-quota-dialog.tsx @@ -87,7 +87,7 @@ export function EditUserQuotaDialog({ )} - + {t("title")} {t("description", { userName })} diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-details-tabs.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-details-tabs.tsx index 25654969b..6cd4b0fcd 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-details-tabs.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-details-tabs.tsx @@ -68,7 +68,7 @@ export function SessionMessagesDetailsTabs({ isResponseCopied, }: SessionMessagesDetailsTabsProps) { const t = useTranslations("dashboard.sessions"); - const codeExpandedMaxHeight = "calc(100vh - 260px)"; + const codeExpandedMaxHeight = "calc(var(--cch-viewport-height, 100vh) - 260px)"; // 后端已根据 STORE_SESSION_MESSAGES 配置进行脱敏,前端直接显示 const requestBodyContent = useMemo(() => { diff --git a/src/app/[locale]/dashboard/sessions/_components/session-messages-dialog.tsx b/src/app/[locale]/dashboard/sessions/_components/session-messages-dialog.tsx index b26143ee4..d5cae7334 100644 --- a/src/app/[locale]/dashboard/sessions/_components/session-messages-dialog.tsx +++ b/src/app/[locale]/dashboard/sessions/_components/session-messages-dialog.tsx @@ -67,7 +67,7 @@ export function SessionMessagesDialog({ sessionId }: SessionMessagesDialogProps) {t("actions.view")} - + {t("details.title")} {sessionId} diff --git a/src/app/[locale]/internal/dashboard/big-screen/loading.tsx b/src/app/[locale]/internal/dashboard/big-screen/loading.tsx index 33e7040e9..60947d22e 100644 --- a/src/app/[locale]/internal/dashboard/big-screen/loading.tsx +++ b/src/app/[locale]/internal/dashboard/big-screen/loading.tsx @@ -3,7 +3,7 @@ import { Skeleton } from "@/components/ui/skeleton"; export default function BigScreenLoading() { return ( -
+
{Array.from({ length: 4 }).map((_, index) => ( diff --git a/src/app/[locale]/internal/dashboard/big-screen/page.tsx b/src/app/[locale]/internal/dashboard/big-screen/page.tsx index 2d0dab8ee..1036f01c0 100644 --- a/src/app/[locale]/internal/dashboard/big-screen/page.tsx +++ b/src/app/[locale]/internal/dashboard/big-screen/page.tsx @@ -606,7 +606,10 @@ const TrafficTrend = ({ }} itemStyle={{ color: "#fff" }} labelFormatter={(value) => `${value}:00`} - formatter={(value) => [`${value ?? 0} 请求`, "数量"]} + formatter={(value) => [ + `${value ?? 0} ${t("chart.requestUnit")}`, + t("chart.countLabel"), + ]} /> @@ -815,7 +818,7 @@ export default function BigScreenPage() { - + {t("errorRules.dialog.addTitle")} diff --git a/src/app/[locale]/settings/error-rules/_components/edit-rule-dialog.tsx b/src/app/[locale]/settings/error-rules/_components/edit-rule-dialog.tsx index c171215b4..6b28b099d 100644 --- a/src/app/[locale]/settings/error-rules/_components/edit-rule-dialog.tsx +++ b/src/app/[locale]/settings/error-rules/_components/edit-rule-dialog.tsx @@ -139,7 +139,7 @@ export function EditRuleDialog({ rule, open, onOpenChange }: EditRuleDialogProps return ( - + {t("errorRules.dialog.editTitle")} diff --git a/src/app/[locale]/settings/layout.tsx b/src/app/[locale]/settings/layout.tsx index 55a633e56..034e072dd 100644 --- a/src/app/[locale]/settings/layout.tsx +++ b/src/app/[locale]/settings/layout.tsx @@ -31,7 +31,7 @@ export default async function SettingsLayout({ const translatedNavItems = await getTranslatedNavItems(); return ( -
+
diff --git a/src/app/[locale]/settings/notifications/_components/binding-selector.tsx b/src/app/[locale]/settings/notifications/_components/binding-selector.tsx index f6ed4a2a4..e7bb8eb43 100644 --- a/src/app/[locale]/settings/notifications/_components/binding-selector.tsx +++ b/src/app/[locale]/settings/notifications/_components/binding-selector.tsx @@ -336,7 +336,7 @@ export function BindingSelector({ type, targets, bindings, onSave }: BindingSele open={templateDialogOpen} onOpenChange={(open) => (open ? setTemplateDialogOpen(true) : closeTemplateDialog())} > - + {t("notifications.bindings.templateOverrideTitle")} diff --git a/src/app/[locale]/settings/notifications/_components/webhook-target-dialog.tsx b/src/app/[locale]/settings/notifications/_components/webhook-target-dialog.tsx index 5fcbb7a30..2932da38e 100644 --- a/src/app/[locale]/settings/notifications/_components/webhook-target-dialog.tsx +++ b/src/app/[locale]/settings/notifications/_components/webhook-target-dialog.tsx @@ -228,7 +228,7 @@ export function WebhookTargetDialog({ return ( - + {mode === "create" diff --git a/src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx b/src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx index 2940ba240..ecda7790f 100644 --- a/src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx +++ b/src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx @@ -251,7 +251,7 @@ export function SyncConflictDialog({ return ( - + diff --git a/src/app/[locale]/settings/providers/_components/adaptive-thinking-editor.tsx b/src/app/[locale]/settings/providers/_components/adaptive-thinking-editor.tsx index f7537553c..bbae95bc4 100644 --- a/src/app/[locale]/settings/providers/_components/adaptive-thinking-editor.tsx +++ b/src/app/[locale]/settings/providers/_components/adaptive-thinking-editor.tsx @@ -81,7 +81,7 @@ export function AdaptiveThinkingEditor({ } disabled={disabled} > - + @@ -118,7 +118,7 @@ export function AdaptiveThinkingEditor({ } disabled={disabled} > - + diff --git a/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx b/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx index 917d30a05..f11ab9676 100644 --- a/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx +++ b/src/app/[locale]/settings/providers/_components/add-provider-dialog.tsx @@ -22,7 +22,7 @@ export function AddProviderDialog({ enableMultiProviderTypes }: AddProviderDialo {t("addProvider")} - + {t("addProvider")} diff --git a/src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx b/src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx index eaebd4992..13b2d9db6 100644 --- a/src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx +++ b/src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx @@ -136,7 +136,7 @@ export function AutoSortPriorityDialog() { {t("button")} - + {t("dialogTitle")} {t("dialogDescription")} diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx b/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx index f55ac2ae7..6920c1888 100644 --- a/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx +++ b/src/app/[locale]/settings/providers/_components/batch-edit/provider-batch-dialog.tsx @@ -119,7 +119,7 @@ function BatchEditDialog({ return ( - + {/* Provider groups */} -
+
{grouped.map((group) => { const excluded = excludedProviderIds.has(group.providerId); return ( @@ -148,8 +148,8 @@ export function ProviderBatchPreviewStep({ {row.status === "changed" ? t("preview.fieldChanged", { field: getFieldLabel(row.field), - before: formatValue(row.before), - after: formatValue(row.after), + before: formatValue(row.before, t), + after: formatValue(row.after, t), }) : t("preview.fieldSkipped", { field: getFieldLabel(row.field), @@ -170,8 +170,8 @@ export function ProviderBatchPreviewStep({ // Helpers // --------------------------------------------------------------------------- -function formatValue(value: unknown): string { - if (value === null || value === undefined) return "null"; +function formatValue(value: unknown, t: (key: string) => string): string { + if (value === null || value === undefined) return t("preview.nullValue"); if (typeof value === "boolean") return String(value); if (typeof value === "number") return String(value); if (typeof value === "string") return value; diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx index f0af08497..6f4a51f23 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/components/form-tab-nav.tsx @@ -42,6 +42,10 @@ export function FormTabNav({ } }; + const activeTabIndex = TAB_CONFIG.findIndex((tab) => tab.id === activeTab); + const stepNumber = activeTabIndex >= 0 ? activeTabIndex + 1 : 0; + const stepProgressWidth = `${(stepNumber / TAB_CONFIG.length) * 100}%`; + if (layout === "horizontal") { return ( {/* Mobile: Bottom Navigation */} -