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

fix(web): 修复阶梯计费 Base64 解码失败与标签不匹配导致的显示错误 (#4530)

* fix(web): 修复阶梯计费表达式解析与匹配逻辑

- 优化 Base64 解码逻辑:引入 UTF-8 感知的解码方法(使用 TextDecoder/Uint8Array),替换原有的简单 `atob`,修复包含非拉丁字符时解码失败的问题。
- 增强阶梯标签匹配机制:新增标签规范化处理(移除空格、统一大小写、转换 `<`/`≤`/`<=` 等符号),确保日志记录中的标签能够与配置中的标签准确匹配。
- 将上述修复同步应用于 default 和 classic 两套前端主题。

* refactor(web): 完善 Base64 解码函数的类型声明

- 根据 CodeRabbitAI 的代码审查建议,将 `decodeBillingExprB64` 方法中 `Array.prototype.map` 回调函数的参数类型由 `any` 替换为更精确的 `number`。
- 提高了代码的类型安全性与可读性。

* fix(web): 修复动态价格明细表中阶梯高亮未能正确匹配的问题

- 在 default 主题的 `DynamicPricingBreakdown` 组件中,引入 `normalizeTierLabel` 函数。
- 替换原有对 `matchedTierLabel` 的严格相等判定,确保在包含全半角符号(如 `≤`/`<=`)或存在空格等格式不一致的场景下,日志详情中的表格依然能准确高亮(Matched)当前命中的对应计费阶梯。

* refactor(web): 移除阶梯计费标签不匹配时的强制兜底逻辑

- 在 default 和 classic 主题中,修改 `resolveMatchedTier` 和相关的阶梯匹配方法,当日志中 `matched_tier` 无法与表达式中的阶梯标签严格对应时,直接返回 `null` 而不再默认退化展示第一阶梯(`tiers[0]`)的价格。
- 遵循“数据准确性优先”的计费展示准则,防止因匹配失败而向用户展示猜测出的单价,避免产生账单误导及客诉风险。
- 在 Classic 主题账单卡片中,对于无法匹配的异常账单明确展示“未匹配到对应阶梯”的提示。

* fix(web): 修复阶梯计费标签正则匹配的短路问题

- 根据 CodeRabbitAI 的代码审查反馈,修正了 `normalizeLabel`(以及 `normalizeTierLabel`)函数中的正则表达式分支顺序。
- 将原本的 `/<|≤|<=/` 调整为 `/<=|≤|</`,以修复 JavaScript 正则引擎从左到右匹配时,会将 `<=` 中的 `<` 优先短路匹配,导致残留 `=` 号的问题。
- 确保了双字符操作符(如 `<=`、`>=`)现在能够被正确完整地替换为单字符(`<`、`>`),保证了计费阶梯日志匹配的准确性。

* fix(web): 完善阶梯计费未匹配展示

---------

Co-authored-by: CaIon <i@caion.me>
wans10 1 неделя назад
Родитель
Сommit
938dc9522b

Разница между файлами не показана из-за своего большого размера
+ 332 - 332
web/classic/src/helpers/render.jsx


+ 2 - 0
web/classic/src/i18n/locales/en.json

@@ -3689,6 +3689,8 @@
     "缓存创建-5分钟 (cc5)": "Cache Creation-5min (cc5)",
     "缓存创建-1小时 (cc1h)": "Cache Creation-1hour (cc1h)",
     "阶梯计费": "Tiered Billing",
+    "阶梯计费(表达式解析失败)": "Tiered Billing (expression parse failed)",
+    "阶梯计费(未匹配到对应阶梯)": "Tiered Billing (no matching tier)",
     "输入 Tokens 阶梯": "Input Token Tiers",
     "输出 Tokens 阶梯": "Output Token Tiers",
     "固定阶梯": "Fixed Tier",

+ 3 - 1
web/classic/src/i18n/locales/fr.json

@@ -3642,6 +3642,8 @@
     "默认折叠侧边栏": "Réduire la barre latérale par défaut",
     "默认测试模型": "Modèle de test par défaut",
     "默认用户消息": "Bonjour",
-    "默认补全倍率": "Taux de complétion par défaut"
+    "默认补全倍率": "Taux de complétion par défaut",
+    "阶梯计费(表达式解析失败)": "Facturation par paliers (échec de l'analyse de l'expression)",
+    "阶梯计费(未匹配到对应阶梯)": "Facturation par paliers (aucun palier correspondant)"
   }
 }

