wfengq/dsh-Tally

tally

按请求记录费用,涵盖重试与子代理,支持节假日峰谷计价、共享计价/用量/余额/预算服务和 DeepSeek 账单对账。

用量与计费

dsh-tally

DeepSeek Harness(dsh)的记账插件:从会话日志里读出每一次计费请求的 token 用量,存成一本本地账本,按官方价目现算金额。

  • 会话日志是唯一的事实来源。账本只是派生出来的索引,随时可以从日志完整重建。
  • 不进入模型请求路径。插件只监听 session/event,不监听任何 waterfall 事件,也不调用 agent.inject()。
  • 提供的服务:ctx.tallyPricing(报价)、ctx.tallyUsage(账本查询)、ctx.tallyBalance(余额)和 ctx.tallyBudget(预算)。接口定义在 dsh-tally-api。
  • 浏览器端通过 Remote 命名空间 tally 访问同样的数据,见下文 Remote API。

安装

dsh plugin --profile web add dsh-tally@beta

插件需要 Node.js >=22.19.0(使用内置的 node:sqlite)。

回填在后台进行,不阻塞 dsh 启动,回填期间实时记账照常进行:

  • 安装后第一次启动:分批读取全部历史会话,完成后在账本里记下“回填已完成”。
  • 之后每次启动:只读取账本里还没有记录的会话(例如插件停用期间新建的会话、上次读取失败的会话),以及上次停在某一轮对话中间的会话;已经完整记账的会话不再读取。
  • 回填进度和出错的会话可以通过 ctx.tallyUsage.status() 查看。
  • 限制:如果另一个 dsh 进程在一个已经完整记账的会话里继续对话,本进程要等这个会话在本进程里再次产生事件、或执行 rebuild() 之后才会补记。
  • 限制:同理,如果在本机停用了插件、或者用没有加载本插件的 profile 继续了一个已经完整记账的会话,这段对话会少记,直到这个会话在加载了插件的 dsh 里再次产生事件、或执行 rebuild() 之后才会补记。

配置项

配置项默认值说明
providerRoutes{ "deepseek-official": "deepseek", "deepseek-account": "deepseek" }会话日志里的路由 id 到价格数据厂商的映射。官方 DeepSeek 在日志里有两个路由 id:deepseek-official(API key 登录)和 deepseek-account(账号登录)。没有映射的路由按同名厂商查价格。
overrides[]厂商价格覆盖,见下文。可以在设置页修改,改动立即生效,插件不重新加载。
dbPath$DSH_HOME/tally/tally.v1.sqlite账本数据库文件。未设置 DSH_HOME 时为 ~/.dsh/tally/tally.v1.sqlite。
backfillBatch20回填时每批同时读取的会话数。
balanceDebounceMs3000记账后刷新余额前的去抖时间(毫秒)。
balanceMinIntervalMs60000同一路由两次自动刷新余额之间的最小间隔(毫秒)。
balanceTimeoutMs10000余额请求的超时时间(毫秒),超时后请求被中止。
budgetPath$DSH_HOME/tally/budgets.json预算文件,同时保存已提醒的阈值。未设置 DSH_HOME 时为 ~/.dsh/tally/budgets.json。
timeZone宿主时区预算按日、按月统计时使用的 IANA 时区,例如 Asia/Shanghai。

价格覆盖

自建网关或第三方转发的路由不在价格数据里,可以用 overrides 指定价格。同一个路由只有第一条覆盖生效。

按某个已知厂商(及可选的模型)的价格计:

overrides:
  - provider: my-gateway
    kind: alias
    target:
      provider: deepseek
      model: deepseek-flash   # 可选,省略时沿用日志里的模型名

手动填写单价(每百万 token,十进制字符串):

overrides:
  - provider: my-gateway
    kind: manual
    currency: CNY
    models:
      my-model:
        input: "2"
        cacheRead: "0.5"     # 可选
        cacheWrite: "2"      # 可选
        output: "8"

手动价格没有峰谷之分(band 为 flat),推理 token 计入输出。某个维度没有填单价却用到了 token 时,这条记录视为“无价格”(cost: null),不会按 0 计。

