diff --git a/.gitignore b/.gitignore index 2476899..acc2955 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ node_modules/ logs/ *.jsonl cache-hit.config.json + +# Runtime tool artifacts (e.g. omo run-continuation) +.omo/ diff --git a/AGENTS.md b/AGENTS.md index c57b25f..1d0a9f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ OpenCode TUI sidebar plugin: **cache hit rate**, **tokens**, **cost**, with **su | Token Speed | [docs/en/token-speed.md](docs/en/token-speed.md) | [docs/zh-CN/token-speed.md](docs/zh-CN/token-speed.md) | | TTFT Hybrid | [docs/en/ttft-hybrid.md](docs/en/ttft-hybrid.md) | [docs/zh-CN/ttft-hybrid.md](docs/zh-CN/ttft-hybrid.md) | | TTFT Troubleshooting | [docs/en/ttft-troubleshooting.md](docs/en/ttft-troubleshooting.md) | [docs/zh-CN/ttft-troubleshooting.md](docs/zh-CN/ttft-troubleshooting.md) | +| Dynamic pricing (plan) | — | [docs/zh-CN/dynamic-pricing-plan.md](docs/zh-CN/dynamic-pricing-plan.md) | | TUI panel | [src/tui-panel/README.md](src/tui-panel/README.md) | [src/tui-panel/README.zh-CN.md](src/tui-panel/README.zh-CN.md) | | Migration plan | [docs/en/frontend-migration-plan.md](docs/en/frontend-migration-plan.md) | [docs/zh-CN/frontend-migration-plan.md](docs/zh-CN/frontend-migration-plan.md) | | Contributing / npm | [CONTRIBUTING.md](CONTRIBUTING.md) | — | @@ -48,6 +49,7 @@ After moving or renaming exports: run full `bun test`; `tests/module-load.test.t - Example: [cache-hit.config.example.json](cache-hit.config.example.json) — **included in npm** `files`. - Runtime: `~/.config/opencode/cache-hit.json` (preferred) or `cache-hit.config.json` beside package root (legacy fallback); **not** published; gitignored. - Defaults: [src/plugin-config.ts](src/plugin-config.ts). +- Dynamic pricing (`dynamicPricing`): time-of-day tiers + context tier (runtime `tiers`/`experimentalOver200K` normalized); see README § Dynamic pricing and [docs/zh-CN/dynamic-pricing-plan.md](docs/zh-CN/dynamic-pricing-plan.md). `levels` absolute prices default to USD/1M; non-USD prices convert via `cost.rate` (when the level currency matches the display currency) or the per-rule `"rate"` (USD → level currency). - Timeline log dir default: `~/.local/share/opencode/logs/cache-hit/`. Supports `~/` expansion in `timeline.dir`. ## npm publish diff --git a/README.md b/README.md index 189885d..0007a48 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The **tool-part TTFT fallback** (capturing `tool.pending` as first-response time - **Cache hit rate**: session total + **per-turn** rate with trend (↑ / ↓ / `-`) on the main block - **Token breakdown**: cache read / write / miss / output (aligned rows with visual-cache) -- **Cost**: session cost with multi-currency config (`USD`, `CNY`, `EUR`, `GBP`, `JPY`); per-million rates and cache savings from provider config +- **Cost**: session cost with multi-currency config (`USD`, `CNY`, `EUR`, `GBP`, `JPY`); per-million rates and cache savings from provider config; **dynamic pricing** for time-of-day tiers (DeepSeek peak/off-peak) and context tiers (`context_over_200k`, e.g. GPT-5.6) - **Sub-agents**: **Agents** section rolls up **child sessions only** (scope labeled in UI); each row shows model name + session ID suffix with **vendor-tinted** label (cost in muted gray) - **Main + Agents**: main block always shown; **Agents** section when sub-agents exist (foldable) - **Collapsible sections**: Detail / Model (and Agents); theme-adaptive hit bar colors @@ -95,7 +95,7 @@ Load errors: `~/.local/share/opencode/log/` (search `cache-hit` or `failed to lo { "currency": "CNY", "costUnit": "USD", - "rate": 7.2 + "rate": 6.77 } ``` @@ -205,6 +205,56 @@ Shows how long the prompt cache has been alive. Color changes when exceeding TTL **Default TTL**: 5 minutes for all providers not listed above. Color changes based on elapsed time vs TTL: green (< TTL), yellow (TTL-2x TTL), red (≥ 2x TTL). +### Dynamic pricing (`dynamicPricing`, default on) + +Model rates are normally static per-million USD from OpenCode's provider registry. Some models price by **time of day** (DeepSeek V4: peak 09:00-12:00 / 14:00-18:00 Beijing time, off-peak half price) or by **context size** (`context_over_200k`, e.g. GPT-5.6: rates roughly double above 200k tokens). + +Without config, the plugin already: + +- Reads the model's context tier from `state.provider` (runtime `tiers` / `experimentalOver200K`, normalized internally) and shows the right tier based on total context (input + cache read) vs the threshold. +- Applies a built-in DeepSeek off-peak 0.5× multiplier when the schedule below matches. + +```json +"dynamicPricing": { + "enabled": true, + "timezone": "Asia/Shanghai", + "schedule": [ + { "level": "peak", "windows": [{"start": "09:00", "end": "12:00"}, {"start": "14:00", "end": "18:00"}] }, + { "level": "offpeak", "windows": [{"start": "18:00", "end": "09:00"}, {"start": "12:00", "end": "14:00"}] } + ], + "contextThreshold": 200000, + "providers": { + "deepseek": { + "models": { + "deepseek/deepseek-v4-flash": { + "multipliers": { "peak": 1, "offpeak": 0.5 } + } + } + } + } +} +``` + +| Field | Default | Meaning | +|-------|---------|---------| +| `enabled` | `true` | Master switch. Disable to restore fully static pricing | +| `timezone` | `Asia/Shanghai` | IANA zone used for schedule matching (DeepSeek prices are Beijing-time based) | +| `schedule` | DeepSeek peak/off-peak | `{level, windows:[{start,end}]}` list; `HH:MM`, cross-midnight windows allowed | +| `contextThreshold` | `200000` | Token threshold for the context tier; per-model `contextThreshold` wins over the runtime tier size from `state.provider` | +| `providers` | `{}` | Per `providerID` → `modelID` rules | + +Per-model rules support two forms (explicit config wins over the built-in DeepSeek default): + +- `multipliers`: factor applied to the static rates per schedule level (e.g. `{"offpeak": 0.5}`) +- `levels`: absolute rates per level, e.g. `{"peak": {"input": 0.44, "output": 0.88, "cacheRead": 0.01}, "offpeak": {"input": 0.22, ...}}`. Cache rates may be written as flat `cacheRead`/`cacheWrite` (or `cache_read`/`cache_write`) or nested `cache: {"read": …, "write": …}` (both are accepted; flat wins if both present). Default unit is **USD per 1M** (same as `state.provider`). To write prices in another currency, set `"currency": "CNY"` and either make it match the display `cost.currency` (converted via `cost.rate`) or provide the per-rule `"rate"` (USD → that currency, e.g. `"rate": 1.08` for EUR). If the currency cannot be converted (no `rate`, currency ≠ display currency), a warning is logged to stderr and the values are treated as USD. `multipliers` are ratios and have no currency. +- `contextThreshold`: per-model override of the global threshold (wins over the runtime tier size from `state.provider`). + +Rates shown in the sidebar switch automatically at schedule boundaries (no polling). Session cost shown is recomputed per message from its request time + context tier when dynamic rules apply (marked `≈`); otherwise OpenCode's own `msg.cost` is used. Sub-agent rows use their **session creation time** (`session.list`) for time-of-day pricing (marked `≈` on the Agents total when any child was recomputed). + +**Timeline dashboard** ([docs/en/timeline.md](docs/en/timeline.md)) also recomputes costs offline: it reads `~/.config/opencode/opencode.json` (JSONC-aware) for provider rates and injects `dynCost` per record (shown with `≈` and in charts/totals when it differs from the original). + +**Refresh official DeepSeek rates**: `bun scripts/fetch-deepseek-pricing.ts` prints a ready-to-paste `dynamicPricing.providers` snippet (CNY by default with `"currency": "CNY"`, `--usd --rate 6.77` for USD). + ## Updating > [!IMPORTANT] @@ -221,7 +271,7 @@ Then reinstall via `Ctrl+P` → install plugin, and **restart OpenCode**. To avoid the pinning issue entirely, install a **pinned version** instead of `@latest`: ```jsonc -{ "plugin": ["opencode-cache-hit@0.6.4"] } +{ "plugin": ["opencode-cache-hit@0.7.0"] } ``` ## Compatibility diff --git a/README.zh-CN.md b/README.zh-CN.md index 740f033..68bc8d6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -33,7 +33,7 @@ OpenCode **TUI 侧边栏插件**:展示 prompt cache 命中率、token 用量 - **命中率**:会话累计 + **单轮**命中率与趋势(↑ / ↓ / `-`) - **Token 明细**:缓存读 / 写 / 未命中 / 输出(对齐 visual-cache 的行布局) -- **费用**:多币种配置(`USD` / `CNY` / `EUR` / `GBP` / `JPY`);从 provider 配置读取百万 token 单价及缓存节省 +- **费用**:多币种配置(`USD` / `CNY` / `EUR` / `GBP` / `JPY`);从 provider 配置读取百万 token 单价及缓存节省;**动态计价**:支持按时段(DeepSeek 高峰/空闲)与按上下文分档(`context_over_200k`,如 GPT-5.6) - **子 agent**:**Agents** 段仅汇总**子 session**(UI 有范围标注);每行显示模型名 + session ID 后缀,**label 按厂商近似品牌色**,金额为灰色 - **主 + Agents**:主块始终显示;有子 agent 时出现可折叠的 **Agents** 段 - **可折叠段落**:Detail / Model(以及 Agents);主题自适应的命中率条颜色 @@ -95,7 +95,7 @@ OpenCode **TUI 侧边栏插件**:展示 prompt cache 命中率、token 用量 { "currency": "CNY", "costUnit": "USD", - "rate": 7.2 + "rate": 6.77 } ``` @@ -205,6 +205,56 @@ jq -r 'select(.rootSessionId=="YOUR_ROOT") | [.created,.scope,.hitPercent,.cost] **默认 TTL**:上表未列出的 provider 均为 5 分钟。颜色基于已存活时间与 TTL 的比值:绿色(< TTL)、黄色(TTL–2×TTL)、红色(≥ 2×TTL)。 +### 动态计价(`dynamicPricing`,默认开启) + +模型单价通常为 OpenCode provider 注册表中的静态 USD/百万 token。部分模型按**时段**计价(DeepSeek V4:高峰 09:00-12:00 / 14:00-18:00 北京时间,空闲半价),或按**上下文大小**分档(`context_over_200k`,如 GPT-5.6:超过 200k token 单价约翻倍)。 + +零配置时插件已自动: + +- 读取 `state.provider` 中模型的上下文档位(运行时 `tiers` / `experimentalOver200K`,内部归一化),按总上下文(输入 + 缓存读)与阈值显示对应档位。 +- 时段匹配时对 DeepSeek 模型应用内置空闲 0.5× 倍率。 + +```json +"dynamicPricing": { + "enabled": true, + "timezone": "Asia/Shanghai", + "schedule": [ + { "level": "peak", "windows": [{"start": "09:00", "end": "12:00"}, {"start": "14:00", "end": "18:00"}] }, + { "level": "offpeak", "windows": [{"start": "18:00", "end": "09:00"}, {"start": "12:00", "end": "14:00"}] } + ], + "contextThreshold": 200000, + "providers": { + "deepseek": { + "models": { + "deepseek/deepseek-v4-flash": { + "multipliers": { "peak": 1, "offpeak": 0.5 } + } + } + } + } +} +``` + +| 字段 | 默认值 | 含义 | +|------|--------|------| +| `enabled` | `true` | 总开关。设为 `false` 恢复完全静态计价 | +| `timezone` | `Asia/Shanghai` | 时段匹配所用 IANA 时区(DeepSeek 按北京时间计价) | +| `schedule` | DeepSeek 高峰/空闲 | `{level, windows:[{start,end}]}` 列表;`HH:MM` 格式,支持跨天窗口 | +| `contextThreshold` | `200000` | 上下文档位的 token 阈值;模型级 `contextThreshold` 优先于 `state.provider` 的运行时档位阈值 | +| `providers` | `{}` | 按 `providerID` → `modelID` 的规则 | + +单模型规则支持两种形式(显式配置优先于内置 DeepSeek 默认): + +- `multipliers`:按时段档对静态价施加系数(如 `{"offpeak": 0.5}`) +- `levels`:各时段档的绝对单价,如 `{"peak": {"input": 0.44, "output": 0.88, "cacheRead": 0.01}, "offpeak": {"input": 0.22, ...}}`。缓存单价可写为扁平 `cacheRead`/`cacheWrite`(或 `cache_read`/`cache_write`),也可写为嵌套 `cache: {"read": …, "write": …}`(两种都接受;同时存在时扁平优先)。默认单位为 **USD/百万 token**(与 `state.provider` 一致)。想用其他币种写价时设 `"currency": "CNY"`:要么与展示币种 `cost.currency` 一致(按 `cost.rate` 换算),要么提供模型级 `"rate"`(USD → 该币种,如 EUR 填 1.08)。无法换算时(无 `rate` 且币种 ≠ 展示币种)向 stderr 告警并按 USD 处理。`multipliers` 是倍率,无币种概念。 +- `contextThreshold`:覆盖全局阈值的模型级配置(优先于 `state.provider` 的运行时档位阈值)。 + +侧边栏单价会在时段边界自动切换(无需轮询)。会话成本在动态规则生效时按每条消息的请求时刻 + 上下文档位重算(标注 `≈`);否则使用 OpenCode 自身的 `msg.cost`。子 agent 行按其**会话创建时刻**(`session.list`)做时段计价(任一子会话被重算时 Agents 合计标注 `≈`)。 + +**时间轴看板**([docs/zh-CN/timeline.md](docs/zh-CN/timeline.md))也会离线重算成本:读取 `~/.config/opencode/opencode.json`(支持 JSONC 注释)获取 provider 单价,逐条注入 `dynCost`(与原值不同时以 `≈` 展示,并计入图表与合计)。 + +**刷新 DeepSeek 官方价**:`bun scripts/fetch-deepseek-pricing.ts` 输出可直接粘贴的 `dynamicPricing.providers` 片段(默认人民币并带 `"currency": "CNY"`,`--usd --rate 6.77` 转美元)。 + ## 更新 > [!IMPORTANT] diff --git a/cache-hit.config.example.json b/cache-hit.config.example.json index ac57dbf..01ddc7d 100644 --- a/cache-hit.config.example.json +++ b/cache-hit.config.example.json @@ -28,5 +28,30 @@ "enabled": true, "providers": { } + }, + "dynamicPricing": { + "enabled": true, + "timezone": "Asia/Shanghai", + "schedule": [ + { "level": "peak", "windows": [{"start": "09:00", "end": "12:00"}, {"start": "14:00", "end": "18:00"}] }, + { "level": "offpeak", "windows": [{"start": "18:00", "end": "09:00"}, {"start": "12:00", "end": "14:00"}] } + ], + "contextThreshold": 200000, + "providers": { + "deepseek": { + "models": { + "deepseek/deepseek-v4-flash": { + "multipliers": { "peak": 1, "offpeak": 0.5 } + }, + "deepseek/deepseek-v4-pro": { + "currency": "CNY", + "levels": { + "offpeak": { "input": 4.5, "output": 13.5, "cacheRead": 0.15, "cacheWrite": 0 }, + "peak": { "input": 9.0, "output": 27.0, "cacheRead": 0.30, "cacheWrite": 0 } + } + } + } + } + } } } diff --git a/docs/README.md b/docs/README.md index 1e898d2..49038f3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,3 +6,5 @@ | 中文 | [README.zh-CN.md](../README.zh-CN.md) | [zh-CN/design.md](./zh-CN/design.md) | [zh-CN/timeline.md](./zh-CN/timeline.md) | [zh-CN/token-speed.md](./zh-CN/token-speed.md) | [zh-CN/ttft-hybrid.md](./zh-CN/ttft-hybrid.md) | [zh-CN/ttft-troubleshooting.md](./zh-CN/ttft-troubleshooting.md) | [../src/tui-panel/README.zh-CN.md](../src/tui-panel/README.zh-CN.md) | [zh-CN/frontend-migration-plan.md](./zh-CN/frontend-migration-plan.md) | Contributing: [CONTRIBUTING.md](../CONTRIBUTING.md). + +> Dynamic pricing (time-of-day + context tiers): design notes in [zh-CN/dynamic-pricing-plan.md](./zh-CN/dynamic-pricing-plan.md). diff --git a/docs/en/design.md b/docs/en/design.md index 62fd0f3..d094a33 100644 --- a/docs/en/design.md +++ b/docs/en/design.md @@ -46,6 +46,20 @@ flowchart TB - Plugin: `createCostFormatter(loadPluginConfig().cost)`; default `costUnit: USD` → `currency: CNY`, `rate: 6.77`. - Config file: `~/.config/opencode/cache-hit.json` (preferred) or `cache-hit.config.json` at plugin root (legacy). Defaults in `plugin-config.ts`. +## Dynamic pricing (`dynamicPricing`) + +Two orthogonal price dimensions resolve to the effective per-1M rates shown in the sidebar: + +- **Context tier**: reads the runtime cost tiers from `state.provider` (`tiers[]` / `experimentalOver200K`, normalized into the internal context tier with its own threshold); picks the tier by total context (`input + cacheRead`) vs the threshold (per-model config > runtime tier size > global > 200k). Zero-config for models like GPT-5.6. +- **Time-of-day tier**: `schedule` (default DeepSeek peak 09:00-12:00 / 14:00-18:00 Beijing, off-peak otherwise) + per-model `multipliers` (default DeepSeek off-peak 0.5×) or absolute `levels` (level miss falls back to static rates; `enabled: false` restores fully static pricing). + +Lookup fallback chain (`src/dynamic-pricing/lookup.ts`): explicit `levels` → explicit `multipliers` → built-in DeepSeek default → static `state.provider` cost. + +- `src/dynamic-pricing/schedule.ts`: timezone-aware window matching (`Intl.DateTimeFormat`), `nextBoundaryMs` drives a `setTimeout`-based refresh in `use-cache-hit-metrics.ts` — no polling. +- Session cost recompute (`src/dynamic-pricing/recompute.ts`): per message, `msg.time.created` selects the tier, total context (`input + cacheRead`) the context tier; `tokens.input` excludes cache, so cache reads are billed separately at `cacheReadRate`. Shown with `≈` when dynamic rules applied. +- Sub-agents: `session.list` entries carry `time.created` (`src/session-list.ts` → `child-session-sync.ts`), so each child's cost can be recomputed at its creation time (`recomputeSubAgentCost`). +- Timeline dashboard (`scripts/timeline-dashboard.ts`): offline recompute reads provider rates from `~/.config/opencode/opencode.json` (JSONC-aware) and injects `dynCost` per `LlmCallRecord` (shown with `≈`, counted in charts/totals). + ## Runtime architecture ```mermaid diff --git a/docs/en/timeline.md b/docs/en/timeline.md index b954168..97e0153 100644 --- a/docs/en/timeline.md +++ b/docs/en/timeline.md @@ -42,6 +42,7 @@ export type LlmCallRecord = { scope: "main" | "child" messageKey: string modelId: string + providerId?: string // provider id at record time (older logs may lack it) created: string completedAt?: string durationMs?: number @@ -308,6 +309,9 @@ bun scripts/plot-hit-rate.ts "$LOG" -o /tmp/hit.svg bun scripts/plot-hit-rate.ts "$LOG" --by-root -o /tmp/hit-multi.svg # interactive HTML dashboard (filters, Chart.js); add --open to launch browser +# reads ~/.config/opencode/opencode.json (JSONC-aware) and recomputes costs with +# dynamic pricing (time-of-day / context tiers) — injected as `dynCost`, shown +# with ≈ and counted in charts/totals when it differs from the original cost bun scripts/timeline-dashboard.ts --open ``` diff --git a/docs/zh-CN/design.md b/docs/zh-CN/design.md index acb372c..9d309f5 100644 --- a/docs/zh-CN/design.md +++ b/docs/zh-CN/design.md @@ -46,6 +46,20 @@ flowchart TB - 插件:`createCostFormatter(loadPluginConfig().cost)`;默认 `costUnit: USD` → `currency: CNY`,`rate: 6.77`。 - 配置路径:优先 `~/.config/opencode/cache-hit.json`,兜底插件根目录 `cache-hit.config.json`。缺省见 `plugin-config.ts` 的 `DEFAULT_PLUGIN_CONFIG`。 +## 动态计价(`dynamicPricing`) + +两个正交的价格维度决定侧边栏显示的有效百万 token 单价: + +- **上下文档位**:读取 `state.provider` 的运行时 cost 档位(`tiers[]` / `experimentalOver200K`,内部归一化为上下文档并携带自身阈值);按总上下文(`input + cacheRead`)与阈值选档(模型级配置 > 运行时档位阈值 > 全局 > 200k)。GPT-5.6 类模型零配置生效。 +- **时段档**:`schedule`(默认 DeepSeek 高峰 09:00-12:00 / 14:00-18:00 北京时间,其余空闲)+ 单模型 `multipliers`(默认 DeepSeek 空闲 0.5×)或绝对价 `levels`(时段未命中回退静态价;`enabled: false` 恢复完全静态计价)。 + +查找回退链(`src/dynamic-pricing/lookup.ts`):显式 `levels` → 显式 `multipliers` → 内置 DeepSeek 默认 → `state.provider` 静态价。 + +- `src/dynamic-pricing/schedule.ts`:基于时区的窗口匹配(`Intl.DateTimeFormat`),`nextBoundaryMs` 驱动 `use-cache-hit-metrics.ts` 中 `setTimeout` 精确刷新——无需轮询。 +- 会话成本重算(`src/dynamic-pricing/recompute.ts`):逐消息用 `msg.time.created` 选时段、总上下文(`input + cacheRead`)选上下文档;`tokens.input` 不含缓存,缓存命中部分按 `cacheReadRate` 单独计费。动态规则生效时展示标注 `≈`。 +- 子 agent:`session.list` 条目携带 `time.created`(`src/session-list.ts` → `child-session-sync.ts`),可按子会话创建时刻重算成本(`recomputeSubAgentCost`)。 +- 时间轴看板(`scripts/timeline-dashboard.ts`):离线重算读取 `~/.config/opencode/opencode.json`(JSONC 感知)的 provider 单价,逐条向 `LlmCallRecord` 注入 `dynCost`(`≈` 展示并计入图表/合计)。 + ## 运行时架构 ```mermaid diff --git a/docs/zh-CN/dynamic-pricing-plan.md b/docs/zh-CN/dynamic-pricing-plan.md new file mode 100644 index 0000000..5dd30ce --- /dev/null +++ b/docs/zh-CN/dynamic-pricing-plan.md @@ -0,0 +1,171 @@ +# 动态计价设计(feat/dynamic-pricing · v0.7.0) + +> 状态:**已实现并提交(ef55650)**。本文档保留设计动机、关键决策与精度边界; +> 用户配置与最新字段以 README § Dynamic pricing 为准。 + +--- + +## 1. 背景 + +- DeepSeek V4 系列自 2026-08 起官方按时间计价:高峰时段(北京时间 9:00-12:00、14:00-18:00)全价,空闲时段半价(缓存命中 / 未命中 / 输出三档各乘 0.5)。 +- 部分模型(如 GPT-5.6 系列)配置了 `cost.context_over_200k`:上下文超过 200k 时价格约翻倍。 +- 以上两类价格都无法用当前插件的静态单档价格表达。 + +## 2. 设计背景:现状计价链路 + +实现前的计价链路(改动动机): + +``` +opencode TUI API(api.state.provider) + └─ 静态单档价格(USD/1M:input / output / cache.read / cache.write) + ▼ +src/pricing.ts:lookupModelCost(providerID, modelID) → ModelCost(基础档) + ▼ +src/use-cache-hit-metrics.ts:pricing memo = computePricing(...) + ├─ rates(input/output/cacheRead/cacheWrite 单价) + └─ saved = (inputRate − cacheReadRate) × cacheRead / 1M + ├─ main-session-view.tsx:展示 rates + saved + cost(Σ msg.cost) + └─ agents-view.tsx:computeSubsSaved 汇总子 agent saved +``` + +关键决策点:价格源 `state.provider` 为静态单档;`msg.cost` 由 opencode 按其内部 tier 数组计算(插件只读);rates/saved 为插件自算(可动态化);消息与 timeline 记录均有请求时刻;子 agent 无时间戳(实现后已从 `session.list` 补齐)。 + +## 3. context_over_200k 支持性调研 + +实现前确认:插件类型与逻辑均未覆盖上下文分档,但**运行时数据可得**: + +- 配置层(opencode.json 的 ProviderConfig)的 `cost.context_over_200k` 会被 opencode 运行时转为 `tiers[]` / `experimentalOver200K`;`api.state.provider` 暴露的是运行时格式(SDK 已确认,含 `tier:{type:"context",size}`)。 +- 插件通过 `normalizeRuntimeCost` 归一化为内部上下文档(含档位阈值),按总上下文(`input + cacheRead`)选档。 + +## 4. 方案:统一计价引擎(双维度正交) + +价格 = f(模型, 用量, **上下文大小**, **请求时刻**) + +### 4.1 价格档位模型 + +把「计价」抽象为**价格档选择**:每个模型可有多档价格,每档带适用条件。 + +``` +type PriceTier = { + condition: { + context?: { min?: number; max?: number } // 上下文 token 范围(如 >200k) + level?: "peak" | "offpeak" | string // 时段档 + } + rates: { input; output; cacheRead; cacheWrite } // USD/1M +} +``` + +选择顺序(fallback 链): +1. **用户配置的显式档**(`dynamicPricing.providers[...]`,含时段档 / 自定义上下文档) +2. **`state.provider` 自带运行时上下文档**(`tiers[]` / `experimentalOver200K`,已归一化)→ 按总上下文(`input + cacheRead`)选档(零配置即支持 GPT-5.6 类模型) +3. **静态基础档**(现状行为,兜底) + +### 4.2 配置设计(新增 `dynamicPricing` 段) + +```jsonc +{ + "dynamicPricing": { + "enabled": true, + "timezone": "Asia/Shanghai", // DeepSeek 以北京时间为准 + "schedule": [ // 时段规则(DeepSeek 官方默认) + { "level": "peak", "windows": [{"start":"09:00","end":"12:00"}, {"start":"14:00","end":"18:00"}] }, + { "level": "offpeak", "windows": [{"start":"18:00","end":"09:00"}, {"start":"12:00","end":"14:00"}] } + ], + "contextThreshold": 200000, // 全局默认上下文档位阈值(默认读 200k) + "providers": { + "deepseek": { + "models": { + "deepseek/deepseek-v4-flash": { + "currency": "CNY", // levels 原始币种(默认 USD);加载时按 cost.rate ÷换算为内部 USD + "levels": { // 时段绝对价(/1M tokens) + "peak": { "input": 3.0, "output": 9.0, "cacheRead": 0.10, "cacheWrite": 0 }, + "offpeak": { "input": 1.5, "output": 4.5, "cacheRead": 0.05, "cacheWrite": 0 } + } + } + } + }, + "openai": { + "models": { + "gpt-5.6": { + "contextThreshold": 200000 // 可覆盖全局阈值;不配则自动用运行时上下文档(tiers/experimentalOver200K) + } + } + } + } + } +} +``` + +- 支持**绝对价**(`levels`,默认 USD/1M,可设 `currency` 用 CNY 等直接写价;缓存单价支持扁平 `cacheRead/cacheWrite` 或嵌套 `cache:{read,write}`,扁平优先)与**倍率**(如 `{"offpeak": 0.5}`,相对静态价打折)两种模式;显式配置优先于内置 DeepSeek 默认。 +- 内置 `deepseek.ts` 默认规则:DeepSeek 模型空闲时段 0.5× 倍率(零配置生效)。 +- 完整字段与示例见 README § Dynamic pricing。 + +### 4.3 模块划分(符合 AGENTS.md 纯逻辑约定) + +``` +src/dynamic-pricing/ + types.ts // ModelPricingRule / ScheduleLevel / TimeWindow / DynamicPricingConfig + schedule.ts // 时段解析、isLevelAt(now)、nextBoundaryMs(now)(距下一边界毫秒数) + context.ts // 上下文档位选择(context_over_200k)+ 倍率缩放 + lookup.ts // resolveModelCost(providers, providerID, modelID, ctx) → 有效四率 + 档位标注 + deepseek.ts // 内置 DeepSeek 空闲 0.5× 默认规则 + recompute.ts // recomputeSessionCost / recomputeSubAgentCost / recomputeRecordCost +``` + +`pricing.ts` 改造(向后兼容): + +```ts +computePricing(providers, providerID, modelID, cacheRead, + ctx?: { now?: number; contextTokens?: number; rules?: DynamicPricingConfig }) +// now / rules 缺省 → 完全回退现状 +// 返回增加 level(时段)与 contextTier(档位)标注 +``` + +### 4.4 跨时段边界的精确刷新 + +`use-cache-hit-metrics` 增加 `now` signal: +- `setTimeout(nextBoundaryMs)` 精确对齐下一边界(9:00/12:00/14:00/18:00),`onCleanup` 清理; +- 跨边界时 rates/saved/时段标记**瞬间切换**,无需每秒轮询。 + +### 4.5 已发生成本(msg.cost)修正 + +- **主会话**:`recomputeSessionCost` 逐条用 `msg.time.created`(时段)+ 总上下文 `input + cacheRead`(上下文档)→ 该 msg 有效价格 × 用量 → 累加。动态规则生效时展示重算值(标注 `≈`),否则回退 `msg.cost`。 +- **子 agent**:`SubAgentSummary.created` 来自 `session.list`(`session-list.ts` → `child-session-sync.ts`),按会话创建时刻重算(`recomputeSubAgentCost`);**无 created 时直接回退 msg.cost,不做近似**(无法确定时段不猜测)。 +- **timeline 离线重算**:dashboard 读取 opencode.json(JSONC 感知)的 provider 单价,逐条按记录时刻重算并注入 `dynCost`。 + +### 4.6 UI + +- rates / saved 行:随时段与上下文档位显示对应价格,可加 `peak/offpeak`、`≤200k/>200k` 标记。 +- cost 行:动态计价生效时用重算值(`≈` 前缀),否则维持 `msg.cost`。 + +## 5. 精度边界与权衡 + +| 边界 | 口径 | +|---|---| +| 请求跨时段边界(11:59:59 发起) | 官方按请求时刻计费 → 用 `msg.time.created` 判定 | +| 上下文大小判定 | `msg.tokens.input`(含缓存);200k 阈值可配置 | +| `state.provider` 静态价滞后 | 显式 `levels` 绝对价绕开;倍率模式受影响 | +| 时区 | 默认 Asia/Shanghai,可覆盖 | +| 节假日 | 官方未声明差异,当前按每日同规则(未实现按日配置) | +| 子 agent 无 created | 直接回退 msg.cost,不按时段重算(不近似) | +| 与 opencode msg.cost 口径差异 | 重算值标注 `≈`;opencode 若已按 tier 计算则差异为时段维度 | + +## 6. 已实施范围(v0.7.0) + +- **M1 计价引擎**:`dynamic-pricing/` 纯函数模块 + 配置 normalize + `pricing.ts` 向后兼容改造 + 时段边界精确刷新(setTimeout,无轮询)。 +- **M2 UI**:cost 重算展示(`≈` 标注,主会话按消息、子 agent 按会话创建时刻)+ rate 行档位/时段标记(peak/offpeak/>200k)+ i18n 双语。 +- **M3 时间戳与离线重算**:子 agent 创建时刻(`session.list`)+ timeline dashboard 离线 `dynCost` 注入(读 opencode.json,JSONC 感知)。 +- **M4 定价刷新脚本**:`scripts/fetch-deepseek-pricing.ts` 抓官方定价页 → 输出可粘贴片段(CNY/USD,含 `currency`)。 + +## 7. 测试 + +- `tests/dynamic-pricing-schedule.test.ts`:时段解析与边界(09:00 整点归属、12:00 切档、跨天 18:00→09:00)、`nextBoundaryMs`、时区换算。 +- `tests/dynamic-pricing-lookup.test.ts`:上下文档位选择(≤/>200k、无 context_over_200k 回退)、lookup fallback 链、内置 DeepSeek 时段倍率、computePricing 向后兼容。 +- `tests/dynamic-pricing-recompute.test.ts`:跨时段/跨档消息序列重算、子 agent 重算、记录重算、配置 normalize(含非 USD levels 换算)。 + +## 8. 参考 + +- DeepSeek 官方定价:https://api-docs.deepseek.com/zh-cn/quick_start/pricing (高峰 9:00-12:00、14:00-18:00,空闲半价) +- models.dev schema 支持上下文分档;opencode 核心将其转为 `tier: { type: "context", size: 200000 }` 数组 +- opencode 社区:issue #592(Gemini context tier)、PR #20808 +- 参考实现:GPT-5.6 类模型通过 `context_over_200k` 配置、运行时暴露为 `tiers` / `experimentalOver200K` diff --git a/docs/zh-CN/timeline.md b/docs/zh-CN/timeline.md index d901313..622bbd7 100644 --- a/docs/zh-CN/timeline.md +++ b/docs/zh-CN/timeline.md @@ -47,6 +47,7 @@ export type LlmCallRecord = { /** OpenCode message id;SDK 若无则用稳定合成键,见下文 */ messageKey: string modelId: string + providerId?: string // 记录时的 provider id(旧日志可能缺失) created: string completedAt?: string durationMs?: number @@ -321,6 +322,8 @@ bun scripts/plot-hit-rate.ts $LOG -o /tmp/hit.svg bun scripts/plot-hit-rate.ts $LOG --by-root -o /tmp/hit-multi.svg # 交互式 HTML 仪表盘(筛选、Chart.js);加 --open 才会打开浏览器 +# 读取 ~/.config/opencode/opencode.json(支持 JSONC 注释)并用动态计价(时段 / 上下文档位) +# 重算成本——注入为 `dynCost`,与原值不同时以 ≈ 展示并计入图表/合计 bun scripts/timeline-dashboard.ts --open ``` diff --git a/package.json b/package.json index 3da7b61..cba7ba7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-cache-hit", - "version": "0.6.4", + "version": "0.7.0", "description": "OpenCode TUI sidebar: prompt cache hit rate, tokens & cost with sub-agent rollup. Works with opencode-visual-cache; optional per-call JSONL timeline.", "type": "module", "license": "MIT", diff --git a/scripts/README.md b/scripts/README.md index 7d4c38e..5ba4199 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -100,3 +100,14 @@ Use a real path, not `$LOG`, unless you exported it first: set -x LOG logs/timeline-(date +%Y-%m-%d).jsonl # fish: set log ... bun scripts/plot-hit-rate.ts $LOG -o /tmp/hit.svg ``` + +## `fetch-deepseek-pricing.ts` (Bun, no install) + +Fetch DeepSeek official peak/off-peak pricing and print a ready-to-paste `dynamicPricing.providers` snippet for cache-hit.json: + +```bash +bun scripts/fetch-deepseek-pricing.ts # CNY per 1M tokens, includes "currency": "CNY" +bun scripts/fetch-deepseek-pricing.ts --usd --rate 6.77 # USD (divided by rate) +``` + +Output is JSON to merge under `"dynamicPricing"`; non-USD `levels` are converted to internal USD at config load using `cost.rate`. diff --git a/scripts/fetch-deepseek-pricing.ts b/scripts/fetch-deepseek-pricing.ts new file mode 100644 index 0000000..617f43b --- /dev/null +++ b/scripts/fetch-deepseek-pricing.ts @@ -0,0 +1,98 @@ +#!/usr/bin/env bun +/** + * Fetch DeepSeek official peak/off-peak pricing and print a ready-to-paste + * `dynamicPricing.providers` snippet for cache-hit.json. + * + * bun scripts/fetch-deepseek-pricing.ts # CNY per 1M tokens + * bun scripts/fetch-deepseek-pricing.ts --usd # USD (divide by --rate) + * bun scripts/fetch-deepseek-pricing.ts --usd --rate 6.77 + * bun scripts/fetch-deepseek-pricing.ts --url # custom page URL + * + * Output is JSON you can merge into cache-hit.json under "dynamicPricing": + * "dynamicPricing": { ...existing..., } + */ + +// Make this file a module for TS tooling (top-level await below). +export {} + +const argv = process.argv +const url = + argv.indexOf("--url") >= 0 + ? argv[argv.indexOf("--url") + 1] + : "https://api-docs.deepseek.com/zh-cn/quick_start/pricing" +const useUsd = argv.includes("--usd") +const rateIdx = argv.indexOf("--rate") +const rate = rateIdx >= 0 ? Number(argv[rateIdx + 1]) : 6.77 + +type Group = { offpeak: number[]; peak: number[] } + +function parsePriceGroups(html: string): Group[] { + // Official table lists, in order: cache-hit input, cache-miss input, output. + const offpeak = [...html.matchAll(/空闲时段<\/td>([\d.]+)元<\/td>([\d.]+)元<\/td>/g)].map( + (m) => [Number(m[1]), Number(m[2])], + ) + const peak = [...html.matchAll(/高峰时段<\/td>([\d.]+)元<\/td>([\d.]+)元<\/td>/g)].map( + (m) => [Number(m[1]), Number(m[2])], + ) + if (offpeak.length !== 3 || peak.length !== 3) { + throw new Error( + `table parse failed: found ${offpeak.length} offpeak / ${peak.length} peak rows (expected 3 each)`, + ) + } + return offpeak.map((o, i) => ({ offpeak: o, peak: peak[i] })) +} + +function convert(v: number): number { + if (!useUsd) return v + if (!Number.isFinite(rate) || rate <= 0) throw new Error("--rate must be a positive number") + return Math.round((v / rate) * 1e6) / 1e6 +} + +function modelLevels(groups: Group[], col: 0 | 1) { + // groups[0] = cache-hit input (cacheRead), [1] = cache-miss input (input), [2] = output. + // Each group holds [flash, pro]; `col` picks the model column. + const pick = (g: Group, level: "offpeak" | "peak") => convert(g[level][col]) + return { + offpeak: { + input: pick(groups[1], "offpeak"), + output: pick(groups[2], "offpeak"), + cacheRead: pick(groups[0], "offpeak"), + cacheWrite: 0, + }, + peak: { + input: pick(groups[1], "peak"), + output: pick(groups[2], "peak"), + cacheRead: pick(groups[0], "peak"), + cacheWrite: 0, + }, + } +} + +try { + console.error(`fetching ${url} ...`) + const html = await (await fetch(url)).text() + const groups = parsePriceGroups(html) + const unit = useUsd ? `USD (÷${rate})` : "CNY" + const snippet = { + providers: { + "deepseek": { + models: { + "deepseek/deepseek-v4-flash": { + currency: useUsd ? "USD" : "CNY", + levels: modelLevels(groups, 0), + }, + "deepseek/deepseek-v4-pro": { + currency: useUsd ? "USD" : "CNY", + levels: modelLevels(groups, 1), + }, + }, + }, + }, + } + console.error(`parsed ${groups.length} price groups, unit: ${unit} per 1M tokens`) + console.error("paste the object below into cache-hit.json → \"dynamicPricing\" (merge with existing keys):") + console.log(JSON.stringify(snippet, null, 2)) +} catch (e) { + console.error(`error: ${(e as Error).message}`) + process.exit(1) +} diff --git a/scripts/timeline-dashboard.ts b/scripts/timeline-dashboard.ts index ab823ee..49d4ec9 100644 --- a/scripts/timeline-dashboard.ts +++ b/scripts/timeline-dashboard.ts @@ -12,7 +12,7 @@ */ import { execSync } from "child_process" -import { existsSync, readdirSync } from "fs" +import { existsSync, readFileSync, readdirSync } from "fs" import { homedir } from "os" import { basename, dirname, resolve } from "path" import { Glob } from "bun" @@ -23,7 +23,56 @@ import { type CostDisplayEmbed, } from "../src/format-cost.ts" import { loadPluginConfig } from "../src/load-config.ts" +import { recomputeRecordCost } from "../src/dynamic-pricing/recompute.ts" import type { LlmCallRecord } from "../src/timeline/types.ts" +import type { ProviderInfo } from "../src/types.ts" +import { parseJsonc } from "../src/jsonc.ts" + +/** + * 从 opencode.json 的 `provider` 段加载静态价格(含 context_over_200k), + * 供离线重算使用。文件不存在返回空数组;文件存在但解析失败时告警后返回空数组。 + */ +function loadOpencodeProviders(): ProviderInfo[] { + const path = process.env.OPENCODE_CONFIG ?? `${homedir()}/.config/opencode/opencode.json` + if (!existsSync(path)) return [] + try { + const raw = parseJsonc<{ + provider?: Record + }>(readFileSync(path, "utf8")) + const out: ProviderInfo[] = [] + for (const [pid, pv] of Object.entries(raw.provider ?? {})) { + const po = pv as { models?: Record } | undefined + if (!po?.models) continue + const models: ProviderInfo["models"] = {} + for (const [mid, mv] of Object.entries(po.models)) { + const mo = mv as { cost?: Record } | undefined + const c = mo?.cost + if (!c || typeof c !== "object") continue + const num = (x: unknown) => (typeof x === "number" && Number.isFinite(x) ? x : 0) + const over = c.context_over_200k as Record | undefined + models[mid] = { + cost: { + input: num(c.input), + output: num(c.output), + cache: { read: num(c.cache_read), write: num(c.cache_write) }, + context_over_200k: over + ? { + input: num(over.input), + output: num(over.output), + cache: { read: num(over.cache_read), write: num(over.cache_write) }, + } + : undefined, + }, + } + } + if (Object.keys(models).length > 0) out.push({ id: pid, models }) + } + return out + } catch { + console.error(`warning: failed to read or parse ${path}; offline dynamic pricing is disabled`) + return [] + } +} function timestampSuffix(): string { const d = new Date() @@ -342,6 +391,19 @@ function fmtCost(amount) { return "~" + COST_DISPLAY.symbol + v.toFixed(COST_DISPLAY.decimals) } +/* Dynamic pricing: prefer recomputed dynCost when present. */ +function costOf(r) { + if (r && r.dynCost !== undefined && r.dynCost !== null) return r.dynCost + return r ? r.cost : 0 +} +function fmtCostOf(r) { + if (!r) return "-" + if (r.dynCost !== undefined && r.dynCost !== null && Math.abs(r.dynCost - r.cost) > 1e-12) { + return "\u2248" + fmtCost(r.dynCost) + } + return fmtCost(r.cost) +} + function applyCostLabels() { var th = document.getElementById("thSessionCost") if (th) th.textContent = COST_DISPLAY.chartLabel @@ -374,8 +436,8 @@ function scopePillClass(scope) { } function renderSummary(data) { - var ti = 0, to = 0, tcr = 0, tcw = 0, tc = 0, tt = 0 - data.forEach(function(r){ ti+=r.input; to+=r.output; tcr+=r.cacheRead; tcw+=r.cacheWrite; tc+=r.cost }) + var ti = 0, to = 0, tcr = 0, tcw = 0, tc = 0, tt = 0, dyn = 0 + data.forEach(function(r){ ti+=r.input; to+=r.output; tcr+=r.cacheRead; tcw+=r.cacheWrite; tc+=costOf(r); if (r.dynCost != null) dyn++ }) tt = ti+to+tcr+tcw var hits = hitValues(data) var avg = hits.length ? hits.reduce(function(s,h){return s+h},0)/hits.length : 0 @@ -388,7 +450,7 @@ function renderSummary(data) { {l:"Total Input", v:ti.toLocaleString(), s:"Out "+to.toLocaleString()}, {l:"Cache Read", v:tcr.toLocaleString(), s:"Write "+tcw.toLocaleString()}, {l:"Avg Hit Rate", v:avg.toFixed(1)+"%", s:hits.length+" plottable calls", c:cls}, - {l:"Total Cost", v:fmtCost(tc), s:COST_DISPLAY.costUnit !== COST_DISPLAY.currency ? "raw "+COST_DISPLAY.costUnit+" in JSONL" : ""}, + {l:"Total Cost", v:(dyn>0?"\u2248":"")+fmtCost(tc), s:COST_DISPLAY.costUnit !== COST_DISPLAY.currency ? "raw "+COST_DISPLAY.costUnit+" in JSONL" : ""}, {l:"Models", v:models||"(none)", s:""}, {l:"Date Range", v:data.length?data[0].created.slice(0,10):"-", s:data.length?"~ "+data[data.length-1].created.slice(0,10):""} ] @@ -490,7 +552,7 @@ function buildHitCostChart(data) { { label:"Hit %", data:data.map(function(r){return r.hitPercent}), yAxisID:"y", borderColor:"#3fb950", backgroundColor:"#3fb95022", fill:true, tension:0.2, pointRadius:2, pointBackgroundColor:"#3fb950" }, - { label:COST_DISPLAY.chartLabel, data:data.map(function(r){return convertCost(r.cost)}), yAxisID:"y1", + { label:COST_DISPLAY.chartLabel, data:data.map(function(r){return convertCost(costOf(r))}), yAxisID:"y1", borderColor:"#f85149", backgroundColor:"#f8514922", fill:true, tension:0.2, pointRadius:2, pointBackgroundColor:"#f85149" } ] @@ -555,7 +617,7 @@ function renderSessionTable(data) { ti:rd.reduce(function(s,r){return s+r.input},0), to:rd.reduce(function(s,r){return s+r.output},0), tcr:rd.reduce(function(s,r){return s+r.cacheRead},0), - avg:avg, cost:rd.reduce(function(s,r){return s+r.cost},0), + avg:avg, cost:rd.reduce(function(s,r){return s+costOf(r)},0), dynUsed:rd.some(function(r){return r.dynCost != null}), avgTtft: ttfts.length ? ttfts.reduce(function(s,v){return s+v},0)/ttfts.length : null, avgTps: ttps.length ? ttps.reduce(function(s,v){return s+v},0)/ttps.length : null, avgTpot: tpots.length ? tpots.reduce(function(s,v){return s+v},0)/tpots.length : null, @@ -569,7 +631,7 @@ function renderSessionTable(data) { ''+esc(r.model)+''+esc(r.scope)+ ''+r.calls+''+(r.totalT/1e6).toFixed(2)+'M'+r.ti.toLocaleString()+ ''+r.to.toLocaleString()+''+r.tcr.toLocaleString()+ - ''+r.avg.toFixed(1)+'%'+fmtCost(r.cost)+ + ''+r.avg.toFixed(1)+'%'+(r.dynUsed?"\u2248":"")+fmtCost(r.cost)+ ''+fmtTtft(r.avgTtft)+''+fmtTps(r.avgTps)+ ''+fmtTpot(r.avgTpot)+ ''+r.start.slice(0,19)+'' @@ -584,6 +646,7 @@ function expandDetailGrid(r) { var rawCost = v v = String(rawCost) + " " + COST_DISPLAY.costUnit if (COST_DISPLAY.rate !== 1) v += " (" + fmtCost(rawCost) + ")" + if (r.dynCost != null && Math.abs(r.dynCost - r.cost) > 1e-12) v += " | dyn " + fmtCost(r.dynCost) } else if (typeof v === "boolean") v = v ? "true" : "false" else if (Array.isArray(v)) { @@ -613,7 +676,7 @@ function renderDetailTable(data) { ''+r.cacheRead.toLocaleString()+''+ ''+r.cacheWrite.toLocaleString()+''+ ''+hitPct+''+ - ''+fmtCost(r.cost)+''+ + ''+fmtCostOf(r)+''+ ''+fmtDur(r.durationMs)+''+ ''+fmtTtft(r.ttftMs)+''+ ''+fmtTps(r.tps)+''+ @@ -718,6 +781,25 @@ if (records.length === 0) { process.exit(1) } +// 动态计价离线重算:按每条记录的时刻 + 上下文档位注入 dynCost(前端优先展示)。 +{ + const providers = loadOpencodeProviders() + const rules = loadPluginConfig().dynamicPricing + if (providers.length > 0) { + let injected = 0 + for (const r of records) { + const dc = recomputeRecordCost(r, providers, rules) + if (dc !== null) { + r.dynCost = dc + injected++ + } + } + if (injected > 0) { + console.error(`dynamic pricing: recomputed cost for ${injected}/${records.length} records`) + } + } +} + function loadCostContext(): { embed: CostDisplayEmbed; format: (n: number) => string } { try { const cost = normalizeCostDisplay(loadPluginConfig().cost) diff --git a/src/agents-view.tsx b/src/agents-view.tsx index 2d5152d..31af19c 100644 --- a/src/agents-view.tsx +++ b/src/agents-view.tsx @@ -1,13 +1,12 @@ /** @jsxImportSource @opentui/solid */ -import { For, Show } from "solid-js" +import { createMemo, For, Show } from "solid-js" import { TokenDetailRows } from "./cache-hit-rows.tsx" import type { CacheHitMetrics } from "./use-cache-hit-metrics.ts" import { aggregateSubAgents } from "./stats.ts" -import { computeSubsSaved } from "./pricing.ts" import { formatTokenCount } from "./format-tokens.ts" import { formatSubAgentLabel, modelRowColor } from "./format-model.ts" import { TuiMetricRow, type PanelLayout } from "./tui-panel/index.ts" -import type { ProviderInfo, SubAgentSummary } from "./types.ts" +import type { SubAgentSummary } from "./types.ts" function subHasActivity(sub: SubAgentSummary): boolean { return sub.cost > 0 || sub.cacheRead > 0 || sub.cacheWrite > 0 || sub.input > 0 @@ -16,14 +15,30 @@ function subHasActivity(sub: SubAgentSummary): boolean { export function AgentsView(props: { m: CacheHitMetrics layout: PanelLayout - providers: ReadonlyArray formatCost: (n: number) => string formatSpeed: (v: number | undefined) => string }) { const { m, layout } = props const total = () => aggregateSubAgents(m.subs()) - const subsSaved = () => computeSubsSaved(m.subs(), props.providers) + const subsSaved = () => m.subsSaved() + + // 子 agent 动态成本汇总:存在任一动态价时展示重算值(≈ 前缀)。 + const shownSubCost = createMemo(() => { + const map = m.subAgentDynamicCosts() + let dynamic = false + let sum = 0 + for (const sub of m.subs()) { + const rec = map.get(sub.id) + if (rec !== undefined && rec !== null) { + dynamic = true + sum += rec + } else { + sum += sub.cost + } + } + return { value: sum, approx: dynamic } + }) return ( <> @@ -38,12 +53,12 @@ export function AgentsView(props: { /> - 0}> + 0}> diff --git a/src/child-session-sync.ts b/src/child-session-sync.ts index 7e34293..f4febe0 100644 --- a/src/child-session-sync.ts +++ b/src/child-session-sync.ts @@ -1,4 +1,4 @@ -import { childSessionIdsForParent, parseSessionListResponse } from "./session-list.ts" +import { childSessionEntriesForParent, parseSessionListResponse, type SessionListEntry } from "./session-list.ts" import type { OpenCodeTuiApi } from "./types.ts" /** Debounce for session.list after foreign-session message.updated (streaming fires often). */ @@ -18,6 +18,8 @@ export function createChildSessionSync(opts: { getParentId: () => string setChildIds: (ids: string[]) => void onSynced?: () => void + /** Optional: receives full list entries (incl. `created` for time-of-day pricing). */ + setChildEntries?: (entries: SessionListEntry[]) => void debounceMs?: number }) { let listGen = 0 @@ -33,6 +35,7 @@ export function createChildSessionSync(opts: { const parentId = opts.getParentId() if (!parentId) { opts.setChildIds([]) + opts.setChildEntries?.([]) return } const gen = listGen @@ -42,12 +45,15 @@ export function createChildSessionSync(opts: { .then( (all) => { if (gen !== listGen || opts.getParentId() !== parentId) return - opts.setChildIds(childSessionIdsForParent(parseSessionListResponse(all), parentId)) + const entries = childSessionEntriesForParent(parseSessionListResponse(all), parentId) + opts.setChildIds(entries.map((e) => e.id)) + opts.setChildEntries?.(entries) opts.onSynced?.() }, () => { if (gen !== listGen || opts.getParentId() !== parentId) return opts.setChildIds([]) + opts.setChildEntries?.([]) }, ) } diff --git a/src/dynamic-pricing/context.ts b/src/dynamic-pricing/context.ts new file mode 100644 index 0000000..6d0cdc3 --- /dev/null +++ b/src/dynamic-pricing/context.ts @@ -0,0 +1,92 @@ +import type { ModelCost } from "../types.ts" + +/** + * 上下文分档:contextTokens 超过 threshold 时返回分档价格,否则基础档。 + * threshold 优先用模型自身档位阈值(`cost.contextThreshold`,来自运行时 tier.size)。 + */ +export function selectContextRates( + cost: ModelCost, + contextTokens: number | undefined, + threshold = 200_000, +): ModelCost { + if (!cost.context_over_200k) return cost + if (contextTokens === undefined) return cost + const eff = cost.contextThreshold ?? threshold + return contextTokens > eff ? cost.context_over_200k : cost +} + +type RuntimeTier = { + input: number + output: number + cache: { read: number; write: number } + tier?: { type: "context"; size: number } +} + +/** + * 运行时 cost(`api.state.provider`)→ 插件 ModelCost。 + * opencode 运行时把配置层的 context_over_200k 转为 `tiers[]` / `experimentalOver200K`, + * 这里归一化回插件字段(含档位阈值 tier.size)。配置层字段已存在时幂等返回。 + */ +export function normalizeRuntimeCost(cost: ModelCost): ModelCost { + if (cost.context_over_200k) return cost + const raw = cost as ModelCost & { + tiers?: RuntimeTier[] + experimentalOver200K?: ModelCost + } + if (raw.experimentalOver200K) { + return { ...cost, context_over_200k: raw.experimentalOver200K, contextThreshold: 200_000 } + } + const tier = raw.tiers?.find((t) => t.tier?.type === "context") + if (tier) { + return { + ...cost, + context_over_200k: { + input: tier.input, + output: tier.output, + cache: { read: tier.cache?.read ?? 0, write: tier.cache?.write ?? 0 }, + }, + contextThreshold: tier.tier?.size ?? 200_000, + } + } + return cost +} + +/** 各率乘系数(倍率模式)。系数为 1 时原样返回。 */ +export function scaleRates(cost: ModelCost, factor: number): ModelCost { + if (factor === 1) return cost + return { + input: cost.input * factor, + output: cost.output * factor, + cache: { read: cost.cache.read * factor, write: cost.cache.write * factor }, + context_over_200k: cost.context_over_200k + ? { + input: cost.context_over_200k.input * factor, + output: cost.context_over_200k.output * factor, + cache: { + read: cost.context_over_200k.cache.read * factor, + write: cost.context_over_200k.cache.write * factor, + }, + } + : undefined, + } +} + +/** + * 用量 → 成本(USD)。`input` 为未命中输入 token(不含缓存,与 opencode 语义一致), + * 缓存命中部分按 `cacheRead` 单独以 cacheReadRate 计费。 + */ +export function billingCost( + rates: ModelCost, + input: number, + output: number, + cacheRead: number, + cacheWrite: number, +): number { + return ( + (input * rates.input + + output * rates.output + + cacheRead * rates.cache.read + + cacheWrite * rates.cache.write) / + 1_000_000 + ) +} diff --git a/src/dynamic-pricing/deepseek.ts b/src/dynamic-pricing/deepseek.ts new file mode 100644 index 0000000..11212f2 --- /dev/null +++ b/src/dynamic-pricing/deepseek.ts @@ -0,0 +1,18 @@ +import type { ModelPricingRule } from "./types.ts" + +/** + * DeepSeek 官方时段计价:空闲时段价格为高峰时段的一半。 + * 内置默认以"倍率"形式相对 `state.provider` 静态价生效(静态价视为高峰价), + * 用户可在配置中显式覆盖为绝对价。 + */ +export const DEEPSEEK_DEFAULT_RULE: ModelPricingRule = { + multipliers: { peak: 1, offpeak: 0.5 }, +} + +/** providerID 或 modelID 是否为 DeepSeek 官方命名空间。 */ +export function isDeepSeek(providerID: string, modelID: string): boolean { + const pid = providerID.toLowerCase() + const mid = modelID.toLowerCase() + // provider 名含 deepseek(如 deepseek)或 modelID 以官方前缀 deepseek/ 开头。 + return pid.includes("deepseek") || mid.startsWith("deepseek/") +} diff --git a/src/dynamic-pricing/lookup.ts b/src/dynamic-pricing/lookup.ts new file mode 100644 index 0000000..3ce85e5 --- /dev/null +++ b/src/dynamic-pricing/lookup.ts @@ -0,0 +1,108 @@ +import type { ModelCost, ProviderInfo } from "../types.ts" +import { normalizeRuntimeCost, selectContextRates, scaleRates } from "./context.ts" +import { DEEPSEEK_DEFAULT_RULE, isDeepSeek } from "./deepseek.ts" +import { isLevelAt } from "./schedule.ts" +import type { DynamicPricingConfig, ModelPricingRule } from "./types.ts" + +/** 静态价格查询:providerID + modelID → ModelCost(运行时 tiers 已归一化),未命中返回 null。 */ +export function lookupModelCost( + providers: ReadonlyArray, + providerID: string | undefined, + modelID: string | undefined, +): ModelCost | null { + if (!providerID || !modelID) return null + for (const p of providers) { + if (p.id !== providerID) continue + const model = p.models[modelID] + return model?.cost ? normalizeRuntimeCost(model.cost) : null + } + return null +} + +export type PricingLookupContext = { + now?: number + contextTokens?: number + rules?: DynamicPricingConfig +} + +export type ResolvedPricing = { + rates: ModelCost + /** 命中的时段档名(如 "peak"/"offpeak");未启用时段规则时为 undefined。 */ + level?: string + /** 上下文分档:基础档 "base" 或超阈值档 "over";模型无分档时 undefined。 */ + contextTier?: "base" | "over" + /** 是否应用了动态规则(显式配置 / 内置 DeepSeek 默认)。 */ + explicit: boolean +} + +function effectiveRule( + rules: DynamicPricingConfig | undefined, + providerID: string, + modelID: string, +): ModelPricingRule | undefined { + // 总开关关闭 → 完全静态(显式规则、内置默认、context 分档均不生效)。 + if (!rules?.enabled) return undefined + const explicit = rules.providers[providerID]?.models[modelID] + if (explicit) return explicit + // 空 schedule → 无时段可判,不应用内置默认(避免虚假的 ≈ 标记)。 + if (rules.schedule.length > 0 && isDeepSeek(providerID, modelID)) { + return DEEPSEEK_DEFAULT_RULE + } + return undefined +} + +function resolveLevel(now: number, rules: DynamicPricingConfig | undefined): string | undefined { + if (!rules?.enabled) return undefined + const tz = rules.timezone || "UTC" + return isLevelAt(now, rules.schedule, tz) +} + +function tierOf(cost: ModelCost, tokens: number | undefined, threshold: number): "base" | "over" | undefined { + if (!cost.context_over_200k || tokens === undefined) return undefined + const eff = cost.contextThreshold ?? threshold + return tokens > eff ? "over" : "base" +} + +/** + * 解析模型当前有效价格。优先级: + * 1. 用户显式 `levels` 绝对价(按时段档,未命中回退静态价)→ 2. 用户显式 `multipliers` 倍率 + * → 3. 内置 DeepSeek 默认倍率 → 4. `state.provider` 静态价(含 context 分档)。 + * `enabled: false` 时所有动态维度关闭,仅返回静态基础价。 + */ +export function resolveModelCost( + providers: ReadonlyArray, + providerID: string, + modelID: string, + ctx: PricingLookupContext = {}, +): ResolvedPricing | null { + const base = lookupModelCost(providers, providerID, modelID) + if (!base) return null + // 总开关关闭 → 完全静态基础价(含 context 分档一并关闭),与 README 一致。 + if (ctx.rules && !ctx.rules.enabled) return { rates: base, explicit: false } + const now = ctx.now ?? Date.now() + const rules = ctx.rules + const level = resolveLevel(now, rules) + const rule = effectiveRule(rules, providerID, modelID) + // 分档阈值优先级:模型级配置 rule.contextThreshold > 运行时 tier.size(cost.contextThreshold) + // > 全局 contextThreshold > 默认 200k。统一到 base 上,保证 tierOf 与 selectContextRates 口径一致。 + const effThreshold = + rule?.contextThreshold ?? base.contextThreshold ?? rules?.contextThreshold ?? 200_000 + const effBase = base.contextThreshold === effThreshold ? base : { ...base, contextThreshold: effThreshold } + const contextTier = tierOf(effBase, ctx.contextTokens, effThreshold) + + if (rule?.levels) { + const absolute = level ? rule.levels[level] : undefined + if (absolute) { + // 绝对价是完整价格(无 context 分档语义)→ 不标注 context badge,避免误导。 + return { rates: absolute, level, contextTier: undefined, explicit: true } + } + // 时段未命中(自定义 schedule 未覆盖当前时刻 / 档位名不匹配)→ 回退静态价,不任意套用第一个档。 + return { rates: selectContextRates(effBase, ctx.contextTokens, effThreshold), level: undefined, contextTier, explicit: true } + } + if (rule?.multipliers) { + const factor = level ? rule.multipliers[level] ?? 1 : 1 + return { rates: scaleRates(selectContextRates(effBase, ctx.contextTokens, effThreshold), factor), level, contextTier, explicit: true } + } + // 显式配置了该模型(含仅 contextThreshold)→ 视为动态规则生效。 + return { rates: selectContextRates(effBase, ctx.contextTokens, effThreshold), level, contextTier, explicit: rule !== undefined } +} diff --git a/src/dynamic-pricing/recompute.ts b/src/dynamic-pricing/recompute.ts new file mode 100644 index 0000000..91ca09a --- /dev/null +++ b/src/dynamic-pricing/recompute.ts @@ -0,0 +1,130 @@ +import type { AssistantMessage, ProviderInfo, SubAgentSummary } from "../types.ts" +import { billingCost } from "./context.ts" +import { resolveModelCost } from "./lookup.ts" +import type { DynamicPricingConfig } from "./types.ts" + +export type RecomputeResult = { + /** 按每条消息的请求时刻 + 上下文大小重算的总成本(USD)。 */ + cost: number + /** 参与重算的消息数(有 tokens 且有价格)。 */ + counted: number + /** 是否有任何消息应用了动态规则(时段 / 上下文分档 / 倍率)。 */ + dynamic: boolean +} + +const EMPTY_RESULT: RecomputeResult = { cost: 0, counted: 0, dynamic: false } + +/** + * 逐条重算会话成本: + * - 时段:`msg.time.created`(请求发起时刻)→ 命中时段档 + * - 上下文:总输入(`input + cacheRead`,openCode 语义下 input 不含缓存)→ context_over_200k 分档 + * - 用量:input / output / cache.read / cache.write(input 为未命中部分,缓存单独计费) + * 无法定价的消息(无 tokens 或无模型价格)跳过。所有消息均不可定价 → null。 + */ +export function recomputeSessionCost( + messages: ReadonlyArray, + providers: ReadonlyArray, + rules: DynamicPricingConfig | undefined, +): RecomputeResult | null { + if (!messages.length) return null + let cost = 0 + let counted = 0 + let dynamic = false + for (const msg of messages) { + const tokens = msg.tokens + if (!tokens) continue + const input = tokens.input ?? 0 + const output = tokens.output ?? 0 + const cacheRead = tokens.cache?.read ?? 0 + const cacheWrite = tokens.cache?.write ?? 0 + if (input + output + cacheRead + cacheWrite === 0) continue + const resolved = resolveModelCost(providers, msg.providerID ?? "", msg.modelID ?? "", { + now: msg.time?.created, + contextTokens: input + cacheRead, + rules, + }) + if (!resolved) continue + cost += billingCost(resolved.rates, input, output, cacheRead, cacheWrite) + counted += 1 + if (resolved.explicit) dynamic = true + } + if (counted === 0) return null + return { cost, counted, dynamic } +} + +/** + * 子 agent 成本重算:用聚合 tokens + 会话创建时刻(`sub.created`)近似逐条重算。 + * 无 created 或模型不可定价 → null(调用方回退 msg.cost,不按时段猜测)。 + */ +export function recomputeSubAgentCost( + sub: SubAgentSummary, + providers: ReadonlyArray, + rules: DynamicPricingConfig | undefined, +): number | null { + // 无创建时刻 → 无法按时段定价,回退 msg.cost(调用方处理)。 + if (sub.created === undefined) return null + const input = sub.input + const output = sub.output + const cacheRead = sub.cacheRead + const cacheWrite = sub.cacheWrite + if (input + output + cacheRead + cacheWrite === 0) return null + const resolved = resolveModelCost(providers, sub.providerID, sub.model, { + now: sub.created, + contextTokens: input + cacheRead, + rules, + }) + if (!resolved) return null + return resolved.explicit ? billingCost(resolved.rates, input, output, cacheRead, cacheWrite) : null +} + +/** + * timeline 记录离线重算:按记录时刻(`created`)+ 上下文档位重算成本。 + * 无 providerId 时按 modelId 在各 provider 中匹配;不可定价 → null。 + */ +export function recomputeRecordCost( + record: { + modelId?: string + providerId?: string + created?: string + input?: number + output?: number + cacheRead?: number + cacheWrite?: number + }, + providers: ReadonlyArray, + rules: DynamicPricingConfig | undefined, +): number | null { + const modelId = record.modelId ?? "" + if (!modelId) return null + const created = record.created ? Date.parse(record.created) : undefined + if (!Number.isFinite(created ?? 0)) return null + const input = record.input ?? 0 + const output = record.output ?? 0 + const cacheRead = record.cacheRead ?? 0 + const cacheWrite = record.cacheWrite ?? 0 + if (input + output + cacheRead + cacheWrite === 0) return null + + const byId = record.providerId + ? resolveModelCost(providers, record.providerId, modelId, { + now: created, + contextTokens: input + cacheRead, + rules, + }) + : null + const resolved = + byId ?? + (() => { + for (const p of providers) { + if (!p.models[modelId]) continue + const r = resolveModelCost(providers, p.id, modelId, { + now: created, + contextTokens: input + cacheRead, + rules, + }) + if (r) return r + } + return null + })() + if (!resolved) return null + return resolved.explicit ? billingCost(resolved.rates, input, output, cacheRead, cacheWrite) : null +} diff --git a/src/dynamic-pricing/schedule.ts b/src/dynamic-pricing/schedule.ts new file mode 100644 index 0000000..788c404 --- /dev/null +++ b/src/dynamic-pricing/schedule.ts @@ -0,0 +1,125 @@ +import type { DynamicPricingSchedule, TimeWindow } from "./types.ts" + +const MINUTES_PER_DAY = 24 * 60 + +/** "09:00" → 540;"18:30" → 1110。非法输入返回 null。 */ +export function parseClockTime(raw: string): number | null { + const m = raw.match(/^(\d{1,2}):(\d{2})$/) + if (!m) return null + const h = Number(m[1]) + const min = Number(m[2]) + if (h > 23 || min > 59) return null + return h * 60 + min +} + +export function inWindow(dayMinute: number, w: TimeWindow): boolean { + if (w.start <= w.end) return dayMinute >= w.start && dayMinute < w.end + // 跨天窗口:[start, 24:00) ∪ [00:00, end) + return dayMinute >= w.start || dayMinute < w.end +} + +export type TzParts = { + year: number + month: number // 1-12 + day: number + hour: number // 0-23("24:xx" 已归一化) + minute: number + second: number +} + +const tzFormatterCache = new Map() + +function tzFormatter(timezone: string): Intl.DateTimeFormat { + let f = tzFormatterCache.get(timezone) + if (!f) { + f = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + hour12: false, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + tzFormatterCache.set(timezone, f) + } + return f +} + +/** 取某时刻在指定时区的日历字段(hour "24" 归一化为次日 0 点)。 */ +export function tzPartsOf(ts: number, timezone: string): TzParts { + const parts = Object.fromEntries( + tzFormatter(timezone).formatToParts(new Date(ts)).map((p) => [p.type, p.value]), + ) + let year = Number(parts.year) + let month = Number(parts.month) + let day = Number(parts.day) + let hour = Number(parts.hour) + const minute = Number(parts.minute) + const second = Number(parts.second) + if (hour === 24) { + hour = 0 + const d = new Date(Date.UTC(year, month - 1, day + 1)) + year = d.getUTCFullYear() + month = d.getUTCMonth() + 1 + day = d.getUTCDate() + } + return { year, month, day, hour, minute, second } +} + +/** 指定时区下"当天 00:00:00"的 epoch 毫秒(真实时区零点,非 UTC 零点)。 */ +export function startOfDayEpoch(ts: number, timezone: string): number { + const p = tzPartsOf(ts, timezone) + const elapsedMs = p.hour * 3_600_000 + p.minute * 60_000 + p.second * 1000 + return Math.floor(ts / 1000) * 1000 - elapsedMs +} + +/** 指定时区下该时刻的"当天分钟数"(0..1439.99)。 */ +export function dayMinuteOf(ts: number, timezone: string): number { + const p = tzPartsOf(ts, timezone) + return p.hour * 60 + p.minute + p.second / 60 +} + +/** + * 判定 now 命中的时段档名(按 schedule 顺序,首个匹配)。 + * schedule 为空或未命中 → undefined。 + */ +export function isLevelAt( + now: number, + schedule: DynamicPricingSchedule, + timezone: string, +): string | undefined { + if (schedule.length === 0) return undefined + const min = dayMinuteOf(now, timezone) + for (const lvl of schedule) { + for (const w of lvl.windows) { + if (inWindow(min, w)) return lvl.level + } + } + return undefined +} + +/** + * 距下一个时段窗口边界(任一 level 任一 window 的 start/end)的毫秒数。 + * 用于精确调度 UI 刷新;无任何边界时返回 24h。 + */ +export function nextBoundaryMs( + now: number, + schedule: DynamicPricingSchedule, + timezone: string, +): number { + const todayMin = dayMinuteOf(now, timezone) + let best = Number.POSITIVE_INFINITY + for (const lvl of schedule) { + for (const w of lvl.windows) { + for (const m of [w.start, w.end]) { + let dayOffset = 0 + if (m <= todayMin) dayOffset = 1 // 当天该边界已过 → 次日 + const boundaryMs = startOfDayEpoch(now, timezone) + (m + dayOffset * MINUTES_PER_DAY) * 60_000 + if (boundaryMs > now) best = Math.min(best, boundaryMs - now) + } + } + } + return Number.isFinite(best) ? best : MINUTES_PER_DAY * 60_000 +} diff --git a/src/dynamic-pricing/types.ts b/src/dynamic-pricing/types.ts new file mode 100644 index 0000000..e5db516 --- /dev/null +++ b/src/dynamic-pricing/types.ts @@ -0,0 +1,63 @@ +import type { ModelCost } from "../types.ts" + +/** 24h 时间窗口,单位:当天分钟数 [start, end)。end <= start 表示跨天(end 属次日)。 */ +export type TimeWindow = { + start: number + end: number +} + +/** 一个时段档(如 peak / offpeak)及其时间窗口。 */ +export type ScheduleLevel = { + level: string + windows: TimeWindow[] +} + +export type DynamicPricingSchedule = ScheduleLevel[] + +/** + * 单模型计价规则。 + * - `levels`:绝对价(level 名 → USD/1M 四率),优先于倍率。 + * - `multipliers`:相对 `state.provider` 静态价的倍率(如 offpeak 0.5)。 + * - `contextThreshold`:覆盖全局上下文分档阈值(token 数)。 + */ +export type ModelPricingRule = { + /** + * 绝对价(level 名 → USD/1M 四率),优先于倍率。 + * `currency` 指定 levels 的原始币种(默认 USD);非 USD 在配置加载时按 + * `cost.rate` 换算为内部 USD 口径(CNY ÷ rate)。 + */ + levels?: Record + multipliers?: Record + contextThreshold?: number + /** levels 绝对价的币种:USD(默认)| CNY | EUR | GBP | JPY。 */ + currency?: string + /** USD → levels 币种 的汇率(用于非 USD levels 换算,如 CNY 填 6.77); + * 缺省仅在 currency === 展示币种时按 cost.rate 推断,否则告警并视作 USD。 */ + rate?: number +} + +export type DynamicPricingConfig = { + /** 总开关。默认 true(仅对 DeepSeek 模型应用内置时段规则 + 读取 context_over_200k 分档)。 */ + enabled: boolean + /** IANA 时区名,如 "Asia/Shanghai"。空 → 系统时区。 */ + timezone: string + schedule: DynamicPricingSchedule + /** 全局上下文分档阈值(token 数),默认 200_000。 */ + contextThreshold: number + providers: Record }> +} + +export const DEFAULT_SCHEDULE: DynamicPricingSchedule = [ + // DeepSeek 官方高峰时段(北京时间)。 + { level: "peak", windows: [{ start: 9 * 60, end: 12 * 60 }, { start: 14 * 60, end: 18 * 60 }] }, + // 其余为空闲时段(跨天窗口覆盖 18:00 → 次日 09:00 与 12:00 → 14:00)。 + { level: "offpeak", windows: [{ start: 18 * 60, end: 9 * 60 }, { start: 12 * 60, end: 14 * 60 }] }, +] + +export const DEFAULT_DYNAMIC_PRICING: DynamicPricingConfig = { + enabled: true, + timezone: "Asia/Shanghai", + schedule: DEFAULT_SCHEDULE, + contextThreshold: 200_000, + providers: {}, +} diff --git a/src/format-cost.ts b/src/format-cost.ts index 64285bd..16addb2 100644 --- a/src/format-cost.ts +++ b/src/format-cost.ts @@ -29,7 +29,7 @@ export const DEFAULT_COST_DISPLAY: CostDisplayConfig = { rate: 6.77, } -function resolveExchangeRate(cfg: CostDisplayConfig): number { +export function resolveExchangeRate(cfg: CostDisplayConfig): number { if (cfg.convert?.rate && cfg.convert.rate > 0) return cfg.convert.rate if (cfg.rate && cfg.rate > 0) return cfg.rate const unit = cfg.costUnit ?? "USD" diff --git a/src/i18n.ts b/src/i18n.ts index fd91b1d..9638f7c 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -33,6 +33,13 @@ export type UiStrings = { ttft: string /** Shown in Speed → Now when no stream is active (not a missing-data dash). */ streamingIdle: string + /** Prefix for session cost recomputed from dynamic pricing. */ + approx: string + /** Time-of-day tier badges appended to the Rate label. */ + peakBadge: string + offpeakBadge: string + /** Context tier badge shown when over the context threshold. */ + over200kBadge: string } const EN: UiStrings = { @@ -66,6 +73,10 @@ const EN: UiStrings = { trend: "Trend:", ttft: "TTFT:", streamingIdle: "·", + approx: "≈", + peakBadge: "peak", + offpeakBadge: "offpeak", + over200kBadge: ">200k", } const ZH: UiStrings = { @@ -99,6 +110,10 @@ const ZH: UiStrings = { trend: "趋势:", ttft: "首Token:", streamingIdle: "·", + approx: "≈", + peakBadge: "高峰", + offpeakBadge: "空闲", + over200kBadge: ">200k", } export function resolveLang(raw: unknown): Lang { diff --git a/src/jsonc.ts b/src/jsonc.ts new file mode 100644 index 0000000..11cdb91 --- /dev/null +++ b/src/jsonc.ts @@ -0,0 +1,58 @@ +/** + * Minimal JSONC helpers for Bun scripts and config loading. + * + * JSONC here means: regular JSON plus line comments, block comments, and + * trailing commas before `}` / `]`. Comments inside strings are preserved + * (including URLs and `//` in string values). + */ + +const JSON_WHITESPACE = new Set([" ", "\t", "\n", "\r"]) + +function withoutTrailingComma(out: string): string { + let end = out.length + while (end > 0 && JSON_WHITESPACE.has(out[end - 1])) end-- + return end > 0 && out[end - 1] === "," ? out.slice(0, end - 1) : out +} + +/** Strip JSONC comments and trailing commas while preserving string contents. */ +export function stripJsonc(src: string): string { + let out = "" + let inStr = false + let esc = false + for (let i = 0; i < src.length; i++) { + const c = src[i] + const n = src[i + 1] + if (inStr) { + out += c + if (esc) esc = false + else if (c === "\\") esc = true + else if (c === '"') inStr = false + continue + } + if (c === '"') { + inStr = true + out += c + continue + } + if (c === "/" && n === "/") { + while (i < src.length && src[i] !== "\n") i++ + continue + } + if (c === "/" && n === "*") { + i += 2 + while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) i++ + i++ + continue + } + if (c === "}" || c === "]") { + out = withoutTrailingComma(out) + } + out += c + } + return out +} + +/** Parse a JSONC string into `T`. Throws `SyntaxError` for malformed input. */ +export function parseJsonc(src: string): T { + return JSON.parse(stripJsonc(src)) as T +} diff --git a/src/main-session-view.tsx b/src/main-session-view.tsx index ac3c7b8..6557666 100644 --- a/src/main-session-view.tsx +++ b/src/main-session-view.tsx @@ -33,6 +33,22 @@ export function MainSessionView(props: { const now = props.streamingNow() return formatStreamingNowDisplay(now.phase, now.speed, m.t().streamingIdle, m.useTps()) }) + + /** 动态规则生效时展示重算成本(≈ 前缀),否则回退 OpenCode 的 msg.cost。 */ + const shownCost = createMemo(() => { + const rec = m.recomputedCost() + if (rec && rec.dynamic) return { value: rec.cost, approx: true } + return { value: m.main().cost, approx: false } + }) + + const rateLabel = createMemo(() => { + const p = m.pricing() + const badges: string[] = [] + if (p.level === "peak") badges.push(m.t().peakBadge) + else if (p.level === "offpeak") badges.push(m.t().offpeakBadge) + if (p.contextTier === "over") badges.push(m.t().over200kBadge) + return badges.length > 0 ? `${m.t().rate} ${badges.join("·")}` : m.t().rate + }) return ( <> - 0}> + 0}> @@ -148,7 +164,7 @@ export function MainSessionView(props: { diff --git a/src/plugin-config.ts b/src/plugin-config.ts index 0f80402..84877b4 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -1,5 +1,14 @@ -import { type CostDisplayConfig, normalizeCostDisplay, DEFAULT_COST_DISPLAY } from "./format-cost.ts" +import { type CostDisplayConfig, normalizeCostDisplay, DEFAULT_COST_DISPLAY, resolveExchangeRate, CURRENCY_PRESETS } from "./format-cost.ts" import { resolveLang, type Lang } from "./i18n.ts" +import { + type DynamicPricingConfig, + type DynamicPricingSchedule, + type ModelPricingRule, + DEFAULT_DYNAMIC_PRICING, +} from "./dynamic-pricing/types.ts" +import { parseClockTime } from "./dynamic-pricing/schedule.ts" + +export type { DynamicPricingConfig } from "./dynamic-pricing/types.ts" export type DisplayConfig = { /** `en` | `zh` | `auto` (follow system locale). Default `en`. */ @@ -103,6 +112,8 @@ export type PluginConfig = { display: DisplayConfig timeline: TimelineConfig cacheTTL: CacheTTLConfig + /** 动态计价:时段(peak/offpeak)与上下文分档(context_over_200k)。 */ + dynamicPricing: DynamicPricingConfig } export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { @@ -110,6 +121,7 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { display: { ...DEFAULT_DISPLAY }, timeline: { ...DEFAULT_TIMELINE }, cacheTTL: { ...DEFAULT_CACHE_TTL }, + dynamicPricing: structuredClone(DEFAULT_DYNAMIC_PRICING), } const TOOL_SUMMARY_KEYS: ReadonlySet = new Set([ @@ -233,15 +245,146 @@ export function parseDuration(raw: string): number | null { return Math.floor(value * multiplier) } +function normalizeModelPricingRule(raw: unknown): ModelPricingRule { + const rule: ModelPricingRule = {} + if (!raw || typeof raw !== "object") return rule + const o = raw as Record + if (typeof o.currency === "string" && o.currency.toUpperCase() in CURRENCY_PRESETS) { + rule.currency = o.currency.toUpperCase() + } + if (typeof o.rate === "number" && Number.isFinite(o.rate) && o.rate > 0) { + rule.rate = o.rate + } + const levels = o.levels + if (levels && typeof levels === "object") { + const out: Record = {} + for (const [level, v] of Object.entries(levels as Record)) { + const lv = v as Record | undefined + if (!lv || typeof lv !== "object") continue + const num = (x: unknown) => (typeof x === "number" && Number.isFinite(x) ? x : 0) + // 兼容两种写法:扁平 cacheRead/cacheWrite(文档/示例)与嵌套 cache:{read,write}(ModelCost 类型)。 + const nestCache = lv.cache as { read?: unknown; write?: unknown } | undefined + out[level] = { + input: num(lv.input), + output: num(lv.output), + cache: { + read: num(lv.cacheRead ?? lv.cache_read ?? nestCache?.read), + write: num(lv.cacheWrite ?? lv.cache_write ?? nestCache?.write), + }, + } + } + if (Object.keys(out).length > 0) rule.levels = out + } + const multipliers = o.multipliers + if (multipliers && typeof multipliers === "object") { + const out: Record = {} + for (const [level, v] of Object.entries(multipliers as Record)) { + if (typeof v === "number" && Number.isFinite(v) && v > 0) out[level] = v + } + if (Object.keys(out).length > 0) rule.multipliers = out + } + if (typeof o.contextThreshold === "number" && Number.isFinite(o.contextThreshold) && o.contextThreshold > 0) { + rule.contextThreshold = Math.floor(o.contextThreshold) + } + return rule +} + +function normalizeSchedule(raw: unknown): DynamicPricingSchedule { + if (!Array.isArray(raw)) return structuredClone(DEFAULT_DYNAMIC_PRICING.schedule) + const out: DynamicPricingSchedule = [] + for (const item of raw) { + const o = item as Record | undefined + if (!o || typeof o !== "object") continue + if (typeof o.level !== "string" || !Array.isArray(o.windows)) continue + const windows = o.windows + .map((w) => { + const ww = w as Record | undefined + if (!ww || typeof ww !== "object") return null + const start = typeof ww.start === "string" ? parseClockTime(ww.start) : null + const end = typeof ww.end === "string" ? parseClockTime(ww.end) : null + if (start === null || end === null) return null + return { start, end } + }) + .filter((w): w is { start: number; end: number } => w !== null) + if (windows.length > 0) out.push({ level: o.level, windows }) + } + return out.length > 0 ? out : structuredClone(DEFAULT_DYNAMIC_PRICING.schedule) +} + +export function normalizeDynamicPricingConfig( + raw: unknown, + opts?: { usdRate?: number; displayCurrency?: string }, +): DynamicPricingConfig { + const d = structuredClone(DEFAULT_DYNAMIC_PRICING) + if (!raw || typeof raw !== "object") return d + const o = raw as Record + if (typeof o.enabled === "boolean") d.enabled = o.enabled + if (typeof o.timezone === "string" && o.timezone.length > 0) d.timezone = o.timezone + if (o.schedule !== undefined) d.schedule = normalizeSchedule(o.schedule) + if (typeof o.contextThreshold === "number" && Number.isFinite(o.contextThreshold) && o.contextThreshold > 0) { + d.contextThreshold = Math.floor(o.contextThreshold) + } + if (o.providers && typeof o.providers === "object") { + const providers: DynamicPricingConfig["providers"] = {} + for (const [pid, pv] of Object.entries(o.providers as Record)) { + const po = pv as Record | undefined + if (!po || typeof po !== "object") continue + const modelsRaw = po.models + if (!modelsRaw || typeof modelsRaw !== "object") continue + const models: Record = {} + for (const [mid, mv] of Object.entries(modelsRaw as Record)) { + const rule = normalizeModelPricingRule(mv) + // 非 USD 的 levels 绝对价在加载时换算为内部 USD 口径,lookup 恒按 USD 计算。 + // 汇率(USD → levels 币种)优先级:rule.rate > levelsCurrency===展示币种时 cost.rate + // > 无法推断时告警并视作 USD(避免用错误的展示汇率换算,如 EUR 除 USD→CNY)。 + if (rule.levels && rule.currency && rule.currency !== "USD") { + let usdPerLevel: number | undefined = rule.rate + if (usdPerLevel === undefined && rule.currency === opts?.displayCurrency && opts?.usdRate && opts.usdRate > 0) { + usdPerLevel = opts.usdRate + } + if (usdPerLevel === undefined || usdPerLevel <= 0) { + console.error( + `dynamicPricing: cannot convert ${rule.currency} levels to USD for ${pid}/${mid} — ` + + `set "rate" (USD→${rule.currency}) or use cost.currency = ${rule.currency}; treating values as USD`, + ) + } else { + for (const [level, rates] of Object.entries(rule.levels)) { + rule.levels[level] = { + input: rates.input / usdPerLevel, + output: rates.output / usdPerLevel, + cache: { read: rates.cache.read / usdPerLevel, write: rates.cache.write / usdPerLevel }, + } + } + delete rule.currency + } + } + if (Object.keys(rule).length > 0) models[mid] = rule + } + if (Object.keys(models).length > 0) providers[pid] = { models } + } + if (Object.keys(providers).length > 0) d.providers = providers + } + return d +} + export function normalizePluginConfig(raw: unknown): PluginConfig { if (!raw || typeof raw !== "object") return structuredClone(DEFAULT_PLUGIN_CONFIG) const o = raw as Record const cost = normalizeCostDisplay(raw) const displayRaw = o.display + // levels 非 USD 绝对价按展示汇率换算为内部 USD。可用汇率:模型级 rule.rate + // (USD → levels 币种);或当 levels 币种与展示币种相同时,回退使用 cost.rate。 + // 显示 USD、levels 为 CNY 等币种时两者不匹配,需要显式配置模型级 rate。 + const usdRate = + cost.currency === "USD" ? (DEFAULT_COST_DISPLAY.rate ?? 6.77) : resolveExchangeRate(cost) return { cost, display: normalizeDisplayConfig(displayRaw), timeline: normalizeTimelineConfig(o.timeline), cacheTTL: normalizeCacheTTLConfig(o.cacheTTL), + dynamicPricing: normalizeDynamicPricingConfig(o.dynamicPricing, { + usdRate, + displayCurrency: cost.currency, + }), } } diff --git a/src/plugin.tsx b/src/plugin.tsx index 682e10c..30aa39d 100644 --- a/src/plugin.tsx +++ b/src/plugin.tsx @@ -22,6 +22,7 @@ export const tui = async (api: OpenCodeTuiApi) => { display={pluginConfig.display} timeline={pluginConfig.timeline} cacheTTL={pluginConfig.cacheTTL} + dynamicPricing={pluginConfig.dynamicPricing} formatCost={formatCost} formatRate={formatRate} api={api} diff --git a/src/pricing.ts b/src/pricing.ts index 8856ccd..c841e65 100644 --- a/src/pricing.ts +++ b/src/pricing.ts @@ -1,4 +1,10 @@ import type { ModelCost, ProviderInfo, SubAgentSummary } from "./types.ts" +import type { DynamicPricingConfig } from "./dynamic-pricing/types.ts" +import { lookupModelCost, resolveModelCost } from "./dynamic-pricing/lookup.ts" +import { billingCost } from "./dynamic-pricing/context.ts" + +export type { ModelCost } from "./types.ts" +export { lookupModelCost } from "./dynamic-pricing/lookup.ts" export type PricingInfo = { inputRate: number @@ -6,6 +12,12 @@ export type PricingInfo = { cacheReadRate: number cacheWriteRate: number saved: number + /** 命中的时段档名(如 "peak"/"offpeak");未启用时段规则时为 undefined。 */ + level?: string + /** 上下文分档:基础档 "base" 或超阈值档 "over";模型无分档时 undefined。 */ + contextTier?: "base" | "over" + /** 是否应用了动态规则(用户配置 / 内置 DeepSeek 默认)。 */ + dynamic: boolean } export const EMPTY_PRICING: PricingInfo = { @@ -14,20 +26,16 @@ export const EMPTY_PRICING: PricingInfo = { cacheReadRate: 0, cacheWriteRate: 0, saved: 0, + dynamic: false, } -export function lookupModelCost( - providers: ReadonlyArray, - providerID: string | undefined, - modelID: string | undefined, -): ModelCost | null { - if (!providerID || !modelID) return null - for (const p of providers) { - if (p.id !== providerID) continue - const model = p.models[modelID] - return model?.cost ?? null - } - return null +export type PricingContext = { + /** 当前时刻(ms),用于时段判定。默认 Date.now()。 */ + now?: number + /** 上下文大小(token 数),用于 context_over_200k 分档判定。 */ + contextTokens?: number + /** 动态计价配置;缺省时完全回退静态价。 */ + rules?: DynamicPricingConfig } export function computePricing( @@ -35,22 +43,44 @@ export function computePricing( providerID: string | undefined, modelID: string | undefined, cacheRead: number, + ctx: PricingContext = {}, ): PricingInfo { - const cost = lookupModelCost(providers, providerID, modelID) - if (!cost) return EMPTY_PRICING + const resolved = resolveModelCost(providers, providerID ?? "", modelID ?? "", { + now: ctx.now, + contextTokens: ctx.contextTokens, + rules: ctx.rules, + }) + if (!resolved) return EMPTY_PRICING + const cost = resolved.rates const inputRate = cost.input const outputRate = cost.output const cacheReadRate = cost.cache.read const cacheWriteRate = cost.cache.write const saved = inputRate > cacheReadRate ? (cacheRead * (inputRate - cacheReadRate)) / 1_000_000 : 0 - return { inputRate, outputRate, cacheReadRate, cacheWriteRate, saved } + return { + inputRate, + outputRate, + cacheReadRate, + cacheWriteRate, + saved, + level: resolved.level, + contextTier: resolved.contextTier, + dynamic: resolved.explicit, + } } -export function computeSubsSaved(subs: readonly SubAgentSummary[], providers: ReadonlyArray): number { +export function computeSubsSaved( + subs: readonly SubAgentSummary[], + providers: ReadonlyArray, + ctx: PricingContext = {}, +): number { let total = 0 for (const sub of subs) { - const p = computePricing(providers, sub.providerID, sub.model, sub.cacheRead) + const p = computePricing(providers, sub.providerID, sub.model, sub.cacheRead, { + ...ctx, + contextTokens: sub.input + sub.cacheRead, + }) total += p.saved } return total diff --git a/src/session-list.ts b/src/session-list.ts index 6152f2a..8d44862 100644 --- a/src/session-list.ts +++ b/src/session-list.ts @@ -1,11 +1,30 @@ -export type SessionListEntry = { id: string; parentID?: string } +export type SessionListEntry = { id: string; parentID?: string; created?: number } export function parseSessionListResponse(all: unknown): SessionListEntry[] { const list = Array.isArray(all) ? all : ((all as { data?: unknown })?.data ?? []) if (!Array.isArray(list)) return [] - return list as SessionListEntry[] + return list.map((raw) => { + const s = raw as { + id?: string + parentID?: string + time?: { created?: number } + createdAt?: number + } + return { + id: s.id ?? "", + parentID: s.parentID, + created: s.time?.created ?? s.createdAt, + } + }).filter((e) => e.id.length > 0) } export function childSessionIdsForParent(list: SessionListEntry[], parentId: string): string[] { return list.filter((s) => s.parentID === parentId).map((s) => s.id) } + +export function childSessionEntriesForParent( + list: SessionListEntry[], + parentId: string, +): SessionListEntry[] { + return list.filter((s) => s.parentID === parentId) +} diff --git a/src/sidebar-host.tsx b/src/sidebar-host.tsx index 0d17d98..8fa5872 100644 --- a/src/sidebar-host.tsx +++ b/src/sidebar-host.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/solid */ import { createSignal, createMemo, createEffect, onCleanup } from "solid-js" import { CacheHitSidebar } from "./widget.tsx" -import type { DisplayConfig, TimelineConfig, CacheTTLConfig } from "./plugin-config.ts" +import type { DisplayConfig, TimelineConfig, CacheTTLConfig, DynamicPricingConfig } from "./plugin-config.ts" import { isToolSummaryEnabled } from "./plugin-config.ts" import { createTimelineCollector } from "./timeline/collector.ts" import { @@ -27,6 +27,7 @@ import { withModelFallback, } from "./stats.ts" import { createChildSessionSync } from "./child-session-sync.ts" +import type { SessionListEntry } from "./session-list.ts" import { loadPluginConfig } from "./load-config.ts" import { computeAvgTokenTpotMs, computeAvgTokenSpeed } from "./token-speed.ts" import { @@ -53,12 +54,14 @@ export function CacheHitSidebarHost(props: { display: DisplayConfig timeline: TimelineConfig cacheTTL: CacheTTLConfig + dynamicPricing: DynamicPricingConfig formatCost: (amount: number) => string formatRate: (perMillion: number) => string api: OpenCodeTuiApi }) { const [refreshTick, setRefreshTick] = createSignal(0) const [childIds, setChildIds] = createSignal([]) + const [childEntries, setChildEntries] = createSignal([]) /** Re-read cache-hit.config.json when parent session changes (picks up edits without full plugin reload). */ const runtimeConfig = createMemo(() => { @@ -67,6 +70,7 @@ export function CacheHitSidebarHost(props: { }) const display = createMemo(() => runtimeConfig().display) const cacheTTL = createMemo(() => runtimeConfig().cacheTTL) + const dynamicPricing = createMemo(() => runtimeConfig().dynamicPricing) const timelineConfig = createMemo(() => runtimeConfig().timeline) const bumpRefresh = () => setRefreshTick((v) => v + 1) @@ -96,6 +100,7 @@ export function CacheHitSidebarHost(props: { getDirectory: () => props.api.state.path.directory, getParentId: () => props.sessionId, setChildIds, + setChildEntries, onSynced: () => { bumpRefresh() }, @@ -131,8 +136,10 @@ export function CacheHitSidebarHost(props: { const subAgentList = createMemo(() => { void refreshTick() const useTps = display().speedUnit === "tps" + const createdById = new Map(childEntries().map((e) => [e.id, e.created])) return childIds() .map((cid) => { + const created = createdById.get(cid) const session = props.api.state.session.get?.(cid) if (session) { const snap = aggregateFromSessionObject(session) @@ -142,7 +149,7 @@ export function CacheHitSidebarHost(props: { const speed = msgs ? (useTps ? computeAvgTokenSpeed(msgs) || undefined : computeAvgTokenTpotMs(msgs)) : undefined - return toSubAgentSummary(cid, merged, speed) + return toSubAgentSummary(cid, merged, speed, created) } } const msgs = props.api.state.session.messages(cid) @@ -152,7 +159,7 @@ export function CacheHitSidebarHost(props: { const speed = useTps ? computeAvgTokenSpeed(msgs as AssistantMessage[]) || undefined : computeAvgTokenTpotMs(msgs as AssistantMessage[]) - return toSubAgentSummary(cid, snap, speed) + return toSubAgentSummary(cid, snap, speed, created) }) .filter(Boolean) as SubAgentSummary[] }) @@ -295,6 +302,7 @@ export function CacheHitSidebarHost(props: { theme={props.theme} display={display()} cacheTTL={cacheTTL()} + dynamicPricing={dynamicPricing()} messages={mainMessages} main={mainSnap} subAgents={subAgentList} diff --git a/src/stats.ts b/src/stats.ts index a783ff7..86dbc14 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -53,7 +53,12 @@ export function aggregateSessionFromMessages(messages: readonly AssistantMessage return { model, providerID, input, output, reasoning, cacheRead, cacheWrite, cost } } -export function toSubAgentSummary(id: string, snap: SessionSnapshot, speed?: number): SubAgentSummary { +export function toSubAgentSummary( + id: string, + snap: SessionSnapshot, + speed?: number, + created?: number, +): SubAgentSummary { return { id, model: snap.model, @@ -65,6 +70,7 @@ export function toSubAgentSummary(id: string, snap: SessionSnapshot, speed?: num cacheRead: snap.cacheRead, cacheWrite: snap.cacheWrite, speed, + created, } } diff --git a/src/timeline/records.ts b/src/timeline/records.ts index edafb4a..ca1cc88 100644 --- a/src/timeline/records.ts +++ b/src/timeline/records.ts @@ -65,6 +65,7 @@ export function assistantMessageToRecord( scope, messageKey: messageKeyFor(msg, sessionId), modelId: msg.modelID ?? "", + providerId: msg.providerID ?? "", created: msToISOString(timing.created), completedAt: timing.completedAt !== undefined ? msToISOString(timing.completedAt) : undefined, durationMs: timing.durationMs, diff --git a/src/timeline/types.ts b/src/timeline/types.ts index b1085ac..e86423c 100644 --- a/src/timeline/types.ts +++ b/src/timeline/types.ts @@ -9,6 +9,8 @@ export type LlmCallRecord = { scope: "main" | "child" messageKey: string modelId: string + /** Provider id at record time (may be absent in older logs). */ + providerId?: string created: string completedAt?: string durationMs?: number @@ -19,6 +21,8 @@ export type LlmCallRecord = { cacheRead: number cacheWrite: number cost: number + /** Injected by timeline-dashboard: cost recomputed with dynamic pricing (time-of-day / context tier). */ + dynCost?: number hitPercent: number | null skippedForHit: boolean ttftMs?: number diff --git a/src/types.ts b/src/types.ts index 64bfaba..c50716c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,6 +20,8 @@ export type SubAgentSummary = { cacheRead: number cacheWrite: number speed?: number + /** Session creation time (ms), from session.list — enables time-of-day pricing recompute. */ + created?: number } export type AssistantMessage = { @@ -48,6 +50,14 @@ export type ModelCost = { input: number output: number cache: { read: number; write: number } + /** + * 上下文分档价格(阈值见 `contextThreshold`,默认 200k)。 + * 兼容两处来源:opencode.json 配置层的 `context_over_200k`, + * 以及运行时 `state.provider` 的 `tiers`/`experimentalOver200K`(由 normalizeRuntimeCost 归一化)。 + */ + context_over_200k?: ModelCost + /** 该分档的阈值(token 数);来自运行时 tier.size,缺省用全局 contextThreshold。 */ + contextThreshold?: number } export type ProviderInfo = { diff --git a/src/use-cache-hit-metrics.ts b/src/use-cache-hit-metrics.ts index efc0e65..ee272a9 100644 --- a/src/use-cache-hit-metrics.ts +++ b/src/use-cache-hit-metrics.ts @@ -1,5 +1,5 @@ -import { createMemo, type Accessor } from "solid-js" -import type { DisplayConfig } from "./plugin-config.ts" +import { createMemo, createSignal, onCleanup, type Accessor } from "solid-js" +import type { DisplayConfig, DynamicPricingConfig } from "./plugin-config.ts" import { getUiStrings, resolveLang } from "./i18n.ts" import { formatHitBar, @@ -18,7 +18,9 @@ import { mainSessionHasStats, shortModelName, } from "./stats.ts" -import { computePricing, type PricingInfo } from "./pricing.ts" +import { computePricing, computeSubsSaved, type PricingInfo } from "./pricing.ts" +import { recomputeSessionCost, recomputeSubAgentCost } from "./dynamic-pricing/recompute.ts" +import { nextBoundaryMs } from "./dynamic-pricing/schedule.ts" import { computeAvgTokenTpotMs, computeTokenTpotMs, @@ -49,6 +51,7 @@ export function useCacheHitMetrics(props: { main: Accessor subAgents: Accessor providers: Accessor> + dynamicPricing: DynamicPricingConfig layout: PanelLayout firstPartTime: Accessor> }) { @@ -60,10 +63,56 @@ export function useCacheHitMetrics(props: { const perCall = createMemo(() => computePerCallHitTrend(props.messages())) const sessionRatio = createMemo(() => cacheHitRatio(main().cacheRead, main().input)) + // 动态计价:跨时段边界精确刷新(无需每秒轮询)。 + // 单一 timer 引用 + 组件 owner 级 onCleanup:递归安排的后续 timer 在卸载时一并清理 + // (不能在 setTimeout 回调内注册 onCleanup——已脱离 Solid owner,卸载不触发)。 + const [now, setNow] = createSignal(Date.now()) + let boundaryTimer: ReturnType | undefined + const scheduleBoundary = () => { + const rules = props.dynamicPricing + const ms = + rules.schedule.length > 0 ? nextBoundaryMs(now(), rules.schedule, rules.timezone || "UTC") : 0 + if (ms <= 0) return + boundaryTimer = setTimeout(() => { + setNow(Date.now()) + scheduleBoundary() + }, ms) + } + onCleanup(() => { + if (boundaryTimer !== undefined) clearTimeout(boundaryTimer) + }) + scheduleBoundary() + const pricing = createMemo(() => - computePricing(props.providers(), main().providerID, main().model, main().cacheRead), + computePricing(props.providers(), main().providerID, main().model, main().cacheRead, { + now: now(), + contextTokens: main().input + main().cacheRead, + rules: props.dynamicPricing, + }), + ) + + // 子 agent 缓存节省:按当前时段 + 各子会话总输入(input + cacheRead)判定档位。 + const subsSaved = createMemo(() => + computeSubsSaved(subs(), props.providers(), { + now: now(), + rules: props.dynamicPricing, + }), ) + // 动态成本重算(按每条消息的请求时刻 + 上下文分档);不可定价时回退 null。 + const recomputedCost = createMemo(() => + recomputeSessionCost(props.messages(), props.providers(), props.dynamicPricing), + ) + + // 子 agent 动态成本(按其会话创建时刻 + 聚合 tokens 近似);无 created / 不可定价 → null。 + const subAgentDynamicCosts = createMemo(() => { + const rules = props.dynamicPricing + const providers = props.providers() + return new Map( + subs().map((s) => [s.id, recomputeSubAgentCost(s, providers, rules)] as const), + ) + }) + const mainHasStats = createMemo(() => mainSessionHasStats(main())) const hasData = createMemo(() => mainHasStats() || subs().length > 0) @@ -192,6 +241,9 @@ export function useCacheHitMetrics(props: { pctLabel: createMemo(() => formatPercentOneDecimal(perCall().hitPercent)), modelShort: createMemo(() => shortModelName(main().model)), totalSubCost: createMemo(() => subs().reduce((s, a) => s + a.cost, 0)), + recomputedCost, + subAgentDynamicCosts, + subsSaved, collapsedHitSummary, useTps, lastSpeed, diff --git a/src/widget.tsx b/src/widget.tsx index f15dfda..4216dc7 100644 --- a/src/widget.tsx +++ b/src/widget.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @opentui/solid */ import { createMemo, createSignal, Show, type Accessor } from "solid-js" -import type { DisplayConfig, CacheTTLConfig } from "./plugin-config.ts" +import type { DisplayConfig, CacheTTLConfig, DynamicPricingConfig } from "./plugin-config.ts" import type { AssistantMessage, ProviderInfo, SessionSnapshot, SubAgentSummary } from "./types.ts" import type { StreamingPhase } from "./streaming-state.ts" import { PLUGIN_VERSION } from "./version.ts" @@ -26,6 +26,7 @@ export function CacheHitSidebar(props: { theme: Record display: DisplayConfig cacheTTL: CacheTTLConfig + dynamicPricing: DynamicPricingConfig messages: Accessor main: Accessor subAgents: Accessor @@ -51,6 +52,7 @@ export function CacheHitSidebar(props: { main: props.main, subAgents: props.subAgents, providers: props.providers, + dynamicPricing: props.dynamicPricing, layout, firstPartTime: props.firstPartTime, }) @@ -129,7 +131,7 @@ export function CacheHitSidebar(props: { suffix={agentsSuffix()} onToggle={agents.toggle} > - + diff --git a/tests/dynamic-pricing-lookup.test.ts b/tests/dynamic-pricing-lookup.test.ts new file mode 100644 index 0000000..5eb29b0 --- /dev/null +++ b/tests/dynamic-pricing-lookup.test.ts @@ -0,0 +1,336 @@ +import { describe, test, expect } from "bun:test" +import { resolveModelCost, lookupModelCost } from "../src/dynamic-pricing/lookup.ts" +import { computePricing } from "../src/pricing.ts" +import type { ProviderInfo } from "../src/types.ts" +import { DEFAULT_SCHEDULE } from "../src/dynamic-pricing/types.ts" + +const TZ = "Asia/Shanghai" + +/** 北京时间 y-m-d h:m → epoch ms。 */ +function bjt(y: number, m: number, d: number, h: number, min = 0): number { + return Date.UTC(y, m - 1, d, h - 8, min) +} + +const PROVIDERS: ProviderInfo[] = [ + { + id: "deepseek", + models: { + "deepseek/deepseek-v4-flash": { + cost: { input: 0.5, output: 1.0, cache: { read: 0.01, write: 0 } }, + }, + }, + }, + { + id: "openai", + models: { + "gpt-5.6": { + cost: { + input: 1.0, + output: 3.0, + cache: { read: 0.1, write: 0 }, + context_over_200k: { input: 2.0, output: 5.0, cache: { read: 0.2, write: 0 } }, + }, + }, + }, + }, +] + +const DEFAULT_RULES = { + enabled: true, + timezone: TZ, + schedule: DEFAULT_SCHEDULE, + contextThreshold: 200_000, + providers: {}, +} + +describe("lookupModelCost", () => { + test("finds static cost including context_over_200k", () => { + const cost = lookupModelCost(PROVIDERS, "openai", "gpt-5.6") + expect(cost?.context_over_200k?.input).toBe(2.0) + }) +}) + +describe("resolveModelCost — context tier", () => { + test("uses over-200k tier when contextTokens exceed threshold", () => { + const r = resolveModelCost(PROVIDERS, "openai", "gpt-5.6", { + contextTokens: 250_000, + rules: DEFAULT_RULES, + }) + expect(r?.rates.input).toBe(2.0) + expect(r?.contextTier).toBe("over") + }) + test("uses base tier under threshold", () => { + const r = resolveModelCost(PROVIDERS, "openai", "gpt-5.6", { + contextTokens: 100_000, + rules: DEFAULT_RULES, + }) + expect(r?.rates.input).toBe(1.0) + expect(r?.contextTier).toBe("base") + }) + test("no context info → base tier", () => { + const r = resolveModelCost(PROVIDERS, "openai", "gpt-5.6", { rules: DEFAULT_RULES }) + expect(r?.rates.input).toBe(1.0) + expect(r?.contextTier).toBeUndefined() + }) +}) + +describe("resolveModelCost — built-in DeepSeek time-of-day", () => { + test("peak keeps static rates", () => { + const r = resolveModelCost(PROVIDERS, "deepseek", "deepseek/deepseek-v4-flash", { + now: bjt(2026, 8, 10, 10, 0), + rules: DEFAULT_RULES, + }) + expect(r?.rates.input).toBe(0.5) + expect(r?.level).toBe("peak") + expect(r?.explicit).toBe(true) + }) + test("offpeak halves static rates", () => { + const r = resolveModelCost(PROVIDERS, "deepseek", "deepseek/deepseek-v4-flash", { + now: bjt(2026, 8, 10, 22, 0), + rules: DEFAULT_RULES, + }) + expect(r?.rates.input).toBe(0.25) + expect(r?.rates.cache.read).toBe(0.005) + expect(r?.level).toBe("offpeak") + }) + test("disabled rules → static rates, no level", () => { + const r = resolveModelCost(PROVIDERS, "deepseek", "deepseek/deepseek-v4-flash", { + now: bjt(2026, 8, 10, 22, 0), + rules: { ...DEFAULT_RULES, enabled: false }, + }) + expect(r?.rates.input).toBe(0.5) + expect(r?.level).toBeUndefined() + }) +}) + +describe("resolveModelCost — explicit rules override", () => { + test("absolute levels win over multiplier fallback", () => { + const rules = { + ...DEFAULT_RULES, + providers: { + deepseek: { + models: { + "deepseek/deepseek-v4-flash": { + levels: { + peak: { input: 0.44, output: 0.88, cache: { read: 0.01, write: 0 } }, + offpeak: { input: 0.22, output: 0.44, cache: { read: 0.005, write: 0 } }, + }, + }, + }, + }, + }, + } + const r = resolveModelCost(PROVIDERS, "deepseek", "deepseek/deepseek-v4-flash", { + now: bjt(2026, 8, 10, 22, 0), + rules, + }) + expect(r?.rates.input).toBe(0.22) + expect(r?.level).toBe("offpeak") + }) + test("per-model contextThreshold override", () => { + const rules = { + ...DEFAULT_RULES, + providers: { + openai: { models: { "gpt-5.6": { contextThreshold: 100_000 } } }, + }, + } + const r = resolveModelCost(PROVIDERS, "openai", "gpt-5.6", { + contextTokens: 150_000, + rules, + }) + expect(r?.rates.input).toBe(2.0) + expect(r?.contextTier).toBe("over") + }) + test("unknown model returns null", () => { + expect(resolveModelCost(PROVIDERS, "openai", "nope", { rules: DEFAULT_RULES })).toBeNull() + }) + + test("absolute levels keep no context badge; multipliers keep it (#3)", () => { + const rules = { + ...DEFAULT_RULES, + providers: { + openai: { + models: { + "gpt-5.6": { + levels: { peak: { input: 0.9, output: 1.8, cache: { read: 0.05, write: 0 } } }, + }, + }, + }, + }, + } + // 命中时段 → 绝对价(完整价格,无 context 分档语义)→ 不标注 badge + const hit = resolveModelCost(PROVIDERS, "openai", "gpt-5.6", { + now: bjt(2026, 8, 10, 10, 0), // peak + contextTokens: 250_000, + rules, + }) + expect(hit?.rates.input).toBe(0.9) + expect(hit?.contextTier).toBeUndefined() + + // multipliers 模式 → context 档叠加,badge 保留 + const rules2 = { + ...DEFAULT_RULES, + providers: { + openai: { models: { "gpt-5.6": { multipliers: { peak: 1, offpeak: 1 } } } }, + }, + } + const tiered = resolveModelCost(PROVIDERS, "openai", "gpt-5.6", { + now: bjt(2026, 8, 10, 10, 0), + contextTokens: 250_000, + rules: rules2, + }) + expect(tiered?.contextTier).toBe("over") + }) +}) + +describe("resolveModelCost — runtime cost normalization (#1)", () => { + const RUNTIME_PROVIDERS: ProviderInfo[] = [ + { + id: "openai", + models: { + "gpt-5.6": { + cost: { + input: 1.0, + output: 3.0, + cache: { read: 0.1, write: 0 }, + // 运行时格式:opencode 把配置层 context_over_200k 转为 tiers / experimentalOver200K + experimentalOver200K: { input: 2.0, output: 5.0, cache: { read: 0.2, write: 0 } }, + }, + }, + }, + }, + { + id: "anthropic", + models: { + "claude-x": { + cost: { + input: 3, + output: 15, + cache: { read: 0.3, write: 3.75 }, + tiers: [ + { input: 6, output: 30, cache: { read: 0.6, write: 7.5 }, tier: { type: "context", size: 100_000 } }, + ], + }, + }, + }, + }, + ] + + test("normalizes experimentalOver200K into context tier", () => { + const r = resolveModelCost(RUNTIME_PROVIDERS, "openai", "gpt-5.6", { + contextTokens: 250_000, + rules: DEFAULT_RULES, + }) + expect(r?.rates.input).toBe(2.0) + expect(r?.contextTier).toBe("over") + }) + + test("normalizes tiers[] with its own size threshold", () => { + const r = resolveModelCost(RUNTIME_PROVIDERS, "anthropic", "claude-x", { + contextTokens: 150_000, // > tier.size 100k,尽管 < 全局 200k + rules: DEFAULT_RULES, + }) + expect(r?.rates.input).toBe(6) + expect(r?.contextTier).toBe("over") + }) + + test("per-model rule threshold wins over runtime tier size", () => { + // 运行时 experimentalOver200K 阈值为 200k;用户显式配置 100k → 150k 应判 over。 + const rules = { + ...DEFAULT_RULES, + providers: { + openai: { models: { "gpt-5.6": { contextThreshold: 100_000 } } }, + }, + } + const r = resolveModelCost(RUNTIME_PROVIDERS, "openai", "gpt-5.6", { + contextTokens: 150_000, + rules, + }) + expect(r?.rates.input).toBe(2.0) + expect(r?.contextTier).toBe("over") + }) +}) + +describe("resolveModelCost — enabled:false disables everything (#2)", () => { + test("explicit levels are ignored when disabled", () => { + const rules = { + ...DEFAULT_RULES, + enabled: false, + providers: { + deepseek: { + models: { + "deepseek/deepseek-v4-flash": { + levels: { + peak: { input: 9, output: 27, cache: { read: 0.3, write: 0 } }, + offpeak: { input: 4.5, output: 13.5, cache: { read: 0.15, write: 0 } }, + }, + }, + }, + }, + }, + } + const r = resolveModelCost(PROVIDERS, "deepseek", "deepseek/deepseek-v4-flash", { + now: bjt(2026, 8, 10, 10, 0), // peak + rules, + }) + expect(r?.rates.input).toBe(0.5) // 静态价,非 9 + expect(r?.explicit).toBe(false) + }) + + test("context tier is disabled too", () => { + const rules = { ...DEFAULT_RULES, enabled: false } + const r = resolveModelCost(PROVIDERS, "openai", "gpt-5.6", { + contextTokens: 250_000, + rules, + }) + expect(r?.rates.input).toBe(1.0) // 基础档,不应用 context_over_200k + expect(r?.contextTier).toBeUndefined() + }) +}) + +describe("resolveModelCost — level miss falls back to static (#4)", () => { + test("levels do not match current level → static rates, not first level", () => { + const rules = { + ...DEFAULT_RULES, + // 自定义 schedule 仅覆盖 20:00-22:00,当前 10:00 未命中任何时段 + schedule: [{ level: "peak", windows: [{ start: 20 * 60, end: 22 * 60 }] }], + providers: { + deepseek: { + models: { + "deepseek/deepseek-v4-flash": { + levels: { + peak: { input: 9, output: 27, cache: { read: 0.3, write: 0 } }, + offpeak: { input: 4.5, output: 13.5, cache: { read: 0.15, write: 0 } }, + }, + }, + }, + }, + }, + } + const r = resolveModelCost(PROVIDERS, "deepseek", "deepseek/deepseek-v4-flash", { + now: bjt(2026, 8, 10, 10, 0), + rules, + }) + expect(r?.rates.input).toBe(0.5) // 回退静态价,而非 9 或 4.5 + expect(r?.level).toBeUndefined() + }) +}) + +describe("computePricing — backward compat + dynamic fields", () => { + test("no ctx → static behavior, dynamic=false", () => { + const p = computePricing(PROVIDERS, "deepseek", "deepseek/deepseek-v4-flash", 1_000_000) + expect(p.inputRate).toBe(0.5) + expect(p.dynamic).toBe(false) + expect(p.level).toBeUndefined() + }) + test("with rules + now → level and halved rates", () => { + const p = computePricing(PROVIDERS, "deepseek", "deepseek/deepseek-v4-flash", 1_000_000, { + now: bjt(2026, 8, 10, 22, 0), + rules: DEFAULT_RULES, + }) + expect(p.inputRate).toBe(0.25) + expect(p.level).toBe("offpeak") + expect(p.dynamic).toBe(true) + expect(p.saved).toBe(((0.25 - 0.005) * 1_000_000) / 1_000_000) + }) +}) diff --git a/tests/dynamic-pricing-recompute.test.ts b/tests/dynamic-pricing-recompute.test.ts new file mode 100644 index 0000000..e40ee69 --- /dev/null +++ b/tests/dynamic-pricing-recompute.test.ts @@ -0,0 +1,428 @@ +import { describe, test, expect } from "bun:test" +import { recomputeSessionCost, recomputeSubAgentCost, recomputeRecordCost } from "../src/dynamic-pricing/recompute.ts" +import { normalizeDynamicPricingConfig } from "../src/plugin-config.ts" +import type { AssistantMessage, ProviderInfo } from "../src/types.ts" +import { DEFAULT_SCHEDULE } from "../src/dynamic-pricing/types.ts" + +const TZ = "Asia/Shanghai" + +function bjt(y: number, m: number, d: number, h: number, min = 0): number { + return Date.UTC(y, m - 1, d, h - 8, min) +} + +const PROVIDERS: ProviderInfo[] = [ + { + id: "deepseek", + models: { + "deepseek/deepseek-v4-flash": { + cost: { input: 0.5, output: 1.0, cache: { read: 0.01, write: 0 } }, + }, + }, + }, + { + id: "openai", + models: { + "gpt-5.6": { + cost: { + input: 1.0, + output: 3.0, + cache: { read: 0.1, write: 0 }, + context_over_200k: { input: 2.0, output: 5.0, cache: { read: 0.2, write: 0 } }, + }, + }, + }, + }, +] + +const RULES = { + enabled: true, + timezone: TZ, + schedule: DEFAULT_SCHEDULE, + contextThreshold: 200_000, + providers: {}, +} + +function msg(partial: Partial & Pick): AssistantMessage { + return { role: "assistant", ...partial } +} + +describe("recomputeSessionCost", () => { + test("prices each message by its own request time (peak vs offpeak)", () => { + const messages = [ + msg({ + id: "m1", + providerID: "deepseek", + modelID: "deepseek/deepseek-v4-flash", + time: { created: bjt(2026, 8, 10, 10, 0) }, // peak + tokens: { input: 100_000, output: 10_000, cache: { read: 50_000 } }, + }), + msg({ + id: "m2", + providerID: "deepseek", + modelID: "deepseek/deepseek-v4-flash", + time: { created: bjt(2026, 8, 10, 22, 0) }, // offpeak → 半价 + tokens: { input: 100_000, output: 10_000, cache: { read: 50_000 } }, + }), + ] + const result = recomputeSessionCost(messages, PROVIDERS, RULES) + expect(result).not.toBeNull() + // opencode 语义:tokens.input 不含缓存 → input 全量按 inputRate,缓存按 cacheReadRate 单独计费。 + // m1: (100k*0.5 + 10k*1.0 + 50k*0.01)/1M + const m1 = (100_000 * 0.5 + 10_000 * 1.0 + 50_000 * 0.01) / 1_000_000 + const m2 = m1 / 2 + expect(result!.cost).toBeCloseTo(m1 + m2, 10) + expect(result!.counted).toBe(2) + expect(result!.dynamic).toBe(true) + }) + + test("context tier per message", () => { + const messages = [ + msg({ + id: "m1", + providerID: "openai", + modelID: "gpt-5.6", + time: { created: bjt(2026, 8, 10, 10, 0) }, + tokens: { input: 100_000, output: 1_000 }, // ≤200k → base + }), + msg({ + id: "m2", + providerID: "openai", + modelID: "gpt-5.6", + time: { created: bjt(2026, 8, 10, 11, 0) }, + tokens: { input: 300_000, output: 1_000 }, // >200k → over + }), + ] + const result = recomputeSessionCost(messages, PROVIDERS, RULES) + const expected = (100_000 * 1.0 + 1_000 * 3.0 + 300_000 * 2.0 + 1_000 * 5.0) / 1_000_000 + expect(result!.cost).toBeCloseTo(expected, 10) + }) + + test("returns null when nothing is pricable", () => { + expect(recomputeSessionCost([], PROVIDERS, RULES)).toBeNull() + expect( + recomputeSessionCost([msg({ id: "x", tokens: {} })], PROVIDERS, RULES), + ).toBeNull() + expect( + recomputeSessionCost([msg({ id: "x", providerID: "nope", modelID: "nope", tokens: { input: 100 } })], PROVIDERS, RULES), + ).toBeNull() + }) +}) + +describe("recomputeSubAgentCost", () => { + test("uses session created time for time-of-day pricing", () => { + const sub = { + id: "s1", + model: "deepseek/deepseek-v4-flash", + providerID: "deepseek", + cost: 0.5, + input: 100_000, + output: 10_000, + reasoning: 0, + cacheRead: 50_000, + cacheWrite: 0, + created: bjt(2026, 8, 10, 22, 0), // offpeak → 半价 + } + const cost = recomputeSubAgentCost(sub, PROVIDERS, RULES) + const expected = (100_000 * 0.25 + 10_000 * 0.5 + 50_000 * 0.005) / 1_000_000 + expect(cost).toBeCloseTo(expected, 10) + }) + test("returns null without created (cannot price time-of-day)", () => { + const sub = { + id: "s1", + model: "deepseek/deepseek-v4-flash", + providerID: "deepseek", + cost: 0.5, + input: 100_000, + output: 10_000, + reasoning: 0, + cacheRead: 50_000, + cacheWrite: 0, + } + expect(recomputeSubAgentCost(sub, PROVIDERS, RULES)).toBeNull() + }) + test("returns null for unknown model", () => { + const sub = { + id: "s1", + model: "nope", + providerID: "deepseek", + cost: 0, + input: 100, + output: 0, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + created: bjt(2026, 8, 10, 22, 0), + } + expect(recomputeSubAgentCost(sub, PROVIDERS, RULES)).toBeNull() + }) +}) + +describe("recomputeRecordCost", () => { + test("recomputes from record created time + context tier (explicit rule)", () => { + const rules = { + ...RULES, + providers: { + openai: { models: { "gpt-5.6": { contextThreshold: 200_000 } } }, + }, + } + const r = recomputeRecordCost( + { + modelId: "gpt-5.6", + providerId: "openai", + created: "2026-08-10T10:00:00+08:00", // 北京 10:00 + input: 300_000, + output: 1_000, + cacheRead: 0, + cacheWrite: 0, + }, + PROVIDERS, + rules, + ) + const expected = (300_000 * 2.0 + 1_000 * 5.0) / 1_000_000 + expect(r).toBeCloseTo(expected, 10) + }) + test("matches provider by modelId when providerId absent", () => { + const r = recomputeRecordCost( + { + modelId: "deepseek/deepseek-v4-flash", + created: "2026-08-10T22:00:00+08:00", // offpeak + input: 100_000, + output: 10_000, + cacheRead: 50_000, + cacheWrite: 0, + }, + PROVIDERS, + RULES, + ) + const expected = (100_000 * 0.25 + 10_000 * 0.5 + 50_000 * 0.005) / 1_000_000 + expect(r).toBeCloseTo(expected, 10) + }) + test("returns null when no model matches or nothing to price", () => { + expect(recomputeRecordCost({ modelId: "nope", created: "2026-08-10T22:00:00+08:00", input: 100 }, PROVIDERS, RULES)).toBeNull() + expect(recomputeRecordCost({ modelId: "", created: "2026-08-10T22:00:00+08:00" }, PROVIDERS, RULES)).toBeNull() + expect(recomputeRecordCost({ modelId: "deepseek/deepseek-v4-flash", created: "bad" }, PROVIDERS, RULES)).toBeNull() + }) +}) + +describe("normalizeDynamicPricingConfig", () => { + test("defaults when absent", () => { + const cfg = normalizeDynamicPricingConfig(undefined) + expect(cfg.enabled).toBe(true) + expect(cfg.timezone).toBe("Asia/Shanghai") + expect(cfg.schedule.length).toBeGreaterThan(0) + expect(cfg.contextThreshold).toBe(200_000) + expect(cfg.providers).toEqual({}) + }) + test("parses explicit rules", () => { + const cfg = normalizeDynamicPricingConfig({ + enabled: false, + timezone: "UTC", + contextThreshold: 100_000, + schedule: [{ level: "peak", windows: [{ start: "08:00", end: "20:00" }] }], + providers: { + deepseek: { + models: { + "deepseek/deepseek-v4-flash": { + multipliers: { offpeak: 0.5 }, + }, + }, + }, + }, + }) + expect(cfg.enabled).toBe(false) + expect(cfg.timezone).toBe("UTC") + expect(cfg.contextThreshold).toBe(100_000) + expect(cfg.schedule[0].windows[0]).toEqual({ start: 480, end: 1200 }) + expect(cfg.providers.deepseek.models["deepseek/deepseek-v4-flash"].multipliers?.offpeak).toBe(0.5) + }) + test("rejects malformed schedule and falls back to defaults", () => { + const cfg = normalizeDynamicPricingConfig({ schedule: [{ level: "peak", windows: [{ start: "99:99", end: "x" }] }] }) + expect(cfg.schedule.length).toBeGreaterThan(0) + }) + test("converts non-USD levels to internal USD at load time", () => { + const cfg = normalizeDynamicPricingConfig( + { + providers: { + deepseek: { + models: { + "deepseek/deepseek-v4-flash": { + currency: "CNY", + levels: { + offpeak: { input: 1.5, output: 4.5, cacheRead: 0.05, cacheWrite: 0 }, + peak: { input: 3, output: 9, cacheRead: 0.1, cacheWrite: 0 }, + }, + }, + }, + }, + }, + }, + { usdRate: 6.77, displayCurrency: "CNY" }, + ) + const rule = cfg.providers.deepseek.models["deepseek/deepseek-v4-flash"] + expect(rule.levels?.offpeak.input).toBeCloseTo(1.5 / 6.77, 6) + expect(rule.levels?.peak.output).toBeCloseTo(9 / 6.77, 6) + expect(rule.currency).toBeUndefined() + }) + test("keeps USD levels untouched", () => { + const cfg = normalizeDynamicPricingConfig( + { + providers: { + deepseek: { + models: { + "deepseek/deepseek-v4-flash": { + levels: { offpeak: { input: 0.22, output: 0.44, cacheRead: 0.005, cacheWrite: 0 } }, + }, + }, + }, + }, + }, + { usdRate: 6.77 }, + ) + const rule = cfg.providers.deepseek.models["deepseek/deepseek-v4-flash"] + expect(rule.levels?.offpeak.input).toBe(0.22) + }) + test("converts CNY levels when display currency matches (#5)", () => { + const cfg = normalizeDynamicPricingConfig( + { + providers: { + deepseek: { + models: { + "deepseek/deepseek-v4-flash": { + currency: "CNY", + levels: { offpeak: { input: 6.77, output: 20, cacheRead: 0.1, cacheWrite: 0 } }, + }, + }, + }, + }, + }, + { usdRate: 6.77, displayCurrency: "CNY" }, + ) + const rule = cfg.providers.deepseek.models["deepseek/deepseek-v4-flash"] + expect(rule.levels?.offpeak.input).toBeCloseTo(1, 6) // 6.77 / 6.77 + }) + test("uses explicit rule.rate for non-display currency (#5)", () => { + const cfg = normalizeDynamicPricingConfig( + { + providers: { + deepseek: { + models: { + "deepseek/deepseek-v4-flash": { + currency: "EUR", + rate: 1.08, // USD → EUR + levels: { offpeak: { input: 1.08, output: 2, cacheRead: 0.01, cacheWrite: 0 } }, + }, + }, + }, + }, + }, + { usdRate: 6.77, displayCurrency: "CNY" }, + ) + const rule = cfg.providers.deepseek.models["deepseek/deepseek-v4-flash"] + expect(rule.levels?.offpeak.input).toBeCloseTo(1, 6) // 1.08 / 1.08(不除以 USD→CNY 的 6.77) + }) + test("keeps unconvertible levels as-is with a warning (#5)", () => { + const original = console.error + const calls: string[] = [] + console.error = (...a: unknown[]) => calls.push(String(a[0])) + try { + const cfg = normalizeDynamicPricingConfig( + { + providers: { + deepseek: { + models: { + "deepseek/deepseek-v4-flash": { + currency: "EUR", + levels: { offpeak: { input: 1.08, output: 2, cacheRead: 0.01, cacheWrite: 0 } }, + }, + }, + }, + }, + }, + { usdRate: 6.77, displayCurrency: "CNY" }, + ) + const rule = cfg.providers.deepseek.models["deepseek/deepseek-v4-flash"] + expect(rule.levels?.offpeak.input).toBe(1.08) // 无法换算 → 原样保留(视作 USD) + expect(calls.some((c) => c.includes("cannot convert EUR"))).toBe(true) + } finally { + console.error = original + } + }) + test("parses nested cache object in levels (ModelCost shape)", () => { + const cfg = normalizeDynamicPricingConfig( + { + providers: { + openai: { + models: { + "gpt-5.6": { + levels: { peak: { input: 0.9, output: 1.8, cache: { read: 0.05, write: 0.02 } } }, + }, + }, + }, + }, + }, + { usdRate: 6.77, displayCurrency: "CNY" }, + ) + const peak = cfg.providers.openai.models["gpt-5.6"].levels?.peak + expect(peak?.cache.read).toBe(0.05) + expect(peak?.cache.write).toBe(0.02) + }) + test("flat cache fields win over nested cache object", () => { + const cfg = normalizeDynamicPricingConfig( + { + providers: { + openai: { + models: { + "gpt-5.6": { + levels: { + peak: { input: 0.9, output: 1.8, cacheRead: 0.11, cache: { read: 0.05, write: 0 } }, + }, + }, + }, + }, + }, + }, + { usdRate: 6.77, displayCurrency: "CNY" }, + ) + const peak = cfg.providers.openai.models["gpt-5.6"].levels?.peak + expect(peak?.cache.read).toBe(0.11) // 扁平优先 + }) + test("malformed nested cache (non-object) is safely treated as 0", () => { + const cfg = normalizeDynamicPricingConfig( + { + providers: { + openai: { + models: { + "gpt-5.6": { + levels: { peak: { input: 0.9, output: 1.8, cache: 5 } }, + }, + }, + }, + }, + }, + { usdRate: 6.77, displayCurrency: "CNY" }, + ) + const peak = cfg.providers.openai.models["gpt-5.6"].levels?.peak + expect(peak?.cache.read).toBe(0) + expect(peak?.cache.write).toBe(0) + }) + test("CNY levels with nested cache are converted to USD", () => { + const cfg = normalizeDynamicPricingConfig( + { + providers: { + deepseek: { + models: { + "deepseek/deepseek-v4-flash": { + currency: "CNY", + levels: { offpeak: { input: 6.77, output: 13.54, cache: { read: 0.677, write: 0 } } }, + }, + }, + }, + }, + }, + { usdRate: 6.77, displayCurrency: "CNY" }, + ) + const offpeak = cfg.providers.deepseek.models["deepseek/deepseek-v4-flash"].levels?.offpeak + expect(offpeak?.cache.read).toBeCloseTo(0.1, 6) // 0.677 / 6.77 + expect(offpeak?.input).toBeCloseTo(1, 6) + }) +}) diff --git a/tests/dynamic-pricing-schedule.test.ts b/tests/dynamic-pricing-schedule.test.ts new file mode 100644 index 0000000..69627c6 --- /dev/null +++ b/tests/dynamic-pricing-schedule.test.ts @@ -0,0 +1,101 @@ +import { describe, test, expect } from "bun:test" +import { + parseClockTime, + inWindow, + tzPartsOf, + startOfDayEpoch, + dayMinuteOf, + isLevelAt, + nextBoundaryMs, +} from "../src/dynamic-pricing/schedule.ts" +import { DEFAULT_SCHEDULE } from "../src/dynamic-pricing/types.ts" + +const TZ = "Asia/Shanghai" // UTC+8,无 DST + +/** 北京时间 y-m-d h:m → epoch ms。 */ +function bjt(y: number, m: number, d: number, h: number, min = 0, s = 0): number { + return Date.UTC(y, m - 1, d, h - 8, min, s) +} + +describe("parseClockTime", () => { + test("parses HH:MM", () => { + expect(parseClockTime("09:00")).toBe(540) + expect(parseClockTime("18:30")).toBe(1110) + expect(parseClockTime("00:00")).toBe(0) + expect(parseClockTime("23:59")).toBe(1439) + }) + test("rejects invalid input", () => { + expect(parseClockTime("9")).toBeNull() + expect(parseClockTime("24:00")).toBeNull() + expect(parseClockTime("09:60")).toBeNull() + expect(parseClockTime("abc")).toBeNull() + }) +}) + +describe("inWindow", () => { + test("same-day window is half-open", () => { + const w = { start: 540, end: 720 } // 09:00-12:00 + expect(inWindow(540, w)).toBe(true) + expect(inWindow(719, w)).toBe(true) + expect(inWindow(720, w)).toBe(false) + expect(inWindow(539, w)).toBe(false) + }) + test("cross-day window covers wrap-around", () => { + const w = { start: 1080, end: 540 } // 18:00 - 次日09:00 + expect(inWindow(1080, w)).toBe(true) + expect(inWindow(1439, w)).toBe(true) + expect(inWindow(0, w)).toBe(true) + expect(inWindow(539, w)).toBe(true) + expect(inWindow(540, w)).toBe(false) + expect(inWindow(1000, w)).toBe(false) + }) +}) + +describe("tzPartsOf / dayMinuteOf", () => { + test("maps epoch to Beijing wall clock", () => { + const ts = bjt(2026, 8, 10, 10, 30, 15) + expect(tzPartsOf(ts, TZ)).toEqual({ year: 2026, month: 8, day: 10, hour: 10, minute: 30, second: 15 }) + expect(dayMinuteOf(ts, TZ)).toBe(10 * 60 + 30 + 15 / 60) + expect(startOfDayEpoch(ts, TZ)).toBe(bjt(2026, 8, 10, 0)) + }) +}) + +describe("isLevelAt (DeepSeek schedule)", () => { + test("peak windows", () => { + expect(isLevelAt(bjt(2026, 8, 10, 9, 0), DEFAULT_SCHEDULE, TZ)).toBe("peak") + expect(isLevelAt(bjt(2026, 8, 10, 11, 59), DEFAULT_SCHEDULE, TZ)).toBe("peak") + expect(isLevelAt(bjt(2026, 8, 10, 14, 0), DEFAULT_SCHEDULE, TZ)).toBe("peak") + expect(isLevelAt(bjt(2026, 8, 10, 17, 59), DEFAULT_SCHEDULE, TZ)).toBe("peak") + }) + test("offpeak boundaries", () => { + expect(isLevelAt(bjt(2026, 8, 10, 12, 0), DEFAULT_SCHEDULE, TZ)).toBe("offpeak") + expect(isLevelAt(bjt(2026, 8, 10, 13, 30), DEFAULT_SCHEDULE, TZ)).toBe("offpeak") + expect(isLevelAt(bjt(2026, 8, 10, 18, 0), DEFAULT_SCHEDULE, TZ)).toBe("offpeak") + expect(isLevelAt(bjt(2026, 8, 10, 23, 59), DEFAULT_SCHEDULE, TZ)).toBe("offpeak") + expect(isLevelAt(bjt(2026, 8, 10, 0, 30), DEFAULT_SCHEDULE, TZ)).toBe("offpeak") + expect(isLevelAt(bjt(2026, 8, 10, 8, 59), DEFAULT_SCHEDULE, TZ)).toBe("offpeak") + }) + test("empty schedule returns undefined", () => { + expect(isLevelAt(Date.now(), [], TZ)).toBeUndefined() + }) +}) + +describe("nextBoundaryMs", () => { + test("next boundary within the day", () => { + // 10:00 → 12:00(2h) + expect(nextBoundaryMs(bjt(2026, 8, 10, 10, 0), DEFAULT_SCHEDULE, TZ)).toBe(2 * 3_600_000) + // 13:00 → 14:00(1h) + expect(nextBoundaryMs(bjt(2026, 8, 10, 13, 0), DEFAULT_SCHEDULE, TZ)).toBe(3_600_000) + // 08:00 → 09:00(1h) + expect(nextBoundaryMs(bjt(2026, 8, 10, 8, 0), DEFAULT_SCHEDULE, TZ)).toBe(3_600_000) + }) + test("rolls to tomorrow after 18:00", () => { + // 18:30 → 次日 09:00(14.5h) + expect(nextBoundaryMs(bjt(2026, 8, 10, 18, 30), DEFAULT_SCHEDULE, TZ)).toBe(14.5 * 3_600_000) + // 23:59 → 次日 09:00 + expect(nextBoundaryMs(bjt(2026, 8, 10, 23, 59), DEFAULT_SCHEDULE, TZ)).toBe(9 * 3_600_000 + 60_000) + }) + test("empty schedule falls back to 24h", () => { + expect(nextBoundaryMs(Date.now(), [], TZ)).toBe(24 * 3_600_000) + }) +}) diff --git a/tests/jsonc.test.ts b/tests/jsonc.test.ts new file mode 100644 index 0000000..3490501 --- /dev/null +++ b/tests/jsonc.test.ts @@ -0,0 +1,62 @@ +import { describe, test, expect } from "bun:test" +import { parseJsonc, stripJsonc } from "../src/jsonc.ts" + +describe("stripJsonc", () => { + test("removes line comments", () => { + expect(stripJsonc(`{\n// line comment\n"a": 1}`)).toBe(`{\n"a": 1}`) + }) + + test("removes block comments", () => { + expect(stripJsonc(`{"a": /* block */ 1}`)).toBe(`{"a": 1}`) + }) + + test("removes trailing commas before objects and arrays", () => { + expect(stripJsonc(`{"a": 1,}`)).toBe(`{"a": 1}`) + expect(stripJsonc(`[1, 2, ]`)).toBe(`[1, 2]`) + }) + + test("removes trailing commas when comments sit between comma and close", () => { + expect(stripJsonc(`{"a": 1, // trailing\n}`)).toBe(`{"a": 1}`) + expect(stripJsonc(`[1, /* trailing */]`)).toBe(`[1]`) + }) + + test("preserves comment markers inside strings", () => { + const src = `{"url":"http://example.com/a//b","block":"/* keep */","quote":"\\" // keep"}` + expect(stripJsonc(src)).toBe(src) + }) + + test("does not treat braces or commas inside strings as JSONC syntax", () => { + expect(stripJsonc(`{"a":"}","b":","}`)).toBe(`{"a":"}","b":","}`) + }) + + test("keeps a string ending with a comma next to a real trailing comma", () => { + // 易误判场景:字符串值以逗号结尾 + 真正的尾逗号。回溯只删字符串外的尾逗号。 + expect(stripJsonc(`{"a": "x,",}`)).toBe(`{"a": "x,"}`) + expect(stripJsonc(`{"a": {"b": "x,",},}`)).toBe(`{"a": {"b": "x,"}}`) + }) +}) + +describe("parseJsonc", () => { + test("parses JSONC into an object", () => { + expect( + parseJsonc<{ a: number; b: number[] }>(`{ + // line comment + "a": 1, /* block comment */ + "b": [1, 2,], + }`), + ).toEqual({ a: 1, b: [1, 2] }) + }) + + test("preserves escaped quotes and URLs in strings", () => { + expect( + parseJsonc<{ url: string; quote: string }>( + `{"url":"http://example.com/a//b","quote":"say \\"hi\\" // now"}`, + ), + ).toEqual({ url: "http://example.com/a//b", quote: 'say "hi" // now' }) + }) + + test("throws SyntaxError for malformed JSONC", () => { + expect(() => parseJsonc(`{"a": 1`)).toThrow(SyntaxError) + expect(() => parseJsonc(`/* unterminated {"a": 1}`)).toThrow(SyntaxError) + }) +})