+ 3 - 1
web/classic/src/i18n/locales/ja.json

@@ -3611,6 +3611,8 @@
     "默认折叠侧边栏": "サイドバーをデフォルトで折りたたむ",
     "默认测试模型": "デフォルトテストモデル",
     "默认用户消息": "こんにちは",
-    "默认补全倍率": "デフォルト補完倍率"
+    "默认补全倍率": "デフォルト補完倍率",
+    "阶梯计费(表达式解析失败)": "段階課金(式の解析に失敗)",
+    "阶梯计费(未匹配到对应阶梯)": "段階課金(一致する階層なし)"
   }
 }

+ 3 - 1
web/classic/src/i18n/locales/ru.json

@@ -3662,6 +3662,8 @@
     "默认折叠侧边栏": "Сворачивать боковую панель по умолчанию",
     "默认测试模型": "Модель для тестирования по умолчанию",
     "默认用户消息": "Здравствуйте",
-    "默认补全倍率": "Коэффициент завершения по умолчанию"
+    "默认补全倍率": "Коэффициент завершения по умолчанию",
+    "阶梯计费(表达式解析失败)": "Многоуровневая тарификация (ошибка разбора выражения)",
+    "阶梯计费(未匹配到对应阶梯)": "Многоуровневая тарификация (подходящий уровень не найден)"
   }
 }

+ 3 - 1
web/classic/src/i18n/locales/vi.json

@@ -4176,6 +4176,8 @@
     "默认折叠侧边栏": "Mặc định thu gọn thanh bên",
     "默认测试模型": "Mô hình kiểm tra mặc định",
     "默认用户消息": "Xin chào",
-    "默认补全倍率": "Tỷ lệ hoàn thành mặc định"
+    "默认补全倍率": "Tỷ lệ hoàn thành mặc định",
+    "阶梯计费(表达式解析失败)": "Thanh toán theo bậc (không phân tích được biểu thức)",
+    "阶梯计费(未匹配到对应阶梯)": "Thanh toán theo bậc (không tìm thấy bậc phù hợp)"
   }
 }

+ 2 - 0
web/classic/src/i18n/locales/zh-CN.json

@@ -3676,6 +3676,8 @@
     "缓存创建-5分钟 (cc5)": "缓存创建-5分钟 (cc5)",
     "缓存创建-1小时 (cc1h)": "缓存创建-1小时 (cc1h)",
     "阶梯计费": "阶梯计费",
+    "阶梯计费(表达式解析失败)": "阶梯计费(表达式解析失败)",
+    "阶梯计费(未匹配到对应阶梯)": "阶梯计费(未匹配到对应阶梯)",
     "输入 Tokens 阶梯": "输入 Tokens 阶梯",
     "输出 Tokens 阶梯": "输出 Tokens 阶梯",
     "固定阶梯": "固定阶梯",

+ 3 - 1
web/classic/src/i18n/locales/zh-TW.json

@@ -3635,6 +3635,8 @@
     "默认折叠侧边栏": "預設摺疊側邊欄",
     "默认测试模型": "預設測試模型",
     "默认用户消息": "你好",
-    "默认补全倍率": "預設補全倍率"
+    "默认补全倍率": "預設補全倍率",
+    "阶梯计费(表达式解析失败)": "階梯計費(表達式解析失敗)",
+    "阶梯计费(未匹配到对应阶梯)": "階梯計費(未匹配到對應階梯)"
   }
 }

+ 3 - 1
web/classic/src/i18n/locales/zh.json

@@ -2588,6 +2588,8 @@
     "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?",
     "关闭提示": "关闭提示",
     "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。",
