【免费下载链接】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
本篇技术指南完整讲解 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 工作。
变更清单
| 动作 | 文件 | 目的 |
|---|---|---|
| MODIFY | src/usage/summary.ts | 新增按模型、按提供商的估算成本聚合 |
| MODIFY | tests/usage-summary.test.ts | 固化单请求与 combo 成本归属到模型/提供商行 |
| MODIFY | gui/src/components/provider-workspace/types.ts | 新增带服务端成本的共享模型用量契约 |
| MODIFY | gui/src/provider-workspace/usage.ts | 新增共享的 USD 估算格式器 |
| MODIFY | tests/provider-workspace-data.test.ts | 固化格式器对 null/非法/多语言环境的行为 |
| MODIFY | gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx | 在既有用量 fetch 中捕获模型行(含服务端成本)与汇总成本 |
| MODIFY | gui/src/pages/Providers.tsx | 通过显式 props 映射器转发两个新的 detail-slot 字段 |
| MODIFY | gui/src/components/provider-workspace/ProviderDetails.tsx | 接收并转发新数据给ProviderUsage |
| MODIFY | gui/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 成本能按尝试归属到各模型/提供商桶的依据。
由此可以推出该阶段的三条设计决策:
- 与全局汇总同价:
addEstimatedCost(summary.ts#L614 附近)已经用同一对估算器定义了全局 summary 的计价行为;第二轮遍历复用它们,保证"同一笔定价同时进入全局汇总、模型行、提供商行"三者一致; - 未定价行不填假零:
estimate为null时continue,estimatedCostUsd保持**缺省(absent)**而非0,消费端据此区分"未定价"与"成本为零"; - 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 条源级不变量)
实施完毕后,在源码层面应逐条核验:
UsageModel与UsageProvider暴露增量可选estimatedCostUsd,ProviderModelUsageRow携带同一模型行字段,且没有新增重复的成本汇总类型;- 一条已定价的非 combo 请求,其估算值同时进入全局 summary、模型行与提供商行;纯未定价行保持可选字段缺省;
- 每次已定价的 combo 尝试只把自身成本记入自身模型/提供商桶;合成的
combo身份不得收到任何估算值; - Shell 仍然只发起一次
/api/usage?range=30d请求; - 每条
data.models记录按其provider键存储,存储后的行不再含provider字段,且estimatedCostUsd值(含数值 0)被保留; - 选中没有任何模型行的提供商时得到
modelUsage === undefined,不构造假的空用量记录——空态呈现由 Phase 2 决定; summary.estimatedCostUsd === 0被保留为0,不被当作"不可用";- 失败/非 OK 的用量请求不触碰初始用量状态,与现状一致;
- 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
相关推荐
opencodex 服务端大文件重构实战:把 2322 行的 server.ts 拆成内聚模块的分阶段实施方法论
opencodex 服务端大文件重构实战:把 2322 行的 server.ts 拆成内聚模块的分阶段实施方法论 导读 本文以 opencodex 仓库中的一次
解锁Java生态宝藏:从零构建企业级知识图谱的技术架构深度剖析
解锁Java生态宝藏:从零构建企业级知识图谱的技术架构深度剖析 在当今数据爆炸的时代,企业面临着海量信息孤岛和碎片化数据的严峻挑战。传统的数据管理方式已无法满足
文档知识库CANN/pypto-gym Qwen3Next样例
Qwen3Next 样例 Examples 本目录包含了 PyPTO Qwen3Next 模型的开发样例代码。我们对 Qwen3Next 的核心注意力机制进行了
人工智能大模型算子库模型优化AI 技能CANNAscend
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考