单价写错(例如不是合法的十进制数)、或者别名指向的厂商或模型不在价格数据里的覆盖会被跳过:插件照常运行,日志里输出一条警告,ctx.tallyUsage.status().errors 里也会列出这条覆盖对应的厂商。

overrides 是可在设置页直接编辑的配置项(volatile):保存后价格规则在原地重建,插件不重新加载。因为金额是查询时现算的,修改后所有查询(包括历史账目)立即按新规则计价,不需要重建账本;Remote 的 status 和 catalog 也随之更新,打开着的 watch 流会收到一条新的 hello,提示界面重新读取数据。

记账口径

  • 计量单位是一次计费尝试。幂等键是 (会话 id, 结算事件的 seq),同一条记录不会被记两次。

  • 用量规则与官方 token-meter 一致:同一次尝试里,最终的 assistant 消息用量替换流式阶段的用量;llm/retry-started 之后算作新的一次计费,所以失败重试的请求也会计费。

  • 厂商与模型:优先取 assistant/message 里记录的 provider 和 model;以 assistant/attempt 或 llm/retry 结束的尝试,取最近一次 request/header 里的配置。

  • 计费时间取尝试开始时的事件时间(step/start 或 llm/retry-started),据此判断峰谷时段。

  • 用量未知:厂商没有上报用量、或上报的用量不满足官方校验规则时,记为 usageKnown: false,不计金额,在汇总里计入 unknownUsageCount。

  • 分叉会话:从父会话继承来的前缀事件不记账,只记分叉之后自己的部分。

  • 子代理会话照常计费,归到最近的非子代理祖先名下(rootSessionId)。分叉会话的 rootSessionId 是它自己。

  • 金额不入库。账本只存 token 数、路由、模型和计费时间,查询时按当前价格数据现算,所以价格数据被修正后,历史金额会自动更正。

  • 金额一律是十进制字符串,内部用 BigInt 定点运算,不使用浮点数。不同币种分开统计,不做换算。查不到价格时返回 null,永远不会当作 "0"。

  • 重建:ctx.tallyUsage.rebuild() 清空账本(包括“回填已完成”标记)后从全部会话日志重新回填。数据库 schema 版本不一致时也会自动删库重建。

  • 未结束的对话:读取一个停在对话中间的会话时,会话查询服务会在日志末尾补上表示“中断”的结束事件,方便展示。账本不记录这些补出来的事件,等写入方之后写下真实的结束事件时再继续记账,不会漏记。

  • tally/recorded 事件只在实时记账时发出,即一次请求结算的时候;回填和重建写入的历史账目不发事件,需要历史数据时请用 query 或 session 查询。

  • 已删除的会话:dsh 没有“会话已删除”事件,所以每次回填扫描(包括启动时的增量扫描)在列出会话之后,把账本里有账目、但已不在会话列表中的会话标记为已删除;之后重新出现在列表里的会话会清除标记。只有当列表里至少有一个已持久化的会话时才做这项比对,避免持久化服务不可用时把所有会话都误标为删除;正在运行的会话不会被标记。删除标记不影响任何金额:账目保留,汇总照常计入。按会话分组查询(groupBy: 'session')时,每组的 deletedCount 给出其中属于已删除会话的账目数。

余额

ctx.tallyBalance 是各路由余额查询函数的注册表,本身不读取凭据、也不发起网络请求;查询函数由厂商插件(例如 dsh-tally-balance-deepseek)通过 register(route, fetcher) 注册。route 是会话日志里的路由 id,例如 deepseek-official、deepseek-account。

  • get(route) 返回缓存的快照,没有缓存时先查询一次;没有注册查询函数的路由返回 null。
  • refresh(route) 立即查询,不受自动刷新间隔限制;同一路由同时只有一个请求在途。
  • 每次实时记账(tally/recorded)后,该路由会在去抖时间(balanceDebounceMs)后自动刷新一次;同一路由两次自动刷新的起点之间至少间隔 balanceMinIntervalMs。
  • 查询失败时不会显示 0:返回上一次成功的快照并标记 stale: true,附带 error 和 errorCode;从未成功过时返回 wallets: [] 并附带 error、errorCode。
  • errorCode 取值为 'not-configured'(未配置密钥)、'signed-out'(账号已退出)、'timeout'(超时)、'failed'(其他)。fetcher 抛出的异常带有字符串属性 code,且取值是这四个之一时照搬;超时一律为 'timeout',其余为 'failed'。
  • 首次失败后会重试:get() 遇到从未成功过的快照(wallets 为空且带 error),并且距离上次尝试已超过 balanceMinIntervalMs 时,会重新查询,而不是一直返回旧的失败结果。
  • 快照内容变化、或标记为过期时发出 tally/balance-changed 事件。