-    "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。"
+    "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。",
+    "阶梯计费(表达式解析失败)": "阶梯计费(表达式解析失败)",
+    "阶梯计费(未匹配到对应阶梯)": "阶梯计费(未匹配到对应阶梯)"
   }
 }

+ 7 - 3
web/default/src/features/pricing/components/dynamic-pricing-breakdown.tsx

@@ -21,6 +21,7 @@ import {
   MATCH_LT,
   MATCH_RANGE,
   SOURCE_TIME,
+  normalizeTierLabel,
   parseTiersFromExpr,
   splitBillingExprAndRequestRules,
   tryParseRequestRuleExpr,
@@ -168,6 +169,9 @@ export function DynamicPricingBreakdown({
 
   const hasTiers = tiers.length > 0
   const hasRules = ruleGroups.length > 0
+  const normalizedMatchedTierLabel = normalizeTierLabel(
+    matchedTierLabel ?? undefined
+  )
 
   if (!expr) return null
 
@@ -307,9 +311,9 @@ export function DynamicPricingBreakdown({
                 {tiers.map((tier, i) => {
                   const condSummary = formatConditionSummary(tier.conditions, t)
                   const isMatched =
-                    matchedTierLabel != null &&
-                    matchedTierLabel !== '' &&
-                    tier.label === matchedTierLabel
+                    normalizedMatchedTierLabel !== '' &&
+                    normalizeTierLabel(tier.label) ===
+                      normalizedMatchedTierLabel
                   return (
                     <TableRow
                       key={`tier-${i}`}

+ 9 - 0
web/default/src/features/pricing/lib/billing-expr.ts

@@ -286,6 +286,15 @@ export function parseTiersFromExpr(exprStr: string): ParsedTier[] {
   }
 }
 
+export function normalizeTierLabel(label: string | undefined): string {
+  if (!label) return ''
+  return label
+    .replace(/<[==]?|≤|<[==]?/g, '<')
+    .replace(/>[==]?|≥|>[==]?/g, '>')
+    .replace(/\s+/g, '')
+    .toLowerCase()
+}
+
 // ---------------------------------------------------------------------------
 // Request rule parser
 // ---------------------------------------------------------------------------

+ 46 - 38
web/default/src/features/usage-logs/components/columns/common-logs-columns.tsx

@@ -115,51 +115,59 @@ function buildDetailSegments(
     const text = prices.join(' / ')
     return showUnit ? `${text}/M` : text
   }
+  const isTieredExpr = other.billing_mode === 'tiered_expr'
   const tieredSummary = getTieredBillingSummary(other)
-  if (tieredSummary) {
-    const baseEntries = tieredSummary.priceEntries
-      .filter((entry) => ['inputPrice', 'outputPrice'].includes(entry.field))
-      .map((entry) => formatPriceCompact(entry.price))
-    if (baseEntries.length > 0) {
-      const tierLabel = tieredSummary.tier.label || t('Default')
-      segments.push({
-        text: `${tierLabel} · ${formatPriceList(baseEntries, true)}`,
-      })
-    }
-
-    const cacheEntries = tieredSummary.priceEntries
-      .filter((entry) =>
-        [
-          'cacheReadPrice',
-          'cacheCreatePrice',
-          'cacheCreate1hPrice',
-        ].includes(entry.field)
-      )
-      .map((entry) => {
-        return formatPriceCompact(entry.price)
-      })
-    if (cacheEntries.length > 0) {
-      segments.push({
-        text: `${t('Cache')} ${formatPriceList(cacheEntries, false)}`,
-        muted: true,
-      })
-    }
+  if (isTieredExpr) {
+    if (tieredSummary) {
+      const baseEntries = tieredSummary.priceEntries
+        .filter((entry) => ['inputPrice', 'outputPrice'].includes(entry.field))
+        .map((entry) => formatPriceCompact(entry.price))
+      if (baseEntries.length > 0) {
+        const tierLabel = tieredSummary.tier.label || t('Default')
+        segments.push({
+          text: `${tierLabel} · ${formatPriceList(baseEntries, true)}`,
+        })
+      }
 
-    const otherEntries = tieredSummary.priceEntries
-      .filter(
-        (entry) =>
-          ![
-            'inputPrice',
-            'outputPrice',
+      const cacheEntries = tieredSummary.priceEntries
+        .filter((entry) =>
+          [
             'cacheReadPrice',
             'cacheCreatePrice',
             'cacheCreate1hPrice',
           ].includes(entry.field)
-      )
-      .map((entry) => `${t(entry.shortLabel)} ${formatPrice(entry.price)}`)
-    if (otherEntries.length > 0) {
+        )
+        .map((entry) => {
+          return formatPriceCompact(entry.price)
+        })
+      if (cacheEntries.length > 0) {
+        segments.push({
+          text: `${t('Cache')} ${formatPriceList(cacheEntries, false)}`,
+          muted: true,
+        })
+      }
+
+      const otherEntries = tieredSummary.priceEntries
+        .filter(
+          (entry) =>
+            ![
+              'inputPrice',
+              'outputPrice',
+              'cacheReadPrice',
+              'cacheCreatePrice',
+              'cacheCreate1hPrice',
+            ].includes(entry.field)
+        )
+        .map((entry) => `${t(entry.shortLabel)} ${formatPrice(entry.price)}`)
+      if (otherEntries.length > 0) {
+        segments.push({
+          text: otherEntries.join(' · '),
+          muted: true,
+        })
+      }
+    } else {
       segments.push({
-        text: otherEntries.join(' · '),
+        text: `${t('Dynamic Pricing')} · ${t('No matching results')}`,
         muted: true,
       })
     }

+ 19 - 11
web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx

@@ -126,6 +126,7 @@ function BillingBreakdown(props: {
   const { log, other, isAdmin } = props
   const isPerCall = isPerCallBilling(other.model_price)
   const isClaude = other.claude === true
+  const isTieredExpr = other.billing_mode === 'tiered_expr'
   const tieredSummary = getTieredBillingSummary(other)
 
   const rows: Array<{ label: string; value: string }> = []
@@ -133,21 +134,28 @@ function BillingBreakdown(props: {
   const fmtPrice = (usd: number) => formatBillingCurrencyFromUSD(usd, priceOpts)
   const baseInputUSD = other.model_ratio != null ? other.model_ratio * 2.0 : 0
 
-  if (tieredSummary) {
+  if (isTieredExpr) {
     rows.push({
       label: t('Billing Mode'),
       value: t('Dynamic Pricing'),
     })
-    if (tieredSummary.tier.label) {
+    if (tieredSummary) {
+      if (tieredSummary.tier.label) {
+        rows.push({
+          label: t('Matched Tier'),
+          value: tieredSummary.tier.label,
+        })
+      }
+      for (const entry of tieredSummary.priceEntries) {
+        rows.push({
+          label: t(entry.shortLabel),
+          value: `${fmtPrice(entry.price)}/M`,
+        })
+      }
+    } else {
       rows.push({
         label: t('Matched Tier'),
-        value: tieredSummary.tier.label,
-      })
-    }
-    for (const entry of tieredSummary.priceEntries) {
-      rows.push({
-        label: t(entry.shortLabel),
-        value: `${fmtPrice(entry.price)}/M`,
+        value: t('No matching results'),
       })
     }
   } else if (isPerCall) {
@@ -184,7 +192,7 @@ function BillingBreakdown(props: {
     })
   }
 
-  if (!tieredSummary && isClaude && hasAnyCacheTokens(other)) {
+  if (!isTieredExpr && isClaude && hasAnyCacheTokens(other)) {
     if (other.cache_ratio != null && other.cache_ratio !== 1) {
       rows.push({
         label: t('Cache Read'),
@@ -220,7 +228,7 @@ function BillingBreakdown(props: {
     }
   }
 
-  if (!tieredSummary) {
+  if (!isTieredExpr) {
     if (other.audio_ratio != null && other.audio_ratio !== 1) {
       rows.push({
         label: t('Audio input'),

+ 59 - 36
web/default/src/features/usage-logs/lib/format.ts

@@ -1,12 +1,15 @@
 import type { StatusBadgeProps } from '@/components/status-badge'
 import {
   BILLING_PRICING_VARS,
+  normalizeTierLabel,
   parseTiersFromExpr,
   type ParsedTier,
 } from '@/features/pricing/lib/billing-expr'
 import type { UsageLog } from '../data/schema'
 import type { LogOtherData } from '../types'
 
+export { normalizeTierLabel }
+
 const PARAM_OVERRIDE_ACTION_MAP: Record<string, string> = {
   set: 'Set',
   delete: 'Delete',
@@ -36,8 +39,8 @@ const PARAM_OVERRIDE_ACTION_MAP: Record<string, string> = {
  * Get localized label for a param override action
  */
 export function getParamOverrideActionLabel(
-  action: string,
-  t: (key: string) => string
+    action: string,
+    t: (key: string) => string
 ): string {
   const key = PARAM_OVERRIDE_ACTION_MAP[action.toLowerCase()]
   return key ? t(key) : action
@@ -47,7 +50,7 @@ export function getParamOverrideActionLabel(
  * Parse a param override audit line into action and content
  */
 export function parseAuditLine(
-  line: string
+    line: string
 ): { action: string; content: string } | null {
   if (typeof line !== 'string') return null
   const firstSpace = line.indexOf(' ')
@@ -64,9 +67,9 @@ export function parseAuditLine(
 export function isViolationFeeLog(other: LogOtherData | null): boolean {
   if (!other) return false
   return (
-    other.violation_fee === true ||
-    Boolean(other.violation_fee_code) ||
-    Boolean(other.violation_fee_marker)
+      other.violation_fee === true ||
+      Boolean(other.violation_fee_code) ||
+      Boolean(other.violation_fee_marker)
   )
 }
 
@@ -88,7 +91,7 @@ export function parseLogOther(other: string): LogOtherData | null {
  * Get time color based on duration (in seconds)
  */
 export function getTimeColor(
-  seconds: number
+    seconds: number
 ): 'success' | 'warning' | 'danger' {
   if (seconds < 10) return 'success'
   if (seconds < 30) return 'warning'
@@ -99,7 +102,7 @@ export function getTimeColor(
  * Get first-response-token color based on latency (in seconds)
  */
 export function getFirstResponseTimeColor(
-  seconds: number
+    seconds: number
 ): 'success' | 'warning' | 'danger' {
   if (seconds < 5) return 'success'
   if (seconds < 10) return 'warning'
@@ -110,7 +113,7 @@ export function getFirstResponseTimeColor(
  * Get throughput color based on generated tokens per second
  */
 export function getThroughputColor(
-  tokensPerSecond: number
+    tokensPerSecond: number
 ): 'success' | 'warning' | 'danger' {
   if (tokensPerSecond >= 30) return 'success'
   if (tokensPerSecond >= 15) return 'warning'
@@ -121,8 +124,8 @@ export function getThroughputColor(
  * Get response color using throughput only when enough output tokens exist.
  */
 export function getResponseTimeColor(
-  seconds: number,
-  completionTokens: number
+    seconds: number,
+    completionTokens: number
 ): 'success' | 'warning' | 'danger' {
   if (completionTokens < 100 || seconds <= 0) return getTimeColor(seconds)
   return getThroughputColor(completionTokens / seconds)
@@ -138,9 +141,9 @@ export function formatModelName(log: UsageLog): {
 } {
   const other = parseLogOther(log.other)
   const isMapped = !!(
-    other?.is_model_mapped &&
-    other?.upstream_model_name &&
-    other.upstream_model_name !== ''
+      other?.is_model_mapped &&
+      other?.upstream_model_name &&
+      other.upstream_model_name !== ''
   )
 
   return {
@@ -157,7 +160,25 @@ export function formatModelName(log: UsageLog): {
 export function decodeBillingExprB64(exprB64: string | undefined): string {
   if (!exprB64) return ''
   try {
-    return atob(exprB64)
+    const binaryString =
+        typeof window !== 'undefined'
+            ? window.atob(exprB64)
+            : Buffer.from(exprB64, 'base64').toString('binary')
+    const bytes = new Uint8Array(binaryString.length)
+
+    for (let i = 0; i < binaryString.length; i++) {
+      bytes[i] = binaryString.charCodeAt(i)
+    }
+
+    if (typeof TextDecoder !== 'undefined') {
+      return new TextDecoder().decode(bytes)
+    }
+
+    return decodeURIComponent(
+        Array.prototype.map
+            .call(bytes, (byte: number) => '%' + byte.toString(16).padStart(2, '0'))
+            .join('')
+    )
   } catch {
     return ''
   }
@@ -165,19 +186,21 @@ export function decodeBillingExprB64(exprB64: string | undefined): string {
 
 /**
  * Resolve which parsed tier corresponds to the matched_tier label in a log
- * entry. Falls back to the first tier when the label is missing or unknown,
- * which mirrors the behaviour of the classic frontend renderer.
+ * entry. Missing or unknown labels do not fall back to another tier because
+ * that would display guessed unit prices.
  */
 export function resolveMatchedTier(
-  tiers: ParsedTier[],
-  matchedLabel: string | undefined
+    tiers: ParsedTier[],
+    matchedLabel: string | undefined
 ): ParsedTier | null {
   if (tiers.length === 0) return null
-  if (matchedLabel) {
-    const found = tiers.find((tier) => tier.label === matchedLabel)
-    if (found) return found
-  }
-  return tiers[0]
+  if (!matchedLabel) return null
+  const found = tiers.find((tier) => {
+    const l1 = normalizeTierLabel(tier.label)
+    const l2 = normalizeTierLabel(matchedLabel)
+    return l1 === l2 && l1 !== ''
+  })
+  return found || null
 }
 
 /**
@@ -197,19 +220,19 @@ export interface TieredBillingSummary {
  * not exercise the cache path (mirrors the classic frontend behaviour).
  */
 export function hasAnyCacheTokens(
-  other: LogOtherData | null | undefined
+    other: LogOtherData | null | undefined
 ): boolean {
   if (!other) return false
   return (
-    (other.cache_tokens || 0) > 0 ||
-    (other.cache_creation_tokens || 0) > 0 ||
-    (other.cache_creation_tokens_5m || 0) > 0 ||
-    (other.cache_creation_tokens_1h || 0) > 0
+      (other.cache_tokens || 0) > 0 ||
+      (other.cache_creation_tokens || 0) > 0 ||
+      (other.cache_creation_tokens_5m || 0) > 0 ||
+      (other.cache_creation_tokens_1h || 0) > 0
   )
 }
 
 export function getTieredBillingSummary(
-  other: LogOtherData | null
+    other: LogOtherData | null
 ): TieredBillingSummary | null {
   if (!other || other.billing_mode !== 'tiered_expr') return null
   const exprStr = decodeBillingExprB64(other.expr_b64)
@@ -244,16 +267,16 @@ export function getTieredBillingSummary(
  * @param unit - Unit of the timestamps ('seconds' or 'milliseconds')
  */
 export function formatDuration(
-  submitTime?: number,
-  finishTime?: number,
-  unit: 'seconds' | 'milliseconds' = 'milliseconds'
+    submitTime?: number,
+    finishTime?: number,
+    unit: 'seconds' | 'milliseconds' = 'milliseconds'
 ): { durationSec: number; variant: StatusBadgeProps['variant'] } | null {
   if (!submitTime || !finishTime) return null
 
   const durationSec =
-    unit === 'milliseconds'
-      ? (finishTime - submitTime) / 1000
-      : finishTime - submitTime
+      unit === 'milliseconds'
+          ? (finishTime - submitTime) / 1000
+          : finishTime - submitTime
 
   return { durationSec, variant: durationSec > 60 ? 'red' : 'green' }
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов