Просмотр исходного кода

refactor: move effort badge from table to request detail dialog

Move the Anthropic effort badge display from the usage logs table
(both admin and my-usage) into the SummaryTab's Session Info section
within the ErrorDetailsDialog. This declutters the table view and
provides richer context by showing override information (original vs
overridden effort) when a provider parameter override changes the
effort value.

- Add extractAnthropicEffortInfo utility combining anthropic_effort
  and provider_parameter_override special settings
- Display effort badge(s) in SummaryTab with override arrow notation
- Remove effort badge from ModelDisplayWithRedirect, both usage tables
- Add i18n keys (effort.label, effort.overridden) for all 5 languages
- Add 11 table-driven unit tests covering all branches
ding113 4 недель назад
Родитель
Сommit
f680c4487a

+ 4 - 0
messages/en/dashboard.json

@@ -332,6 +332,10 @@
         "technicalTimeline": "Technical Timeline",
         "copyTimeline": "Copy Timeline"
       },
+      "effort": {
+        "label": "Effort",
+        "overridden": "Overridden by provider"
+      },
       "logicTrace": {
         "title": "Decision Chain",
         "noDecisionData": "No decision data available",

+ 4 - 0
messages/ja/dashboard.json

@@ -332,6 +332,10 @@
         "technicalTimeline": "技術タイムライン",
         "copyTimeline": "タイムラインをコピー"
       },
+      "effort": {
+        "label": "Effort",
+        "overridden": "プロバイダーにより上書き"
+      },
       "logicTrace": {
         "title": "決定チェーン",
         "noDecisionData": "決定データがありません",

+ 4 - 0
messages/ru/dashboard.json

@@ -332,6 +332,10 @@
         "technicalTimeline": "Техническая хронология",
         "copyTimeline": "Копировать хронологию"
       },
+      "effort": {
+        "label": "Effort",
+        "overridden": "Переопределено провайдером"
+      },
       "logicTrace": {
         "title": "Цепочка решений",
         "noDecisionData": "Нет данных о решениях",

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

@@ -332,6 +332,10 @@
         "technicalTimeline": "技术时间线",
         "copyTimeline": "复制时间线"
       },
+      "effort": {
+        "label": "Effort",
+        "overridden": "已被供应商覆写"
+      },
       "logicTrace": {
         "title": "决策链",
         "noDecisionData": "暂无决策数据",

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

@@ -332,6 +332,10 @@
         "technicalTimeline": "技術時間軸",
         "copyTimeline": "複製時間軸"
       },
+      "effort": {
+        "label": "Effort",
+        "overridden": "已被供應商覆寫"
+      },
       "logicTrace": {
         "title": "決策鏈",
         "noDecisionData": "暫無決策資料",

+ 55 - 25
src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx

@@ -15,10 +15,12 @@ import {
   Zap,
 } from "lucide-react";
 import { useTranslations } from "next-intl";
+import { AnthropicEffortBadge } from "@/components/customs/anthropic-effort-badge";
 import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Link } from "@/i18n/routing";
 import { cn, formatTokenAmount } from "@/lib/utils";
+import { extractAnthropicEffortInfo } from "@/lib/utils/anthropic-effort";
 import { formatCurrency } from "@/lib/utils/currency";
 import {
   getPricingResolutionSpecialSetting,
@@ -76,6 +78,7 @@ export function SummaryTab({
     ? t(`billingDetails.pricingSource.${pricingResolution.source}`)
     : null;
   const hasPriorityServiceTier = hasPriorityServiceTierSpecialSetting(specialSettings);
+  const effortInfo = extractAnthropicEffortInfo(specialSettings);
   const isFake200PostStreamFailure =
     typeof errorMessage === "string" && errorMessage.startsWith("FAKE_200_");
   const fake200Code =
@@ -201,36 +204,63 @@ export function SummaryTab({
       )}
 
       {/* Session Info */}
-      {sessionId && (
+      {(sessionId || effortInfo) && (
         <div className="space-y-2">
           <h4 className="text-sm font-semibold">{t("metadata.sessionInfo")}</h4>
-          <div className="rounded-lg border bg-card p-4">
-            <div className="flex items-center gap-3">
-              <div className="flex-1 min-w-0">
-                <div className="flex items-center gap-2">
-                  <code className="text-xs font-mono break-all">{sessionId}</code>
-                  {requestSequence && (
-                    <Badge variant="outline" className="text-xs shrink-0">
-                      #{requestSequence}
-                    </Badge>
+          <div className="rounded-lg border bg-card divide-y">
+            {sessionId && (
+              <div className="p-4">
+                <div className="flex items-center gap-3">
+                  <div className="flex-1 min-w-0">
+                    <div className="flex items-center gap-2">
+                      <code className="text-xs font-mono break-all">{sessionId}</code>
+                      {requestSequence && (
+                        <Badge variant="outline" className="text-xs shrink-0">
+                          #{requestSequence}
+                        </Badge>
+                      )}
+                    </div>
+                  </div>
+                  {hasMessages && !checkingMessages && (
+                    <Link
+                      href={
+                        requestSequence
+                          ? `/dashboard/sessions/${sessionId}/messages?seq=${requestSequence}`
+                          : `/dashboard/sessions/${sessionId}/messages`
+                      }
+                    >
+                      <Button variant="outline" size="sm">
+                        <ExternalLink className="h-4 w-4 mr-2" />
+                        {t("viewDetails")}
+                      </Button>
+                    </Link>
                   )}
                 </div>
               </div>
-              {hasMessages && !checkingMessages && (
-                <Link
-                  href={
-                    requestSequence
-                      ? `/dashboard/sessions/${sessionId}/messages?seq=${requestSequence}`
-                      : `/dashboard/sessions/${sessionId}/messages`
-                  }
-                >
-                  <Button variant="outline" size="sm">
-                    <ExternalLink className="h-4 w-4 mr-2" />
-                    {t("viewDetails")}
-                  </Button>
-                </Link>
-              )}
-            </div>
+            )}
+            {effortInfo && (
+              <div className="p-4">
+                <div className="flex items-center gap-2 flex-wrap">
+                  <span className="text-xs text-muted-foreground">{t("effort.label")}:</span>
+                  <AnthropicEffortBadge
+                    effort={effortInfo.originalEffort}
+                    label={effortInfo.originalEffort}
+                  />
+                  {effortInfo.isOverridden && effortInfo.overriddenEffort && (
+                    <>
+                      <ArrowRight className="h-3 w-3 text-muted-foreground" />
+                      <AnthropicEffortBadge
+                        effort={effortInfo.overriddenEffort}
+                        label={effortInfo.overriddenEffort}
+                      />
+                    </>
+                  )}
+                </div>
+                {effortInfo.isOverridden && (
+                  <p className="text-[11px] text-muted-foreground mt-1">{t("effort.overridden")}</p>
+                )}
+              </div>
+            )}
           </div>
         </div>
       )}

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

@@ -4,7 +4,6 @@ import { ArrowRight } from "lucide-react";
 import { useTranslations } from "next-intl";
 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";
@@ -14,7 +13,6 @@ interface ModelDisplayWithRedirectProps {
   originalModel: string | null;
   currentModel: string | null;
   billingModelSource: BillingModelSource;
-  anthropicEffort?: string | null;
   onRedirectClick?: () => void;
 }
 
@@ -22,11 +20,9 @@ export function ModelDisplayWithRedirect({
   originalModel,
   currentModel,
   billingModelSource,
-  anthropicEffort,
   onRedirectClick,
 }: ModelDisplayWithRedirectProps) {
   const tCommon = useTranslations("common");
-  const tDashboard = useTranslations("dashboard");
   // 判断是否发生重定向
   const isRedirected = originalModel && currentModel && originalModel !== currentModel;
 
@@ -44,50 +40,37 @@ export function ModelDisplayWithRedirect({
     [billingModel, tCommon]
   );
 
-  const effortBadge = anthropicEffort ? (
-    <AnthropicEffortBadge
-      effort={anthropicEffort}
-      label={tDashboard("logs.table.anthropicEffort", { effort: anthropicEffort })}
-    />
-  ) : null;
-
   if (!isRedirected) {
     return (
-      <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 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>
     );
   }
 
   // 计费模型 + 重定向标记(只显示图标)
   return (
-    <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 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>
   );
 }

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

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

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

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

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

@@ -5,7 +5,6 @@ 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";
@@ -123,12 +122,6 @@ 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">

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

@@ -42,3 +42,65 @@ export function extractAnthropicEffortFromSpecialSettings(
 
   return null;
 }
+
+export interface AnthropicEffortOverrideInfo {
+  originalEffort: string;
+  overriddenEffort: string | null;
+  isOverridden: boolean;
+}
+
+/**
+ * Extract anthropic effort info with override detection from special settings.
+ *
+ * Combines `anthropic_effort` (original client request) with
+ * `provider_parameter_override` changes on `output_config.effort`
+ * to determine whether effort was overridden by a provider.
+ */
+export function extractAnthropicEffortInfo(
+  specialSettings: SpecialSetting[] | null | undefined
+): AnthropicEffortOverrideInfo | null {
+  if (!Array.isArray(specialSettings) || specialSettings.length === 0) {
+    return null;
+  }
+
+  const originalEffort = extractAnthropicEffortFromSpecialSettings(specialSettings);
+
+  let overrideBefore: string | null = null;
+  let overrideAfter: string | null = null;
+  let overrideChanged = false;
+
+  for (const setting of specialSettings) {
+    if (setting.type !== "provider_parameter_override") {
+      continue;
+    }
+    for (const change of setting.changes) {
+      if (change.path === "output_config.effort") {
+        overrideBefore = normalizeAnthropicEffort(change.before);
+        overrideAfter = normalizeAnthropicEffort(change.after);
+        overrideChanged = change.changed;
+        break;
+      }
+    }
+    if (overrideChanged) break;
+  }
+
+  if (overrideChanged) {
+    const effective = originalEffort ?? overrideBefore;
+    if (!effective) return null;
+    return {
+      originalEffort: effective,
+      overriddenEffort: overrideAfter,
+      isOverridden: true,
+    };
+  }
+
+  if (originalEffort) {
+    return {
+      originalEffort,
+      overriddenEffort: null,
+      isOverridden: false,
+    };
+  }
+
+  return null;
+}

+ 197 - 0
tests/unit/lib/utils/anthropic-effort.test.ts

@@ -0,0 +1,197 @@
+import { describe, expect, test } from "vitest";
+import type { SpecialSetting } from "@/types/special-settings";
+import {
+  extractAnthropicEffortInfo,
+  type AnthropicEffortOverrideInfo,
+} from "@/lib/utils/anthropic-effort";
+
+describe("extractAnthropicEffortInfo", () => {
+  const cases: Array<{
+    name: string;
+    input: SpecialSetting[] | null | undefined;
+    expected: AnthropicEffortOverrideInfo | null;
+  }> = [
+    {
+      name: "null specialSettings returns null",
+      input: null,
+      expected: null,
+    },
+    {
+      name: "undefined specialSettings returns null",
+      input: undefined,
+      expected: null,
+    },
+    {
+      name: "empty array returns null",
+      input: [],
+      expected: null,
+    },
+    {
+      name: "no effort-related settings returns null",
+      input: [
+        {
+          type: "response_fixer",
+          scope: "response",
+          hit: true,
+          fixersApplied: [],
+          totalBytesProcessed: 0,
+          processingTimeMs: 0,
+        },
+      ],
+      expected: null,
+    },
+    {
+      name: "anthropic_effort only (no override) returns original effort",
+      input: [
+        {
+          type: "anthropic_effort",
+          scope: "request",
+          hit: true,
+          effort: "medium",
+        },
+      ],
+      expected: {
+        originalEffort: "medium",
+        overriddenEffort: null,
+        isOverridden: false,
+      },
+    },
+    {
+      name: "anthropic_effort + override with changed:true returns overridden info",
+      input: [
+        {
+          type: "anthropic_effort",
+          scope: "request",
+          hit: true,
+          effort: "medium",
+        },
+        {
+          type: "provider_parameter_override",
+          scope: "provider",
+          providerId: 1,
+          providerName: "test",
+          providerType: "claude",
+          hit: true,
+          changed: true,
+          changes: [
+            { path: "max_tokens", before: 1024, after: 1024, changed: false },
+            { path: "output_config.effort", before: "medium", after: "high", changed: true },
+          ],
+        },
+      ],
+      expected: {
+        originalEffort: "medium",
+        overriddenEffort: "high",
+        isOverridden: true,
+      },
+    },
+    {
+      name: "override with changed:false returns non-overridden info",
+      input: [
+        {
+          type: "anthropic_effort",
+          scope: "request",
+          hit: true,
+          effort: "high",
+        },
+        {
+          type: "provider_parameter_override",
+          scope: "provider",
+          providerId: 1,
+          providerName: "test",
+          providerType: "claude",
+          hit: true,
+          changed: false,
+          changes: [
+            { path: "output_config.effort", before: "high", after: "high", changed: false },
+          ],
+        },
+      ],
+      expected: {
+        originalEffort: "high",
+        overriddenEffort: null,
+        isOverridden: false,
+      },
+    },
+    {
+      name: "fallback: no anthropic_effort but override exists uses before as original",
+      input: [
+        {
+          type: "provider_parameter_override",
+          scope: "provider",
+          providerId: 1,
+          providerName: "test",
+          providerType: "claude",
+          hit: true,
+          changed: true,
+          changes: [{ path: "output_config.effort", before: "low", after: "max", changed: true }],
+        },
+      ],
+      expected: {
+        originalEffort: "low",
+        overriddenEffort: "max",
+        isOverridden: true,
+      },
+    },
+    {
+      name: "override with no effort path returns effort from anthropic_effort only",
+      input: [
+        {
+          type: "anthropic_effort",
+          scope: "request",
+          hit: true,
+          effort: "auto",
+        },
+        {
+          type: "provider_parameter_override",
+          scope: "provider",
+          providerId: 1,
+          providerName: "test",
+          providerType: "claude",
+          hit: true,
+          changed: true,
+          changes: [{ path: "max_tokens", before: 1024, after: 2048, changed: true }],
+        },
+      ],
+      expected: {
+        originalEffort: "auto",
+        overriddenEffort: null,
+        isOverridden: false,
+      },
+    },
+    {
+      name: "anthropic_effort with whitespace-only effort is ignored",
+      input: [
+        {
+          type: "anthropic_effort",
+          scope: "request",
+          hit: true,
+          effort: "   ",
+        },
+      ],
+      expected: null,
+    },
+    {
+      name: "override changed:true but both originalEffort and overrideBefore are null returns null",
+      input: [
+        {
+          type: "provider_parameter_override",
+          scope: "provider",
+          providerId: 1,
+          providerName: "test",
+          providerType: "claude",
+          hit: true,
+          changed: true,
+          changes: [{ path: "output_config.effort", before: null, after: "high", changed: true }],
+        },
+      ],
+      expected: null,
+    },
+  ];
+
+  for (const { name, input, expected } of cases) {
+    test(name, () => {
+      expect(extractAnthropicEffortInfo(input)).toEqual(expected);
+    });
+  }
+});