Browse Source

release v0.5.8 (#795)

* fix(proxy): extract model from Gemini Vertex AI publishers path for correct billing

When Gemini requests use the Vertex AI URL format
/v1/publishers/google/models/{model}:generateContent, the system
failed to extract the model name, falling back to a hardcoded
"gemini-2.5-flash" default and causing incorrect billing.

Add publishers path regex to extractModelFromPath() and
detectFormatByEndpoint() to handle this URL pattern.

* fix(proxy): correct Host header to match actual request target in standard path

buildHeaders() derives Host from provider.url, but the actual fetch target
(proxyUrl) may use a different host when activeEndpoint.baseUrl differs or
MCP passthrough overrides the base URL. This causes undici TLS certificate
validation failures. After proxyUrl is computed, re-derive Host from it.

* perf(logs): hide stats summary panel when no filters are active

Skip rendering UsageLogsStatsPanel and its aggregation query when all
filter conditions are empty, preventing full-table scans that cause
CPU overload.

* fix(proxy): remove deterministic session ID to prevent collision across conversations (#793)

generateDeterministicSessionId() hashes (UA, IP, API key prefix) with no time
dimension, producing identical session IDs for the same user hours apart. This
merges unrelated conversations into one session, polluting usage logs, session
tracking, and concurrent session limits.

The existing fallback in getOrCreateSessionId() (content hash -> random ID)
already provides correct session continuity without collision risk.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* perf(logs): hide stats panel in virtualized view when no filters active

Apply the same hasStatsFilters guard from the old view to the
virtualized logs view, preventing an unconditional full-table
aggregation query on page load. Also remove the unused legacy
usage-logs-view.tsx which is no longer imported anywhere.

* fix(my-usage): UX improvements for quota and statistics cards (#794)

* style(my-usage): use Badge for provider group values

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(my-usage): use currency symbol instead of code in quota cards

Replace manual `${currency} ${num.toFixed(2)}` formatting with
`formatCurrency()` so quota values display "$3.50" instead of "USD 3.50",
consistent with all other currency displays in the app.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style(my-usage): replace unlimited text with infinity icon in quota cards

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(my-usage): paginate model breakdown in statistics summary card

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* chore(my-usage): suppress biome exhaustive-deps for intentional stats reset

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(my-usage): address PR #794 review comments

- Fix abbreviateModel/abbreviateClient crash on empty split parts
- Fix pagination reset on auto-refresh by using dateRange deps
- Restore noData fallback in model breakdown columns
- Add i18n for pagination controls with aria-labels (5 langs)
- Fix quota label overflow for long translations (w-8 -> w-auto)
- Rename Infinity -> InfinityIcon to avoid shadowing global
- Remove redundant span wrappers in TooltipTrigger asChild

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: John Doe <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Co-authored-by: miraserver <[email protected]>
Co-authored-by: John Doe <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
Ding 2 weeks ago
parent
commit
ed0601a33c

+ 3 - 0
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",

+ 3 - 0
messages/ja/myUsage.json

@@ -92,6 +92,9 @@
     "keyStats": "キー",
     "userStats": "ユーザー",
     "noData": "選択期間のデータがありません",
+    "breakdownPrevPage": "前のページ",
+    "breakdownNextPage": "次のページ",
+    "breakdownPageIndicator": "{current} / {total}",
     "unknownModel": "不明",
     "modal": {
       "requests": "リクエスト",

+ 3 - 0
messages/ru/myUsage.json

@@ -92,6 +92,9 @@
     "keyStats": "Ключ",
     "userStats": "Пользователь",
     "noData": "Нет данных за выбранный период",
+    "breakdownPrevPage": "Предыдущая страница",
+    "breakdownNextPage": "Следующая страница",
+    "breakdownPageIndicator": "{current} / {total}",
     "unknownModel": "Неизвестно",
     "modal": {
       "requests": "Запросов",

+ 3 - 0
messages/zh-CN/myUsage.json

@@ -92,6 +92,9 @@
     "keyStats": "密钥",
     "userStats": "用户",
     "noData": "所选时段无数据",
+    "breakdownPrevPage": "上一页",
+    "breakdownNextPage": "下一页",
+    "breakdownPageIndicator": "{current} / {total}",
     "unknownModel": "未知",
     "modal": {
       "requests": "请求",

+ 3 - 0
messages/zh-TW/myUsage.json

@@ -92,6 +92,9 @@
     "keyStats": "金鑰",
     "userStats": "使用者",
     "noData": "所選時段無資料",
+    "breakdownPrevPage": "上一頁",
+    "breakdownNextPage": "下一頁",
+    "breakdownPageIndicator": "{current} / {total}",
     "unknownModel": "不明",
     "modal": {
       "requests": "請求",

+ 19 - 16
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 (
     <>
       <div className="space-y-4">
         {/* Stats Summary - Collapsible */}
-        <UsageLogsStatsPanel
-          filters={{
-            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,
-          }}
-          currencyCode={resolvedCurrencyCode}
-        />
+        {hasStatsFilters && (
+          <UsageLogsStatsPanel filters={statsFilters} currencyCode={resolvedCurrencyCode} />
+        )}
 
         {/* Filter Criteria */}
         <Card className="border-border/50">

+ 0 - 262
src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx

@@ -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<UsageLogsResult | null>(null);
-  const [error, setError] = useState<string | null>(null);
-  const [isAutoRefresh, setIsAutoRefresh] = useState(true);
-  const [isManualRefreshing, setIsManualRefreshing] = useState(false);
-
-  // 追踪新增记录(用于动画高亮)
-  const [newLogIds, setNewLogIds] = useState<Set<number>>(new Set());
-  const previousLogsRef = useRef<Map<number, boolean>>(new Map());
-  const previousParamsRef = useRef<string>("");
-
-  // 从 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<typeof filters, "page">) => {
-    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 (
-    <div className="space-y-6">
-      {/* 可折叠统计面板 - 默认折叠,按需加载 */}
-      <UsageLogsStatsPanel
-        filters={{
-          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,
-        }}
-        currencyCode={currencyCode}
-      />
-
-      {/* 筛选器 */}
-      <Card>
-        <CardHeader>
-          <CardTitle>{t("title.filterCriteria")}</CardTitle>
-        </CardHeader>
-        <CardContent>
-          <UsageLogsFilters
-            isAdmin={isAdmin}
-            providers={providers}
-            initialKeys={initialKeys}
-            filters={filters}
-            onChange={handleFilterChange}
-            onReset={() => router.push("/dashboard/logs")}
-            serverTimeZone={serverTimeZone}
-          />
-        </CardContent>
-      </Card>
-
-      {/* 数据表格 */}
-      <Card>
-        <CardHeader>
-          <div className="flex items-center justify-between">
-            <CardTitle>{t("title.usageLogs")}</CardTitle>
-            <div className="flex items-center gap-2">
-              {/* 手动刷新按钮 */}
-              <Button
-                variant="outline"
-                size="sm"
-                onClick={handleManualRefresh}
-                disabled={isPending}
-                className="gap-2"
-              >
-                <RefreshCw className={`h-4 w-4 ${isManualRefreshing ? "animate-spin" : ""}`} />
-                {t("logs.actions.refresh")}
-              </Button>
-
-              {/* 自动刷新开关 */}
-              <Button
-                variant={isAutoRefresh ? "default" : "outline"}
-                size="sm"
-                onClick={() => setIsAutoRefresh(!isAutoRefresh)}
-                className="gap-2"
-              >
-                {isAutoRefresh ? (
-                  <>
-                    <Pause className="h-4 w-4" />
-                    {t("logs.actions.stopAutoRefresh")}
-                  </>
-                ) : (
-                  <>
-                    <Play className="h-4 w-4" />
-                    {t("logs.actions.startAutoRefresh")}
-                  </>
-                )}
-              </Button>
-            </div>
-          </div>
-        </CardHeader>
-        <CardContent>
-          {error ? (
-            <div className="text-center py-8 text-destructive">{error}</div>
-          ) : !data ? (
-            <div className="text-center py-8 text-muted-foreground">{t("logs.stats.loading")}</div>
-          ) : (
-            <UsageLogsTable
-              logs={data.logs}
-              total={data.total}
-              page={filters.page || 1}
-              pageSize={50}
-              onPageChange={handlePageChange}
-              isPending={isPending}
-              newLogIds={newLogIds}
-              currencyCode={currencyCode}
-              billingModelSource={billingModelSource}
-            />
-          )}
-        </CardContent>
-      </Card>
-    </div>
-  );
-}

+ 4 - 4
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({
                 <div className="flex items-center gap-1.5">
                   <span className="text-muted-foreground">{t("daily")}:</span>
                   {dailyPct === null ? (
-                    <Infinity className="h-4 w-4 text-muted-foreground" />
+                    <InfinityIcon className="h-4 w-4 text-muted-foreground" />
                   ) : (
                     <>
                       <span className={cn("font-semibold", getPercentColor(dailyPct))}>
@@ -108,7 +108,7 @@ export function CollapsibleQuotaCard({
                 <div className="flex items-center gap-1.5">
                   <span className="text-muted-foreground">{t("monthly")}:</span>
                   {monthlyPct === null ? (
-                    <Infinity className="h-4 w-4 text-muted-foreground" />
+                    <InfinityIcon className="h-4 w-4 text-muted-foreground" />
                   ) : (
                     <>
                       <span className={cn("font-semibold", getPercentColor(monthlyPct))}>
@@ -122,7 +122,7 @@ export function CollapsibleQuotaCard({
                 <div className="flex items-center gap-1.5">
                   <span className="text-muted-foreground">{t("total")}:</span>
                   {totalPct === null ? (
-                    <Infinity className="h-4 w-4 text-muted-foreground" />
+                    <InfinityIcon className="h-4 w-4 text-muted-foreground" />
                   ) : (
                     <>
                       <span className={cn("font-semibold", getPercentColor(totalPct))}>

+ 109 - 16
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 (
     <div
@@ -45,16 +100,20 @@ export function ProviderGroupInfo({
           <span>{tGroup("title")}</span>
         </div>
         <div className="space-y-1">
-          <div className="flex items-baseline gap-1.5">
-            <span className="text-xs text-muted-foreground">{tGroup("keyGroup")}:</span>
-            <span className="text-sm font-semibold text-foreground">{keyDisplay}</span>
+          <div className="flex flex-wrap items-center gap-1.5">
+            <span className="shrink-0 text-xs text-muted-foreground">{tGroup("keyGroup")}:</span>
+            <Badge variant="outline" className="cursor-default text-xs">
+              {keyDisplay}
+            </Badge>
             {inherited && (
               <span className="text-xs text-muted-foreground">({tGroup("inheritedFromUser")})</span>
             )}
           </div>
-          <div className="flex items-baseline gap-1.5">
-            <span className="text-xs text-muted-foreground">{tGroup("userGroup")}:</span>
-            <span className="text-sm font-semibold text-foreground">{userDisplay}</span>
+          <div className="flex flex-wrap items-center gap-1.5">
+            <span className="shrink-0 text-xs text-muted-foreground">{tGroup("userGroup")}:</span>
+            <Badge variant="outline" className="cursor-default text-xs">
+              {userDisplay}
+            </Badge>
           </div>
         </div>
       </div>
@@ -66,13 +125,47 @@ export function ProviderGroupInfo({
           <span>{tRestrictions("title")}</span>
         </div>
         <div className="space-y-1">
-          <div className="flex items-baseline gap-1.5">
-            <span className="text-xs text-muted-foreground">{tRestrictions("models")}:</span>
-            <span className="text-sm font-semibold text-foreground">{modelsDisplay}</span>
+          <div className="flex flex-wrap items-center gap-1.5">
+            <span className="shrink-0 text-xs text-muted-foreground">
+              {tRestrictions("models")}:
+            </span>
+            {hasModels ? (
+              userAllowedModels.map((name) => (
+                <Tooltip key={name}>
+                  <TooltipTrigger asChild>
+                    <Badge variant="outline" className="cursor-default font-mono text-xs">
+                      {abbreviateModel(name)}
+                    </Badge>
+                  </TooltipTrigger>
+                  <TooltipContent>{name}</TooltipContent>
+                </Tooltip>
+              ))
+            ) : (
+              <span className="text-sm font-semibold text-foreground">
+                {tRestrictions("noRestrictions")}
+              </span>
+            )}
           </div>
-          <div className="flex items-baseline gap-1.5">
-            <span className="text-xs text-muted-foreground">{tRestrictions("clients")}:</span>
-            <span className="text-sm font-semibold text-foreground">{clientsDisplay}</span>
+          <div className="flex flex-wrap items-center gap-1.5">
+            <span className="shrink-0 text-xs text-muted-foreground">
+              {tRestrictions("clients")}:
+            </span>
+            {hasClients ? (
+              userAllowedClients.map((name) => (
+                <Tooltip key={name}>
+                  <TooltipTrigger asChild>
+                    <Badge variant="outline" className="cursor-default font-mono text-xs">
+                      {abbreviateClient(name)}
+                    </Badge>
+                  </TooltipTrigger>
+                  <TooltipContent>{name}</TooltipContent>
+                </Tooltip>
+              ))
+            ) : (
+              <span className="text-sm font-semibold text-foreground">
+                {tRestrictions("noRestrictions")}
+              </span>
+            )}
           </div>
         </div>
       </div>

+ 101 - 98
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 (
-    <div className="space-y-3">
-      <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
-        {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 (
-            <Card key={item.key} className="border-border/70">
-              <CardHeader className="pb-3">
-                <CardTitle className="text-sm font-semibold text-muted-foreground">
-                  {item.title}
-                </CardTitle>
-              </CardHeader>
-              <CardContent className="space-y-3">
-                <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
-                  <QuotaColumn
-                    label={t("keyLevel")}
-                    current={item.keyCurrent}
-                    limit={item.keyLimit}
-                    percent={keyPct}
-                    tone={keyTone}
-                    currency={item.key === "concurrent" ? undefined : currencyCode}
-                  />
-                  <QuotaColumn
-                    label={t("userLevel")}
-                    current={item.userCurrent ?? 0}
-                    limit={item.userLimit}
-                    percent={userPct}
-                    tone={userTone}
-                    currency={item.key === "concurrent" ? undefined : currencyCode}
-                    muted={!hasUserData}
-                  />
-                </div>
-              </CardContent>
-            </Card>
-          );
-        })}
-        {items.length === 0 && !loading ? (
-          <Card>
-            <CardContent className="py-6 text-center text-sm text-muted-foreground">
-              {t("empty")}
-            </CardContent>
-          </Card>
-        ) : null}
-      </div>
+    <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
+      {items.map((item) => {
+        const isCurrency = item.key !== "concurrent";
+        const currency = isCurrency ? currencyCode : undefined;
+
+        return (
+          <QuotaBlock
+            key={item.key}
+            title={item.title}
+            keyCurrent={item.keyCurrent}
+            keyLimit={item.keyLimit}
+            userCurrent={item.userCurrent ?? 0}
+            userLimit={item.userLimit}
+            currency={currency}
+          />
+        );
+      })}
+      {items.length === 0 && !loading ? (
+        <div className="col-span-full py-6 text-center text-sm text-muted-foreground">
+          {t("empty")}
+        </div>
+      ) : null}
     </div>
   );
 }
 
-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 (
-    <div className="space-y-3" aria-busy="true">
-      <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
-        {Array.from({ length: 6 }).map((_, index) => (
-          <Card key={index} className="border-border/70">
-            <CardHeader className="pb-3">
-              <Skeleton className="h-4 w-20" />
-            </CardHeader>
-            <CardContent className="space-y-3">
-              <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
-                <Skeleton className="h-16 w-full" />
-                <Skeleton className="h-16 w-full" />
-              </div>
-            </CardContent>
-          </Card>
-        ))}
-      </div>
-      <div className="flex items-center gap-2 text-xs text-muted-foreground">
-        <Skeleton className="h-3 w-3 rounded-full" />
-        <span>{label}</span>
-      </div>
+    <div className="space-y-2 rounded-md border bg-card/50 p-3">
+      <div className="text-xs font-semibold text-muted-foreground">{title}</div>
+      <QuotaRow
+        label={t("keyLevel")}
+        current={keyCurrent}
+        limit={keyLimit}
+        percent={keyPct}
+        currency={currency}
+      />
+      <QuotaRow
+        label={t("userLevel")}
+        current={userCurrent}
+        limit={userLimit}
+        percent={userPct}
+        currency={currency}
+      />
     </div>
   );
 }
 
-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 (
-    <div className={cn("space-y-2 rounded-md border bg-card/50 p-3", muted && "opacity-70")}>
-      {/* Label */}
-      <div className="text-xs font-medium text-muted-foreground">{label}</div>
-
-      {/* Values - split into two lines to avoid overlap */}
-      <div className="space-y-0.5">
-        <div className="text-sm font-mono font-medium text-foreground">{formatValue(current)}</div>
-        <div className="text-xs text-muted-foreground">/ {limitDisplay}</div>
-      </div>
-
-      {/* Progress bar or placeholder */}
+    <div className="flex items-center gap-2">
+      <span className="w-auto shrink-0 whitespace-nowrap text-[11px] text-muted-foreground">
+        {label}
+      </span>
       {!unlimited ? (
         <Progress value={percent ?? 0} className={progressClass} aria-label={ariaLabel} />
       ) : (
         <div
-          className="h-2 rounded-full bg-muted/50"
+          className="h-1.5 flex-1 rounded-full bg-muted/50"
           role="progressbar"
           aria-label={`${label}: ${t("unlimited")}`}
           aria-valuetext={t("unlimited")}
         />
       )}
+      <span className="shrink-0 text-right font-mono text-xs text-foreground">
+        {formatValue(current)}
+        <span className="text-muted-foreground">
+          {" / "}
+          {unlimited ? <InfinityIcon className="inline h-3.5 w-3.5" /> : limitDisplay}
+        </span>
+      </span>
+    </div>
+  );
+}
+
+function QuotaCardsSkeleton({ label }: { label: string }) {
+  return (
+    <div className="space-y-3" aria-busy="true">
+      <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
+        {Array.from({ length: 6 }).map((_, index) => (
+          <div key={index} className="space-y-2 rounded-md border bg-card/50 p-3">
+            <Skeleton className="h-3 w-16" />
+            <Skeleton className="h-4 w-full" />
+            <Skeleton className="h-4 w-full" />
+          </div>
+        ))}
+      </div>
+      <div className="flex items-center gap-2 text-xs text-muted-foreground">
+        <Skeleton className="h-3 w-3 rounded-full" />
+        <span>{label}</span>
+      </div>
     </div>
   );
 }

+ 111 - 39
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 (
     <Card className={className}>
       <CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between space-y-0 pb-4">
@@ -220,60 +244,71 @@ export function StatisticsSummaryCard({
             <div className="space-y-3">
               <p className="text-sm font-medium text-muted-foreground">{t("modelBreakdown")}</p>
               <div className="grid gap-4 md:grid-cols-2">
-                {/* Key Stats */}
                 <div className="space-y-2">
                   <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
                     {t("keyStats")}
                   </p>
-                  {stats.keyModelBreakdown.length > 0 ? (
-                    <div className="space-y-2">
-                      {stats.keyModelBreakdown.map((item, index) => (
-                        <ModelBreakdownRow
-                          key={`key-${item.model ?? "unknown"}-${index}`}
-                          model={item.model}
-                          requests={item.requests}
-                          cost={item.cost}
-                          inputTokens={item.inputTokens}
-                          outputTokens={item.outputTokens}
-                          cacheCreationTokens={item.cacheCreationTokens}
-                          cacheReadTokens={item.cacheReadTokens}
-                          currencyCode={currencyCode}
-                          totalCost={stats.totalCost}
-                        />
-                      ))}
-                    </div>
+                  {keyPageItems.length > 0 ? (
+                    <ModelBreakdownColumn
+                      pageItems={keyPageItems}
+                      currencyCode={currencyCode}
+                      totalCost={stats.totalCost}
+                      keyPrefix="key"
+                      pageOffset={sliceStart}
+                    />
                   ) : (
-                    <p className="text-sm text-muted-foreground py-2">{t("noData")}</p>
+                    <p className="text-sm text-muted-foreground">{t("noData")}</p>
                   )}
                 </div>
 
-                {/* User Stats */}
                 <div className="space-y-2">
                   <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
                     {t("userStats")}
                   </p>
-                  {stats.userModelBreakdown.length > 0 ? (
-                    <div className="space-y-2">
-                      {stats.userModelBreakdown.map((item, index) => (
-                        <ModelBreakdownRow
-                          key={`user-${item.model ?? "unknown"}-${index}`}
-                          model={item.model}
-                          requests={item.requests}
-                          cost={item.cost}
-                          inputTokens={item.inputTokens}
-                          outputTokens={item.outputTokens}
-                          cacheCreationTokens={item.cacheCreationTokens}
-                          cacheReadTokens={item.cacheReadTokens}
-                          currencyCode={currencyCode}
-                          totalCost={stats.totalCost}
-                        />
-                      ))}
-                    </div>
+                  {userPageItems.length > 0 ? (
+                    <ModelBreakdownColumn
+                      pageItems={userPageItems}
+                      currencyCode={currencyCode}
+                      totalCost={stats.totalCost}
+                      keyPrefix="user"
+                      pageOffset={sliceStart}
+                    />
                   ) : (
-                    <p className="text-sm text-muted-foreground py-2">{t("noData")}</p>
+                    <p className="text-sm text-muted-foreground">{t("noData")}</p>
                   )}
                 </div>
               </div>
+
+              {breakdownTotalPages > 1 && (
+                <div className="flex items-center justify-between pt-1">
+                  <Button
+                    size="icon"
+                    variant="ghost"
+                    className="h-7 w-7"
+                    aria-label={t("breakdownPrevPage")}
+                    disabled={breakdownPage <= 1}
+                    onClick={() => setBreakdownPage((p) => p - 1)}
+                  >
+                    <ChevronLeft className="h-4 w-4" />
+                  </Button>
+                  <span className="text-xs text-muted-foreground">
+                    {t("breakdownPageIndicator", {
+                      current: breakdownPage,
+                      total: breakdownTotalPages,
+                    })}
+                  </span>
+                  <Button
+                    size="icon"
+                    variant="ghost"
+                    className="h-7 w-7"
+                    aria-label={t("breakdownNextPage")}
+                    disabled={breakdownPage >= breakdownTotalPages}
+                    onClick={() => setBreakdownPage((p) => p + 1)}
+                  >
+                    <ChevronRight className="h-4 w-4" />
+                  </Button>
+                </div>
+              )}
             </div>
           </>
         ) : (
@@ -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 (
+    <div className="space-y-2">
+      {pageItems.map((item, index) => (
+        <ModelBreakdownRow
+          key={`${keyPrefix}-${item.model ?? "unknown"}-${pageOffset + index}`}
+          model={item.model}
+          requests={item.requests}
+          cost={item.cost}
+          inputTokens={item.inputTokens}
+          outputTokens={item.outputTokens}
+          cacheCreationTokens={item.cacheCreationTokens}
+          cacheReadTokens={item.cacheReadTokens}
+          currencyCode={currencyCode}
+          totalCost={totalCost}
+        />
+      ))}
+    </div>
+  );
+}
+
 interface ModelBreakdownRowProps {
   model: string | null;
   requests: number;

+ 7 - 0
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,

+ 5 - 0
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,

+ 5 - 6
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();

+ 7 - 34
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<string, unknown>): Record<string
   return optimized;
 }
 
-function extractModelFromPath(pathname: string): string | null {
+export function extractModelFromPath(pathname: string): string | null {
+  // 匹配 Vertex AI 路径:/v1/publishers/google/models/{model}:<action>
+  const publishersMatch = pathname.match(/\/publishers\/google\/models\/([^/:]+)(?::[^/]+)?/);
+  if (publishersMatch?.[1]) {
+    return publishersMatch[1];
+  }
+
   // 匹配官方 Gemini 路径:/v1beta/models/{model}:<action>
   const geminiMatch = pathname.match(/\/v1beta\/models\/([^/:]+)(?::[^/]+)?/);
   if (geminiMatch?.[1]) {

+ 90 - 0
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();
+  });
+});

+ 0 - 15
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<string, unknown>).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}$/);
-  });
-});

+ 166 - 0
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");
+  });
+});

+ 0 - 3
tests/unit/proxy/session-guard-warmup-intercept.test.ts

@@ -81,9 +81,6 @@ function createMockSession(overrides: Partial<ProxySession> = {}): ProxySession
     getRequestSequence() {
       return this.requestSequence ?? 1;
     },
-    generateDeterministicSessionId() {
-      return "deterministic_session_id";
-    },
     getMessages() {
       return [];
     },