diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 0590ae221..5e4a8a88f 100644 --- a/messages/en/dashboard.json +++ b/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...", diff --git a/messages/en/myUsage.json b/messages/en/myUsage.json index 0e39e8496..851da6c1d 100644 --- a/messages/en/myUsage.json +++ b/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", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 4e3b946cd..697746a6e 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -158,6 +158,7 @@ "nonBilling": "非課金", "skipped": "スキップ", "specialSettings": "特殊設定", + "anthropicEffort": "Effort: {effort}", "times": "回", "loadedCount": "{count} 件のレコードを読み込みました", "loadingMore": "読み込み中...", diff --git a/messages/ja/myUsage.json b/messages/ja/myUsage.json index 901e10ab6..80761d0a8 100644 --- a/messages/ja/myUsage.json +++ b/messages/ja/myUsage.json @@ -58,7 +58,8 @@ "next": "次へ", "noLogs": "ログがありません", "unknownModel": "不明なモデル", - "billingModel": "課金: {model}" + "billingModel": "課金: {model}", + "anthropicEffort": "Effort: {effort}" }, "expiration": { "title": "有効期限", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 1af0a96f2..9afd185e7 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -158,6 +158,7 @@ "nonBilling": "Не тарифицируется", "skipped": "Пропущено", "specialSettings": "Особые", + "anthropicEffort": "Effort: {effort}", "times": "раз", "loadedCount": "Загружено {count} записей", "loadingMore": "Загрузка...", diff --git a/messages/ru/myUsage.json b/messages/ru/myUsage.json index 5ccfec871..c85dd01ea 100644 --- a/messages/ru/myUsage.json +++ b/messages/ru/myUsage.json @@ -58,7 +58,8 @@ "next": "Вперед", "noLogs": "Нет записей", "unknownModel": "Неизвестная модель", - "billingModel": "Биллинг: {model}" + "billingModel": "Биллинг: {model}", + "anthropicEffort": "Effort: {effort}" }, "expiration": { "title": "Срок действия", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index b4cea4419..3e366dad4 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -158,6 +158,7 @@ "nonBilling": "非计费", "skipped": "已跳过", "specialSettings": "特殊设置", + "anthropicEffort": "Effort: {effort}", "times": "次", "loadedCount": "已加载 {count} 条记录", "loadingMore": "加载更多中...", diff --git a/messages/zh-CN/myUsage.json b/messages/zh-CN/myUsage.json index 6cf939337..5b230d9df 100644 --- a/messages/zh-CN/myUsage.json +++ b/messages/zh-CN/myUsage.json @@ -58,7 +58,8 @@ "next": "下一页", "noLogs": "暂无日志", "unknownModel": "未知模型", - "billingModel": "计费:{model}" + "billingModel": "计费:{model}", + "anthropicEffort": "Effort: {effort}" }, "expiration": { "title": "过期时间", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index cecc8daa7..0f81ba706 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -158,6 +158,7 @@ "nonBilling": "非計費", "skipped": "已跳過", "specialSettings": "特殊設定", + "anthropicEffort": "Effort: {effort}", "times": "次數", "loadedCount": "已載入 {count} 筆記錄", "loadingMore": "載入更多中...", diff --git a/messages/zh-TW/myUsage.json b/messages/zh-TW/myUsage.json index f803a617b..41be1b6e8 100644 --- a/messages/zh-TW/myUsage.json +++ b/messages/zh-TW/myUsage.json @@ -58,7 +58,8 @@ "next": "下一頁", "noLogs": "暫無日誌", "unknownModel": "未知的模型", - "billingModel": "計費:{model}" + "billingModel": "計費:{model}", + "anthropicEffort": "Effort: {effort}" }, "expiration": { "title": "到期時間", diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index 9b5f7e439..ed3ba8340 100644 --- a/src/actions/my-usage.ts +++ b/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, diff --git a/src/app/[locale]/dashboard/logs/_components/model-display-with-redirect.tsx b/src/app/[locale]/dashboard/logs/_components/model-display-with-redirect.tsx index c59553a11..6700aaddc 100644 --- a/src/app/[locale]/dashboard/logs/_components/model-display-with-redirect.tsx +++ b/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 ? ( + + ) : null; + if (!isRedirected) { return ( -
- {billingModel ? : null} - - {billingModel || "-"} - +
+
+ {billingModel ? : null} + + {billingModel || "-"} + +
+ {effortBadge}
); } // 计费模型 + 重定向标记(只显示图标) return ( -
- {billingModel ? : null} - - {billingModel} - - { - e.stopPropagation(); - onRedirectClick?.(); - }} - > - - +
+
+ {billingModel ? : null} + + {billingModel} + + { + e.stopPropagation(); + onRedirectClick?.(); + }} + > + + +
+ {effortBadge}
); } diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx index dd9065476..e09415d1d 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx @@ -263,11 +263,12 @@ export function UsageLogsTable({ -
+
setDialogState({ logId: log.id, scrollToRedirect: true }) } diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index 10288f03a..d2153c8ae 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/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 }) } diff --git a/src/app/[locale]/my-usage/_components/usage-logs-table.tsx b/src/app/[locale]/my-usage/_components/usage-logs-table.tsx index cf492e857..2274dff0c 100644 --- a/src/app/[locale]/my-usage/_components/usage-logs-table.tsx +++ b/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 })}
) : null} + {log.anthropicEffort ? ( + + ) : null}
diff --git a/src/app/v1/_lib/proxy/message-service.ts b/src/app/v1/_lib/proxy/message-service.ts index 1c67cacb1..27afd7cd2 100644 --- a/src/app/v1/_lib/proxy/message-service.ts +++ b/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, diff --git a/src/components/customs/anthropic-effort-badge.tsx b/src/components/customs/anthropic-effort-badge.tsx new file mode 100644 index 000000000..a93ea4c61 --- /dev/null +++ b/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 = { + 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 ( + + {label} + + ); +} diff --git a/src/lib/utils/anthropic-effort.ts b/src/lib/utils/anthropic-effort.ts new file mode 100644 index 000000000..66e903c70 --- /dev/null +++ b/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).output_config; + if (!outputConfig || typeof outputConfig !== "object" || Array.isArray(outputConfig)) { + return null; + } + + return normalizeAnthropicEffort((outputConfig as Record).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; +} diff --git a/src/lib/utils/special-settings.ts b/src/lib/utils/special-settings.ts index 0b9e2e2e4..d75380406 100644 --- a/src/lib/utils/special-settings.ts +++ b/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 "anthropic_context_1m_header_override": diff --git a/src/repository/usage-logs.ts b/src/repository/usage-logs.ts index 7bf3d48c0..708503b16 100644 --- a/src/repository/usage-logs.ts +++ b/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, }; }); diff --git a/src/types/special-settings.ts b/src/types/special-settings.ts index e8ad72cfa..eaf8570f4 100644 --- a/src/types/special-settings.ts +++ b/src/types/special-settings.ts @@ -14,6 +14,7 @@ export type SpecialSetting = | BillingHeaderRectifierSpecialSetting | CodexSessionIdCompletionSpecialSetting | ClaudeMetadataUserIdInjectionSpecialSetting + | AnthropicEffortSpecialSetting | AnthropicCacheTtlHeaderOverrideSpecialSetting | AnthropicContext1mHeaderOverrideSpecialSetting | GeminiGoogleSearchOverrideSpecialSetting @@ -71,6 +72,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 相关标头覆写审计 *