Przeglądaj źródła

feat(usage): add anthropic effort tracking and display. #900 (#901)

* feat(usage): add anthropic effort tracking and display. #900

* chore: format code (effort-tag-558c8fd)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Hwwwww-dev 4 tygodni temu
rodzic
commit
d11e0243ae

+ 1 - 0
messages/en/dashboard.json

@@ -158,6 +158,7 @@
       "nonBilling": "Non-Billing",
       "skipped": "Skipped",
       "specialSettings": "Special",
+      "anthropicEffort": "Effort: {effort}",
       "times": "times",
       "loadedCount": "Loaded {count} records",
       "loadingMore": "Loading more...",

+ 2 - 1
messages/en/myUsage.json

@@ -58,7 +58,8 @@
     "next": "Next",
     "noLogs": "No logs",
     "unknownModel": "Unknown model",
-    "billingModel": "Billing: {model}"
+    "billingModel": "Billing: {model}",
+    "anthropicEffort": "Effort: {effort}"
   },
   "expiration": {
     "title": "Expiration",

+ 1 - 0
messages/ja/dashboard.json

@@ -158,6 +158,7 @@
       "nonBilling": "非課金",
       "skipped": "スキップ",
       "specialSettings": "特殊設定",
+      "anthropicEffort": "Effort: {effort}",
       "times": "回",
       "loadedCount": "{count} 件のレコードを読み込みました",
       "loadingMore": "読み込み中...",

+ 2 - 1
messages/ja/myUsage.json

@@ -58,7 +58,8 @@
     "next": "次へ",
     "noLogs": "ログがありません",
     "unknownModel": "不明なモデル",
-    "billingModel": "課金: {model}"
+    "billingModel": "課金: {model}",
+    "anthropicEffort": "Effort: {effort}"
   },
   "expiration": {
     "title": "有効期限",

+ 1 - 0
messages/ru/dashboard.json

@@ -158,6 +158,7 @@
       "nonBilling": "Не тарифицируется",
       "skipped": "Пропущено",
       "specialSettings": "Особые",
+      "anthropicEffort": "Effort: {effort}",
       "times": "раз",
       "loadedCount": "Загружено {count} записей",
       "loadingMore": "Загрузка...",

+ 2 - 1
messages/ru/myUsage.json

@@ -58,7 +58,8 @@
     "next": "Вперед",
     "noLogs": "Нет записей",
     "unknownModel": "Неизвестная модель",
-    "billingModel": "Биллинг: {model}"
+    "billingModel": "Биллинг: {model}",
+    "anthropicEffort": "Effort: {effort}"
   },
   "expiration": {
     "title": "Срок действия",

+ 1 - 0
messages/zh-CN/dashboard.json

@@ -158,6 +158,7 @@
       "nonBilling": "非计费",
       "skipped": "已跳过",
       "specialSettings": "特殊设置",
+      "anthropicEffort": "Effort: {effort}",
       "times": "次",
       "loadedCount": "已加载 {count} 条记录",
       "loadingMore": "加载更多中...",

+ 2 - 1
messages/zh-CN/myUsage.json

@@ -58,7 +58,8 @@
     "next": "下一页",
     "noLogs": "暂无日志",
     "unknownModel": "未知模型",
-    "billingModel": "计费:{model}"
+    "billingModel": "计费:{model}",
+    "anthropicEffort": "Effort: {effort}"
   },
   "expiration": {
     "title": "过期时间",

+ 1 - 0
messages/zh-TW/dashboard.json

@@ -158,6 +158,7 @@
       "nonBilling": "非計費",
       "skipped": "已跳過",
       "specialSettings": "特殊設定",
+      "anthropicEffort": "Effort: {effort}",
       "times": "次數",
       "loadedCount": "已載入 {count} 筆記錄",
       "loadingMore": "載入更多中...",

+ 2 - 1
messages/zh-TW/myUsage.json

@@ -58,7 +58,8 @@
     "next": "下一頁",
     "noLogs": "暫無日誌",
     "unknownModel": "未知的模型",
-    "billingModel": "計費:{model}"
+    "billingModel": "計費:{model}",
+    "anthropicEffort": "Effort: {effort}"
   },
   "expiration": {
     "title": "到期時間",

+ 2 - 0
src/actions/my-usage.ts

@@ -152,6 +152,7 @@ export interface MyUsageLogEntry {
   createdAt: Date | null;
   model: string | null;
   billingModel: string | null;
+  anthropicEffort?: string | null;
   modelRedirect: string | null;
   inputTokens: number;
   outputTokens: number;
@@ -506,6 +507,7 @@ export async function getMyUsageLogs(
         createdAt: log.createdAt,
         model: log.model,
         billingModel,
+        anthropicEffort: log.anthropicEffort ?? null,
         modelRedirect,
         inputTokens: log.inputTokens ?? 0,
         outputTokens: log.outputTokens ?? 0,

+ 45 - 26
src/app/[locale]/dashboard/logs/_components/model-display-with-redirect.tsx

@@ -2,8 +2,9 @@
 
 import { ArrowRight } from "lucide-react";
 import { useTranslations } from "next-intl";
-import { useCallback } from "react";
+import { type MouseEvent, useCallback } from "react";
 import { toast } from "sonner";
+import { AnthropicEffortBadge } from "@/components/customs/anthropic-effort-badge";
 import { ModelVendorIcon } from "@/components/customs/model-vendor-icon";
 import { Badge } from "@/components/ui/badge";
 import { copyTextToClipboard } from "@/lib/utils/clipboard";
@@ -13,6 +14,7 @@ interface ModelDisplayWithRedirectProps {
   originalModel: string | null;
   currentModel: string | null;
   billingModelSource: BillingModelSource;
+  anthropicEffort?: string | null;
   onRedirectClick?: () => void;
 }
 
@@ -20,10 +22,11 @@ export function ModelDisplayWithRedirect({
   originalModel,
   currentModel,
   billingModelSource,
+  anthropicEffort,
   onRedirectClick,
 }: ModelDisplayWithRedirectProps) {
-  const t = useTranslations("common");
-
+  const tCommon = useTranslations("common");
+  const tDashboard = useTranslations("dashboard");
   // 判断是否发生重定向
   const isRedirected = originalModel && currentModel && originalModel !== currentModel;
 
@@ -31,44 +34,60 @@ export function ModelDisplayWithRedirect({
   const billingModel = billingModelSource === "original" ? originalModel : currentModel;
 
   const handleCopyModel = useCallback(
-    (e: React.MouseEvent) => {
+    (e: MouseEvent) => {
       e.stopPropagation();
       if (!billingModel) return;
       void copyTextToClipboard(billingModel).then((ok) => {
-        if (ok) toast.success(t("copySuccess"));
+        if (ok) toast.success(tCommon("copySuccess"));
       });
     },
-    [billingModel, t]
+    [billingModel, tCommon]
   );
 
+  const effortBadge = anthropicEffort ? (
+    <AnthropicEffortBadge
+      effort={anthropicEffort}
+      label={tDashboard("logs.table.anthropicEffort", { effort: anthropicEffort })}
+    />
+  ) : null;
+
   if (!isRedirected) {
     return (
-      <div className="flex items-center gap-1.5 min-w-0">
-        {billingModel ? <ModelVendorIcon modelId={billingModel} /> : null}
-        <span className="truncate cursor-pointer hover:underline" onClick={handleCopyModel}>
-          {billingModel || "-"}
-        </span>
+      <div className="min-w-0 flex flex-col items-start gap-1">
+        <div className="flex items-center gap-1.5 min-w-0">
+          {billingModel ? <ModelVendorIcon modelId={billingModel} /> : null}
+          <span
+            className="truncate max-w-full cursor-pointer hover:underline"
+            onClick={handleCopyModel}
+          >
+            {billingModel || "-"}
+          </span>
+        </div>
+        {effortBadge}
       </div>
     );
   }
 
   // 计费模型 + 重定向标记(只显示图标)
   return (
-    <div className="flex items-center gap-1.5 min-w-0">
-      {billingModel ? <ModelVendorIcon modelId={billingModel} /> : null}
-      <span className="truncate cursor-pointer hover:underline" onClick={handleCopyModel}>
-        {billingModel}
-      </span>
-      <Badge
-        variant="outline"
-        className="cursor-pointer text-xs border-blue-300 text-blue-700 dark:border-blue-700 dark:text-blue-300 px-1 shrink-0"
-        onClick={(e) => {
-          e.stopPropagation();
-          onRedirectClick?.();
-        }}
-      >
-        <ArrowRight className="h-3 w-3" />
-      </Badge>
+    <div className="min-w-0 flex flex-col items-start gap-1">
+      <div className="flex items-center gap-1.5 min-w-0">
+        {billingModel ? <ModelVendorIcon modelId={billingModel} /> : null}
+        <span className="truncate cursor-pointer hover:underline" onClick={handleCopyModel}>
+          {billingModel}
+        </span>
+        <Badge
+          variant="outline"
+          className="cursor-pointer text-xs border-blue-300 text-blue-700 dark:border-blue-700 dark:text-blue-300 px-1 shrink-0"
+          onClick={(e) => {
+            e.stopPropagation();
+            onRedirectClick?.();
+          }}
+        >
+          <ArrowRight className="h-3 w-3" />
+        </Badge>
+      </div>
+      {effortBadge}
     </div>
   );
 }

+ 2 - 1
src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx

@@ -263,11 +263,12 @@ export function UsageLogsTable({
                       <TooltipProvider>
                         <Tooltip>
                           <TooltipTrigger asChild>
-                            <div className="flex items-center gap-1 min-w-0 cursor-help">
+                            <div className="min-w-0 cursor-help">
                               <ModelDisplayWithRedirect
                                 originalModel={log.originalModel}
                                 currentModel={log.model}
                                 billingModelSource={billingModelSource}
+                                anthropicEffort={log.anthropicEffort}
                                 onRedirectClick={() =>
                                   setDialogState({ logId: log.id, scrollToRedirect: true })
                                 }

+ 1 - 0
src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx

@@ -538,6 +538,7 @@ export function VirtualizedLogsTable({
                                 originalModel={log.originalModel}
                                 currentModel={log.model}
                                 billingModelSource={billingModelSource}
+                                anthropicEffort={log.anthropicEffort}
                                 onRedirectClick={() =>
                                   setDialogState({ logId: log.id, scrollToRedirect: true })
                                 }

+ 7 - 0
src/app/[locale]/my-usage/_components/usage-logs-table.tsx

@@ -5,6 +5,7 @@ import { useTimeZone, useTranslations } from "next-intl";
 import { useCallback } from "react";
 import { toast } from "sonner";
 import type { MyUsageLogEntry } from "@/actions/my-usage";
+import { AnthropicEffortBadge } from "@/components/customs/anthropic-effort-badge";
 import { ModelVendorIcon } from "@/components/customs/model-vendor-icon";
 import { Badge } from "@/components/ui/badge";
 import { Skeleton } from "@/components/ui/skeleton";
@@ -122,6 +123,12 @@ export function UsageLogsTable({
                         {t("billingModel", { model: log.billingModel })}
                       </div>
                     ) : null}
+                    {log.anthropicEffort ? (
+                      <AnthropicEffortBadge
+                        effort={log.anthropicEffort}
+                        label={t("anthropicEffort", { effort: log.anthropicEffort })}
+                      />
+                    ) : null}
                   </TableCell>
                   <TableCell className="text-right text-xs font-mono tabular-nums">
                     <div className="flex flex-col items-end leading-tight">

+ 19 - 0
src/app/v1/_lib/proxy/message-service.ts

@@ -1,3 +1,4 @@
+import { extractAnthropicEffortFromRequestBody } from "@/lib/utils/anthropic-effort";
 import { createMessageRequest } from "@/repository/message";
 import type { ProxySession } from "./session";
 
@@ -31,6 +32,24 @@ export class ProxyMessageService {
       session.setOriginalModel(currentModel);
     }
 
+    const isAnthropicProvider =
+      provider.providerType === "claude" || provider.providerType === "claude-auth";
+    const hasAnthropicEffortAudit = session
+      .getSpecialSettings()
+      ?.some((setting) => setting.type === "anthropic_effort");
+
+    if (isAnthropicProvider && !hasAnthropicEffortAudit) {
+      const anthropicEffort = extractAnthropicEffortFromRequestBody(session.request.message);
+      if (anthropicEffort) {
+        session.addSpecialSetting({
+          type: "anthropic_effort",
+          scope: "request",
+          hit: true,
+          effort: anthropicEffort,
+        });
+      }
+    }
+
     const messageRequest = await createMessageRequest({
       provider_id: provider.id,
       user_id: authState.user.id,

+ 39 - 0
src/components/customs/anthropic-effort-badge.tsx

@@ -0,0 +1,39 @@
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+
+const ANTHROPIC_EFFORT_BADGE_STYLES: Record<string, string> = {
+  auto: "border-sky-300 bg-gradient-to-r from-cyan-50 via-sky-50 to-indigo-50 text-sky-800 dark:border-sky-700 dark:from-cyan-950/40 dark:via-sky-950/40 dark:to-indigo-950/40 dark:text-sky-200",
+  low: "border-slate-200 bg-slate-50 text-slate-700 dark:border-slate-700 dark:bg-slate-900/40 dark:text-slate-300",
+  medium:
+    "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300",
+  high: "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950/30 dark:text-rose-300",
+  max: "border-red-300 bg-red-100 text-red-800 dark:border-red-700 dark:bg-red-950/40 dark:text-red-200",
+};
+
+const DEFAULT_BADGE_STYLE =
+  "border-muted-foreground/20 bg-muted/40 text-muted-foreground dark:border-muted-foreground/30 dark:bg-muted/20";
+
+export function getAnthropicEffortBadgeClassName(effort: string): string {
+  return ANTHROPIC_EFFORT_BADGE_STYLES[effort.trim().toLowerCase()] ?? DEFAULT_BADGE_STYLE;
+}
+
+interface AnthropicEffortBadgeProps {
+  effort: string;
+  label: string;
+  className?: string;
+}
+
+export function AnthropicEffortBadge({ effort, label, className }: AnthropicEffortBadgeProps) {
+  return (
+    <Badge
+      variant="outline"
+      className={cn(
+        "w-fit px-1 text-[10px] leading-tight whitespace-nowrap",
+        getAnthropicEffortBadgeClassName(effort),
+        className
+      )}
+    >
+      {label}
+    </Badge>
+  );
+}

+ 44 - 0
src/lib/utils/anthropic-effort.ts

@@ -0,0 +1,44 @@
+import type { SpecialSetting } from "@/types/special-settings";
+
+function normalizeAnthropicEffort(value: unknown): string | null {
+  if (typeof value !== "string") {
+    return null;
+  }
+
+  const trimmed = value.trim();
+  return trimmed.length > 0 ? trimmed : null;
+}
+
+export function extractAnthropicEffortFromRequestBody(requestBody: unknown): string | null {
+  if (!requestBody || typeof requestBody !== "object" || Array.isArray(requestBody)) {
+    return null;
+  }
+
+  const outputConfig = (requestBody as Record<string, unknown>).output_config;
+  if (!outputConfig || typeof outputConfig !== "object" || Array.isArray(outputConfig)) {
+    return null;
+  }
+
+  return normalizeAnthropicEffort((outputConfig as Record<string, unknown>).effort);
+}
+
+export function extractAnthropicEffortFromSpecialSettings(
+  specialSettings: SpecialSetting[] | null | undefined
+): string | null {
+  if (!Array.isArray(specialSettings)) {
+    return null;
+  }
+
+  for (const setting of specialSettings) {
+    if (setting.type !== "anthropic_effort") {
+      continue;
+    }
+
+    const normalized = normalizeAnthropicEffort(setting.effort);
+    if (normalized) {
+      return normalized;
+    }
+  }
+
+  return null;
+}

+ 2 - 0
src/lib/utils/special-settings.ts

@@ -50,6 +50,8 @@ function buildSettingKey(setting: SpecialSetting): string {
       ]);
     case "guard_intercept":
       return JSON.stringify([setting.type, setting.guard, setting.action, setting.statusCode]);
+    case "anthropic_effort":
+      return JSON.stringify([setting.type, setting.hit, setting.effort]);
     case "anthropic_cache_ttl_header_override":
       return JSON.stringify([setting.type, setting.ttl]);
     case "thinking_signature_rectifier":

+ 52 - 6
src/repository/usage-logs.ts

@@ -5,6 +5,7 @@ import { db } from "@/drizzle/db";
 import { keys as keysTable, messageRequest, providers, usageLedger, users } from "@/drizzle/schema";
 import { TTLMap } from "@/lib/cache/ttl-map";
 import { isLedgerOnlyMode } from "@/lib/ledger-fallback";
+import { extractAnthropicEffortFromSpecialSettings } from "@/lib/utils/anthropic-effort";
 import { buildUnifiedSpecialSettings } from "@/lib/utils/special-settings";
 import type { ProviderChainItem } from "@/types/message";
 import type { SpecialSetting } from "@/types/special-settings";
@@ -68,6 +69,7 @@ export interface UsageLogRow {
   swapCacheTtlApplied: boolean | null; // 是否启用了swap cache TTL billing
   specialSettings: SpecialSetting[] | null; // 特殊设置(审计/展示)
   _liveChain?: { chain: ProviderChainItem[]; phase: string; updatedAt: number } | null;
+  anthropicEffort?: string | null;
 }
 
 export interface UsageLogSummary {
@@ -221,6 +223,7 @@ export async function findUsageLogsBatch(
       cacheTtlApplied: row.cacheTtlApplied,
       context1mApplied: row.context1mApplied,
     });
+    const anthropicEffort = extractAnthropicEffortFromSpecialSettings(unifiedSpecialSettings);
 
     return {
       ...row,
@@ -233,6 +236,7 @@ export async function findUsageLogsBatch(
       providerChain: row.providerChain as ProviderChainItem[] | null,
       endpoint: row.endpoint,
       specialSettings: unifiedSpecialSettings,
+      anthropicEffort,
     };
   });
 
@@ -423,6 +427,43 @@ interface UsageLogSlimRow {
   cacheCreation5mInputTokens: number | null;
   cacheCreation1hInputTokens: number | null;
   cacheTtlApplied: string | null;
+  anthropicEffort?: string | null;
+}
+
+function mapUsageLogSlimRow(row: {
+  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 | { toString(): string };
+  durationMs: number | null;
+  cacheCreationInputTokens: number | null;
+  cacheReadInputTokens: number | null;
+  cacheCreation5mInputTokens: number | null;
+  cacheCreation1hInputTokens: number | null;
+  cacheTtlApplied: string | null;
+  specialSettings?: SpecialSetting[] | null;
+}): UsageLogSlimRow {
+  const { specialSettings, ...rest } = row;
+  const unifiedSpecialSettings = buildUnifiedSpecialSettings({
+    existing: Array.isArray(specialSettings) ? specialSettings : null,
+    blockedBy: null,
+    blockedReason: null,
+    statusCode: rest.statusCode,
+    cacheTtlApplied: rest.cacheTtlApplied,
+    context1mApplied: null,
+  });
+  const anthropicEffort = extractAnthropicEffortFromSpecialSettings(unifiedSpecialSettings);
+
+  return {
+    ...rest,
+    costUsd: rest.costUsd?.toString() ?? null,
+    anthropicEffort,
+  };
 }
 
 // my-usage logs: short TTL cache for total count to avoid repeated COUNT(*) on pagination/polling.
@@ -474,6 +515,7 @@ export async function findUsageLogsForKeySlim(
       cacheCreation5mInputTokens: messageRequest.cacheCreation5mInputTokens,
       cacheCreation1hInputTokens: messageRequest.cacheCreation1hInputTokens,
       cacheTtlApplied: messageRequest.cacheTtlApplied,
+      specialSettings: messageRequest.specialSettings,
     })
     .from(messageRequest)
     .where(and(...conditions))
@@ -553,7 +595,11 @@ export async function findUsageLogsForKeySlim(
     if (cachedTotal !== undefined) {
       ledgerTotal = Math.max(cachedTotal, ledgerTotal);
       return {
-        logs: ledgerPageRows.map((row) => ({ ...row, costUsd: row.costUsd?.toString() ?? null })),
+        logs: ledgerPageRows.map((row) => ({
+          ...row,
+          costUsd: row.costUsd?.toString() ?? null,
+          anthropicEffort: null,
+        })),
         total: ledgerTotal,
       };
     }
@@ -575,6 +621,7 @@ export async function findUsageLogsForKeySlim(
     const ledgerLogs: UsageLogSlimRow[] = ledgerPageRows.map((row) => ({
       ...row,
       costUsd: row.costUsd?.toString() ?? null,
+      anthropicEffort: null,
     }));
 
     usageLogSlimTotalCache.set(totalCacheKey, ledgerTotal);
@@ -587,7 +634,7 @@ export async function findUsageLogsForKeySlim(
   if (cachedTotal !== undefined) {
     total = Math.max(cachedTotal, total);
     return {
-      logs: pageRows.map((row) => ({ ...row, costUsd: row.costUsd?.toString() ?? null })),
+      logs: pageRows.map((row) => mapUsageLogSlimRow(row)),
       total,
     };
   }
@@ -606,10 +653,7 @@ export async function findUsageLogsForKeySlim(
     total = countResults[0]?.totalRows ?? 0;
   }
 
-  const logs: UsageLogSlimRow[] = pageRows.map((row) => ({
-    ...row,
-    costUsd: row.costUsd?.toString() ?? null,
-  }));
+  const logs: UsageLogSlimRow[] = pageRows.map((row) => mapUsageLogSlimRow(row));
 
   usageLogSlimTotalCache.set(totalCacheKey, total);
   return { logs, total };
@@ -823,6 +867,7 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis
       cacheTtlApplied: row.cacheTtlApplied,
       context1mApplied: row.context1mApplied,
     });
+    const anthropicEffort = extractAnthropicEffortFromSpecialSettings(unifiedSpecialSettings);
 
     return {
       ...row,
@@ -835,6 +880,7 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis
       providerChain: row.providerChain as ProviderChainItem[] | null,
       endpoint: row.endpoint,
       specialSettings: unifiedSpecialSettings,
+      anthropicEffort,
     };
   });
 

+ 14 - 0
src/types/special-settings.ts

@@ -14,6 +14,7 @@ export type SpecialSetting =
   | BillingHeaderRectifierSpecialSetting
   | CodexSessionIdCompletionSpecialSetting
   | ClaudeMetadataUserIdInjectionSpecialSetting
+  | AnthropicEffortSpecialSetting
   | AnthropicCacheTtlHeaderOverrideSpecialSetting
   | GeminiGoogleSearchOverrideSpecialSetting
   | PricingResolutionSpecialSetting
@@ -70,6 +71,19 @@ export type GuardInterceptSpecialSetting = {
   reason: string | null;
 };
 
+/**
+ * Anthropic effort 请求参数审计
+ *
+ * 用于记录原始 Anthropic 请求体中的 output_config.effort,
+ * 便于在使用记录中以标签形式展示。
+ */
+export type AnthropicEffortSpecialSetting = {
+  type: "anthropic_effort";
+  scope: "request";
+  hit: boolean;
+  effort: string;
+};
+
 /**
  * Anthropic 缓存 TTL 相关标头覆写审计
  *