diff --git a/messages/en/myUsage.json b/messages/en/myUsage.json index 0ebe076a2..0e39e8496 100644 --- a/messages/en/myUsage.json +++ b/messages/en/myUsage.json @@ -92,6 +92,9 @@ "keyStats": "Key", "userStats": "User", "noData": "No data for selected period", + "breakdownPrevPage": "Previous page", + "breakdownNextPage": "Next page", + "breakdownPageIndicator": "{current} / {total}", "unknownModel": "Unknown", "modal": { "requests": "Requests", diff --git a/messages/ja/myUsage.json b/messages/ja/myUsage.json index 4d0b1bb7e..901e10ab6 100644 --- a/messages/ja/myUsage.json +++ b/messages/ja/myUsage.json @@ -92,6 +92,9 @@ "keyStats": "キー", "userStats": "ユーザー", "noData": "選択期間のデータがありません", + "breakdownPrevPage": "前のページ", + "breakdownNextPage": "次のページ", + "breakdownPageIndicator": "{current} / {total}", "unknownModel": "不明", "modal": { "requests": "リクエスト", diff --git a/messages/ru/myUsage.json b/messages/ru/myUsage.json index bb3b61bd7..5ccfec871 100644 --- a/messages/ru/myUsage.json +++ b/messages/ru/myUsage.json @@ -92,6 +92,9 @@ "keyStats": "Ключ", "userStats": "Пользователь", "noData": "Нет данных за выбранный период", + "breakdownPrevPage": "Предыдущая страница", + "breakdownNextPage": "Следующая страница", + "breakdownPageIndicator": "{current} / {total}", "unknownModel": "Неизвестно", "modal": { "requests": "Запросов", diff --git a/messages/zh-CN/myUsage.json b/messages/zh-CN/myUsage.json index 9eaaf7925..6cf939337 100644 --- a/messages/zh-CN/myUsage.json +++ b/messages/zh-CN/myUsage.json @@ -92,6 +92,9 @@ "keyStats": "密钥", "userStats": "用户", "noData": "所选时段无数据", + "breakdownPrevPage": "上一页", + "breakdownNextPage": "下一页", + "breakdownPageIndicator": "{current} / {total}", "unknownModel": "未知", "modal": { "requests": "请求", diff --git a/messages/zh-TW/myUsage.json b/messages/zh-TW/myUsage.json index b5247a160..f803a617b 100644 --- a/messages/zh-TW/myUsage.json +++ b/messages/zh-TW/myUsage.json @@ -92,6 +92,9 @@ "keyStats": "金鑰", "userStats": "使用者", "noData": "所選時段無資料", + "breakdownPrevPage": "上一頁", + "breakdownNextPage": "下一頁", + "breakdownPageIndicator": "{current} / {total}", "unknownModel": "不明", "modal": { "requests": "請求", 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 6e17b6934..d57980e91 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 @@ -251,26 +251,29 @@ function UsageLogsViewContent({ }; }, []); + const statsFilters = { + userId: filters.userId, + keyId: filters.keyId, + providerId: filters.providerId, + sessionId: filters.sessionId, + startTime: filters.startTime, + endTime: filters.endTime, + statusCode: filters.statusCode, + excludeStatusCode200: filters.excludeStatusCode200, + model: filters.model, + endpoint: filters.endpoint, + minRetryCount: filters.minRetryCount, + }; + + const hasStatsFilters = Object.values(statsFilters).some((v) => v !== undefined && v !== false); + return ( <>
{/* Stats Summary - Collapsible */} - + {hasStatsFilters && ( + + )} {/* Filter Criteria */} diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx deleted file mode 100644 index 4f6eba172..000000000 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx +++ /dev/null @@ -1,262 +0,0 @@ -"use client"; - -import { Pause, Play, RefreshCw } from "lucide-react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useRef, useState, useTransition } from "react"; -import { getUsageLogs } from "@/actions/usage-logs"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { useVisibilityPolling } from "@/hooks/use-visibility-polling"; -import type { CurrencyCode } from "@/lib/utils/currency"; -import type { UsageLogsResult } from "@/repository/usage-logs"; -import type { Key } from "@/types/key"; -import type { ProviderDisplay } from "@/types/provider"; -import type { BillingModelSource } from "@/types/system-config"; -import { buildLogsUrlQuery, parseLogsUrlFilters } from "../_utils/logs-query"; -import { UsageLogsFilters } from "./usage-logs-filters"; -import { UsageLogsStatsPanel } from "./usage-logs-stats-panel"; -import { UsageLogsTable } from "./usage-logs-table"; - -interface UsageLogsViewProps { - isAdmin: boolean; - providers: ProviderDisplay[]; - initialKeys: Key[]; - searchParams: { [key: string]: string | string[] | undefined }; - currencyCode?: CurrencyCode; - billingModelSource?: BillingModelSource; - serverTimeZone?: string; -} - -export function UsageLogsView({ - isAdmin, - providers, - initialKeys, - searchParams, - currencyCode = "USD", - billingModelSource = "original", - serverTimeZone, -}: UsageLogsViewProps) { - const t = useTranslations("dashboard"); - const router = useRouter(); - const params = useSearchParams(); - const [isPending, startTransition] = useTransition(); - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const [isAutoRefresh, setIsAutoRefresh] = useState(true); - const [isManualRefreshing, setIsManualRefreshing] = useState(false); - - // 追踪新增记录(用于动画高亮) - const [newLogIds, setNewLogIds] = useState>(new Set()); - const previousLogsRef = useRef>(new Map()); - const previousParamsRef = useRef(""); - - // 从 URL 参数解析筛选条件 - // 使用毫秒时间戳传递时间,避免时区问题 - const parsedFilters = parseLogsUrlFilters(searchParams); - const filters = { ...parsedFilters, page: parsedFilters.page ?? 1 } as const; - - // 使用 ref 来存储最新的值,避免闭包陷阱 - const isPendingRef = useRef(isPending); - const filtersRef = useRef(filters); - const isAutoRefreshRef = useRef(isAutoRefresh); - - isPendingRef.current = isPending; - - // 更新 filtersRef - filtersRef.current = filters; - isAutoRefreshRef.current = isAutoRefresh; - - // 加载数据 - // shouldDetectNew: 是否检测新增记录(只在刷新时为 true,筛选/翻页时为 false) - const loadData = useCallback( - async (shouldDetectNew = false) => { - startTransition(async () => { - const result = await getUsageLogs(filtersRef.current); - if (result.ok && result.data) { - // 只在刷新时检测新增(非筛选/翻页) - if (shouldDetectNew && previousLogsRef.current.size > 0) { - const newIds = result.data.logs - .filter((log) => !previousLogsRef.current.has(log.id)) - .map((log) => log.id) - .slice(0, 10); // 限制最多高亮 10 条 - - if (newIds.length > 0) { - setNewLogIds(new Set(newIds)); - // 800ms 后清除高亮 - setTimeout(() => setNewLogIds(new Set()), 800); - } - } - - // 更新记录缓存 - previousLogsRef.current = new Map(result.data.logs.map((log) => [log.id, true])); - - setData(result.data); - setError(null); - } else { - setError(!result.ok && "error" in result ? result.error : t("logs.error.loadFailed")); - setData(null); - } - }); - }, - [t] - ); - - // 手动刷新(检测新增) - const handleManualRefresh = async () => { - setIsManualRefreshing(true); - await loadData(true); // 刷新时检测新增 - setTimeout(() => setIsManualRefreshing(false), 500); - }; - - // 监听 URL 参数变化(筛选/翻页时重置缓存) - useEffect(() => { - const currentParams = params.toString(); - - // 获取当前页码,如果页码 > 1 则自动暂停自动刷新 - // 避免新数据进入导致用户漏掉中间记录 (Issue #332) - const currentPage = parseInt(params.get("page") || "1", 10); - if (currentPage > 1 && isAutoRefreshRef.current) { - setIsAutoRefresh(false); - } - - if (previousParamsRef.current && previousParamsRef.current !== currentParams) { - // URL 变化 = 用户操作(筛选/翻页),重置缓存,不检测新增 - previousLogsRef.current = new Map(); - loadData(false); - } else if (!previousParamsRef.current) { - // 首次加载,不检测新增 - loadData(false); - } - - previousParamsRef.current = currentParams; - }, [params, loadData]); - - // 自动轮询(5秒间隔,带 Page Visibility API 支持) - // 页面不可见时暂停轮询,重新可见时立即刷新并恢复轮询 - const handlePolling = useCallback(() => { - // 如果正在加载,跳过本次轮询 - if (isPendingRef.current) return; - loadData(true); // 自动刷新时检测新增 - }, [loadData]); - - useVisibilityPolling(handlePolling, { - intervalMs: 5000, // 5 秒间隔(统一轮询周期) - enabled: isAutoRefresh, - executeOnVisible: true, // 页面重新可见时立即刷新 - }); - - // 处理筛选条件变更 - const handleFilterChange = (newFilters: Omit) => { - const query = buildLogsUrlQuery(newFilters); - router.push(`/dashboard/logs?${query.toString()}`); - }; - - // 处理分页 - const handlePageChange = (page: number) => { - const query = new URLSearchParams(params.toString()); - query.set("page", page.toString()); - router.push(`/dashboard/logs?${query.toString()}`); - }; - - return ( -
- {/* 可折叠统计面板 - 默认折叠,按需加载 */} - - - {/* 筛选器 */} - - - {t("title.filterCriteria")} - - - router.push("/dashboard/logs")} - serverTimeZone={serverTimeZone} - /> - - - - {/* 数据表格 */} - - -
- {t("title.usageLogs")} -
- {/* 手动刷新按钮 */} - - - {/* 自动刷新开关 */} - -
-
-
- - {error ? ( -
{error}
- ) : !data ? ( -
{t("logs.stats.loading")}
- ) : ( - - )} -
-
-
- ); -} diff --git a/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx b/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx index ee7e0db50..4d172d1fd 100644 --- a/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx +++ b/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { AlertTriangle, ChevronDown, Infinity, PieChart } from "lucide-react"; +import { AlertTriangle, ChevronDown, Infinity as InfinityIcon, PieChart } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; import type { MyUsageQuota } from "@/actions/my-usage"; @@ -94,7 +94,7 @@ export function CollapsibleQuotaCard({
{t("daily")}: {dailyPct === null ? ( - + ) : ( <> @@ -108,7 +108,7 @@ export function CollapsibleQuotaCard({
{t("monthly")}: {monthlyPct === null ? ( - + ) : ( <> @@ -122,7 +122,7 @@ export function CollapsibleQuotaCard({
{t("total")}: {totalPct === null ? ( - + ) : ( <> diff --git a/src/app/[locale]/my-usage/_components/provider-group-info.tsx b/src/app/[locale]/my-usage/_components/provider-group-info.tsx index fab11b69b..227087f1b 100644 --- a/src/app/[locale]/my-usage/_components/provider-group-info.tsx +++ b/src/app/[locale]/my-usage/_components/provider-group-info.tsx @@ -2,8 +2,65 @@ import { Layers, ShieldCheck } from "lucide-react"; import { useTranslations } from "next-intl"; +import { Badge } from "@/components/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +function abbreviateModel(name: string): string { + const parts = name.split("-").filter(Boolean); + + if (parts.length === 1) { + return parts[0].length <= 4 ? parts[0].toUpperCase() : parts[0].slice(0, 2).toUpperCase(); + } + + const letterParts: string[] = []; + let versionMixed = ""; + const versionNums: string[] = []; + + for (const part of parts) { + if (/^\d{8,}$/.test(part)) continue; + if (/^[a-zA-Z]+$/.test(part)) { + letterParts.push(part); + } else if (/^\d+\.\d+$/.test(part)) { + versionMixed = part; + } else if (/^\d+[a-zA-Z]/.test(part)) { + versionMixed = part; + } else if (/^\d+$/.test(part)) { + versionNums.push(part); + } else { + letterParts.push(part); + } + } + + const prefix = letterParts + .slice(0, 3) + .map((w) => w[0].toUpperCase()) + .join(""); + + let version = ""; + if (versionMixed) { + version = versionMixed; + } else if (versionNums.length > 0) { + version = versionNums.slice(0, 2).join("."); + } + + if (version && prefix) { + return `${prefix}-${version}`; + } + return prefix || name.toUpperCase().substring(0, 3); +} + +function abbreviateClient(name: string): string { + const parts = name.split(/[-\s]+/).filter(Boolean); + if (parts.length === 1) { + return name.slice(0, 2).toUpperCase(); + } + return parts + .slice(0, 3) + .map((w) => w[0].toUpperCase()) + .join(""); +} + interface ProviderGroupInfoProps { keyProviderGroup: string | null; userProviderGroup: string | null; @@ -26,10 +83,8 @@ export function ProviderGroupInfo({ const userDisplay = userProviderGroup ?? tGroup("allProviders"); const inherited = !keyProviderGroup && !!userProviderGroup; - const modelsDisplay = - userAllowedModels.length > 0 ? userAllowedModels.join(", ") : tRestrictions("noRestrictions"); - const clientsDisplay = - userAllowedClients.length > 0 ? userAllowedClients.join(", ") : tRestrictions("noRestrictions"); + const hasModels = userAllowedModels.length > 0; + const hasClients = userAllowedClients.length > 0; return (
{tGroup("title")}
-
- {tGroup("keyGroup")}: - {keyDisplay} +
+ {tGroup("keyGroup")}: + + {keyDisplay} + {inherited && ( ({tGroup("inheritedFromUser")}) )}
-
- {tGroup("userGroup")}: - {userDisplay} +
+ {tGroup("userGroup")}: + + {userDisplay} +
@@ -66,13 +125,47 @@ export function ProviderGroupInfo({ {tRestrictions("title")}
-
- {tRestrictions("models")}: - {modelsDisplay} +
+ + {tRestrictions("models")}: + + {hasModels ? ( + userAllowedModels.map((name) => ( + + + + {abbreviateModel(name)} + + + {name} + + )) + ) : ( + + {tRestrictions("noRestrictions")} + + )}
-
- {tRestrictions("clients")}: - {clientsDisplay} +
+ + {tRestrictions("clients")}: + + {hasClients ? ( + userAllowedClients.map((name) => ( + + + + {abbreviateClient(name)} + + + {name} + + )) + ) : ( + + {tRestrictions("noRestrictions")} + + )}
diff --git a/src/app/[locale]/my-usage/_components/quota-cards.tsx b/src/app/[locale]/my-usage/_components/quota-cards.tsx index 68a8a3a30..9f7496fc9 100644 --- a/src/app/[locale]/my-usage/_components/quota-cards.tsx +++ b/src/app/[locale]/my-usage/_components/quota-cards.tsx @@ -1,13 +1,14 @@ "use client"; +import { Infinity as InfinityIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useMemo } from "react"; import type { MyUsageQuota } from "@/actions/my-usage"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; import type { CurrencyCode } from "@/lib/utils"; import { cn } from "@/lib/utils"; +import { formatCurrency } from "@/lib/utils/currency"; import { calculateUsagePercent, isUnlimited } from "@/lib/utils/limit-helpers"; interface QuotaCardsProps { @@ -79,144 +80,146 @@ export function QuotaCards({ quota, loading = false, currencyCode = "USD" }: Quo } return ( -
-
- {items.map((item) => { - const keyPct = calculateUsagePercent(item.keyCurrent, item.keyLimit); - const userPct = calculateUsagePercent(item.userCurrent ?? 0, item.userLimit); - - const keyTone = getTone(keyPct); - const userTone = getTone(userPct); - const hasUserData = item.userLimit !== null || item.userCurrent !== null; - - return ( - - - - {item.title} - - - -
- - -
-
-
- ); - })} - {items.length === 0 && !loading ? ( - - - {t("empty")} - - - ) : null} -
+
+ {items.map((item) => { + const isCurrency = item.key !== "concurrent"; + const currency = isCurrency ? currencyCode : undefined; + + return ( + + ); + })} + {items.length === 0 && !loading ? ( +
+ {t("empty")} +
+ ) : null}
); } -function QuotaCardsSkeleton({ label }: { label: string }) { +function QuotaBlock({ + title, + keyCurrent, + keyLimit, + userCurrent, + userLimit, + currency, +}: { + title: string; + keyCurrent: number; + keyLimit: number | null; + userCurrent: number; + userLimit: number | null; + currency?: CurrencyCode; +}) { + const t = useTranslations("myUsage.quota"); + + const keyPct = calculateUsagePercent(keyCurrent, keyLimit); + const userPct = calculateUsagePercent(userCurrent, userLimit); + return ( -
-
- {Array.from({ length: 6 }).map((_, index) => ( - - - - - -
- - -
-
-
- ))} -
-
- - {label} -
+
+
{title}
+ +
); } -function QuotaColumn({ +function QuotaRow({ label, current, limit, percent, - tone, currency, - muted = false, }: { label: string; current: number; limit: number | null; percent: number | null; - tone: "default" | "warn" | "danger"; - currency?: string; - muted?: boolean; + currency?: CurrencyCode; }) { const t = useTranslations("myUsage.quota"); + const unlimited = isUnlimited(limit); + const tone = getTone(percent); const formatValue = (value: number) => { const num = Number(value); - if (!Number.isFinite(num)) { - return currency ? `${currency} 0.00` : "0"; - } - return currency ? `${currency} ${num.toFixed(2)}` : String(num); + if (!Number.isFinite(num)) return currency ? formatCurrency(0, currency) : "0"; + return currency ? formatCurrency(num, currency) : String(num); }; - const unlimited = isUnlimited(limit); + const limitDisplay = unlimited ? t("unlimited") : formatValue(limit as number); + const ariaLabel = `${label}: ${formatValue(current)}${!unlimited ? ` / ${limitDisplay}` : ""}`; - const progressClass = cn("h-2", { + const progressClass = cn("h-1.5 flex-1", { "bg-destructive/10 [&>div]:bg-destructive": tone === "danger", "bg-amber-500/10 [&>div]:bg-amber-500": tone === "warn", }); - const limitDisplay = unlimited ? t("unlimited") : formatValue(limit as number); - const ariaLabel = `${label}: ${formatValue(current)}${!unlimited ? ` / ${limitDisplay}` : ""}`; - return ( -
- {/* Label */} -
{label}
- - {/* Values - split into two lines to avoid overlap */} -
-
{formatValue(current)}
-
/ {limitDisplay}
-
- - {/* Progress bar or placeholder */} +
+ + {label} + {!unlimited ? ( ) : (
)} + + {formatValue(current)} + + {" / "} + {unlimited ? : limitDisplay} + + +
+ ); +} + +function QuotaCardsSkeleton({ label }: { label: string }) { + return ( +
+
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ + + +
+ ))} +
+
+ + {label} +
); } diff --git a/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx b/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx index 1d0052018..a73947cea 100644 --- a/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx +++ b/src/app/[locale]/my-usage/_components/statistics-summary-card.tsx @@ -6,6 +6,8 @@ import { ArrowDownRight, ArrowUpRight, BarChart3, + ChevronLeft, + ChevronRight, Coins, Database, Hash, @@ -15,7 +17,11 @@ import { } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; -import { getMyStatsSummary, type MyStatsSummary } from "@/actions/my-usage"; +import { + getMyStatsSummary, + type ModelBreakdownItem, + type MyStatsSummary, +} from "@/actions/my-usage"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -110,9 +116,27 @@ export function StatisticsSummaryCard({ setDateRange(range); }, []); + const [breakdownPage, setBreakdownPage] = useState(1); + + // Reset breakdown page when date range changes + // biome-ignore lint/correctness/useExhaustiveDependencies: deps used as reset trigger on date range change + useEffect(() => { + setBreakdownPage(1); + }, [dateRange.startDate, dateRange.endDate]); + const isLoading = loading || refreshing; const currencyCode = stats?.currencyCode ?? "USD"; + const maxBreakdownLen = Math.max( + stats?.keyModelBreakdown.length ?? 0, + stats?.userModelBreakdown.length ?? 0 + ); + const breakdownTotalPages = Math.ceil(maxBreakdownLen / MODEL_BREAKDOWN_PAGE_SIZE); + const sliceStart = (breakdownPage - 1) * MODEL_BREAKDOWN_PAGE_SIZE; + const sliceEnd = breakdownPage * MODEL_BREAKDOWN_PAGE_SIZE; + const keyPageItems = stats?.keyModelBreakdown.slice(sliceStart, sliceEnd) ?? []; + const userPageItems = stats?.userModelBreakdown.slice(sliceStart, sliceEnd) ?? []; + return ( @@ -220,60 +244,71 @@ export function StatisticsSummaryCard({

{t("modelBreakdown")}

- {/* Key Stats */}

{t("keyStats")}

- {stats.keyModelBreakdown.length > 0 ? ( -
- {stats.keyModelBreakdown.map((item, index) => ( - - ))} -
+ {keyPageItems.length > 0 ? ( + ) : ( -

{t("noData")}

+

{t("noData")}

)}
- {/* User Stats */}

{t("userStats")}

- {stats.userModelBreakdown.length > 0 ? ( -
- {stats.userModelBreakdown.map((item, index) => ( - - ))} -
+ {userPageItems.length > 0 ? ( + ) : ( -

{t("noData")}

+

{t("noData")}

)}
+ + {breakdownTotalPages > 1 && ( +
+ + + {t("breakdownPageIndicator", { + current: breakdownPage, + total: breakdownTotalPages, + })} + + +
+ )}
) : ( @@ -284,6 +319,43 @@ export function StatisticsSummaryCard({ ); } +const MODEL_BREAKDOWN_PAGE_SIZE = 5; + +interface ModelBreakdownColumnProps { + pageItems: ModelBreakdownItem[]; + currencyCode: CurrencyCode; + totalCost: number; + keyPrefix: string; + pageOffset: number; +} + +function ModelBreakdownColumn({ + pageItems, + currencyCode, + totalCost, + keyPrefix, + pageOffset, +}: ModelBreakdownColumnProps) { + return ( +
+ {pageItems.map((item, index) => ( + + ))} +
+ ); +} + interface ModelBreakdownRowProps { model: string | null; requests: number; diff --git a/src/app/v1/_lib/proxy/format-mapper.ts b/src/app/v1/_lib/proxy/format-mapper.ts index 173d17c9c..faf17cfeb 100644 --- a/src/app/v1/_lib/proxy/format-mapper.ts +++ b/src/app/v1/_lib/proxy/format-mapper.ts @@ -61,6 +61,13 @@ export function detectFormatByEndpoint(pathname: string): ClientFormat | null { // OpenAI Chat Completions { pattern: /^\/v1\/chat\/completions$/i, format: "openai" }, + // Gemini Vertex AI (publishers path) + { + pattern: + /^\/v1\/publishers\/google\/models\/[^/:]+:(?:generateContent|streamGenerateContent|countTokens)$/i, + format: "gemini", + }, + // Gemini Direct API { pattern: /^\/v1beta\/models\/[^/:]+:(?:generateContent|streamGenerateContent|countTokens)$/i, diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 87f866386..3759a8570 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -1925,6 +1925,11 @@ export class ProxyForwarder { // buildProxyUrl() 会检测 base_url 是否已包含完整路径,避免重复拼接 proxyUrl = buildProxyUrl(effectiveBaseUrl, session.requestUrl); + // Host header must match actual request target for undici TLS cert validation + // When provider has multiple endpoints, provider.url and proxyUrl hosts may differ + const actualHost = HeaderProcessor.extractHost(proxyUrl); + processedHeaders.set("host", actualHost); + logger.debug("ProxyForwarder: Final proxy URL", { url: proxyUrl, originalPath: session.requestUrl.pathname, diff --git a/src/app/v1/_lib/proxy/session-guard.ts b/src/app/v1/_lib/proxy/session-guard.ts index 069ae05e6..b2685555a 100644 --- a/src/app/v1/_lib/proxy/session-guard.ts +++ b/src/app/v1/_lib/proxy/session-guard.ts @@ -85,12 +85,11 @@ export class ProxySessionGuard { systemSettings.interceptAnthropicWarmupRequests; // 1. 尝试从客户端提取 session_id(metadata.session_id) - const clientSessionId = - SessionManager.extractClientSessionId( - session.request.message, - session.headers, - session.userAgent - ) || session.generateDeterministicSessionId(); + const clientSessionId = SessionManager.extractClientSessionId( + session.request.message, + session.headers, + session.userAgent + ); // 2. 获取 messages 数组 const messages = session.getMessages(); diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 22cf12dca..a163c772d 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -1,4 +1,3 @@ -import crypto from "node:crypto"; import type { Context } from "hono"; import { logger } from "@/lib/logger"; import { clientRequestsContext1m as clientRequestsContext1mHelper } from "@/lib/special-attributes"; @@ -352,38 +351,6 @@ export class ProxySession { return this.providersSnapshot; } - /** - * 生成基于请求指纹的确定性 Session ID - * - * 优先级与参考实现一致: - * - API Key 前缀(x-api-key / x-goog-api-key 的前10位) - * - User-Agent - * - 客户端 IP(x-forwarded-for / x-real-ip) - * - * 当客户端未提供 metadata.session_id 时,可用于稳定绑定会话。 - */ - generateDeterministicSessionId(): string | null { - const apiKeyHeader = this.headers.get("x-api-key") || this.headers.get("x-goog-api-key"); - const apiKeyPrefix = apiKeyHeader ? apiKeyHeader.substring(0, 10) : null; - - const userAgent = this.headers.get("user-agent"); - - // 取链路上的首个 IP - const forwardedFor = this.headers.get("x-forwarded-for"); - const realIp = this.headers.get("x-real-ip"); - const ip = - forwardedFor?.split(",").map((ip) => ip.trim())[0] || (realIp ? realIp.trim() : null); - - const parts = [userAgent, ip, apiKeyPrefix].filter(Boolean); - if (parts.length === 0) { - return null; - } - - const hash = crypto.createHash("sha256").update(parts.join(":"), "utf8").digest("hex"); - // 格式对齐为 sess_{8位}_{12位} - return `sess_${hash.substring(0, 8)}_${hash.substring(8, 20)}`; - } - /** * 获取 messages 数组长度(支持 Claude、Codex 和 Gemini 格式) */ @@ -808,7 +775,13 @@ function optimizeRequestMessage(message: Record): Record + const publishersMatch = pathname.match(/\/publishers\/google\/models\/([^/:]+)(?::[^/]+)?/); + if (publishersMatch?.[1]) { + return publishersMatch[1]; + } + // 匹配官方 Gemini 路径:/v1beta/models/{model}: const geminiMatch = pathname.match(/\/v1beta\/models\/([^/:]+)(?::[^/]+)?/); if (geminiMatch?.[1]) { diff --git a/tests/unit/proxy/gemini-vertex-model-extraction.test.ts b/tests/unit/proxy/gemini-vertex-model-extraction.test.ts new file mode 100644 index 000000000..39c19b6b1 --- /dev/null +++ b/tests/unit/proxy/gemini-vertex-model-extraction.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { extractModelFromPath } from "@/app/v1/_lib/proxy/session"; +import { detectFormatByEndpoint } from "@/app/v1/_lib/proxy/format-mapper"; + +describe("extractModelFromPath - Vertex AI publishers path", () => { + it("extracts model from /v1/publishers/google/models/{model}:generateContent", () => { + expect( + extractModelFromPath( + "/v1/publishers/google/models/gemini-3-pro-image-preview:generateContent" + ) + ).toBe("gemini-3-pro-image-preview"); + }); + + it("extracts model from /v1/publishers/google/models/{model}:streamGenerateContent", () => { + expect( + extractModelFromPath("/v1/publishers/google/models/gemini-2.5-flash:streamGenerateContent") + ).toBe("gemini-2.5-flash"); + }); + + it("extracts model from /v1/publishers/google/models/{model}:countTokens", () => { + expect(extractModelFromPath("/v1/publishers/google/models/gemini-2.5-pro:countTokens")).toBe( + "gemini-2.5-pro" + ); + }); + + it("extracts model from path without action suffix", () => { + expect(extractModelFromPath("/v1/publishers/google/models/gemini-2.5-flash")).toBe( + "gemini-2.5-flash" + ); + }); + + // regression: existing patterns still work + it("still extracts model from /v1beta/models/{model}:generateContent", () => { + expect(extractModelFromPath("/v1beta/models/gemini-2.5-flash:generateContent")).toBe( + "gemini-2.5-flash" + ); + }); + + it("still extracts model from /v1/models/{model}:generateContent", () => { + expect(extractModelFromPath("/v1/models/gemini-2.5-pro:generateContent")).toBe( + "gemini-2.5-pro" + ); + }); + + it("returns null for unrecognized paths", () => { + expect(extractModelFromPath("/v1/messages")).toBeNull(); + expect(extractModelFromPath("/v1/chat/completions")).toBeNull(); + }); +}); + +describe("detectFormatByEndpoint - Vertex AI publishers path", () => { + it('returns "gemini" for /v1/publishers/google/models/{model}:generateContent', () => { + expect( + detectFormatByEndpoint( + "/v1/publishers/google/models/gemini-3-pro-image-preview:generateContent" + ) + ).toBe("gemini"); + }); + + it('returns "gemini" for /v1/publishers/google/models/{model}:streamGenerateContent', () => { + expect( + detectFormatByEndpoint("/v1/publishers/google/models/gemini-2.5-flash:streamGenerateContent") + ).toBe("gemini"); + }); + + it('returns "gemini" for /v1/publishers/google/models/{model}:countTokens', () => { + expect(detectFormatByEndpoint("/v1/publishers/google/models/gemini-2.5-pro:countTokens")).toBe( + "gemini" + ); + }); + + // regression: existing patterns still work + it('still returns "gemini" for /v1beta/models/ path', () => { + expect(detectFormatByEndpoint("/v1beta/models/gemini-2.5-flash:generateContent")).toBe( + "gemini" + ); + }); + + it('still returns "gemini-cli" for /v1internal/models/ path', () => { + expect(detectFormatByEndpoint("/v1internal/models/gemini-2.5-flash:generateContent")).toBe( + "gemini-cli" + ); + }); + + it("returns null for unknown publishers path actions", () => { + expect( + detectFormatByEndpoint("/v1/publishers/google/models/gemini-2.5-flash:unknownAction") + ).toBeNull(); + }); +}); diff --git a/tests/unit/proxy/metadata-injection.test.ts b/tests/unit/proxy/metadata-injection.test.ts index f783bd073..c000d1f43 100644 --- a/tests/unit/proxy/metadata-injection.test.ts +++ b/tests/unit/proxy/metadata-injection.test.ts @@ -128,18 +128,3 @@ describe("injectClaudeMetadataUserId", () => { expect(metadata.user_id).toMatch(/^user_[a-f0-9]{64}_account__session_sess_abc123$/); }); }); - -describe("ProxySession.generateDeterministicSessionId", () => { - it("输出格式应匹配 sess_{8hex}_{12hex}", () => { - const session = Object.create(ProxySession.prototype) as ProxySession; - (session as Record).headers = new Headers([ - ["x-api-key", "sk-test-abcdef123456"], - ["user-agent", "Vitest/1.0"], - ["x-forwarded-for", "203.0.113.1"], - ]); - - const deterministicSessionId = session.generateDeterministicSessionId(); - - expect(deterministicSessionId).toMatch(/^sess_[a-f0-9]{8}_[a-f0-9]{12}$/); - }); -}); diff --git a/tests/unit/proxy/proxy-forwarder-host-header-fix.test.ts b/tests/unit/proxy/proxy-forwarder-host-header-fix.test.ts new file mode 100644 index 000000000..6621aeb6a --- /dev/null +++ b/tests/unit/proxy/proxy-forwarder-host-header-fix.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import type { Provider } from "@/types/provider"; +import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; +import { HeaderProcessor } from "@/app/v1/_lib/headers"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; + +function createSession({ + userAgent, + headers, +}: { + userAgent: string | null; + headers: Headers; +}): ProxySession { + 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: { message: {}, log: "" }, + userAgent, + context: null, + clientAbortSignal: null, + userName: "test-user", + authState: null, + provider: null, + messageContext: null, + sessionId: null, + requestSequence: 1, + originalFormat: "claude", + providerType: null, + originalModelName: null, + originalUrlPathname: null, + providerChain: [], + cacheTtlResolved: null, + context1mApplied: false, + cachedPriceData: undefined, + cachedBillingModelSource: undefined, + isHeaderModified: (key: string) => { + const original = session.originalHeaders?.get(key); + const current = session.headers.get(key); + return original !== current; + }, + }); + + return session as any; +} + +describe("ProxyForwarder - Host header correction for multi-endpoint providers", () => { + it("buildHeaders sets Host from provider.url, which may differ from actual target", () => { + const session = createSession({ + userAgent: "Test/1.0", + headers: new Headers([["user-agent", "Test/1.0"]]), + }); + + const provider = { + providerType: "claude", + url: "https://api.anthropic.com/v1", + key: "test-key", + preserveClientIp: false, + } as unknown as Provider; + + const { buildHeaders } = ProxyForwarder as unknown as { + buildHeaders: (session: ProxySession, provider: Provider) => Headers; + }; + const resultHeaders = buildHeaders(session, provider); + + // buildHeaders uses provider.url for Host + expect(resultHeaders.get("host")).toBe("api.anthropic.com"); + }); + + it("Host header must be corrected when activeEndpoint baseUrl differs from provider.url", () => { + const session = createSession({ + userAgent: "Test/1.0", + headers: new Headers([["user-agent", "Test/1.0"]]), + }); + + const provider = { + providerType: "claude", + url: "https://api.anthropic.com/v1", + key: "test-key", + preserveClientIp: false, + } as unknown as Provider; + + const { buildHeaders } = ProxyForwarder as unknown as { + buildHeaders: (session: ProxySession, provider: Provider) => Headers; + }; + const processedHeaders = buildHeaders(session, provider); + + // Initial Host from provider.url + expect(processedHeaders.get("host")).toBe("api.anthropic.com"); + + // Simulate: activeEndpoint has a different baseUrl (e.g. regional endpoint) + const proxyUrl = "https://eu-west.anthropic.com/v1/messages"; + const actualHost = HeaderProcessor.extractHost(proxyUrl); + processedHeaders.set("host", actualHost); + + // After correction, Host matches actual target + expect(processedHeaders.get("host")).toBe("eu-west.anthropic.com"); + }); + + it("Host header must be corrected when MCP passthrough URL differs from provider.url", () => { + const session = createSession({ + userAgent: "Test/1.0", + headers: new Headers([["user-agent", "Test/1.0"]]), + }); + + const provider = { + providerType: "claude", + url: "https://api.minimaxi.com/anthropic", + key: "test-key", + preserveClientIp: false, + } as unknown as Provider; + + const { buildHeaders } = ProxyForwarder as unknown as { + buildHeaders: (session: ProxySession, provider: Provider) => Headers; + }; + const processedHeaders = buildHeaders(session, provider); + + // Initial Host from provider.url (includes /anthropic path) + expect(processedHeaders.get("host")).toBe("api.minimaxi.com"); + + // MCP passthrough: base domain extraction strips path, URL stays same host + // But if mcpPassthroughUrl points to a different host: + const mcpProxyUrl = "https://mcp.minimaxi.com/v1/tools/list"; + const actualHost = HeaderProcessor.extractHost(mcpProxyUrl); + processedHeaders.set("host", actualHost); + + expect(processedHeaders.get("host")).toBe("mcp.minimaxi.com"); + }); + + it("Host header remains correct when provider.url and proxyUrl share the same host", () => { + const session = createSession({ + userAgent: "Test/1.0", + headers: new Headers([["user-agent", "Test/1.0"]]), + }); + + const provider = { + providerType: "claude", + url: "https://api.anthropic.com/v1", + key: "test-key", + preserveClientIp: false, + } as unknown as Provider; + + const { buildHeaders } = ProxyForwarder as unknown as { + buildHeaders: (session: ProxySession, provider: Provider) => Headers; + }; + const processedHeaders = buildHeaders(session, provider); + + // Same host, correction is a no-op + const proxyUrl = "https://api.anthropic.com/v1/messages"; + const actualHost = HeaderProcessor.extractHost(proxyUrl); + processedHeaders.set("host", actualHost); + + expect(processedHeaders.get("host")).toBe("api.anthropic.com"); + }); + + it("Host header handles port numbers correctly", () => { + const proxyUrl = "https://api.example.com:8443/v1/messages"; + const host = HeaderProcessor.extractHost(proxyUrl); + expect(host).toBe("api.example.com:8443"); + }); +}); diff --git a/tests/unit/proxy/session-guard-warmup-intercept.test.ts b/tests/unit/proxy/session-guard-warmup-intercept.test.ts index 9d3660566..f7443b936 100644 --- a/tests/unit/proxy/session-guard-warmup-intercept.test.ts +++ b/tests/unit/proxy/session-guard-warmup-intercept.test.ts @@ -81,9 +81,6 @@ function createMockSession(overrides: Partial = {}): ProxySession getRequestSequence() { return this.requestSequence ?? 1; }, - generateDeterministicSessionId() { - return "deterministic_session_id"; - }, getMessages() { return []; },