预算

ctx.tallyBudget 管理按日或按月的花费上限,并在花费达到上限的 80% 和 100% 时各提醒一次。

ctx.tallyBudget.set({
  id: 'daily-cny',
  period: 'day',                       // 'day' 或 'month'
  scope: { workspaceId: 'my-project' }, // 可省略 workspaceId,表示全部工作区
  limit: { amount: '10', currency: 'CNY' },
})
ctx.tallyBudget.list()                 // 返回副本
ctx.tallyBudget.remove('daily-cny')
ctx.tallyBudget.status()               // { errors: string[] },加载预算文件时的问题;一切正常时 errors 为空数组

ctx.on('tally/budget-crossed', (alert) => {
  // { budgetId, threshold: 0.8 | 1, spent, limit, periodStart }
})
  • 周期按 timeZone 配置的时区划分自然日或自然月(夏令时当天可能是 23 或 25 小时)。
  • 只累计与预算同币种的费用,不同币种之间不换算。设置了 scope.workspaceId 的预算只统计该工作区的花费。
  • 每个预算在每个周期内,0.8 和 1 两个阈值各只提醒一次。一次跳过两个阈值时,先发 0.8 再发 1。比较用 BigInt 定点数精确进行,不使用浮点数。
  • 提醒记录与预算一起保存在预算文件里,且先写入再发事件,所以重启不会重复提醒。多个进程共用同一个文件时,只有真正写入该记录的那个进程发事件,不会重复提醒。
  • 只有实时记账才会触发检查:回填和重建写入的历史账目不发 tally/recorded,因此不会触发提醒。
  • 检查按顺序依次进行;同一个预算、同一个周期内排队的多条记账事件合并为一次检查(每次检查本来就会重新统计整个周期)。某次检查出错只会记一条警告日志,不影响后续检查。
  • set 会先校验,不合法(例如金额不是大于 0 的十进制字符串)时抛出 tally: invalid budget: …,文件保持不变。
  • 预算文件损坏时服务以空预算启动,原因见 status().errors;文件会在下一次写入时(持有文件锁的情况下)被改名为 budgets.json.corrupt-<时间戳>,读取不会动它。
  • 预算文件有跨进程锁:每次写入(set、remove、记录提醒)都在文件锁(budgets.json.lock)内先重新读盘、合并本次修改,再原子替换文件(权限 0600)。多个 dsh 进程共用同一个 $DSH_HOME 时不会互相覆盖。提醒记录合并时,同一预算取周期更新的一条;周期相同则阈值取并集,已提醒的状态不会被回退。
  • 读取(list 等)前会比较文件的修改时间和大小,别的进程改过就自动重新加载,不需要重启。
  • set 和 remove 仍是同步方法:修改先进入队列,list() 立即可见,文件随后按顺序写入。写入失败(例如等锁超时)时会记一条警告日志,修改从 list() 中消失,原因出现在 status().errors 里。需要确认结果时用 setAndWait / removeAndWait(不属于 dsh-tally-api),写入失败时它们会拒绝;Remote 的 setBudget / removeBudget 用它们,失败时返回 tally/write-failed。

给挂件作者的接入示例

只依赖类型包 dsh-tally-api,不需要依赖 dsh-tally:

pnpm add -D dsh-tally-api
import type { Context } from '@deepseek-ai/cordis'
import type {} from 'dsh-tally-api'

export const name = 'my-cost-widget'

export function apply(ctx: Context) {
  ctx.inject(['tallyUsage'], async (ctx) => {
    // 今天(按宿主时区)的花费,按模型分组
    const start = new Date()
    start.setHours(0, 0, 0, 0)
    const groups = await ctx.tallyUsage.query({
      from: start.getTime(),
      to: Date.now() + 1,          // 左闭右开
      groupBy: 'model',
    })
    for (const g of groups) {
      // g.cost 按币种分开,例如 [{ amount: '1.234', currency: 'CNY' }]
      console.log(g.key, g.requestCount, g.cost.map(m => `${m.amount} ${m.currency}`).join(' + '))
    }

    // 实时记账时每结算一次请求触发一次;回填和重建的历史账目不触发
    ctx.on('tally/recorded', (entry) => {
      if (entry.cost) console.log(entry.sessionId, entry.model, entry.band, entry.cost.amount, entry.cost.currency)
      else console.log(entry.sessionId, entry.model, entry.usageKnown ? '无价格' : '用量未知')
    })

    // 一个会话(含子代理明细)的用量
    // const usage = await ctx.tallyUsage.session(sessionId)

    // 回填进度和错误
    const { backfill, errors } = ctx.tallyUsage.status()
    if (backfill) console.log(`回填 ${backfill.done}/${backfill.total}`)
    for (const e of errors) console.warn(e.sessionId ?? '-', e.message)
  })
}

单独报价可以用 ctx.tallyPricing.quote({ provider, model, usage, at }),provider 直接传日志里的路由 id 即可,映射和覆盖会自动应用。

Remote API

浏览器端(dsh Web 界面)通过 dsh API Gateway 调用本插件的 Remote 命名空间 tally,Host 端服务名为 tallyRemote。dsh-tally-ui 就是基于这套接口实现的;第三方界面也可以直接使用。

  • 除无参方法外,每个方法只有一个参数 request(一个对象)。Host 端用 dsh-tally/remote 中同一份 zod schema 校验参数,不合法时返回 gateway/bad-request,消息里带出错的字段路径。
  • 时间一律是 epoch 毫秒;带 from/to 的请求是左闭右开区间,要求 from < to,跨度不超过 400 天。tz 必须是合法的 IANA 时区名。
  • 金额一律是十进制字符串;没有价格时对应字段为 null 或计入 unpricedCount,不会出现 "0"。
  • 本插件不读凭据、不发网络请求;Remote 方法只读账本、调用其他 tally 服务。

方法

方法参数 request返回说明
status无TallyStatusView插件状态:回填进度和错误、价格目录版本、被忽略的价格覆盖、预算文件问题、已注册余额的路由。空闲时 usage.backfill 为 null,只有回填或重建正在进行时才有进度(与 ctx.tallyUsage.status() 不同,后者可能保留最后一次进度)
queryUsageQueryUsageGroup[]与 ctx.tallyUsage.query 相同;groupBy: 'session' 时已删除会话的分组带 deletedCount
session{ sessionId }SessionView会话用量(含子代理明细),以及该会话最近一次请求的路由和模型
turn{ sessionId, fromSeq, toSeq }TurnUsage一轮对话的用量:[fromSeq, toSeq] 是会话事件序号的闭区间,返回合计和每次请求(含重试)
band{ route, model, at }BandView | nullat 时刻该路由和模型所处的价格时段及下次变化时间;没有价格时为 null
balance{ route }BalanceSnapshot | null缓存中的余额;还没有快照,或者从未成功且距上次尝试已超过 balanceMinIntervalMs 时,先查询一次。该路由没有余额提供者时为 null
refreshBalance{ route }BalanceSnapshot | null立即查询余额。失败时返回上一次成功的值并标记 stale: true,errorCode 说明原因
budgets无Budget[]全部预算
setBudgetBudget无新建或替换预算,等待写盘完成后才返回
removeBudget{ id }无删除预算,等待写盘完成后才返回
catalog无CatalogView价格目录:每个模型当前的价格版本、生效日期和来源,以及生效中的价格覆盖(overrides)
peakSavings{ from, to }PeakSavings高峰时段费用与同样用量按空闲时段价格计算的费用(按币种)
exportCsv{ from, to, tz }string区间内的全部账目,CSV 格式(RFC 4180),时间按 tz 带时区偏移输出;没有价格的行 cost 等列留空
rebuild无无从会话日志重建整本账本;进度通过 watch 的 status 帧和 status() 报告
watch无(可传 AbortSignal 取消)流:TallyWatchFrame实时推送,见下文

错误代码

代码出现在含义
gateway/bad-request所有带参数的方法参数不符合 schema(缺字段、类型不对、区间不合法、时区未知、fromSeq > toSeq 等)
tally/invalid-budgetsetBudget预算没有通过校验,例如金额不是大于 0 的十进制字符串;文件保持不变
tally/not-foundremoveBudget没有这个 id 的预算
tally/write-failedsetBudget、removeBudget预算文件没能写入(例如等文件锁超时),修改没有生效

watch 帧

watch 是一个流式方法,每一帧是下列之一:

帧内容何时出现
{ type: 'hello', status }TallyStatusView流的第一帧;队列溢出(积压超过 1000 帧,最旧的被丢弃)或价格覆盖被修改后,会再发一次
{ type: 'recorded', entry }TallyEntry实时记账写入一条记录(回填和重建不发)
{ type: 'balance-changed', snapshot }BalanceSnapshot某个路由的余额快照变化(包括变为过期)
{ type: 'budget-crossed', alert }BudgetAlert预算达到 80% 或 100%;只由记录这次提醒的进程发出,同一阈值在一个周期内只提醒一次
{ type: 'status', status }TallyStatusView回填或重建进行中,最多每秒一帧;结束时再发一帧(此时 backfill 为 null)

每收到一个 hello 都要重新拉取全部数据(status、当前会话、今日用量、余额等),不能在旧数据上继续累加之后的增量帧:hello 意味着中间可能丢了帧,或者全部金额已经按新的价格覆盖重新计算。断线重连(包括页面重新加载)后的第一帧也是 hello,按同样的规则处理即可保证重连前后显示的数值一致。

第三方界面如何接入

浏览器端需要一份接口描述(TypertRemoteContribution)才能调用 tally 命名空间。dsh-tally 在子路径 dsh-tally/remote 中提供(手写,形状与 dsh 官方生成的 typert.remote-client.js 相同;只依赖 zod,可以放进浏览器 bundle)。客户端插件在 apply 里挂载它:

import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { TallyWatchFrame } from 'dsh-tally-api'
import { mountTallyRemote } from 'dsh-tally/remote'

export const inject = ['remote']

export async function apply(ctx: Context) {
  // 挂载后 ctx.remote.tally 才可用;插件卸载时命名空间随之撤回。
  // 其他插件(例如 dsh-tally-ui)可能已经挂载过:mountTallyRemote 发现 remote.tally 已存在时跳过挂载,
  // 并发挂载时宿主报出的“已挂载”错误也当作成功。
  await mountTallyRemote(ctx)

  ctx.inject(['remote.tally'], (ctx) => {
    const tally = ctx.remote.tally

    async function reloadAll() {
      const status = await tally.status()
      if (status.ok) console.log('价格目录版本', status.value.catalogVersion)
      const today = new Date()
      today.setHours(0, 0, 0, 0)
      const groups = await tally.query({ from: today.getTime(), to: Date.now() + 1, groupBy: 'model' })
      if (!groups.ok) console.warn(groups.error.code, groups.error.message)
    }

    // 断线自动重连的实时流。在独立的循环里消费,不要在 inject 回调里 for await:
    // 回调要立即返回,流的生命周期交给 AbortController 和 ctx.effect。
    const stream = ctx.remote.$stream<TallyWatchFrame>({
      name: 'tally',
      open: (signal: AbortSignal) => tally.watch(signal),
      ended: () => new Error('tally stream ended'),
    })
    const abort = new AbortController()
    ctx.effect(() => () => {
      abort.abort()
      return stream.dispose()
    }, 'tally watch')

    void (async () => {
      try {
        for await (const item of stream) {
          if (abort.signal.aborted) return
          const frame = item.value
          if (frame.type === 'hello') await reloadAll() // 每个 hello 之后重新拉取全部数据
          else if (frame.type === 'recorded') { /* 防抖后重新拉取 */ }
          // 其他帧类型(包括以后新增的)按需处理,不认识的直接忽略
          item.accept()
        }
      } catch (error) {
        if (!abort.signal.aborted) console.warn('tally stream failed', error)
      }
    })()
  })
}

注意:

  • 页面所在插件的 package.json 中,dsh.client.inject 要包含 remote。
  • dsh-tally/remote 和 zod 必须打包进自己的 bundle:dsh 宿主只共享固定的几个模块(react、@deepseek-ai/cordis、@deepseek-ai/dsh-client-ui-* 等),别的模块在浏览器里无法 require()。
  • 每次调用返回 RemoteResult:成功为 { ok: true, value },失败为 { ok: false, error: { code, message } },code 见上文的错误代码表。
  • 不要直接调用 ctx.remote.$mount(TALLY_REMOTE):同一页面里第二次挂载会失败(宿主报 client api: direct method tally/… is already mounted)。mountTallyRemote(ctx) 先检查 remote.tally 服务是否已存在,存在就不再挂载;并发挂载输掉时出现的这类错误(以及 Typert 注册表的 Remote package "dsh-tally" is already registered)都视为成功。它返回 true 表示由本插件挂载(随本插件卸载而撤回),返回 false 表示已由其他插件挂载(命名空间随那个插件卸载而撤回,此时 ctx.inject(['remote.tally'], …) 的回调会随之停止)。除“已挂载”以外的错误照常抛出。
  • 时间字段(from、to、at)必须是 Date 能表示的整数毫秒(绝对值不超过 8.64e15),否则返回 gateway/bad-request。
  • 向前兼容:以后的次版本可能给枚举字段增加新值、给 watch 流增加新的帧类型。客户端遇到不认识的帧类型必须忽略(照常 accept()),不能报错或断开。注意:把旧版 dsh-tally/remote 打包进自己 bundle 的客户端用的是旧的严格编解码器,遇到新的帧类型或枚举值会解码失败;需要随 dsh-tally 升级重新打包。

版本承诺

服务接口以 dsh-tally-api 为准,严格遵守 semver、只做加法;破坏性修改必须升主版本,旧主版本继续维护 6 个月。

许可证

MIT

对账

dsh-tally 自带一个命令行工具,用来把账本算出的费用和厂商账单逐行对比:

# DeepSeek 官方导出:直接传 ZIP,或传其中的费用表
dsh-tally reconcile --bill ./deepseek-export.zip
dsh-tally reconcile --bill ./cost-2026-10-01_2026-10-31.csv

# 其他厂商或手工整理的账单:通用 CSV
dsh-tally reconcile --bill ./bill.csv --format csv

它按“天 × 模型 × 币种”对比账本和账单,日期默认按北京时间(--tz Asia/Shanghai)划分。账本数据库以只读方式打开,不会被修改;工具不读凭据,也不联网。账本所在目录不可写时(例如备份目录),只要没有未合并的 -wal 文件,也能直接读取。--from、--to 之外的账单行不参与对账,报告会说明有几行。

参数说明
--bill <文件>账单文件(必填):CSV,或 DeepSeek 官方导出的 ZIP
--format <格式>账单格式:csv(通用 CSV)、deepseek(DeepSeek 费用表或官方 ZIP)。不指定时,DeepSeek 费用表和 ZIP 会按内容自动识别
--from / --to对账日期范围(含首尾),默认取账单覆盖的范围
--tz <时区>划分日期的时区,默认 Asia/Shanghai。账单里带时区偏移的时间(如 DeepSeek 的 2026-10-01T00:00:00+08:00)也按这个时区换算成日期
--db <路径>账本数据库,默认 $DSH_HOME/tally/tally.v1.sqlite
--currency <币种>账单没有币种列时使用的币种
--column <列=列名>指定列名,可重复使用。默认识别 日期/date、模型/model、金额/费用/amount/cost、币种/currency
--model-map <a=b>把账单里的模型名 a 当作 b,例如 deepseek-chat=deepseek-flash
--route <路由=厂商>追加路由映射,可重复使用。默认已包含 deepseek-official 和 deepseek-account → deepseek;如果你在插件配置里改过 providerRoutes,这里要传同样的映射
--overrides <文件>价格覆盖配置文件(JSON 数组,格式与插件配置中的 overrides 相同),让对账和界面上的金额一致
--tolerance <比例>容差,默认 0.01(1%),依据设计文档的准确性标准
--json输出 JSON(字段名为英文)

