From d85a757d6359724cf6984817459dbef3cddef1ba Mon Sep 17 00:00:00 2001 From: "mengzhu.zhu" Date: Sun, 23 Aug 2026 16:31:50 +0800 Subject: [PATCH 1/2] feat: weekday-aware dynamic pricing schedule (DeepSeek weekend off-peak) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TimeWindow gains optional days (ISO 1=Mon..7=Sun; omitted = every day); a level with empty windows is the catch-all fallback level - DEFAULT_SCHEDULE: peak Mon-Fri 09:00-12:00 / 14:00-18:00 Beijing, offpeak fallback covers weekends — no more weekend peak mispricing - isLevelAt: fallback is last-resort (never first-match); tzWeekdayOf added; inWindow applies start-day rule for cross-midnight windows - nextBoundaryMs is weekday-aware (Fri 18:00 -> Mon 09:00), skipping weekend pseudo-boundaries - normalizeSchedule parses days, keeps explicit empty-windows levels, dedupes to at most one fallback; drops all-invalid-window levels - one-time stderr hint when a DeepSeek model resolves against a windowed schedule without days (legacy configs stay compatible) - level badge only when the model prices the current level: plain static models and unpriced levels (e.g. peak-only rule at off-peak) show no peak/offpeak label - docs: README x2 migration note + badge semantics, design x2, dynamic-pricing-plan, example config $comment, fetch-deepseek-pricing prints weekday-aware schedule; version 0.7.2 --- README.md | 22 ++-- README.zh-CN.md | 20 ++-- cache-hit.config.example.json | 8 +- docs/en/design.md | 2 +- docs/zh-CN/design.md | 4 +- docs/zh-CN/dynamic-pricing-plan.md | 29 +++-- package.json | 2 +- scripts/fetch-deepseek-pricing.ts | 13 ++- src/dynamic-pricing/lookup.ts | 34 +++++- src/dynamic-pricing/schedule.ts | 92 ++++++++++++---- src/dynamic-pricing/types.ts | 25 +++-- src/plugin-config.ts | 41 ++++++- tests/dynamic-pricing-lookup.test.ts | 144 +++++++++++++++++++++++++ tests/dynamic-pricing-schedule.test.ts | 119 +++++++++++++++++--- tests/plugin-config.test.ts | 71 ++++++++++++ 15 files changed, 545 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 48d856c..a571bf3 100644 --- a/README.md +++ b/README.md @@ -209,20 +209,23 @@ Shows how long the prompt cache has been alive. Color changes when exceeding 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). +Model rates are normally static per-million USD from OpenCode's provider registry. Some models price by **time of day** (DeepSeek V4: peak Mon–Fri 09:00-12:00 / 14:00-18:00 Beijing time, off-peak half price — weekends are off-peak) 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. +- Applies a built-in DeepSeek off-peak 0.5× multiplier when the schedule below matches (weekdays only for peak, weekends fall to the off-peak fallback). ```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"}] } + { "level": "peak", "windows": [ + { "start": "09:00", "end": "12:00", "days": [1, 2, 3, 4, 5] }, + { "start": "14:00", "end": "18:00", "days": [1, 2, 3, 4, 5] } + ] }, + { "level": "offpeak", "windows": [] } ], "contextThreshold": 200000, "providers": { @@ -241,7 +244,7 @@ Without config, the plugin already: |-------|---------|---------| | `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 | +| `schedule` | DeepSeek peak/off-peak | `{level, windows:[{start,end,days?}]}` list; `HH:MM`, cross-midnight windows allowed. `days` is an optional ISO weekday list (1=Monday … 7=Sunday); omitted or `[]` = every day. A level with **empty `windows`** is the **catch-all fallback**: it applies whenever no windowed level matches (e.g. weekends with the default DeepSeek schedule) | | `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 | @@ -251,11 +254,14 @@ Per-model rules support two forms (explicit config wins over the built-in DeepSe - `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). +Rates shown in the sidebar switch automatically at schedule boundaries (no polling). The `peak`/`off-peak` badge on the rate row appears **only when the model actually prices that level** (the level exists in its explicit `levels`/`multipliers`, or the built-in DeepSeek default applies); plain static models and unpriced levels (e.g. a peak-only rule at an off-peak moment) show no badge. 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). + +> [!NOTE] +> **Migration (weekday-aware schedules).** Schedules now support an optional `days` field (ISO weekday, 1=Monday … 7=Sunday; omitted = every day) and a catch-all **fallback level** (a level with empty `windows`). DeepSeek's official peak is **Monday–Friday** 09:00-12:00 / 14:00-18:00 Beijing time; weekends are off-peak. The built-in default schedule is weekday-aware, and new configs written from the examples above are too. **Legacy configs without `days` keep the old behavior — weekends are still billed as peak.** To pick up the fix, add `"days": [1,2,3,4,5]` to your `peak` windows (or use the new default schedule with an `offpeak` fallback). If you resolve a DeepSeek model while your configured schedule has windowed levels but no `days`, the plugin logs a one-time hint to stderr. **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). +**Refresh official DeepSeek rates**: `bun scripts/fetch-deepseek-pricing.ts` prints a ready-to-paste `dynamicPricing` snippet — a weekday-aware `schedule` (peak Mon–Fri, off-peak catch-all fallback) plus `providers` rates (CNY by default with `"currency": "CNY"`, `--usd --rate 6.77` for USD). ## Updating @@ -273,7 +279,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.7.1"] } +{ "plugin": ["opencode-cache-hit@0.7.2"] } ``` ## Compatibility diff --git a/README.zh-CN.md b/README.zh-CN.md index 8799132..5c186c0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -209,20 +209,23 @@ jq -r 'select(.rootSessionId=="YOUR_ROOT") | [.created,.scope,.hitPercent,.cost] ### 动态计价(`dynamicPricing`,默认开启) -模型单价通常为 OpenCode provider 注册表中的静态 USD/百万 token。部分模型按**时段**计价(DeepSeek V4:高峰 09:00-12:00 / 14:00-18:00 北京时间,空闲半价),或按**上下文大小**分档(`context_over_200k`,如 GPT-5.6:超过 200k token 单价约翻倍)。 +模型单价通常为 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× 倍率。 +- 时段匹配时对 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"}] } + { "level": "peak", "windows": [ + { "start": "09:00", "end": "12:00", "days": [1, 2, 3, 4, 5] }, + { "start": "14:00", "end": "18:00", "days": [1, 2, 3, 4, 5] } + ] }, + { "level": "offpeak", "windows": [] } ], "contextThreshold": 200000, "providers": { @@ -241,7 +244,7 @@ jq -r 'select(.rootSessionId=="YOUR_ROOT") | [.created,.scope,.hitPercent,.cost] |------|--------|------| | `enabled` | `true` | 总开关。设为 `false` 恢复完全静态计价 | | `timezone` | `Asia/Shanghai` | 时段匹配所用 IANA 时区(DeepSeek 按北京时间计价) | -| `schedule` | DeepSeek 高峰/空闲 | `{level, windows:[{start,end}]}` 列表;`HH:MM` 格式,支持跨天窗口 | +| `schedule` | DeepSeek 高峰/空闲 | `{level, windows:[{start,end,days?}]}` 列表;`HH:MM` 格式,支持跨天窗口。`days` 为可选 ISO 星期列表(1=周一 … 7=周日);省略或 `[]` = 每天。**`windows` 为空的 level 是「回退档」**:任何窗口级未命中时兜底生效(如默认 schedule 的周末) | | `contextThreshold` | `200000` | 上下文档位的 token 阈值;模型级 `contextThreshold` 优先于 `state.provider` 的运行时档位阈值 | | `providers` | `{}` | 按 `providerID` → `modelID` 的规则 | @@ -251,11 +254,14 @@ jq -r 'select(.rootSessionId=="YOUR_ROOT") | [.created,.scope,.hitPercent,.cost] - `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 合计标注 `≈`)。 +侧边栏单价会在时段边界自动切换(无需轮询)。单价行的 `peak`/`offpeak` 徽标**仅在该模型对当前时段档有定价时**显示(当前档存在于其显式 `levels`/`multipliers` 中,或内置 DeepSeek 默认生效);纯静态价模型、以及未定价的档(如只配 peak 的模型在 offpeak 时刻)不标注。会话成本在动态规则生效时按每条消息的请求时刻 + 上下文档位重算(标注 `≈`);否则使用 OpenCode 自身的 `msg.cost`。子 agent 行按其**会话创建时刻**(`session.list`)做时段计价(任一子会话被重算时 Agents 合计标注 `≈`)。 + +> [!NOTE] +> **迁移提示(星期感知 schedule)**。schedule 现支持可选 `days` 字段(ISO 星期,1=周一 … 7=周日;省略 = 每天)与「**回退档**」(`windows` 为空的 level)。DeepSeek 官方高峰为**周一至周五**北京时间 09:00-12:00 / 14:00-18:00,周末为空闲。内置默认 schedule 已是星期感知写法,上方示例亦然;**未写 `days` 的旧配置保持原语义——周末仍按高峰计费**。要修复,请为 `peak` 窗口补 `"days": [1,2,3,4,5]`(或改用含 `offpeak` 回退档的新默认 schedule)。若你解析 DeepSeek 模型时配置的 schedule 存在窗口级档但均未写 `days`,插件会向 stderr 输出一次性提示。 **时间轴看板**([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` 转美元)。 +**刷新 DeepSeek 官方价**:`bun scripts/fetch-deepseek-pricing.ts` 输出可直接粘贴的 `dynamicPricing` 片段(星期感知 `schedule`:peak 仅周一~周五 + offpeak 回退档;`providers` 默认人民币并带 `"currency": "CNY"`,`--usd --rate 6.77` 转美元)。 ## 更新 diff --git a/cache-hit.config.example.json b/cache-hit.config.example.json index 2299fe3..3540abd 100644 --- a/cache-hit.config.example.json +++ b/cache-hit.config.example.json @@ -33,9 +33,13 @@ "dynamicPricing": { "enabled": true, "timezone": "Asia/Shanghai", + "$comment-schedule": "Weekday-aware schedule: days = ISO weekday (1=Monday…7=Sunday); omitted = every day. A level with empty windows is the catch-all fallback. DeepSeek official: peak is Mon-Fri 09:00-12:00 / 14:00-18:00 Beijing, everything else (incl. weekends) is off-peak. Legacy configs without days keep peak on weekends — add days:[1,2,3,4,5] to match DeepSeek weekend idle pricing.", "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"}] } + { "level": "peak", "windows": [ + { "start": "09:00", "end": "12:00", "days": [1, 2, 3, 4, 5] }, + { "start": "14:00", "end": "18:00", "days": [1, 2, 3, 4, 5] } + ] }, + { "level": "offpeak", "windows": [] } ], "contextThreshold": 200000, "providers": { diff --git a/docs/en/design.md b/docs/en/design.md index d094a33..aaba56f 100644 --- a/docs/en/design.md +++ b/docs/en/design.md @@ -51,7 +51,7 @@ flowchart TB 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). +- **Time-of-day tier**: `schedule` (default DeepSeek peak Mon–Fri 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). Windows may carry an optional `days` list (ISO 1=Monday…7=Sunday; omitted = every day); a level with empty `windows` is the **catch-all fallback** (e.g. the default `offpeak`, which covers weekends). Cross-midnight windows anchor to their open day (evening part on `days` day, morning part on the next day). Lookup fallback chain (`src/dynamic-pricing/lookup.ts`): explicit `levels` → explicit `multipliers` → built-in DeepSeek default → static `state.provider` cost. diff --git a/docs/zh-CN/design.md b/docs/zh-CN/design.md index 9d309f5..01f6260 100644 --- a/docs/zh-CN/design.md +++ b/docs/zh-CN/design.md @@ -51,11 +51,11 @@ flowchart TB 两个正交的价格维度决定侧边栏显示的有效百万 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` 恢复完全静态计价)。 +- **时段档**:`schedule`(默认 DeepSeek 高峰为北京时间**周一至周五** 09:00-12:00 / 14:00-18:00,其余含周末为空闲)+ 单模型 `multipliers`(默认 DeepSeek 空闲 0.5×)或绝对价 `levels`(时段未命中回退静态价;`enabled: false` 恢复完全静态计价)。窗口可带可选 `days`(ISO 1=周一…7=周日;省略 = 每天);`windows` 为空的 level 即「**回退档**」(默认 `offpeak` 兜底周末等一切未覆盖时刻)。跨天窗口锚定开启日(`days` 日承载晚间段,次日承载早晨段)。 查找回退链(`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/schedule.ts`:基于时区的窗口匹配(`Intl.DateTimeFormat`,含 `tzWeekdayOf` 星期判定),`nextBoundaryMs` 星期感知地驱动 `use-cache-hit-metrics.ts` 中 `setTimeout` 精确刷新(周五 18:00 后直达周一 09:00,周末不轮询)——无需轮询。 - 会话成本重算(`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`(`≈` 展示并计入图表/合计)。 diff --git a/docs/zh-CN/dynamic-pricing-plan.md b/docs/zh-CN/dynamic-pricing-plan.md index 5dd30ce..4c0ed17 100644 --- a/docs/zh-CN/dynamic-pricing-plan.md +++ b/docs/zh-CN/dynamic-pricing-plan.md @@ -7,7 +7,7 @@ ## 1. 背景 -- DeepSeek V4 系列自 2026-08 起官方按时间计价:高峰时段(北京时间 9:00-12:00、14:00-18:00)全价,空闲时段半价(缓存命中 / 未命中 / 输出三档各乘 0.5)。 +- DeepSeek V4 系列自 2026-08 起官方按时间计价:高峰时段(北京时间**周一至周五** 9:00-12:00、14:00-18:00)全价,空闲时段半价(缓存命中 / 未命中 / 输出三档各乘 0.5);**周末全天为空闲**。 - 部分模型(如 GPT-5.6 系列)配置了 `cost.context_over_200k`:上下文超过 200k 时价格约翻倍。 - 以上两类价格都无法用当前插件的静态单档价格表达。 @@ -67,9 +67,12 @@ type PriceTier = { "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"}] } + "schedule": [ // 时段规则(DeepSeek 官方默认;星期感知) + { "level": "peak", "windows": [ + { "start":"09:00","end":"12:00","days":[1,2,3,4,5] }, // 周一~周五 + { "start":"14:00","end":"18:00","days":[1,2,3,4,5] } + ] }, + { "level": "offpeak", "windows": [] } // 回退档:周末等一切未覆盖时刻 ], "contextThreshold": 200000, // 全局默认上下文档位阈值(默认读 200k) "providers": { @@ -104,8 +107,8 @@ type PriceTier = { ``` src/dynamic-pricing/ - types.ts // ModelPricingRule / ScheduleLevel / TimeWindow / DynamicPricingConfig - schedule.ts // 时段解析、isLevelAt(now)、nextBoundaryMs(now)(距下一边界毫秒数) + types.ts // ModelPricingRule / ScheduleLevel / TimeWindow(days?) / DynamicPricingConfig + schedule.ts // 时段解析、tzWeekdayOf(ISO 星期)、isLevelAt(now)(含回退档)、nextBoundaryMs(now)(星期感知,距下一边界毫秒数) context.ts // 上下文档位选择(context_over_200k)+ 倍率缩放 lookup.ts // resolveModelCost(providers, providerID, modelID, ctx) → 有效四率 + 档位标注 deepseek.ts // 内置 DeepSeek 空闲 0.5× 默认规则 @@ -125,6 +128,7 @@ computePricing(providers, providerID, modelID, cacheRead, `use-cache-hit-metrics` 增加 `now` signal: - `setTimeout(nextBoundaryMs)` 精确对齐下一边界(9:00/12:00/14:00/18:00),`onCleanup` 清理; +- 星期感知:周五 18:00 后的下一边界直达周一 09:00(周末无有效边界,不轮询); - 跨边界时 rates/saved/时段标记**瞬间切换**,无需每秒轮询。 ### 4.5 已发生成本(msg.cost)修正 @@ -135,7 +139,7 @@ computePricing(providers, providerID, modelID, cacheRead, ### 4.6 UI -- rates / saved 行:随时段与上下文档位显示对应价格,可加 `peak/offpeak`、`≤200k/>200k` 标记。 +- rates / saved 行:随时段与上下文档位显示对应价格,可加 `peak/offpeak`、`≤200k/>200k` 标记。`peak/offpeak` 徽标仅在该模型对当前时段档有定价(当前档存在于显式 `levels`/`multipliers` 或内置 DeepSeek 默认)时显示,静态价模型与未定价档不标注。 - cost 行:动态计价生效时用重算值(`≈` 前缀),否则维持 `msg.cost`。 ## 5. 精度边界与权衡 @@ -146,7 +150,8 @@ computePricing(providers, providerID, modelID, cacheRead, | 上下文大小判定 | `msg.tokens.input`(含缓存);200k 阈值可配置 | | `state.provider` 静态价滞后 | 显式 `levels` 绝对价绕开;倍率模式受影响 | | 时区 | 默认 Asia/Shanghai,可覆盖 | -| 节假日 | 官方未声明差异,当前按每日同规则(未实现按日配置) | +| 节假日 | 官方未声明差异,当前按每日同规则(未实现节假日感知) | +| 星期 | `days`(ISO 1=周一…7=周日)可选;省略 = 每天。旧配置未写 `days` 时周末仍按高峰(向后兼容,README 有迁移提示 + 运行时一次性 stderr 提示) | | 子 agent 无 created | 直接回退 msg.cost,不按时段重算(不近似) | | 与 opencode msg.cost 口径差异 | 重算值标注 `≈`;opencode 若已按 tier 计算则差异为时段维度 | @@ -156,16 +161,18 @@ computePricing(providers, providerID, modelID, cacheRead, - **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`)。 +- **M5 星期感知(weekday-aware schedule)**:`TimeWindow.days?` + 回退档(空 `windows` level 兜底);`nextBoundaryMs` 跳过周末;DeepSeek 周末自动空闲(默认 schedule 生效,无需配置);旧配置需手动补 `days:[1..5]`(README/示例/运行时提示三触点)。 ## 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-schedule.test.ts`:时段解析与边界(09:00 整点归属、12:00 切档、跨天 18:00→09:00)、`nextBoundaryMs`、时区换算;**星期感知**(周末→offpeak、回退档语义、跨天起始日规则、`nextBoundaryMs` 周五→周一、`tzWeekdayOf`)。 +- `tests/dynamic-pricing-lookup.test.ts`:上下文档位选择(≤/>200k、无 context_over_200k 回退)、lookup fallback 链、内置 DeepSeek 时段倍率(含周末 0.5×)、computePricing 向后兼容。 - `tests/dynamic-pricing-recompute.test.ts`:跨时段/跨档消息序列重算、子 agent 重算、记录重算、配置 normalize(含非 USD levels 换算)。 +- `tests/plugin-config.test.ts`:`days` 解析、非法 `days` 忽略、空 windows 档保留与去重。 ## 8. 参考 -- DeepSeek 官方定价:https://api-docs.deepseek.com/zh-cn/quick_start/pricing (高峰 9:00-12:00、14:00-18:00,空闲半价) +- 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/package.json b/package.json index f2c2351..8dbae31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-cache-hit", - "version": "0.7.1", + "version": "0.7.2", "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/fetch-deepseek-pricing.ts b/scripts/fetch-deepseek-pricing.ts index 617f43b..c229878 100644 --- a/scripts/fetch-deepseek-pricing.ts +++ b/scripts/fetch-deepseek-pricing.ts @@ -9,7 +9,9 @@ * bun scripts/fetch-deepseek-pricing.ts --url # custom page URL * * Output is JSON you can merge into cache-hit.json under "dynamicPricing": - * "dynamicPricing": { ...existing..., } + * "dynamicPricing": { ...existing..., } + * The printed schedule is weekday-aware (peak Mon-Fri, offpeak catch-all fallback) + * to match DeepSeek's official weekend-idle pricing. */ // Make this file a module for TS tooling (top-level await below). @@ -74,6 +76,15 @@ try { const groups = parsePriceGroups(html) const unit = useUsd ? `USD (÷${rate})` : "CNY" const snippet = { + // DeepSeek 官方高峰:北京时间周一~周五 9:00-12:00 / 14:00-18:00;其余(含周末)为空闲。 + // 旧写法(peak 无 days)周末仍按高峰,请保留 days:[1,2,3,4,5]。 + schedule: [ + { level: "peak", windows: [ + { start: "09:00", end: "12:00", days: [1, 2, 3, 4, 5] }, + { start: "14:00", end: "18:00", days: [1, 2, 3, 4, 5] }, + ] }, + { level: "offpeak", windows: [] }, // 回退档:一切未覆盖时刻(含周末)为空闲 + ], providers: { "deepseek": { models: { diff --git a/src/dynamic-pricing/lookup.ts b/src/dynamic-pricing/lookup.ts index 3ce85e5..c7511fd 100644 --- a/src/dynamic-pricing/lookup.ts +++ b/src/dynamic-pricing/lookup.ts @@ -2,7 +2,7 @@ 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" +import { type DynamicPricingConfig, type ModelPricingRule } from "./types.ts" /** 静态价格查询:providerID + modelID → ModelCost(运行时 tiers 已归一化),未命中返回 null。 */ export function lookupModelCost( @@ -35,6 +35,27 @@ export type ResolvedPricing = { explicit: boolean } +/** 已提示过「DeepSeek schedule 无 days」的 providerID/modelID(每配置生命周期仅一次)。 */ +const warnedNoWeekday = new Set() + +/** + * DeepSeek 且 schedule 存在窗口级档但均未写 `days` → 一次性 stderr 提示(仅提示,不改计费结果)。 + * 旧配置(peak 无 days)周末仍按高峰计费,需手动补 `days:[1,2,3,4,5]`。 + */ +function maybeWarnDeepSeekWeekend(rules: DynamicPricingConfig | undefined, providerID: string, modelID: string): void { + if (!rules?.enabled || !isDeepSeek(providerID, modelID)) return + const hasWindowed = rules.schedule.some((l) => l.windows.length > 0) + const hasDays = rules.schedule.some((l) => l.windows.some((w) => w.days && w.days.length > 0)) + if (!(hasWindowed && !hasDays)) return + const key = `${providerID}/${modelID}` + if (warnedNoWeekday.has(key)) return + warnedNoWeekday.add(key) + console.warn( + `[dynamicPricing] ${key} 的 schedule 未按星期区分 —— 周末 9:00-12:00/14:00-18:00 仍可能按高峰计费。` + + `请在 peak 窗口加 "days":[1,2,3,4,5] 以匹配 DeepSeek 周末空闲定价。`, + ) +} + function effectiveRule( rules: DynamicPricingConfig | undefined, providerID: string, @@ -82,6 +103,7 @@ export function resolveModelCost( const now = ctx.now ?? Date.now() const rules = ctx.rules const level = resolveLevel(now, rules) + maybeWarnDeepSeekWeekend(rules, providerID, modelID) const rule = effectiveRule(rules, providerID, modelID) // 分档阈值优先级:模型级配置 rule.contextThreshold > 运行时 tier.size(cost.contextThreshold) // > 全局 contextThreshold > 默认 200k。统一到 base 上,保证 tierOf 与 selectContextRates 口径一致。 @@ -101,8 +123,12 @@ export function resolveModelCost( } if (rule?.multipliers) { const factor = level ? rule.multipliers[level] ?? 1 : 1 - return { rates: scaleRates(selectContextRates(effBase, ctx.contextTokens, effThreshold), factor), level, contextTier, explicit: true } + // 仅当 multipliers 含当前档时才标注(如只配了 peak 的模型,offpeak 时刻价格=1× 全价,不贴「空闲」)。 + const levelShown = level !== undefined && rule.multipliers[level] !== undefined + return { rates: scaleRates(selectContextRates(effBase, ctx.contextTokens, effThreshold), factor), level: levelShown ? level : undefined, contextTier, explicit: true } } - // 显式配置了该模型(含仅 contextThreshold)→ 视为动态规则生效。 - return { rates: selectContextRates(effBase, ctx.contextTokens, effThreshold), level, contextTier, explicit: rule !== undefined } + // 仅当该模型真正按时段计价(显式 levels/multipliers 或内置 DeepSeek 默认)时才报告 level; + // 无规则或仅 contextThreshold 的模型不标注时段档(避免默认回退档给静态价模型贴上「空闲/peak」)。 + const levelAware = rule?.levels !== undefined || rule?.multipliers !== undefined + return { rates: selectContextRates(effBase, ctx.contextTokens, effThreshold), level: levelAware ? level : undefined, contextTier, explicit: rule !== undefined } } diff --git a/src/dynamic-pricing/schedule.ts b/src/dynamic-pricing/schedule.ts index 788c404..281e0f0 100644 --- a/src/dynamic-pricing/schedule.ts +++ b/src/dynamic-pricing/schedule.ts @@ -1,7 +1,5 @@ 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})$/) @@ -12,10 +10,28 @@ export function parseClockTime(raw: string): number | 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 +/** ISO 星期(1=周一…7=周日)的前一日,回绕(周一的前一日是周日)。 */ +function prevWeekday(weekday: number): number { + return ((weekday - 2 + 7) % 7) + 1 +} + +/** 窗口是否每天适用(`days` 省略或空数组)。 */ +function isEveryDayWindow(w: TimeWindow): boolean { + return !w.days || w.days.length === 0 +} + +export function inWindow(dayMinute: number, weekday: number, w: TimeWindow): boolean { + const anyDay = isEveryDayWindow(w) + if (w.start <= w.end) { + // 非跨天:仅当日;days 作用于「当日」。 + if (!anyDay && !w.days.includes(weekday)) return false + return dayMinute >= w.start && dayMinute < w.end + } + // 跨天窗口锚定「开启日」:晚间段 [start, 24:00) 归属 weekday; + // 早晨段 [00:00, end) 由前一日开启(weekday 的前一日,回绕)。 + if (dayMinute >= w.start) return anyDay || w.days.includes(weekday) + const prev = prevWeekday(weekday) + return dayMinute < w.end && (anyDay || w.days.includes(prev)) } export type TzParts = { @@ -81,9 +97,18 @@ export function dayMinuteOf(ts: number, timezone: string): number { return p.hour * 60 + p.minute + p.second / 60 } +/** 指定时区下该时刻的 ISO 星期(1=周一 … 7=周日)。 */ +export function tzWeekdayOf(ts: number, timezone: string): number { + const p = tzPartsOf(ts, timezone) + // Date.getUTCDay():0=周日…6=周六 → ISO 1=周一…7=周日。 + const dow = new Date(Date.UTC(p.year, p.month - 1, p.day)).getUTCDay() + return ((dow + 6) % 7) + 1 +} + /** - * 判定 now 命中的时段档名(按 schedule 顺序,首个匹配)。 - * schedule 为空或未命中 → undefined。 + * 判定 now 命中的时段档名(按 schedule 顺序,首个匹配的窗口级)。 + * 回退档契约见 types.ts ScheduleLevel:last-resort,不参与 first-match。 + * schedule 为空或窗口级与回退档均未命中 → undefined。 */ export function isLevelAt( now: number, @@ -92,34 +117,59 @@ export function isLevelAt( ): string | undefined { if (schedule.length === 0) return undefined const min = dayMinuteOf(now, timezone) + const weekday = tzWeekdayOf(now, timezone) + let fallback: string | undefined for (const lvl of schedule) { + if (lvl.windows.length === 0) { + fallback = lvl.level + continue + } for (const w of lvl.windows) { - if (inWindow(min, w)) return lvl.level + if (inWindow(min, weekday, w)) return lvl.level } } - return undefined + return fallback } /** - * 距下一个时段窗口边界(任一 level 任一 window 的 start/end)的毫秒数。 - * 用于精确调度 UI 刷新;无任何边界时返回 24h。 + * 距下一个时段窗口边界的毫秒数(任一 level 任一 window 的 start/end)。 + * 星期感知:自 now 所在日向前扫描最多 7 天,只收集「该日承载」的边界 + * (同天窗口的 start/end 落在开启日;跨天窗口的 start 落在开启日、end 落在次日), + * 因此周五 18:00 的下一边界会跳过周末直达周一 09:00。 + * 无任何窗口级时返回 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) + const t0 = startOfDayEpoch(now, timezone) + for (let d = 0; d <= 7; d++) { + const dayStart = t0 + d * 86_400_000 + const wd = tzWeekdayOf(dayStart, timezone) + for (const lvl of schedule) { + for (const w of lvl.windows) { + for (const m of [w.start, w.end]) { + const b = dayStart + m * 60_000 + if (b > now && boundaryCarriedByDay(m, wd, w)) { + best = Math.min(best, b - now) + } + } } } } - return Number.isFinite(best) ? best : MINUTES_PER_DAY * 60_000 + return Number.isFinite(best) ? best : 86_400_000 +} + +/** 边界分钟 m 是否由 weekday 当日承载(见 nextBoundaryMs 注释)。 */ +function boundaryCarriedByDay(m: number, weekday: number, w: TimeWindow): boolean { + if (w.start <= w.end) { + // 同天窗口:开启日当天承载 start 与 end 两个边界。 + return isEveryDayWindow(w) || w.days.includes(weekday) + } + // 跨天窗口:start 由开启日承载;end 落在次日,由「开启日的次日」承载。 + const anyDay = isEveryDayWindow(w) + if (m === w.start) return anyDay || w.days.includes(weekday) + return anyDay || w.days.includes(prevWeekday(weekday)) } diff --git a/src/dynamic-pricing/types.ts b/src/dynamic-pricing/types.ts index e5db516..8d75aa1 100644 --- a/src/dynamic-pricing/types.ts +++ b/src/dynamic-pricing/types.ts @@ -1,12 +1,22 @@ import type { ModelCost } from "../types.ts" -/** 24h 时间窗口,单位:当天分钟数 [start, end)。end <= start 表示跨天(end 属次日)。 */ +/** + * 24h 时间窗口,单位:当天分钟数 [start, end)。end <= start 表示跨天(end 属次日)。 + * `days`:适用星期(ISO,1=周一 … 7=周日)。省略或空数组 = 每天。 + */ export type TimeWindow = { start: number end: number + days?: number[] } -/** 一个时段档(如 peak / offpeak)及其时间窗口。 */ +/** + * 一个时段档(如 peak / offpeak)及其时间窗口。 + * + * `windows` 为空的 level 是「回退档」(catch-all 兜底),契约:永远 last-resort—— + * isLevelAt 不将其纳入 first-match(即使写在数组首位),任何窗口级未命中时兜底; + * 全 schedule 至多 1 个(normalizeSchedule 负责重排至末尾并去重)。 + */ export type ScheduleLevel = { level: string windows: TimeWindow[] @@ -48,10 +58,13 @@ export type DynamicPricingConfig = { } 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 }] }, + // DeepSeek 官方高峰时段:北京时间周一~周五 9:00-12:00 / 14:00-18:00(周末为空闲)。 + { level: "peak", windows: [ + { start: 9 * 60, end: 12 * 60, days: [1, 2, 3, 4, 5] }, + { start: 14 * 60, end: 18 * 60, days: [1, 2, 3, 4, 5] }, + ] }, + // 回退档(windows 为空):其余全部(周末全天、工作日午餐 12-14 与夜间)为空闲。 + { level: "offpeak", windows: [] }, ] export const DEFAULT_DYNAMIC_PRICING: DynamicPricingConfig = { diff --git a/src/plugin-config.ts b/src/plugin-config.ts index 84877b4..4e24529 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -4,6 +4,7 @@ import { type DynamicPricingConfig, type DynamicPricingSchedule, type ModelPricingRule, + type TimeWindow, DEFAULT_DYNAMIC_PRICING, } from "./dynamic-pricing/types.ts" import { parseClockTime } from "./dynamic-pricing/schedule.ts" @@ -289,6 +290,23 @@ function normalizeModelPricingRule(raw: unknown): ModelPricingRule { return rule } +/** 解析 `days`(ISO 1=周一…7=周日):过滤非法值并告警、去重、排序;空/非法结果 = 全周(省略)。 */ +function normalizeDays(raw: unknown): number[] | undefined { + if (!Array.isArray(raw)) return undefined + const seen = new Set() + for (const v of raw) { + if (typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 7) { + seen.add(v) + } else { + console.warn( + `dynamicPricing: ignoring invalid schedule days value ${JSON.stringify(v)}` + + " (expected integer 1..7, ISO 1=Monday … 7=Sunday)", + ) + } + } + return seen.size > 0 ? [...seen].sort() : undefined +} + function normalizeSchedule(raw: unknown): DynamicPricingSchedule { if (!Array.isArray(raw)) return structuredClone(DEFAULT_DYNAMIC_PRICING.schedule) const out: DynamicPricingSchedule = [] @@ -296,6 +314,11 @@ function normalizeSchedule(raw: unknown): DynamicPricingSchedule { const o = item as Record | undefined if (!o || typeof o !== "object") continue if (typeof o.level !== "string" || !Array.isArray(o.windows)) continue + // 显式空 windows 的 level = 回退档(兜底)。 + if (o.windows.length === 0) { + out.push({ level: o.level, windows: [] }) + continue + } const windows = o.windows .map((w) => { const ww = w as Record | undefined @@ -303,12 +326,24 @@ function normalizeSchedule(raw: unknown): DynamicPricingSchedule { 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 } + const days = normalizeDays(ww.days) + return days ? { start, end, days } : { start, end } }) - .filter((w): w is { start: number; end: number } => w !== null) + .filter((w): w is TimeWindow => w !== null) + // 窗口全部非法 → 整档丢弃(与旧行为一致,避免 typo 配置静默变为兜底档)。 if (windows.length > 0) out.push({ level: o.level, windows }) } - return out.length > 0 ? out : structuredClone(DEFAULT_DYNAMIC_PRICING.schedule) + // 回退档至多 1 个(契约见 types.ts ScheduleLevel):截断多余的空 windows 档,windowed 档保持原顺序。 + const windowed = out.filter((l) => l.windows.length > 0) + const fallbacks = out.filter((l) => l.windows.length === 0) + if (fallbacks.length > 1) { + console.warn( + "dynamicPricing: schedule has more than one fallback level (level with empty windows); " + + "keeping only the first as the catch-all", + ) + } + const merged = [...windowed, ...fallbacks.slice(0, 1)] + return merged.length > 0 ? merged : structuredClone(DEFAULT_DYNAMIC_PRICING.schedule) } export function normalizeDynamicPricingConfig( diff --git a/tests/dynamic-pricing-lookup.test.ts b/tests/dynamic-pricing-lookup.test.ts index 5eb29b0..f4e8faf 100644 --- a/tests/dynamic-pricing-lookup.test.ts +++ b/tests/dynamic-pricing-lookup.test.ts @@ -93,6 +93,22 @@ describe("resolveModelCost — built-in DeepSeek time-of-day", () => { expect(r?.rates.cache.read).toBe(0.005) expect(r?.level).toBe("offpeak") }) + test("weekend peak hours → offpeak 0.5× (DeepSeek weekend idle)", () => { + // 周六 10:00 本应全价的时段 → 空闲半价 + const sat = resolveModelCost(PROVIDERS, "deepseek", "deepseek/deepseek-v4-flash", { + now: bjt(2026, 8, 15, 10, 0), + rules: DEFAULT_RULES, + }) + expect(sat?.level).toBe("offpeak") + expect(sat?.rates.input).toBe(0.25) + // 周日 15:00 同样 + const sun = resolveModelCost(PROVIDERS, "deepseek", "deepseek/deepseek-v4-flash", { + now: bjt(2026, 8, 16, 15, 0), + rules: DEFAULT_RULES, + }) + expect(sun?.level).toBe("offpeak") + expect(sun?.rates.input).toBe(0.25) + }) test("disabled rules → static rates, no level", () => { const r = resolveModelCost(PROVIDERS, "deepseek", "deepseek/deepseek-v4-flash", { now: bjt(2026, 8, 10, 22, 0), @@ -103,6 +119,65 @@ describe("resolveModelCost — built-in DeepSeek time-of-day", () => { }) }) +describe("resolveModelCost — level only for time-of-day priced models", () => { + test("model without any dynamic rule: no level even when schedule fallback matches", () => { + // gpt-5.6 仅有 context 分档,无 levels/multipliers;周六 10:00 默认回退档命中 offpeak,但不标注 + const r = resolveModelCost(PROVIDERS, "openai", "gpt-5.6", { + now: bjt(2026, 8, 15, 10, 0), + rules: DEFAULT_RULES, + }) + expect(r?.rates.input).toBe(1.0) + expect(r?.level).toBeUndefined() + expect(r?.explicit).toBe(false) + }) + test("contextThreshold-only rule: no level badge (but still explicit)", () => { + const rules = { + ...DEFAULT_RULES, + providers: { + openai: { models: { "gpt-5.6": { contextThreshold: 100_000 } } }, + }, + } + const r = resolveModelCost(PROVIDERS, "openai", "gpt-5.6", { + now: bjt(2026, 8, 10, 10, 0), // 周一 10:00 —— 本会命中 peak + contextTokens: 50_000, + rules, + }) + expect(r?.rates.input).toBe(1.0) + expect(r?.level).toBeUndefined() + expect(r?.explicit).toBe(true) + }) + test("multipliers without the current level: full price, no badge", () => { + // 只配了 peak 的模型:offpeak 时刻 factor=1(全价),不贴「空闲」徽标 + const rules = { + ...DEFAULT_RULES, + providers: { + openai: { + models: { "gpt-5.6": { multipliers: { peak: 0.9 } } }, + }, + }, + } + const offpeak = resolveModelCost(PROVIDERS, "openai", "gpt-5.6", { + now: bjt(2026, 8, 15, 10, 0), // 周六 → 回退档 offpeak + rules, + }) + expect(offpeak?.rates.input).toBe(1.0) // 无 offpeak 档 → 不折价 + expect(offpeak?.level).toBeUndefined() + const peak = resolveModelCost(PROVIDERS, "openai", "gpt-5.6", { + now: bjt(2026, 8, 10, 10, 0), // 周一 10:00 → peak + rules, + }) + expect(peak?.rates.input).toBe(0.9) + expect(peak?.level).toBe("peak") + }) + test("DeepSeek built-in default still reports level", () => { + const r = resolveModelCost(PROVIDERS, "deepseek", "deepseek/deepseek-v4-flash", { + now: bjt(2026, 8, 10, 10, 0), + rules: DEFAULT_RULES, + }) + expect(r?.level).toBe("peak") + }) +}) + describe("resolveModelCost — explicit rules override", () => { test("absolute levels win over multiplier fallback", () => { const rules = { @@ -127,6 +202,30 @@ describe("resolveModelCost — explicit rules override", () => { expect(r?.rates.input).toBe(0.22) expect(r?.level).toBe("offpeak") }) + test("levels + fallback: weekend falls to offpeak levels", () => { + 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 } }, + }, + }, + }, + }, + }, + } + // 周六 10:00 → 回退档 offpeak → 取 levels.offpeak + const r = resolveModelCost(PROVIDERS, "deepseek", "deepseek/deepseek-v4-flash", { + now: bjt(2026, 8, 15, 10, 0), + rules, + }) + expect(r?.level).toBe("offpeak") + expect(r?.rates.input).toBe(0.22) + }) test("per-model contextThreshold override", () => { const rules = { ...DEFAULT_RULES, @@ -288,6 +387,51 @@ describe("resolveModelCost — enabled:false disables everything (#2)", () => { }) }) +describe("resolveModelCost — DeepSeek legacy schedule warning (#Q6)", () => { + const DS_PRO: ProviderInfo[] = [ + { + id: "deepseek", + models: { + // 独立模型 key,避免与其它用例共用模块级去重 Set + "deepseek/deepseek-v4-pro": { + cost: { input: 1.0, output: 2.0, cache: { read: 0.1, write: 0 } }, + }, + }, + }, + ] + test("warns once for windowed schedule without days; silent with days / non-DeepSeek", () => { + const warnings: string[] = [] + const origWarn = console.warn + console.warn = (msg?: unknown) => { warnings.push(String(msg)) } + try { + const legacy = { + ...DEFAULT_RULES, + schedule: [{ level: "peak", windows: [{ start: 9 * 60, end: 12 * 60 }] }], + } + const hit = (rules: typeof DEFAULT_RULES) => + resolveModelCost(DS_PRO, "deepseek", "deepseek/deepseek-v4-pro", { + now: bjt(2026, 8, 10, 10, 0), + rules, + }) + // 旧配置(无 days)→ 仅提示一次(Set 去重) + hit(legacy) + hit(legacy) + expect(warnings.length).toBe(1) + expect(warnings[0]).toContain("days") + // 星期感知默认 schedule → 不提示 + warnings.length = 0 + hit(DEFAULT_RULES) + expect(warnings.length).toBe(0) + // 非 DeepSeek 模型 → 不提示 + warnings.length = 0 + resolveModelCost(PROVIDERS, "openai", "gpt-5.6", { rules: legacy }) + expect(warnings.length).toBe(0) + } finally { + console.warn = origWarn + } + }) +}) + describe("resolveModelCost — level miss falls back to static (#4)", () => { test("levels do not match current level → static rates, not first level", () => { const rules = { diff --git a/tests/dynamic-pricing-schedule.test.ts b/tests/dynamic-pricing-schedule.test.ts index 69627c6..aa752e8 100644 --- a/tests/dynamic-pricing-schedule.test.ts +++ b/tests/dynamic-pricing-schedule.test.ts @@ -5,6 +5,7 @@ import { tzPartsOf, startOfDayEpoch, dayMinuteOf, + tzWeekdayOf, isLevelAt, nextBoundaryMs, } from "../src/dynamic-pricing/schedule.ts" @@ -35,39 +36,71 @@ describe("parseClockTime", () => { 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) + expect(inWindow(540, 1, w)).toBe(true) + expect(inWindow(719, 1, w)).toBe(true) + expect(inWindow(720, 1, w)).toBe(false) + expect(inWindow(539, 1, 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) + expect(inWindow(1080, 1, w)).toBe(true) + expect(inWindow(1439, 1, w)).toBe(true) + expect(inWindow(0, 1, w)).toBe(true) + expect(inWindow(539, 1, w)).toBe(true) + expect(inWindow(540, 1, w)).toBe(false) + expect(inWindow(1000, 1, w)).toBe(false) + }) + test("days filter applies to the current day (same-day window)", () => { + const w = { start: 540, end: 720, days: [1, 2, 3, 4, 5] } // 工作日 09:00-12:00 + expect(inWindow(600, 1, w)).toBe(true) // 周一 + expect(inWindow(600, 5, w)).toBe(true) // 周五 + expect(inWindow(600, 6, w)).toBe(false) // 周六 + expect(inWindow(600, 7, w)).toBe(false) // 周日 + }) + test("cross-day window anchors to open day (start-day rule)", () => { + const w = { start: 1080, end: 540, days: [1, 2, 3, 4, 5] } // 工作日 18:00 - 次日09:00 + // 开启日晚间段:仅工作日命中 + expect(inWindow(1200, 1, w)).toBe(true) // 周一 20:00 + expect(inWindow(1200, 5, w)).toBe(true) // 周五 20:00 + expect(inWindow(1200, 6, w)).toBe(false) // 周六 20:00 + // 次日早晨段:归属开启日(前一日) + expect(inWindow(180, 2, w)).toBe(true) // 周二 03:00(周一开启) + expect(inWindow(180, 6, w)).toBe(true) // 周六 03:00(周五开启) + expect(inWindow(180, 7, w)).toBe(false) // 周日 03:00(周六非开启日) + expect(inWindow(180, 1, w)).toBe(false) // 周一 03:00(周日非开启日) }) }) -describe("tzPartsOf / dayMinuteOf", () => { +describe("tzPartsOf / dayMinuteOf / tzWeekdayOf", () => { 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)) }) + test("ISO weekday 1=Monday … 7=Sunday (2026-08-10 is Monday)", () => { + expect(tzWeekdayOf(bjt(2026, 8, 10, 12, 0), TZ)).toBe(1) // 周一 + expect(tzWeekdayOf(bjt(2026, 8, 11, 12, 0), TZ)).toBe(2) // 周二 + expect(tzWeekdayOf(bjt(2026, 8, 14, 12, 0), TZ)).toBe(5) // 周五 + expect(tzWeekdayOf(bjt(2026, 8, 15, 12, 0), TZ)).toBe(6) // 周六 + expect(tzWeekdayOf(bjt(2026, 8, 16, 12, 0), TZ)).toBe(7) // 周日 + expect(tzWeekdayOf(bjt(2026, 8, 17, 12, 0), TZ)).toBe(1) // 下周一 + // 跨时区:UTC 周一凌晨 = 北京时间周一早晨,星期一致 + expect(tzWeekdayOf(Date.UTC(2026, 7, 10, 0, 30), TZ)).toBe(1) + // 北京 00:30 周一 = UTC 周日 16:30 → 按北京时间仍为周一 + expect(tzWeekdayOf(bjt(2026, 8, 10, 0, 30), "UTC")).toBe(7) + }) }) describe("isLevelAt (DeepSeek schedule)", () => { - test("peak windows", () => { - expect(isLevelAt(bjt(2026, 8, 10, 9, 0), DEFAULT_SCHEDULE, TZ)).toBe("peak") + test("peak windows on weekdays", () => { + 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") + expect(isLevelAt(bjt(2026, 8, 14, 9, 0), DEFAULT_SCHEDULE, TZ)).toBe("peak") // 周五 }) - test("offpeak boundaries", () => { + test("offpeak boundaries on weekdays (fallback)", () => { 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") @@ -75,9 +108,56 @@ describe("isLevelAt (DeepSeek schedule)", () => { 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("weekend peak hours → offpeak (DeepSeek weekend idle)", () => { + // 周六 09:00/11:00/15:00/17:00 全部为空闲 + expect(isLevelAt(bjt(2026, 8, 15, 9, 0), DEFAULT_SCHEDULE, TZ)).toBe("offpeak") + expect(isLevelAt(bjt(2026, 8, 15, 11, 0), DEFAULT_SCHEDULE, TZ)).toBe("offpeak") + expect(isLevelAt(bjt(2026, 8, 15, 15, 0), DEFAULT_SCHEDULE, TZ)).toBe("offpeak") + expect(isLevelAt(bjt(2026, 8, 15, 17, 0), DEFAULT_SCHEDULE, TZ)).toBe("offpeak") + // 周日 09:00/17:00 同样空闲 + expect(isLevelAt(bjt(2026, 8, 16, 9, 0), DEFAULT_SCHEDULE, TZ)).toBe("offpeak") + expect(isLevelAt(bjt(2026, 8, 16, 17, 0), DEFAULT_SCHEDULE, TZ)).toBe("offpeak") + }) test("empty schedule returns undefined", () => { expect(isLevelAt(Date.now(), [], TZ)).toBeUndefined() }) + test("fallback level catches unmatched moments; no fallback → undefined", () => { + const onlyWindowed = [{ level: "peak", windows: [{ start: 9 * 60, end: 12 * 60 }] }] + // 12:00 未命中窗口级,无回退档 → undefined + expect(isLevelAt(bjt(2026, 8, 10, 12, 0), onlyWindowed, TZ)).toBeUndefined() + expect(isLevelAt(bjt(2026, 8, 10, 10, 0), onlyWindowed, TZ)).toBe("peak") + + const withFallback = [ + { level: "peak", windows: [{ start: 9 * 60, end: 12 * 60 }] }, + { level: "offpeak", windows: [] }, + ] + expect(isLevelAt(bjt(2026, 8, 10, 10, 0), withFallback, TZ)).toBe("peak") + expect(isLevelAt(bjt(2026, 8, 10, 12, 0), withFallback, TZ)).toBe("offpeak") + expect(isLevelAt(bjt(2026, 8, 10, 3, 0), withFallback, TZ)).toBe("offpeak") + + // 仅回退档:任何时刻都命中回退档 + const onlyFallback = [{ level: "offpeak", windows: [] }] + expect(isLevelAt(bjt(2026, 8, 10, 10, 0), onlyFallback, TZ)).toBe("offpeak") + }) + test("fallback is last-resort even when written first", () => { + const sched = [ + { level: "offpeak", windows: [] }, + { level: "peak", windows: [{ start: 9 * 60, end: 12 * 60, days: [1, 2, 3, 4, 5] }] }, + ] + // 周一 10:00 → peak 窗口级优先于回退档 + expect(isLevelAt(bjt(2026, 8, 10, 10, 0), sched, TZ)).toBe("peak") + // 周一 13:00 → 回退档 + expect(isLevelAt(bjt(2026, 8, 10, 13, 0), sched, TZ)).toBe("offpeak") + }) + test("cross-day window start-day rule via isLevelAt", () => { + const sched = [{ level: "night", windows: [{ start: 18 * 60, end: 9 * 60, days: [1, 2, 3, 4, 5] }] }] + expect(isLevelAt(bjt(2026, 8, 14, 20, 0), sched, TZ)).toBe("night") // 周五晚 + expect(isLevelAt(bjt(2026, 8, 15, 3, 0), sched, TZ)).toBe("night") // 周六凌晨(周五开启) + expect(isLevelAt(bjt(2026, 8, 15, 20, 0), sched, TZ)).toBeUndefined() // 周六晚 + expect(isLevelAt(bjt(2026, 8, 16, 3, 0), sched, TZ)).toBeUndefined() // 周日凌晨 + expect(isLevelAt(bjt(2026, 8, 17, 7, 0), sched, TZ)).toBeUndefined() // 周一凌晨(周日非开启日) + expect(isLevelAt(bjt(2026, 8, 17, 20, 0), sched, TZ)).toBe("night") // 周一晚 + }) }) describe("nextBoundaryMs", () => { @@ -95,6 +175,17 @@ describe("nextBoundaryMs", () => { // 23:59 → 次日 09:00 expect(nextBoundaryMs(bjt(2026, 8, 10, 23, 59), DEFAULT_SCHEDULE, TZ)).toBe(9 * 3_600_000 + 60_000) }) + test("weekend: skips to Monday 09:00", () => { + // 周五 18:00 → 周一 09:00(63h,跳过周末) + expect(nextBoundaryMs(bjt(2026, 8, 14, 18, 0), DEFAULT_SCHEDULE, TZ)).toBe(63 * 3_600_000) + // 周六 10:00 → 周一 09:00(47h) + expect(nextBoundaryMs(bjt(2026, 8, 15, 10, 0), DEFAULT_SCHEDULE, TZ)).toBe(47 * 3_600_000) + // 周日 23:00 → 周一 09:00(10h) + expect(nextBoundaryMs(bjt(2026, 8, 16, 23, 0), DEFAULT_SCHEDULE, TZ)).toBe(10 * 3_600_000) + }) + test("fallback-only schedule has no boundaries → 24h", () => { + expect(nextBoundaryMs(Date.now(), [{ level: "offpeak", windows: [] }], TZ)).toBe(24 * 3_600_000) + }) test("empty schedule falls back to 24h", () => { expect(nextBoundaryMs(Date.now(), [], TZ)).toBe(24 * 3_600_000) }) diff --git a/tests/plugin-config.test.ts b/tests/plugin-config.test.ts index fed225d..4697fa8 100644 --- a/tests/plugin-config.test.ts +++ b/tests/plugin-config.test.ts @@ -4,12 +4,14 @@ import { normalizeDisplayConfig, normalizeTimelineConfig, normalizeCacheTTLConfig, + normalizeDynamicPricingConfig, isToolSummaryEnabled, parseDuration, DEFAULT_PLUGIN_CONFIG, DEFAULT_TIMELINE, DEFAULT_CACHE_TTL, } from "../src/plugin-config.ts" +import { DEFAULT_DYNAMIC_PRICING } from "../src/dynamic-pricing/types.ts" describe("normalizeDisplayConfig", () => { test("defaults lang en and panelBorder", () => { @@ -239,3 +241,72 @@ describe("deep clone isolation", () => { expect(DEFAULT_CACHE_TTL.enabled).toBe(true) }) }) + +describe("normalizeDynamicPricingConfig — schedule days + fallback", () => { + test("parses days:[1..5]", () => { + const c = normalizeDynamicPricingConfig({ + schedule: [ + { level: "peak", windows: [{ start: "09:00", end: "12:00", days: [1, 2, 3, 4, 5] }] }, + ], + }) + expect(c.schedule[0].windows[0].days).toEqual([1, 2, 3, 4, 5]) + }) + test("ignores invalid days values and warns", () => { + const c = normalizeDynamicPricingConfig({ + schedule: [ + { level: "peak", windows: [{ start: "09:00", end: "12:00", days: [0, 8, "mon", 2, 7] }] }, + ], + }) + expect(c.schedule[0].windows[0].days).toEqual([2, 7]) + }) + test("days: [] means all days (omitted)", () => { + const c = normalizeDynamicPricingConfig({ + schedule: [ + { level: "peak", windows: [{ start: "09:00", end: "12:00", days: [] }] }, + ], + }) + expect(c.schedule[0].windows[0].days).toBeUndefined() + }) + test("empty windows level kept as fallback", () => { + const c = normalizeDynamicPricingConfig({ + schedule: [ + { level: "peak", windows: [{ start: "09:00", end: "12:00" }] }, + { level: "offpeak", windows: [] }, + ], + }) + expect(c.schedule.length).toBe(2) + expect(c.schedule[0].level).toBe("peak") + expect(c.schedule[1]).toEqual({ level: "offpeak", windows: [] }) + }) + test("dedupes more than one fallback, keeps first, windowed levels first", () => { + const c = normalizeDynamicPricingConfig({ + schedule: [ + { level: "offpeak", windows: [] }, + { level: "peak", windows: [{ start: "09:00", end: "12:00" }] }, + { level: "night", windows: [] }, + ], + }) + expect(c.schedule.map((l) => l.level)).toEqual(["peak", "offpeak"]) + expect(c.schedule[1].windows).toEqual([]) + }) + test("windowed level with all-invalid windows is dropped (not turned into fallback)", () => { + const c = normalizeDynamicPricingConfig({ + schedule: [ + { level: "peak", windows: [{ start: "oops", end: "12:00" }] }, + { level: "offpeak", windows: [{ start: "18:00", end: "09:00" }] }, + ], + }) + expect(c.schedule.map((l) => l.level)).toEqual(["offpeak"]) + }) + test("default schedule is weekday-aware with fallback", () => { + const c = normalizeDynamicPricingConfig({}) + const peak = c.schedule.find((l) => l.level === "peak") + expect(peak?.windows.every((w) => w.days?.length === 5)).toBe(true) + const offpeak = c.schedule.find((l) => l.level === "offpeak") + expect(offpeak?.windows).toEqual([]) + }) + test("invalid raw schedule falls back to defaults", () => { + const c = normalizeDynamicPricingConfig({ schedule: "nope" }) + expect(c.schedule).toEqual(structuredClone(DEFAULT_DYNAMIC_PRICING.schedule)) + }) +}) From 18d964d0095f1212ac302337342778d88449f763 Mon Sep 17 00:00:00 2001 From: "mengzhu.zhu" Date: Sun, 23 Aug 2026 16:50:56 +0800 Subject: [PATCH 2/2] chore: translate code comments to English Code comments were ~29% Chinese, concentrated in the dynamic-pricing module; the rest of the codebase is English. Unify on English comments (no behavior change). The DeepSeek pricing-page regex in fetch-deepseek-pricing.ts keeps Chinese terms (it matches the official page). Also translated the DeepSeek legacy-schedule stderr hint to English for consistency with the other runtime warnings. Bilingual user-facing docs (README/docs zh-CN) and i18n strings are untouched. --- scripts/fetch-deepseek-pricing.ts | 6 ++-- scripts/timeline-dashboard.ts | 7 +++-- src/agents-view.tsx | 2 +- src/dynamic-pricing/context.ts | 17 ++++++----- src/dynamic-pricing/deepseek.ts | 10 +++---- src/dynamic-pricing/lookup.ts | 50 +++++++++++++++++-------------- src/dynamic-pricing/recompute.ts | 27 +++++++++-------- src/dynamic-pricing/schedule.ts | 48 +++++++++++++++-------------- src/dynamic-pricing/types.ts | 45 +++++++++++++++------------- src/main-session-view.tsx | 2 +- src/plugin-config.ts | 26 ++++++++-------- src/pricing.ts | 12 ++++---- src/types.ts | 8 ++--- src/use-cache-hit-metrics.ts | 13 ++++---- 14 files changed, 144 insertions(+), 129 deletions(-) diff --git a/scripts/fetch-deepseek-pricing.ts b/scripts/fetch-deepseek-pricing.ts index c229878..f280b74 100644 --- a/scripts/fetch-deepseek-pricing.ts +++ b/scripts/fetch-deepseek-pricing.ts @@ -76,14 +76,14 @@ try { const groups = parsePriceGroups(html) const unit = useUsd ? `USD (÷${rate})` : "CNY" const snippet = { - // DeepSeek 官方高峰:北京时间周一~周五 9:00-12:00 / 14:00-18:00;其余(含周末)为空闲。 - // 旧写法(peak 无 days)周末仍按高峰,请保留 days:[1,2,3,4,5]。 + // DeepSeek official peak: Beijing time Mon-Fri 9:00-12:00 / 14:00-18:00; everything else (incl. weekends) is off-peak. + // Legacy configs (peak without days) still bill weekends as peak — keep days:[1,2,3,4,5]. schedule: [ { level: "peak", windows: [ { start: "09:00", end: "12:00", days: [1, 2, 3, 4, 5] }, { start: "14:00", end: "18:00", days: [1, 2, 3, 4, 5] }, ] }, - { level: "offpeak", windows: [] }, // 回退档:一切未覆盖时刻(含周末)为空闲 + { level: "offpeak", windows: [] }, // Fallback: everything unmatched (incl. weekends) is off-peak ], providers: { "deepseek": { diff --git a/scripts/timeline-dashboard.ts b/scripts/timeline-dashboard.ts index 49d4ec9..22f5d53 100644 --- a/scripts/timeline-dashboard.ts +++ b/scripts/timeline-dashboard.ts @@ -29,8 +29,9 @@ import type { ProviderInfo } from "../src/types.ts" import { parseJsonc } from "../src/jsonc.ts" /** - * 从 opencode.json 的 `provider` 段加载静态价格(含 context_over_200k), - * 供离线重算使用。文件不存在返回空数组;文件存在但解析失败时告警后返回空数组。 + * Load static prices (incl. context_over_200k) from the `provider` section of + * opencode.json for offline recompute. Missing file → empty array; parse failure + * warns and returns an empty array. */ function loadOpencodeProviders(): ProviderInfo[] { const path = process.env.OPENCODE_CONFIG ?? `${homedir()}/.config/opencode/opencode.json` @@ -781,7 +782,7 @@ if (records.length === 0) { process.exit(1) } -// 动态计价离线重算:按每条记录的时刻 + 上下文档位注入 dynCost(前端优先展示)。 +// Offline dynamic-pricing recompute: inject dynCost per record (time + context tier); the UI prefers it when present. { const providers = loadOpencodeProviders() const rules = loadPluginConfig().dynamicPricing diff --git a/src/agents-view.tsx b/src/agents-view.tsx index 31af19c..ba57aca 100644 --- a/src/agents-view.tsx +++ b/src/agents-view.tsx @@ -23,7 +23,7 @@ export function AgentsView(props: { const subsSaved = () => m.subsSaved() - // 子 agent 动态成本汇总:存在任一动态价时展示重算值(≈ 前缀)。 + // Sub-agent dynamic cost total: show the recomputed value (≈ prefix) when any child is dynamic. const shownSubCost = createMemo(() => { const map = m.subAgentDynamicCosts() let dynamic = false diff --git a/src/dynamic-pricing/context.ts b/src/dynamic-pricing/context.ts index 6d0cdc3..9dc8f25 100644 --- a/src/dynamic-pricing/context.ts +++ b/src/dynamic-pricing/context.ts @@ -1,8 +1,8 @@ import type { ModelCost } from "../types.ts" /** - * 上下文分档:contextTokens 超过 threshold 时返回分档价格,否则基础档。 - * threshold 优先用模型自身档位阈值(`cost.contextThreshold`,来自运行时 tier.size)。 + * Context tier: contextTokens above threshold returns the tiered price, else base. + * threshold prefers the model's own tier size (`cost.contextThreshold`, from runtime tier.size). */ export function selectContextRates( cost: ModelCost, @@ -23,9 +23,10 @@ type RuntimeTier = { } /** - * 运行时 cost(`api.state.provider`)→ 插件 ModelCost。 - * opencode 运行时把配置层的 context_over_200k 转为 `tiers[]` / `experimentalOver200K`, - * 这里归一化回插件字段(含档位阈值 tier.size)。配置层字段已存在时幂等返回。 + * Runtime cost (`api.state.provider`) → plugin ModelCost. + * opencode converts config-level context_over_200k into `tiers[]` / `experimentalOver200K` + * at runtime; here we normalize back to plugin fields (including the tier size). + * Idempotent when the config-level fields already exist. */ export function normalizeRuntimeCost(cost: ModelCost): ModelCost { if (cost.context_over_200k) return cost @@ -51,7 +52,7 @@ export function normalizeRuntimeCost(cost: ModelCost): ModelCost { return cost } -/** 各率乘系数(倍率模式)。系数为 1 时原样返回。 */ +/** Scale all rates by a factor (multiplier mode). Factor 1 returns rates unchanged. */ export function scaleRates(cost: ModelCost, factor: number): ModelCost { if (factor === 1) return cost return { @@ -72,8 +73,8 @@ export function scaleRates(cost: ModelCost, factor: number): ModelCost { } /** - * 用量 → 成本(USD)。`input` 为未命中输入 token(不含缓存,与 opencode 语义一致), - * 缓存命中部分按 `cacheRead` 单独以 cacheReadRate 计费。 + * Usage → cost (USD). `input` is cache-miss input tokens (excludes cache, matching + * opencode semantics); cache hits are billed separately via cacheReadRate × `cacheRead`. */ export function billingCost( rates: ModelCost, diff --git a/src/dynamic-pricing/deepseek.ts b/src/dynamic-pricing/deepseek.ts index 11212f2..cffa0fe 100644 --- a/src/dynamic-pricing/deepseek.ts +++ b/src/dynamic-pricing/deepseek.ts @@ -1,18 +1,18 @@ import type { ModelPricingRule } from "./types.ts" /** - * DeepSeek 官方时段计价:空闲时段价格为高峰时段的一半。 - * 内置默认以"倍率"形式相对 `state.provider` 静态价生效(静态价视为高峰价), - * 用户可在配置中显式覆盖为绝对价。 + * DeepSeek official time-of-day pricing: off-peak is half of peak. + * The built-in default applies as multipliers relative to the `state.provider` + * static price (static = peak); users can override with absolute prices. */ export const DEEPSEEK_DEFAULT_RULE: ModelPricingRule = { multipliers: { peak: 1, offpeak: 0.5 }, } -/** providerID 或 modelID 是否为 DeepSeek 官方命名空间。 */ +/** Whether providerID or modelID is the DeepSeek official namespace. */ export function isDeepSeek(providerID: string, modelID: string): boolean { const pid = providerID.toLowerCase() const mid = modelID.toLowerCase() - // provider 名含 deepseek(如 deepseek)或 modelID 以官方前缀 deepseek/ 开头。 + // provider id contains "deepseek" (e.g. deepseek) or modelID starts with the official deepseek/ prefix. return pid.includes("deepseek") || mid.startsWith("deepseek/") } diff --git a/src/dynamic-pricing/lookup.ts b/src/dynamic-pricing/lookup.ts index c7511fd..e4154cf 100644 --- a/src/dynamic-pricing/lookup.ts +++ b/src/dynamic-pricing/lookup.ts @@ -4,7 +4,7 @@ import { DEEPSEEK_DEFAULT_RULE, isDeepSeek } from "./deepseek.ts" import { isLevelAt } from "./schedule.ts" import { type DynamicPricingConfig, type ModelPricingRule } from "./types.ts" -/** 静态价格查询:providerID + modelID → ModelCost(运行时 tiers 已归一化),未命中返回 null。 */ +/** Static price lookup: providerID + modelID → ModelCost (runtime tiers normalized); miss returns null. */ export function lookupModelCost( providers: ReadonlyArray, providerID: string | undefined, @@ -27,20 +27,21 @@ export type PricingLookupContext = { export type ResolvedPricing = { rates: ModelCost - /** 命中的时段档名(如 "peak"/"offpeak");未启用时段规则时为 undefined。 */ + /** Matched time-of-day level (e.g. "peak"/"offpeak"); undefined when no time-of-day rule applies. */ level?: string - /** 上下文分档:基础档 "base" 或超阈值档 "over";模型无分档时 undefined。 */ + /** Context tier: base "base" or over-threshold "over"; undefined when the model has no tiers. */ contextTier?: "base" | "over" - /** 是否应用了动态规则(显式配置 / 内置 DeepSeek 默认)。 */ + /** Whether dynamic rules applied (explicit config / built-in DeepSeek default). */ explicit: boolean } -/** 已提示过「DeepSeek schedule 无 days」的 providerID/modelID(每配置生命周期仅一次)。 */ +/** providerID/modelID already warned about a DeepSeek schedule without days (once per config lifetime). */ const warnedNoWeekday = new Set() /** - * DeepSeek 且 schedule 存在窗口级档但均未写 `days` → 一次性 stderr 提示(仅提示,不改计费结果)。 - * 旧配置(peak 无 days)周末仍按高峰计费,需手动补 `days:[1,2,3,4,5]`。 + * DeepSeek model whose schedule has windowed levels but none with `days` → one-time + * stderr hint (informational only, does not change billing). Legacy configs (peak + * without days) still bill weekends as peak; add `days:[1,2,3,4,5]` to fix. */ function maybeWarnDeepSeekWeekend(rules: DynamicPricingConfig | undefined, providerID: string, modelID: string): void { if (!rules?.enabled || !isDeepSeek(providerID, modelID)) return @@ -51,8 +52,8 @@ function maybeWarnDeepSeekWeekend(rules: DynamicPricingConfig | undefined, provi if (warnedNoWeekday.has(key)) return warnedNoWeekday.add(key) console.warn( - `[dynamicPricing] ${key} 的 schedule 未按星期区分 —— 周末 9:00-12:00/14:00-18:00 仍可能按高峰计费。` + - `请在 peak 窗口加 "days":[1,2,3,4,5] 以匹配 DeepSeek 周末空闲定价。`, + `[dynamicPricing] ${key} schedule is not weekday-aware — weekends 9:00-12:00/14:00-18:00 may still bill at peak. ` + + `Add "days":[1,2,3,4,5] to the peak windows to match DeepSeek's weekend off-peak pricing.`, ) } @@ -61,11 +62,11 @@ function effectiveRule( providerID: string, modelID: string, ): ModelPricingRule | undefined { - // 总开关关闭 → 完全静态(显式规则、内置默认、context 分档均不生效)。 + // Master switch off → fully static (explicit rules, built-in default, context tiers all inactive). if (!rules?.enabled) return undefined const explicit = rules.providers[providerID]?.models[modelID] if (explicit) return explicit - // 空 schedule → 无时段可判,不应用内置默认(避免虚假的 ≈ 标记)。 + // Empty schedule → nothing to match; skip the built-in default (avoids a spurious ≈ marker). if (rules.schedule.length > 0 && isDeepSeek(providerID, modelID)) { return DEEPSEEK_DEFAULT_RULE } @@ -85,10 +86,10 @@ function tierOf(cost: ModelCost, tokens: number | undefined, threshold: number): } /** - * 解析模型当前有效价格。优先级: - * 1. 用户显式 `levels` 绝对价(按时段档,未命中回退静态价)→ 2. 用户显式 `multipliers` 倍率 - * → 3. 内置 DeepSeek 默认倍率 → 4. `state.provider` 静态价(含 context 分档)。 - * `enabled: false` 时所有动态维度关闭,仅返回静态基础价。 + * Resolve the model's current effective price. Priority: + * 1. explicit `levels` absolute prices (per level; miss falls back to static) → 2. explicit `multipliers` + * → 3. built-in DeepSeek default multipliers → 4. `state.provider` static price (with context tiers). + * `enabled: false` turns off every dynamic dimension and returns the static base price. */ export function resolveModelCost( providers: ReadonlyArray, @@ -98,15 +99,16 @@ export function resolveModelCost( ): ResolvedPricing | null { const base = lookupModelCost(providers, providerID, modelID) if (!base) return null - // 总开关关闭 → 完全静态基础价(含 context 分档一并关闭),与 README 一致。 + // Master switch off → fully static base price (context tiers off too), matching the 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) maybeWarnDeepSeekWeekend(rules, providerID, modelID) const rule = effectiveRule(rules, providerID, modelID) - // 分档阈值优先级:模型级配置 rule.contextThreshold > 运行时 tier.size(cost.contextThreshold) - // > 全局 contextThreshold > 默认 200k。统一到 base 上,保证 tierOf 与 selectContextRates 口径一致。 + // Tier threshold priority: per-model rule.contextThreshold > runtime tier.size + // (cost.contextThreshold) > global contextThreshold > 200k. Unified onto base so + // tierOf and selectContextRates stay consistent. const effThreshold = rule?.contextThreshold ?? base.contextThreshold ?? rules?.contextThreshold ?? 200_000 const effBase = base.contextThreshold === effThreshold ? base : { ...base, contextThreshold: effThreshold } @@ -115,20 +117,22 @@ export function resolveModelCost( if (rule?.levels) { const absolute = level ? rule.levels[level] : undefined if (absolute) { - // 绝对价是完整价格(无 context 分档语义)→ 不标注 context badge,避免误导。 + // Absolute prices are complete (no context-tier semantics) → no context badge, avoids misleading. return { rates: absolute, level, contextTier: undefined, explicit: true } } - // 时段未命中(自定义 schedule 未覆盖当前时刻 / 档位名不匹配)→ 回退静态价,不任意套用第一个档。 + // Level miss (custom schedule doesn't cover now / level name mismatch) → static price, never pick the first level arbitrarily. return { rates: selectContextRates(effBase, ctx.contextTokens, effThreshold), level: undefined, contextTier, explicit: true } } if (rule?.multipliers) { const factor = level ? rule.multipliers[level] ?? 1 : 1 - // 仅当 multipliers 含当前档时才标注(如只配了 peak 的模型,offpeak 时刻价格=1× 全价,不贴「空闲」)。 + // Badge only when multipliers price the current level (e.g. a peak-only rule at an off-peak moment prices 1× full; no off-peak badge). const levelShown = level !== undefined && rule.multipliers[level] !== undefined return { rates: scaleRates(selectContextRates(effBase, ctx.contextTokens, effThreshold), factor), level: levelShown ? level : undefined, contextTier, explicit: true } } - // 仅当该模型真正按时段计价(显式 levels/multipliers 或内置 DeepSeek 默认)时才报告 level; - // 无规则或仅 contextThreshold 的模型不标注时段档(避免默认回退档给静态价模型贴上「空闲/peak」)。 + // Report the level only when the model is actually time-of-day priced (explicit + // levels/multipliers or the built-in DeepSeek default); rule-less or + // contextThreshold-only models get no level badge (the default fallback would + // otherwise label static models off-peak/peak). const levelAware = rule?.levels !== undefined || rule?.multipliers !== undefined return { rates: selectContextRates(effBase, ctx.contextTokens, effThreshold), level: levelAware ? level : undefined, contextTier, explicit: rule !== undefined } } diff --git a/src/dynamic-pricing/recompute.ts b/src/dynamic-pricing/recompute.ts index 91ca09a..f2727e2 100644 --- a/src/dynamic-pricing/recompute.ts +++ b/src/dynamic-pricing/recompute.ts @@ -4,22 +4,22 @@ import { resolveModelCost } from "./lookup.ts" import type { DynamicPricingConfig } from "./types.ts" export type RecomputeResult = { - /** 按每条消息的请求时刻 + 上下文大小重算的总成本(USD)。 */ + /** Total cost recomputed per message (request time + context size), in USD. */ cost: number - /** 参与重算的消息数(有 tokens 且有价格)。 */ + /** Number of messages included (have tokens and a price). */ counted: number - /** 是否有任何消息应用了动态规则(时段 / 上下文分档 / 倍率)。 */ + /** Whether any message applied dynamic rules (time-of-day / context tier / multipliers). */ 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。 + * Recompute session cost message by message: + * - time-of-day: `msg.time.created` (request start) → level + * - context: total input (`input + cacheRead`; opencode semantics: input excludes cache) → context_over_200k tier + * - usage: input / output / cache.read / cache.write (input is the cache-miss part; cache billed separately) + * Messages that cannot be priced (no tokens or no model price) are skipped. Nothing priceable → null. */ export function recomputeSessionCost( messages: ReadonlyArray, @@ -53,15 +53,16 @@ export function recomputeSessionCost( } /** - * 子 agent 成本重算:用聚合 tokens + 会话创建时刻(`sub.created`)近似逐条重算。 - * 无 created 或模型不可定价 → null(调用方回退 msg.cost,不按时段猜测)。 + * Sub-agent cost recompute: aggregate tokens + session creation time (`sub.created`) + * as a per-message approximation. No created or unpriced model → null (caller falls + * back to msg.cost rather than guessing a level). */ export function recomputeSubAgentCost( sub: SubAgentSummary, providers: ReadonlyArray, rules: DynamicPricingConfig | undefined, ): number | null { - // 无创建时刻 → 无法按时段定价,回退 msg.cost(调用方处理)。 + // No creation time → cannot price by level; caller falls back to msg.cost. if (sub.created === undefined) return null const input = sub.input const output = sub.output @@ -78,8 +79,8 @@ export function recomputeSubAgentCost( } /** - * timeline 记录离线重算:按记录时刻(`created`)+ 上下文档位重算成本。 - * 无 providerId 时按 modelId 在各 provider 中匹配;不可定价 → null。 + * Offline recompute for timeline records: by record time (`created`) + context tier. + * Without providerId, match modelId across providers; unpriced → null. */ export function recomputeRecordCost( record: { diff --git a/src/dynamic-pricing/schedule.ts b/src/dynamic-pricing/schedule.ts index 281e0f0..14d765c 100644 --- a/src/dynamic-pricing/schedule.ts +++ b/src/dynamic-pricing/schedule.ts @@ -1,6 +1,6 @@ import type { DynamicPricingSchedule, TimeWindow } from "./types.ts" -/** "09:00" → 540;"18:30" → 1110。非法输入返回 null。 */ +/** "09:00" → 540; "18:30" → 1110. Invalid input returns null. */ export function parseClockTime(raw: string): number | null { const m = raw.match(/^(\d{1,2}):(\d{2})$/) if (!m) return null @@ -10,12 +10,12 @@ export function parseClockTime(raw: string): number | null { return h * 60 + min } -/** ISO 星期(1=周一…7=周日)的前一日,回绕(周一的前一日是周日)。 */ +/** Previous ISO weekday (1=Monday…7=Sunday), wrapping (Monday's previous day is Sunday). */ function prevWeekday(weekday: number): number { return ((weekday - 2 + 7) % 7) + 1 } -/** 窗口是否每天适用(`days` 省略或空数组)。 */ +/** Whether the window applies every day (`days` omitted or empty). */ function isEveryDayWindow(w: TimeWindow): boolean { return !w.days || w.days.length === 0 } @@ -23,12 +23,12 @@ function isEveryDayWindow(w: TimeWindow): boolean { export function inWindow(dayMinute: number, weekday: number, w: TimeWindow): boolean { const anyDay = isEveryDayWindow(w) if (w.start <= w.end) { - // 非跨天:仅当日;days 作用于「当日」。 + // Same-day window: current day only; days filters the current day. if (!anyDay && !w.days.includes(weekday)) return false return dayMinute >= w.start && dayMinute < w.end } - // 跨天窗口锚定「开启日」:晚间段 [start, 24:00) 归属 weekday; - // 早晨段 [00:00, end) 由前一日开启(weekday 的前一日,回绕)。 + // Cross-midnight windows anchor to their open day: the evening part [start, 24:00) + // belongs to weekday; the morning part [00:00, end) is opened by the previous day. if (dayMinute >= w.start) return anyDay || w.days.includes(weekday) const prev = prevWeekday(weekday) return dayMinute < w.end && (anyDay || w.days.includes(prev)) @@ -38,7 +38,7 @@ export type TzParts = { year: number month: number // 1-12 day: number - hour: number // 0-23("24:xx" 已归一化) + hour: number // 0-23 ("24:xx" normalized) minute: number second: number } @@ -63,7 +63,7 @@ function tzFormatter(timezone: string): Intl.DateTimeFormat { return f } -/** 取某时刻在指定时区的日历字段(hour "24" 归一化为次日 0 点)。 */ +/** Calendar fields of a timestamp in the given timezone (hour "24" normalized to next-day 00:00). */ export function tzPartsOf(ts: number, timezone: string): TzParts { const parts = Object.fromEntries( tzFormatter(timezone).formatToParts(new Date(ts)).map((p) => [p.type, p.value]), @@ -84,31 +84,31 @@ export function tzPartsOf(ts: number, timezone: string): TzParts { return { year, month, day, hour, minute, second } } -/** 指定时区下"当天 00:00:00"的 epoch 毫秒(真实时区零点,非 UTC 零点)。 */ +/** Epoch ms of "today 00:00:00" in the given timezone (real zone midnight, not UTC midnight). */ 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)。 */ +/** Minutes-of-day of a timestamp in the given timezone (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 } -/** 指定时区下该时刻的 ISO 星期(1=周一 … 7=周日)。 */ +/** ISO weekday of a timestamp in the given timezone (1=Monday … 7=Sunday). */ export function tzWeekdayOf(ts: number, timezone: string): number { const p = tzPartsOf(ts, timezone) - // Date.getUTCDay():0=周日…6=周六 → ISO 1=周一…7=周日。 + // Date.getUTCDay(): 0=Sunday…6=Saturday → ISO 1=Monday…7=Sunday. const dow = new Date(Date.UTC(p.year, p.month - 1, p.day)).getUTCDay() return ((dow + 6) % 7) + 1 } /** - * 判定 now 命中的时段档名(按 schedule 顺序,首个匹配的窗口级)。 - * 回退档契约见 types.ts ScheduleLevel:last-resort,不参与 first-match。 - * schedule 为空或窗口级与回退档均未命中 → undefined。 + * Level name matching `now` (first matching windowed level, in schedule order). + * Fallback contract: see types.ts ScheduleLevel — last-resort, not first-match. + * Empty schedule, or no windowed level / fallback match → undefined. */ export function isLevelAt( now: number, @@ -132,11 +132,12 @@ export function isLevelAt( } /** - * 距下一个时段窗口边界的毫秒数(任一 level 任一 window 的 start/end)。 - * 星期感知:自 now 所在日向前扫描最多 7 天,只收集「该日承载」的边界 - * (同天窗口的 start/end 落在开启日;跨天窗口的 start 落在开启日、end 落在次日), - * 因此周五 18:00 的下一边界会跳过周末直达周一 09:00。 - * 无任何窗口级时返回 24h。 + * Milliseconds to the next schedule boundary (any level, any window start/end). + * Weekday-aware: scan up to 7 days forward from today, collecting only the + * boundaries carried by each day (same-day windows carry start/end on their open + * day; cross-midnight windows carry start on the open day and end on the next), + * so the next boundary after Friday 18:00 skips the weekend to Monday 09:00. + * No windowed level → 24h. */ export function nextBoundaryMs( now: number, @@ -162,13 +163,14 @@ export function nextBoundaryMs( return Number.isFinite(best) ? best : 86_400_000 } -/** 边界分钟 m 是否由 weekday 当日承载(见 nextBoundaryMs 注释)。 */ +/** Whether boundary minute m is carried by that weekday's day (see nextBoundaryMs). */ function boundaryCarriedByDay(m: number, weekday: number, w: TimeWindow): boolean { if (w.start <= w.end) { - // 同天窗口:开启日当天承载 start 与 end 两个边界。 + // Same-day window: the open day carries both start and end boundaries. return isEveryDayWindow(w) || w.days.includes(weekday) } - // 跨天窗口:start 由开启日承载;end 落在次日,由「开启日的次日」承载。 + // Cross-midnight window: start is carried by the open day; end falls on the + // next day, carried by the day after the open day. const anyDay = isEveryDayWindow(w) if (m === w.start) return anyDay || w.days.includes(weekday) return anyDay || w.days.includes(prevWeekday(weekday)) diff --git a/src/dynamic-pricing/types.ts b/src/dynamic-pricing/types.ts index 8d75aa1..361c44e 100644 --- a/src/dynamic-pricing/types.ts +++ b/src/dynamic-pricing/types.ts @@ -1,8 +1,9 @@ import type { ModelCost } from "../types.ts" /** - * 24h 时间窗口,单位:当天分钟数 [start, end)。end <= start 表示跨天(end 属次日)。 - * `days`:适用星期(ISO,1=周一 … 7=周日)。省略或空数组 = 每天。 + * 24h time window in minutes-of-day [start, end). end <= start means the + * window crosses midnight (end belongs to the next day). + * `days`: applicable weekdays (ISO, 1=Monday … 7=Sunday). Omitted or empty = every day. */ export type TimeWindow = { start: number @@ -11,11 +12,12 @@ export type TimeWindow = { } /** - * 一个时段档(如 peak / offpeak)及其时间窗口。 + * One schedule level (e.g. peak / offpeak) with its time windows. * - * `windows` 为空的 level 是「回退档」(catch-all 兜底),契约:永远 last-resort—— - * isLevelAt 不将其纳入 first-match(即使写在数组首位),任何窗口级未命中时兜底; - * 全 schedule 至多 1 个(normalizeSchedule 负责重排至末尾并去重)。 + * Contract: a level with empty `windows` is the catch-all fallback — always + * last-resort, never part of first-match (even when written first); it applies + * whenever no windowed level matches. At most one per schedule (normalizeSchedule + * moves it last and dedupes). */ export type ScheduleLevel = { level: string @@ -25,45 +27,46 @@ export type ScheduleLevel = { export type DynamicPricingSchedule = ScheduleLevel[] /** - * 单模型计价规则。 - * - `levels`:绝对价(level 名 → USD/1M 四率),优先于倍率。 - * - `multipliers`:相对 `state.provider` 静态价的倍率(如 offpeak 0.5)。 - * - `contextThreshold`:覆盖全局上下文分档阈值(token 数)。 + * Per-model pricing rule. + * - `levels`: absolute prices (level name → USD/1M four rates), wins over multipliers. + * - `multipliers`: factors relative to the `state.provider` static price (e.g. offpeak 0.5). + * - `contextThreshold`: overrides the global context-tier threshold (tokens). */ export type ModelPricingRule = { /** - * 绝对价(level 名 → USD/1M 四率),优先于倍率。 - * `currency` 指定 levels 的原始币种(默认 USD);非 USD 在配置加载时按 - * `cost.rate` 换算为内部 USD 口径(CNY ÷ rate)。 + * Absolute prices (level name → USD/1M four rates), wins over multipliers. + * `currency` is the original currency of `levels` (default USD); non-USD is + * converted to internal USD at config load via `cost.rate` (CNY ÷ rate). */ levels?: Record multipliers?: Record contextThreshold?: number - /** levels 绝对价的币种:USD(默认)| CNY | EUR | GBP | JPY。 */ + /** Currency of `levels` absolute prices: USD (default) | CNY | EUR | GBP | JPY. */ currency?: string - /** USD → levels 币种 的汇率(用于非 USD levels 换算,如 CNY 填 6.77); - * 缺省仅在 currency === 展示币种时按 cost.rate 推断,否则告警并视作 USD。 */ + /** Exchange rate USD → levels currency (for non-USD levels, e.g. 6.77 for CNY); + * when omitted, inferred from cost.rate only if currency === display currency, + * otherwise warn and treat as USD. */ rate?: number } export type DynamicPricingConfig = { - /** 总开关。默认 true(仅对 DeepSeek 模型应用内置时段规则 + 读取 context_over_200k 分档)。 */ + /** Master switch. Default true (applies the built-in time-of-day rule only to DeepSeek models and reads the context_over_200k tier). */ enabled: boolean - /** IANA 时区名,如 "Asia/Shanghai"。空 → 系统时区。 */ + /** IANA timezone, e.g. "Asia/Shanghai". Empty → system timezone. */ timezone: string schedule: DynamicPricingSchedule - /** 全局上下文分档阈值(token 数),默认 200_000。 */ + /** Global context-tier threshold (tokens), default 200_000. */ contextThreshold: number providers: Record }> } export const DEFAULT_SCHEDULE: DynamicPricingSchedule = [ - // DeepSeek 官方高峰时段:北京时间周一~周五 9:00-12:00 / 14:00-18:00(周末为空闲)。 + // DeepSeek official peak hours: Beijing time Mon-Fri 9:00-12:00 / 14:00-18:00 (weekends off-peak). { level: "peak", windows: [ { start: 9 * 60, end: 12 * 60, days: [1, 2, 3, 4, 5] }, { start: 14 * 60, end: 18 * 60, days: [1, 2, 3, 4, 5] }, ] }, - // 回退档(windows 为空):其余全部(周末全天、工作日午餐 12-14 与夜间)为空闲。 + // Fallback (empty windows): everything else (weekends, weekday lunch 12-14 and nights) is off-peak. { level: "offpeak", windows: [] }, ] diff --git a/src/main-session-view.tsx b/src/main-session-view.tsx index 6557666..3f88328 100644 --- a/src/main-session-view.tsx +++ b/src/main-session-view.tsx @@ -34,7 +34,7 @@ export function MainSessionView(props: { return formatStreamingNowDisplay(now.phase, now.speed, m.t().streamingIdle, m.useTps()) }) - /** 动态规则生效时展示重算成本(≈ 前缀),否则回退 OpenCode 的 msg.cost。 */ + /** Show the recomputed cost (≈ prefix) when dynamic rules apply, else OpenCode's msg.cost. */ const shownCost = createMemo(() => { const rec = m.recomputedCost() if (rec && rec.dynamic) return { value: rec.cost, approx: true } diff --git a/src/plugin-config.ts b/src/plugin-config.ts index 4e24529..d38a454 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -113,7 +113,7 @@ export type PluginConfig = { display: DisplayConfig timeline: TimelineConfig cacheTTL: CacheTTLConfig - /** 动态计价:时段(peak/offpeak)与上下文分档(context_over_200k)。 */ + /** Dynamic pricing: time-of-day (peak/offpeak) and context tiers (context_over_200k). */ dynamicPricing: DynamicPricingConfig } @@ -263,7 +263,7 @@ function normalizeModelPricingRule(raw: unknown): ModelPricingRule { 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 类型)。 + // Accept both shapes: flat cacheRead/cacheWrite (docs/example) and nested cache:{read,write} (ModelCost type). const nestCache = lv.cache as { read?: unknown; write?: unknown } | undefined out[level] = { input: num(lv.input), @@ -290,7 +290,7 @@ function normalizeModelPricingRule(raw: unknown): ModelPricingRule { return rule } -/** 解析 `days`(ISO 1=周一…7=周日):过滤非法值并告警、去重、排序;空/非法结果 = 全周(省略)。 */ +/** Parse `days` (ISO 1=Monday…7=Sunday): filter invalid values with a warning, dedupe, sort; empty/invalid result = every day (omitted). */ function normalizeDays(raw: unknown): number[] | undefined { if (!Array.isArray(raw)) return undefined const seen = new Set() @@ -314,7 +314,7 @@ function normalizeSchedule(raw: unknown): DynamicPricingSchedule { const o = item as Record | undefined if (!o || typeof o !== "object") continue if (typeof o.level !== "string" || !Array.isArray(o.windows)) continue - // 显式空 windows 的 level = 回退档(兜底)。 + // A level with explicit empty windows is the catch-all fallback. if (o.windows.length === 0) { out.push({ level: o.level, windows: [] }) continue @@ -330,10 +330,10 @@ function normalizeSchedule(raw: unknown): DynamicPricingSchedule { return days ? { start, end, days } : { start, end } }) .filter((w): w is TimeWindow => w !== null) - // 窗口全部非法 → 整档丢弃(与旧行为一致,避免 typo 配置静默变为兜底档)。 + // All windows invalid → drop the level (matches legacy behavior; a typo'd config must not silently become a fallback). if (windows.length > 0) out.push({ level: o.level, windows }) } - // 回退档至多 1 个(契约见 types.ts ScheduleLevel):截断多余的空 windows 档,windowed 档保持原顺序。 + // At most one fallback (contract: types.ts ScheduleLevel): truncate extra empty-windows levels; windowed levels keep their order. const windowed = out.filter((l) => l.windows.length > 0) const fallbacks = out.filter((l) => l.windows.length === 0) if (fallbacks.length > 1) { @@ -369,9 +369,10 @@ export function normalizeDynamicPricingConfig( 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)。 + // Non-USD absolute levels are converted to internal USD at load; lookup always computes in USD. + // Rate (USD → levels currency) priority: rule.rate > cost.rate when the levels currency + // equals the display currency > warn and treat as USD when undeterminable (avoids converting + // with a wrong display rate, e.g. dividing EUR by 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) { @@ -407,9 +408,10 @@ export function normalizePluginConfig(raw: unknown): PluginConfig { 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。 + // Non-USD absolute levels are converted to internal USD at the display rate. Usable + // rates: per-model rule.rate (USD → levels currency); or cost.rate when the levels + // currency equals the display currency. When the display is USD but levels are CNY + // etc., the two don't match — a per-model rate must be configured explicitly. const usdRate = cost.currency === "USD" ? (DEFAULT_COST_DISPLAY.rate ?? 6.77) : resolveExchangeRate(cost) return { diff --git a/src/pricing.ts b/src/pricing.ts index c841e65..6cf971c 100644 --- a/src/pricing.ts +++ b/src/pricing.ts @@ -12,11 +12,11 @@ export type PricingInfo = { cacheReadRate: number cacheWriteRate: number saved: number - /** 命中的时段档名(如 "peak"/"offpeak");未启用时段规则时为 undefined。 */ + /** Matched time-of-day level (e.g. "peak"/"offpeak"); undefined when time-of-day rules are off. */ level?: string - /** 上下文分档:基础档 "base" 或超阈值档 "over";模型无分档时 undefined。 */ + /** Context tier: "base" or over-threshold "over"; undefined when the model has no tiers. */ contextTier?: "base" | "over" - /** 是否应用了动态规则(用户配置 / 内置 DeepSeek 默认)。 */ + /** Whether dynamic rules applied (user config / built-in DeepSeek default). */ dynamic: boolean } @@ -30,11 +30,11 @@ export const EMPTY_PRICING: PricingInfo = { } export type PricingContext = { - /** 当前时刻(ms),用于时段判定。默认 Date.now()。 */ + /** Current time (ms) for time-of-day matching. Default Date.now(). */ now?: number - /** 上下文大小(token 数),用于 context_over_200k 分档判定。 */ + /** Context size (tokens) for context_over_200k tier selection. */ contextTokens?: number - /** 动态计价配置;缺省时完全回退静态价。 */ + /** Dynamic pricing config; when omitted, fully static pricing. */ rules?: DynamicPricingConfig } diff --git a/src/types.ts b/src/types.ts index c50716c..d880a7e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -51,12 +51,12 @@ export type ModelCost = { output: number cache: { read: number; write: number } /** - * 上下文分档价格(阈值见 `contextThreshold`,默认 200k)。 - * 兼容两处来源:opencode.json 配置层的 `context_over_200k`, - * 以及运行时 `state.provider` 的 `tiers`/`experimentalOver200K`(由 normalizeRuntimeCost 归一化)。 + * Context-tier price (threshold: `contextThreshold`, default 200k). + * Accepts two sources: config-level `context_over_200k` in opencode.json, and + * runtime `tiers`/`experimentalOver200K` from `state.provider` (normalized by normalizeRuntimeCost). */ context_over_200k?: ModelCost - /** 该分档的阈值(token 数);来自运行时 tier.size,缺省用全局 contextThreshold。 */ + /** This tier's threshold (tokens); from runtime tier.size, falls back to the global contextThreshold. */ contextThreshold?: number } diff --git a/src/use-cache-hit-metrics.ts b/src/use-cache-hit-metrics.ts index ee272a9..97e3299 100644 --- a/src/use-cache-hit-metrics.ts +++ b/src/use-cache-hit-metrics.ts @@ -63,9 +63,10 @@ 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,卸载不触发)。 + // Dynamic pricing: precise refresh at schedule boundaries (no polling). + // Single timer ref + component-owner onCleanup: recursively scheduled timers are + // cleaned up on unmount (onCleanup inside the setTimeout callback would be outside + // the Solid owner and never fire on unmount). const [now, setNow] = createSignal(Date.now()) let boundaryTimer: ReturnType | undefined const scheduleBoundary = () => { @@ -91,7 +92,7 @@ export function useCacheHitMetrics(props: { }), ) - // 子 agent 缓存节省:按当前时段 + 各子会话总输入(input + cacheRead)判定档位。 + // Sub-agent cache savings: level by current time-of-day + each child's total input (input + cacheRead). const subsSaved = createMemo(() => computeSubsSaved(subs(), props.providers(), { now: now(), @@ -99,12 +100,12 @@ export function useCacheHitMetrics(props: { }), ) - // 动态成本重算(按每条消息的请求时刻 + 上下文分档);不可定价时回退 null。 + // Dynamic cost recompute (per-message request time + context tier); unpriced → null. const recomputedCost = createMemo(() => recomputeSessionCost(props.messages(), props.providers(), props.dynamicPricing), ) - // 子 agent 动态成本(按其会话创建时刻 + 聚合 tokens 近似);无 created / 不可定价 → null。 + // Sub-agent dynamic cost (session creation time + aggregate tokens); no created / unpriced → null. const subAgentDynamicCosts = createMemo(() => { const rules = props.dynamicPricing const providers = props.providers()