☰
OpenCodex 提供商用量成本明细第一阶段:服务端成本聚合与前端数据管线的完整实现契约
2026/9/25 1:23:22 网站建设 项目流程

【免费下载链接】opencodex

Universal provider proxy for OpenAI Codex & Claude Code — use any LLM (Claude, Gemini, Grok, DeepSeek, Ollama…) with Codex CLI, App, SDK, and Claude Code

项目地址:https://gitcode.com/gh_mirrors/ope/opencodex
点击查看免费下载

本篇技术指南完整讲解 OpenCodex(通用 LLM Provider 代理)"提供商用量成本明细"(Provider Usage Cost Breakdown)功能的第一阶段——数据管线(Data Plumbing):如何在服务端用量汇总中新增按模型、按提供商的estimatedCostUsd聚合,并把它沿着ProviderWorkspaceShell → Providers → ProviderDetails → ProviderUsage的 props 链条完整穿透到前端,同时不渲染任何新 UI。读完后你将掌握该功能的成本归属规则(单请求与 combo 多尝试请求)、字段缺省契约、共享格式化器的行为边界,以及可复制的验收检查项与验证命令。

一、阶段定位与核心不变量

该功能的目标是在工作区 UI 的每个提供商 Usage 页签中增加按模型的成本明细(Phase 2 才会渲染 3 列 KPI + 5 列模型表格),而 Phase 1 的职责是把数据"接好线"。原始实施契约见 010_data_plumbing.md,总体计划见 000_plan.md。

Phase 1 必须遵守以下不变量(invariants):

  • 只保留一次用量请求:/api/usage?range=30d是唯一的用量请求,不得引入第二次 fetch;
  • 增量、非破坏的响应形状:estimatedCostUsd以可选字段形式追加到现有UsageModel/UsageProvider响应结构中;
  • models[].provider只用作分组键:它在进入每个提供商私有的ProviderModelUsageRow时被移除;
  • 成本在服务端计算:模型与提供商成本由每条请求匹配到的价格计算得出;combo 请求会把每次物理尝试(attempt)的成本归入该尝试自己的模型/提供商桶,Phase 1 不直接展示新值;
  • 保留现有行为:请求/token 总数、模型加载状态、配额行为、取消守卫(cancellation guard)、fetch 失败行为均不变;
  • 本阶段不包含任何可见 UI、CSS 或 i18n 改动。

一个值得注意的契约细节:原始需求点名的 4 个文件不足以完成数据链路,因为 gui/src/pages/Providers.tsx 是显式逐字段映射DetailSlotData(而非展开传递)的,gui/src/components/provider-workspace/ProviderUsage.tsx 也必须接收新转发过来的 props 才能通过类型检查。因此这两处的最小改动属于"编译闭环改动",而不是 Phase 2 的 UI 工作。

变更清单

动作文件目的
MODIFYsrc/usage/summary.ts新增按模型、按提供商的估算成本聚合
MODIFYtests/usage-summary.test.ts固化单请求与 combo 成本归属到模型/提供商行
MODIFYgui/src/components/provider-workspace/types.ts新增带服务端成本的共享模型用量契约
MODIFYgui/src/provider-workspace/usage.ts新增共享的 USD 估算格式器
MODIFYtests/provider-workspace-data.test.ts固化格式器对 null/非法/多语言环境的行为
MODIFYgui/src/components/provider-workspace/ProviderWorkspaceShell.tsx在既有用量 fetch 中捕获模型行(含服务端成本)与汇总成本
MODIFYgui/src/pages/Providers.tsx通过显式 props 映射器转发两个新的 detail-slot 字段
MODIFYgui/src/components/provider-workspace/ProviderDetails.tsx接收并转发新数据给ProviderUsage
MODIFYgui/src/components/provider-workspace/ProviderUsage.tsx接收 Phase 1 props,不改变渲染输出

二、服务端成本聚合:扩展 usage 响应行

2.1UsageModel与UsageProvider增加可选成本字段

在 src/usage/summary.ts 中,UsageModel与UsageProvider的接口在shareRatio之后追加可选字段:

export interface UsageModel { provider: string; model: string; resolvedModel?: string; requests: number; attemptCount: number; measuredRequests: number; reportedRequests: number; estimatedRequests: number; totalTokens: number; inputTokens: number; outputTokens: number; shareRatio: number; estimatedCostUsd?: number; // ← 新增:可选,未定价行保持缺省 } export interface UsageProvider { provider: string; requests: number; attemptCount: number; measuredRequests: number; reportedRequests: number; estimatedRequests: number; totalTokens: number; shareRatio: number; estimatedCostUsd?: number; // ← 新增 }

当前仓库源码中这两个接口确实已经携带该可选字段(见 src/usage/summary.ts 中UsageModel.estimatedCostUsd(L131)与UsageProvider.estimatedCostUsd(L154)),并且同文件的UsageDayModel、UsageAccount、按日汇总等结构也采用了同样的"可选成本 + 缺省即未定价"约定。

2.2 在buildModels()中追加第二轮 entries 遍历累加按模型成本

实现位置在buildModels()内:在既有的 token/状态累加循环之后、statusesByKey定稿循环之前,追加一次对entries的遍历,复用从./cost导入的estimateRequestCost与estimateComboCost:

for (const entry of entries) { if (entry.attempts?.length) { const estimate = estimateComboCost(entry.attempts); for (const attempt of estimate?.attempts ?? []) { const providerKey = baseProviderLabel(attempt.provider); const model = byKey.get(`${providerKey}${attempt.model}`); if (model) model.estimatedCostUsd = (model.estimatedCostUsd ?? 0) + attempt.cost.total; } continue; } const estimate = estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, }); if (!estimate) continue; const providerKey = baseProviderLabel(entry.provider); const model = byKey.get(`${providerKey}${entry.model}`); if (model) model.estimatedCostUsd = (model.estimatedCostUsd ?? 0) + estimate.cost.total; }

buildProviders()采用完全对称的第二轮遍历,只是按baseProviderLabel(entry.provider)直接取提供商桶累加。

2.3 成本估算器的底层语义与"失败关闭"

这两轮累加复用的估算器定义在 src/usage/cost.ts:

  • estimateRequestCost(cost.ts#L616-L652):先normalizeCostTokens归一化 token,再resolveMatchedPrice解析价格(用户覆盖层 → 目录价),随后应用长上下文档位与 priority 乘数;token 缺失、价格未匹配时返回null;
  • estimateComboCost(cost.ts#L574-L613):对每次尝试按其自身费率计价并求和,且失败关闭(fail closed)——只要有任何一次尝试未定价或不可归一化,整体返回null而不是部分和。其返回值中的attempts数组保留了每次尝试各自的provider、model与cost.total,这正是 combo 成本能按尝试归属到各模型/提供商桶的依据。

由此可以推出该阶段的三条设计决策:

  1. 与全局汇总同价:addEstimatedCost(summary.ts#L614 附近)已经用同一对估算器定义了全局 summary 的计价行为;第二轮遍历复用它们,保证"同一笔定价同时进入全局汇总、模型行、提供商行"三者一致;
  2. 未定价行不填假零:estimate为null时continue,estimatedCostUsd保持**缺省(absent)**而非0,消费端据此区分"未定价"与"成本为零";
  3. combo 不落到合成行:estimateComboCost返回的逐次尝试估算保留了每次尝试的原生模型与提供商,因此没有任何成本被记到合成的combo身份上——失败的尝试与成功的尝试各自独立计入自己的桶。

baseProviderLabel负责把提供商名规范化为分组键(例如把chatgpt归一为openai),这既保证聚合键稳定,也引出了下文第 7 节说明的已知局限。

2.4 用测试固化成本归属契约

tests/usage-summary.test.ts 中新增/扩展的期望分两类。

其一,固化已定价行与未定价行的字段契约(在既有"aggregates estimated cost via model-level prices and counts unpriced rows"用例的全局成本断言之后追加):

expect(all.summary.estimatedCostUsd).toBeCloseTo(expected, 9); expect(all.models.find(row => row.model === "gpt-5.5")?.estimatedCostUsd) .toBeCloseTo((100 * 5 + 10 * 30) / 1e6, 9); expect(all.providers.find(row => row.provider === "openai")?.estimatedCostUsd) .toBeCloseTo((100 * 5 + 10 * 30) / 1e6, 9); expect(all.models.find(row => row.model === "nope-model")?.estimatedCostUsd).toBeUndefined(); expect(all.providers.find(row => row.provider === "nope")?.estimatedCostUsd).toBeUndefined();

其二,固化 combo 尝试级归属(在"keeps one logical combo request while attributing both physical attempts"用例之后新增独立用例)。构造一条含两次物理尝试的 combo 记录——第 1 次openai/gpt-5.5(503,估算 usage,100 input token),第 2 次anthropic/claude-fable-5(200,上报 usage,10 input + 2 output):

test("attributes combo attempt costs to each model and provider row", () => { const combo = entry({ ts: FIXED_NOW - 1, requestId: "priced-combo", provider: "combo", model: "combo/free", usageStatus: "estimated", usage: { inputTokens: 110, outputTokens: 2, totalTokens: 112, estimated: true }, totalTokens: 112, attempts: [ { ordinal: 1, provider: "openai", model: "gpt-5.5", adapter: "openai-chat", status: 503, durationMs: 4, sendCount: 1, recoveryKinds: [], usageStatus: "estimated", inputTokenEstimate: 100, usage: { inputTokens: 100, outputTokens: 0, estimated: true }, totalTokens: 100, }, { ordinal: 2, provider: "anthropic", model: "claude-fable-5", adapter: "openai-chat", status: 200, durationMs: 3, sendCount: 1, recoveryKinds: [], usageStatus: "reported", usage: { inputTokens: 10, outputTokens: 2 }, totalTokens: 12, }, ], }); const sum = summarizeUsage([combo], "30d", FIXED_NOW); expect(sum.models.find(row => row.provider === "openai" && row.model === "gpt-5.5")?.estimatedCostUsd) .toBeCloseTo((100 * 5 + 0 * 30) / 1e6, 9); expect(sum.models.find(row => row.provider === "anthropic" && row.model === "claude-fable-5")?.estimatedCostUsd) .toBeCloseTo((10 * 10 + 2 * 50) / 1e6, 9); expect(sum.providers.find(row => row.provider === "openai")?.estimatedCostUsd) .toBeCloseTo((100 * 5 + 0 * 30) / 1e6, 9); expect(sum.providers.find(row => row.provider === "anthropic")?.estimatedCostUsd) .toBeCloseTo((10 * 10 + 2 * 50) / 1e6, 9); });

这组断言防止两类回归:把整条父级成本记到合成的combo模型/提供商上;或者只把成本记到"成功的那次尝试"上(503 的失败尝试同样应按自身价格计价入账)。

三、前端共享契约:ProviderModelUsageRow与formatCostUsd

3.1 提供商私有的模型行契约

在 gui/src/components/provider-workspace/types.ts 的ProviderUsageTotals声明之后新增:

/** Per-model usage row from /api/usage, filtered by provider. */ export interface ProviderModelUsageRow { model: string; resolvedModel?: string; requests: number; totalTokens: number; inputTokens: number; outputTokens: number; shareRatio: number; estimatedCostUsd?: number; }

设计要点:

  • 行结构刻意不包含provider字段——Shell 把行存放在Record<providerName, rows>下,提供商名就是键本身;
  • estimatedCostUsd保留服务端逐模型估算值;
  • 不需要单独的聚合接口:提供商 KPI 直接对选中提供商的模型行成本求和即可,避免引入第二套成本汇总类型。

3.2 USD 估算格式器

在 gui/src/provider-workspace/usage.ts 的formatTokenCount之后追加纯函数:

/** Format a USD cost estimate for display. Returns "—" when null. */ export function formatCostUsd(value: number | null | undefined, locale = "en"): string { if (value === null || value === undefined || !Number.isFinite(value) || value < 0) return "\u2014"; return `~$${new Intl.NumberFormat(locale, { minimumFractionDigits: 4, maximumFractionDigits: 4, }).format(value)}`; }

行为边界:null/undefined/NaN/负数一律渲染为—(em dash),合法值固定四位小数并带~$估算前缀(~表明这是估算而非账单值),小数分隔符随 locale 走Intl.NumberFormat。对应测试固化在 tests/provider-workspace-data.test.ts:

test("USD estimates use fixed four-decimal localized formatting and reject invalid values", () => { expect(formatCostUsd(undefined)).toBe("\u2014"); expect(formatCostUsd(null)).toBe("\u2014"); expect(formatCostUsd(Number.NaN)).toBe("\u2014"); expect(formatCostUsd(-0.01)).toBe("\u2014"); expect(formatCostUsd(0)).toBe("~$0.0000"); expect(formatCostUsd(1_234.5, "en")).toBe("~$1,234.5000"); expect(formatCostUsd(1_234.5, "de")).toBe("~$1.234,5000"); });

该测试锁死了估算前缀、locale 分隔符与非法值拒绝三项契约,防止 Phase 2 消费该 helper 前被误删或退化。

四、前端数据管线:从 Shell 到 Usage 页签的四级穿透

4.1 Shell:在既有 fetch 中捕获模型行与汇总成本

gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx 做四处改动,核心是扩展现有单次 fetch 而不是新增请求:

DetailSlotData增加两个字段:

export interface DetailSlotData { usageTotals?: ProviderUsageTotals; quotaReport?: ProviderQuotaReportView; modelUsage?: ProviderModelUsageRow[]; // ← 新增 usageSummaryEstimatedCost?: number; // ← 新增 availableModels: string[]; selectedModels: string[]; modelsLoading: boolean; modelsLoadFailed: boolean; onRetryModels?: () => void; }

新增两个状态容器(紧跟既有usageTotals声明之后):

const [usageModels, setUsageModels] = useState<Record<string, ProviderModelUsageRow[]>>({}); const [usageSummary, setUsageSummary] = useState<{ estimatedCostUsd?: number }>({});

替换后的用量 fetch 效果体(注意:请求 URL、取消守卫、静默失败语义全部保持不变):

useEffect(() => { let cancelled = false; fetch(`${apiBase}/api/usage?range=30d`) .then(r => r.ok ? r.json() : null) .then((data: { providers?: Array<{ provider: string; requests: number; totalTokens?: number }>; models?: Array<ProviderModelUsageRow & { provider: string }>; summary?: { estimatedCostUsd?: number }; } | null) => { if (cancelled || !data) return; const byProvider: Record<string, ProviderUsageTotals> = {}; for (const p of data.providers ?? []) byProvider[p.provider] = { requests: p.requests, totalTokens: p.totalTokens }; const modelsByProvider: Record<string, ProviderModelUsageRow[]> = {}; for (const row of data.models ?? []) { const { provider, estimatedCostUsd, ...model } = row; (modelsByProvider[provider] ??= []).push({ ...model, estimatedCostUsd }); } setUsageTotals(byProvider); setUsageModels(modelsByProvider); setUsageSummary({ estimatedCostUsd: data.summary?.estimatedCostUsd }); }) .catch(() => {}); return () => { cancelled = true; }; }, [apiBase]);

这里有一个必须保留的解构细节:const { provider, estimatedCostUsd, ...model } = row;之后用{ ...model, estimatedCostUsd }重组——estimatedCostUsd被单独拆出再放回,是为了确保数值 0 也被原样保留,而不是被 rest 解构与显式字段的顺序差异吞掉。

最后,在detail?.(selectedItem, { ... })的对象字面量中,把选中提供商的行与全局汇总成本透传给 detail 槽位:

detail?.(selectedItem, { usageTotals: usageTotals[selectedItem.name], quotaReport: quotaReports[selectedItem.name], modelUsage: usageModels[selectedItem.name], usageSummaryEstimatedCost: usageSummary.estimatedCostUsd, availableModels: availableModels[selectedItem.name] ?? [], selectedModels: selectedModels[selectedItem.name] ?? [], modelsLoading, modelsLoadFailed, onRetryModels: retryModels, }) ?? (

在 Shell 边界一次性分组的好处:详情面板只拿到自己提供商的行;全局汇总值以显式命名的usageSummaryEstimatedCost传递,语义上明确它是"全局 summary 估算"而非本提供商的成本。

4.2 Providers 页:显式 props 映射器转发

gui/src/pages/Providers.tsx 的detailrender prop 是逐字段显式映射(不做展开),必须在quotaReport之后补两行:

usageTotals={data.usageTotals} quotaReport={data.quotaReport} modelUsage={data.modelUsage} usageSummaryEstimatedCost={data.usageSummaryEstimatedCost} availableModels={data.availableModels}

缺了这一步,Shell 捕获的新数据会在到达ProviderDetails之前被静默丢弃——这是 Phase 1 必须闭合的数据管线接缝。

4.3 ProviderDetails:只做 props 组合边界

gui/src/components/provider-workspace/ProviderDetails.tsx 接收新 props 并转发给ProviderUsage,自身不持有用量派生状态、不做任何成本计算:

{tab === "usage" && ( <ProviderUsage item={item} usageTotals={usageTotals} quotaReport={quotaReport} modelUsage={modelUsage} usageSummaryEstimatedCost={usageSummaryEstimatedCost} /> )}

4.4 ProviderUsage:接收 props 但不渲染(编译闭环改动)

gui/src/components/provider-workspace/ProviderUsage.tsx 的改动只涉及 prop 契约:TypeScript 会拒绝未声明的 JSX props,因此组件签名必须接受新值;用void显式消费,渲染 JSX 保持逐字节不变,Phase 2 渲染 KPI/表格时再移除两条void语句:

export default function ProviderUsage({ item, usageTotals, quotaReport, modelUsage, usageSummaryEstimatedCost, }: { item: WorkspaceItem; usageTotals?: ProviderUsageTotals; quotaReport?: ProviderQuotaReportView; modelUsage?: ProviderModelUsageRow[]; usageSummaryEstimatedCost?: number; }) { const t = useT(); const { locale } = useI18n(); const timeLabels = relativeTimeLabelsFromT(t); const hasUsage = usageTotals?.requests !== undefined; const quota = accountQuotaFromReport(quotaReport); void item; void modelUsage; void usageSummaryEstimatedCost; return ( // …既有 JSX,Phase 1 保持逐字节不变

五、验收检查项(9 条源级不变量)

实施完毕后,在源码层面应逐条核验:

  1. UsageModel与UsageProvider暴露增量可选estimatedCostUsd,ProviderModelUsageRow携带同一模型行字段,且没有新增重复的成本汇总类型;
  2. 一条已定价的非 combo 请求,其估算值同时进入全局 summary、模型行与提供商行;纯未定价行保持可选字段缺省;
  3. 每次已定价的 combo 尝试只把自身成本记入自身模型/提供商桶;合成的combo身份不得收到任何估算值;
  4. Shell 仍然只发起一次/api/usage?range=30d请求;
  5. 每条data.models记录按其provider键存储,存储后的行不再含provider字段,且estimatedCostUsd值(含数值 0)被保留;
  6. 选中没有任何模型行的提供商时得到modelUsage === undefined,不构造假的空用量记录——空态呈现由 Phase 2 决定;
  7. summary.estimatedCostUsd === 0被保留为0,不被当作"不可用";
  8. 失败/非 OK 的用量请求不触碰初始用量状态,与现状一致;
  9. Phase 1 中渲染的ProviderUsageJSX 保持不变。

六、验证命令

以下命令在仓库根目录(Phase 1 实施后)执行:

bun test --isolate tests/usage-summary.test.ts

期望退出码0:用量汇总测试通过,覆盖按模型、按提供商、未定价行与 combo 尝试成本归属。

bun test --isolate tests/provider-workspace-data.test.ts

期望退出码0:provider-workspace 数据测试全部通过,含新增 USD 格式器用例。

cd gui && bun x tsc --noEmit

期望退出码0:完整的ProviderWorkspaceShell → Providers → ProviderDetails → ProviderUsageprops 链条通过类型检查。

git diff --check -- \ src/usage/summary.ts \ tests/usage-summary.test.ts \ gui/src/components/provider-workspace/types.ts \ gui/src/provider-workspace/usage.ts \ tests/provider-workspace-data.test.ts \ gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx \ gui/src/pages/Providers.tsx \ gui/src/components/provider-workspace/ProviderDetails.tsx \ gui/src/components/provider-workspace/ProviderUsage.tsx

期望退出码0且无输出。Phase 1 不要求浏览器/视觉检查——因为渲染 JSX 与样式未变;浏览器 QA 在 Phase 2 渲染成本 KPI 与模型表格时才成为必需。

七、已知局限:chatgpt/openai 键不匹配

计划文档(000_plan.md)将其列为已接受的已知局限:服务端baseProviderLabel会把chatgpt归一化为openai,而工作区可能展示独立的chatgpt提供商;此时以selectedItem.name为键的用量数据(新旧数据均是)会匹配不到,用量显示为不可用。这是既有问题(同样影响已有的 usage totals 显示),不是本功能引入的回归;带回归测试的 GUI 规范用量键 helper 被推迟到后续独立工作项。

八、小结

Phase 1 的价值在于以最小的侵入性建立了一条端到端类型安全、测试固化的成本数据链路:服务端复用既有估算器完成逐模型/逐提供商聚合并遵守"缺省即未定价、combo 按尝试归属"的契约;前端在单次 fetch 内完成捕获与分组,经由 Shell → Providers → Details → Usage 四级 props 穿透,最终由ProviderUsage以void占位完成编译闭环。Phase 2 的 UI 工作(KPI、模型表格、i18n)因此可以在一个数据完全就绪、渲染零变化的中间态之上安全展开。

【免费下载链接】opencodex

Universal provider proxy for OpenAI Codex & Claude Code — use any LLM (Claude, Gemini, Grok, DeepSeek, Ollama…) with Codex CLI, App, SDK, and Claude Code

项目地址:https://gitcode.com/gh_mirrors/ope/opencodex
点击查看免费下载

相关推荐

上一篇:如何快速免费导出QQ空间全部历史说说?
下一篇:如何永久保存微信聊天记录?WeChatMsg让你的数字记忆永不丢失

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询