通用 CSV 解析器能处理 UTF-8 BOM、中文表头、带引号的字段、千分位金额(分组不规范的如 1,2 会被拒绝,不会被当成 12)、货币符号,以及退款写法(-1.50 或 (1.50))。币种单元格为空时使用 --currency。引号没有闭合时直接报错并给出行号。无法解析的行不会被静默丢弃,报告里会列出它们的行号和原因。

账本中的模型名会按价格数据里的别名归并,例如 deepseek-v4-flash 计入 deepseek-flash。账本里完全无法计价的“天 × 模型”(全部未定价或用量未知)也会单独列出,不会被漏掉;汇总里会给出未计入金额的请求数。

DeepSeek 官方账单

DeepSeek 开放平台导出的账单是一个 ZIP,里面有两张表,作用不同:

表文件名示例内容对账中的用途
费用表cost-2026-10-01_2026-10-31.csv每天、每个模型、每个钱包扣了多少钱(cost、currency、wallet_type)唯一的金额来源
用量表amount-2026-10-01_2026-10-31.csv每个 API key 每天的 token 数和请求次数(type、price、amount)只用来统计请求次数;amount 是数量不是金额,不会算进账单
  • 传 ZIP 时,工具在内存里读取(不会解压到磁盘),只用费用表的金额对账,所以不会把同一笔钱算两次。ZIP 里没有费用表、或者有多份费用表时,会直接报错并说明原因。
  • 单独传用量表(包括用 --format csv 强行读取)会被拒绝,提示改用费用表,避免把 token 数量当成金额。
  • 同一天、同一模型可能有多个钱包行:Paid 是从充值余额扣的钱,Granted 是从赠金扣的钱。对账时把它们加在一起,和账本算出的总费用比较;报告最后会按钱包分别列出,只有 Paid 才是现金支出,不要把合计当成充值余额的扣费。
  • 用量表里的请求次数会出现在拆分里,只作参考,不另计费。
  • 模型名按账单原样使用,不会自动把别名合并;需要时用 --model-map 显式指定。
  • 账号 id、API key 名称和 key 本身不会出现在报告、JSON 输出或错误信息里。

目前只用一份合成样例(人民币、Paid 和 Granted 两种钱包、一个模型)验证过格式;其他币种、其他钱包类型、退款、合计行等情况还没有实际样例。遇到无法识别的行,报告会列出行号和原因,不会静默丢弃。

价格数据开始之前的日子:账本只有价格数据生效之后的价格(例如 DeepSeek 的价格从 2026-10-10 开始)。账单里早于这一天、账本里又没有金额的行,会单独列在“不参与比较”里,不计入汇总和超出容差的行数,也不影响退出码;汇总会写明这部分账单金额。

退出码:0 表示全部在容差内;1 表示有超出容差的行,或者账单中有无法解析的行;2 表示参数或文件错误。

dsh plugin add 不会把命令加入 PATH,请用 npx dsh-tally@beta reconcile … 运行(npx 会一并装好它依赖的 dsh 包)。不要直接用 node 运行插件目录里的 lib/cli.js:那些 dsh 包由 dsh 自己提供,不在插件目录下,会报找不到模块。从源码安装时,在仓库根目录用 node packages/tally/lib/cli.js reconcile …。

超出容差时的提示:

提示含义
未定价请求 N 次这些请求没有价格(例如价格历史未录入,或路由没有价格映射),没有计入账本金额
用量未知请求 N 次厂商没有上报用量,没有计入金额
节假日日历缺失的高峰请求 N 次当年节假日数据未录入,这些请求按高峰价计算,可能偏高
账单中没有这一行 / 账本中没有这一行只有一边有数据,差异按 100% 计

对账结果是估算,以厂商账单为准。