deepseek-harness-wallet 0.3.8 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 0.3.10 - 2026-09-05
6
+
7
+ - 拆分可读客户端源码并提交可复现的精简加载产物,使每个文件低于 DSH STORE 的 256 KiB 自动审核上限;安装仍不需要构建或运行依赖。 / Split readable client sources and commit a reproducible compact loader artifact below DSH STORE's 256 KiB per-file review bound, retaining build-free installation and zero runtime dependencies.
8
+ - 声明 alpha.3、alpha.4、alpha.5、rc.1 的精确 DSH 兼容状态;alpha.4/alpha.5/rc.1 均完成隔离 Profile 安装、冷启动、钱包接口与客户端资源验证、卸载和回退。 / Declare exact DSH compatibility for alpha.3, alpha.4, alpha.5, and rc.1; the latter three passed disposable-profile installation, cold start, wallet endpoints/client resource delivery, removal, and rollback.
9
+ - 增加产物复现与文件体积检查,保留全部钱包、账户、套餐及分时计价回归。 / Add artifact reproducibility and byte-bound checks while retaining wallet, account, plan and time-of-use pricing regressions.
10
+
11
+ ## 0.3.9 - 2026-09-04
12
+
13
+ - 第三方 API 自定义价格新增分时计价(Issue #37):每个精确 Provider/模型可在基础价之上配置多个 IANA 时区、适用星期和起止时间规则,并为输入、缓存读、缓存写、输出分别定价;支持跨午夜时段,当前命中的价格段会在设置和钱包详情中显示。 / Added time-of-use custom third-party API pricing (Issue #37): each exact Provider/model route can define multiple IANA-timezone, weekday, and start/end windows over a base rate, with separate input, cache-read, cache-write, and output prices; cross-midnight windows are supported and the active price segment is shown in Settings and wallet details.
14
+ - 分时规则会拒绝无效时区、日期、时间和重叠范围;365 天账本与当前会话按每条保留用量的发生时间重新估算,缺少时间戳的旧聚合或已清除历史安全回退基础价,且 v0.3.8 固定价格规则无需迁移配置。 / Time-window validation rejects invalid timezones, weekdays, times, and overlaps; the 365-day ledger and live session reprice each retained usage event by occurrence time, while legacy aggregates or cleared history without timestamps safely use the base rate and existing v0.3.8 fixed-price rules remain compatible.
15
+ - 将峰谷时钟渲染测试固定到明确的工作日时间,避免测试结果随执行当天是否周末而波动。 / Pinned the peak-clock rendering test to an explicit weekday so its result no longer depends on the day the suite runs.
16
+ - Vision Toolkit 透明包装的 Z.ai 路由会还原为对应套餐 Provider,模型切换后继续显示 5 小时 Token 与月度 MCP 摘要,而不会误显示为普通第三方价格;DeepSeek 与其他包装路由的既有分桶规则保持不变。 / Z.ai routes transparently wrapped by Vision Toolkit now resolve back to their plan provider so model switches retain five-hour Token and monthly MCP summaries instead of looking like ordinary third-party pricing; existing DeepSeek and other wrapper classification remains unchanged.
17
+
5
18
  ## 0.3.8 - 2026-09-02
6
19
 
7
20
  - 新增第三方 API 自定义价格(Issue #36):可按精确 Provider/模型设置币种以及每百万 Token 的输入、缓存读、缓存写和输出价格;本会话与 365 天账本会按当前规则重新估算,并始终标注为用户自定义估算。 / Added custom third-party API pricing (Issue #36) with exact Provider/model matching, currency, and per-million input/cache-read/cache-write/output rates; current-session and 365-day ledger totals are recalculated from the active user rule and always labeled as estimates.
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  > A local-first companion that keeps account status, per-conversation usage, completion reminders, official recharge, flexible layout, and host-gated session controls beside the DSH composer.
16
16
 
17
- > **Version:** v0.3.8 is the current release on GitHub and npm.
17
+ > **Version:** v0.3.10.
18
18
 
19
19
  > If DeepSeek Harness Control Center helps you, please consider leaving a ⭐ Star. Thank you!
20
20
 
@@ -29,10 +29,10 @@
29
29
  - **v4 peak/off-peak ring clock** — a resident 24-hour sidebar footer widget for `v4-flash`, `v4-pro`, and `v4-flash-vision-exp`. Weekday peak windows are 09:00–12:00 and 14:00–18:00 Beijing time. After Friday 18:00 the card previews “weekend all-day off-peak”; Saturday and Sunday name the current all-day off-peak rule; Monday before 09:00 shows the time remaining to enter peak. Optional notifications treat Friday 18:00 through Monday 09:00 as one continuous off-peak period.
30
30
  - **Official pricing sync** — periodically checks the official DeepSeek pricing page and applies only a fully validated table. Network failures retain the last validated rule (or the built-in rule before the first successful sync); an unrecognized page structure is marked for review instead of silently changing billing.
31
31
  - **Z.ai Coding Plan quotas** — a generic official-plan adapter monitors configured Global and China plans without exposing credentials. It separates the 5-hour model-token window from monthly MCP-tool usage, leads with quota remaining from 100% down while keeping usage as secondary context, retains the last successful snapshot on failure, and never converts subscription quota into CNY balance.
32
- - **Provider-aware composer surfaces** — the chip and sidebar clock follow the session's selected provider/model. Z.ai replaces DeepSeek balance, recharge, and peak pricing with plan-window summaries; unrelated providers show only their own session tokens, and DeepSeek V4 restores the wallet and peak clock.
32
+ - **Provider-aware composer surfaces** — the chip and sidebar clock follow the session's selected provider/model. Z.ai—including transparent `vision-toolkit-` variants—replaces DeepSeek balance, recharge, and peak pricing with plan-window summaries; unrelated providers show only their own session tokens, and DeepSeek V4 restores the wallet and peak clock.
33
33
  - **365-day local usage ledger** — Wallet settings keeps the heatmap visible, while compact wallet panels keep it collapsible. Stable request identities are deduplicated, official cost is locked at usage time, official and third-party data stay separate, and prompts or responses are never stored. Collection begins after upgrading to v0.3.2; older aggregate counters have no trustworthy dates and are not backfilled.
34
34
  - **Third-party total** — current-session tokens (input / cache read / output) remain available with zero configuration.
35
- - **Custom third-party pricing** — enter an exact Provider/model route, currency, and per-million input/cache-read/cache-write/output rates. Current-session and 365-day ledger costs are recalculated locally from the saved rule and clearly labeled as user-defined estimates, never as provider balances or invoices.
35
+ - **Custom third-party pricing** — enter an exact Provider/model route, currency, and base per-million input/cache-read/cache-write/output rates, then optionally add multiple IANA-timezone, weekday, and cross-midnight pricing windows. Current-session and 365-day ledger costs are recalculated locally by occurrence time and clearly labeled as user-defined estimates, never as provider balances or invoices.
36
36
  - **Provider classification** — observed wrapper routes appear in the settings page; opted-in routes join the official token/cost bucket for subsequent calls and are priced with the official table. Existing history is not retroactively reclassified.
37
37
  - **Click the chip** to open the detail panel: correctly formatted per-currency balances, cost and token splits, a freely editable low-balance threshold for the active account and currency (two decimals, persisted per account; alerts never mix currencies), manual refresh, and a jump to the official recharge page (first click shows the domain for confirmation — anti-phishing).
38
38
  - **Move, dock, and scale** — drag the chip freely, preview nearby snap targets, use compact horizontal or vertical layouts, adjust its scale from the control panel, and show official or third-party data independently. The peak clock background can be explicitly set to transparent (solid on hover) or solid; there is no automatic mode. The choices are remembered locally.
@@ -74,7 +74,7 @@ Details: [compatibility](#browser-desktop-and-os-compatibility) · [data and tru
74
74
 
75
75
  ## Install
76
76
 
77
- From npm (published stable v0.3.8):
77
+ From npm:
78
78
 
79
79
  ```sh
80
80
  dsh plugin --profile web add deepseek-harness-wallet
@@ -145,12 +145,14 @@ For buildable DSH hosts, the npm package and repository include a versioned [Age
145
145
 
146
146
  ## Data & trust
147
147
 
148
+ Client development now uses five readable files under `src/client/` and a committed `lib/client.js` artifact; run `npm ci`, `npm run build:client`, and `npm run check:client` after source edits. Installation runs no build scripts. Exact-version smoke checks are documented in [0.3.10 compatibility evidence](https://github.com/feibi-mochi/deepseek-harness-control-center/blob/main/docs/compatibility-0.3.10.md).
149
+
148
150
  | Item | Behavior |
149
151
  | --- | --- |
150
152
  | Token accounting | Listens to the `llm/stream` event and buckets per session and provider: `deepseek-official` plus explicitly opted-in wrapper routes use the official bucket; other providers stay third-party; each usage event also locks its contemporaneous official price, so multiple sessions and pricing windows never mix. |
151
153
  | Balance | The wallet plugin itself sends the active key directly only to the official `/user/balance` endpoint. When multi-account switching is enabled, the selected key is also written into the DSH credentials seam; DSH may then use it for subsequent model requests. |
152
154
  | Accounts | Keys live encrypted in `$DSH_HOME/storages/accounts.json`, with an encrypted `accounts.json.bak` fallback for a missing, corrupt, or undecryptable primary. Windows uses current-user DPAPI; other platforms use an owner-only AES-GCM key file, so move `accounts.json`, `.bak`, and `.key` together. If neither copy can be read, account writes fail closed. |
153
- | Usage ledger | Local events and custom third-party price rules live in `$DSH_HOME/storages/wallet.json` with a `wallet.json.bak` recovery copy. Missing/corrupt primaries recover automatically; if neither copy is readable, wallet writes fail closed. Up to 365 days of session/provider/model/token metadata and official locked cost are kept—never prompts, tool arguments, response bodies, or API keys. Third-party estimates are recalculated from the current custom rule. |
155
+ | Usage ledger | Local events and custom third-party price rules live in `$DSH_HOME/storages/wallet.json` with a `wallet.json.bak` recovery copy. Missing/corrupt primaries recover automatically; if neither copy is readable, wallet writes fail closed. Up to 365 days of session/provider/model/token metadata and official locked cost are kept—never prompts, tool arguments, response bodies, or API keys. Third-party estimates are recalculated from the current custom rule and each retained event's occurrence time; aggregate usage without a retained timestamp safely falls back to the base rate. |
154
156
  | Local settings | Layout, scale, visibility, reminder, and panel settings stay in browser-compatible local storage. |
155
157
  | Permanent deletion | Opt-in and host-gated. The wallet never advertises the action unless the host implements the matching session deletion path. |
156
158
  | Model surface | No tools registered, no prompt injection, zero token cost. |
@@ -174,7 +176,7 @@ Historical deepseek-chat and deepseek-reasoner records retain their original fla
174
176
 
175
177
  - [x] 365-day Token heatmap and rebuildable local usage ledger
176
178
  - [x] Z.ai Coding Plan Global/China monitoring on a generic official-plan adapter contract
177
- - [x] User-defined third-party Provider/model pricing
179
+ - [x] User-defined third-party Provider/model base and time-of-use pricing
178
180
  - [ ] Additional provider price/balance adapters only after real-account validation
179
181
 
180
182
  ## License
package/index.js CHANGED
@@ -28,12 +28,13 @@ export const name = 'wallet'
28
28
  export const inject = ['webServer', 'credentials']
29
29
 
30
30
  const OFFICIAL_PROVIDER = 'deepseek-official'
31
+ const TRANSPARENT_PROVIDER_PREFIX = 'vision-toolkit-'
31
32
  // Subscription-plan routes are independent providers. They may contribute to
32
33
  // the generic third-party token ledger, but they must never be promoted into
33
34
  // DeepSeek's official paid-API bucket by the wrapper-provider alias control.
34
35
  const PLAN_PROVIDER_IDS = new Set(PLAN_ADAPTERS.map((adapter) => adapter.provider))
35
36
  const RECHARGE_URL = 'https://platform.deepseek.com/top_up'
36
- const PLUGIN_VERSION = '0.3.8'
37
+ const PLUGIN_VERSION = '0.3.10'
37
38
  const PRICING_SOURCE_URL = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing/'
38
39
  const PRICING_SYNC_INTERVAL_MS = 6 * 60 * 60_000
39
40
  const PRICING_SYNC_TIMEOUT_MS = 8_000
@@ -49,14 +50,21 @@ const ACCOUNTS_CRYPTO_VERSION = 1
49
50
  const CREDENTIAL_REF = 'DEEPSEEK_API_KEY'
50
51
  const DEFAULT_THRESHOLD = 5
51
52
  const BALANCE_REFRESH_MS = 60_000
52
- const STORE_VERSION = 6
53
+ const STORE_VERSION = 7
53
54
  const HISTORY_VERSION = 1
54
55
  const HISTORY_RETENTION_DAYS = 365
55
56
  const HISTORY_MAX_EVENTS = 20_000
56
57
  const CUSTOM_PRICE_MAX_RULES = 100
57
58
  const CUSTOM_PRICE_MAX_RATE = 1_000_000
59
+ const CUSTOM_PRICE_MAX_WINDOWS = 24
58
60
  const HISTORY_TIMEZONE = 'Asia/Shanghai'
59
61
  const DAY_MS = 86_400_000
62
+ const MINUTES_PER_DAY = 1_440
63
+ const MINUTES_PER_WEEK = 10_080
64
+ const CUSTOM_WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0]
65
+ const WEEKDAY_NUMBER = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }
66
+ const customTimeFormatters = new Map()
67
+ const customTimezoneNames = new Map()
60
68
  const PLAN_REFRESH_MS = 5 * 60_000
61
69
  const PLAN_STALE_MS = 15 * 60_000
62
70
  const PLAN_REFRESH_TIMEOUT_MS = 12_000
@@ -797,7 +805,8 @@ function publicHistoryAggregate(aggregate, hasOfficial = true) {
797
805
  }
798
806
  }
799
807
 
800
- function historyAggregate(events) {
808
+ function historyAggregate(events, customRules = null) {
809
+ const ruleMap = customRules || customPriceRuleMap(store.customPrices)
801
810
  const official = emptyHistoryAggregate()
802
811
  const third = emptyHistoryAggregate()
803
812
  const models = new Map()
@@ -811,10 +820,18 @@ function historyAggregate(events) {
811
820
  const key = event.provider + '\u0000' + event.model
812
821
  let model = models.get(key)
813
822
  if (model === undefined) {
814
- model = { provider: event.provider, model: event.model, official: event.official, ...emptyHistoryAggregate() }
823
+ model = { provider: event.provider, model: event.model, official: event.official, customCost: null, ...emptyHistoryAggregate() }
815
824
  models.set(key, model)
816
825
  }
817
826
  addHistoryAggregate(model, event)
827
+ if (!event.official) {
828
+ const rule = ruleMap.get(event.provider + '\u0000' + event.model)
829
+ const priced = rule === undefined ? null : customCostWithRule(rule, event.usage, event.occurredAt)
830
+ if (priced !== null) {
831
+ if (model.customCost === null) model.customCost = { cost: 0, currency: priced.currency }
832
+ model.customCost.cost += priced.cost
833
+ }
834
+ }
818
835
  }
819
836
  const total = emptyHistoryAggregate()
820
837
  for (const source of [official, third]) {
@@ -825,13 +842,12 @@ function historyAggregate(events) {
825
842
  total.priced = official.priced
826
843
  const publicModels = [...models.values()].map((model) => {
827
844
  const aggregate = publicHistoryAggregate(model, model.official)
828
- const priced = model.official ? null : customCostOf(model.provider, model.model, model.tokens, store.customPrices)
829
845
  return {
830
846
  provider: model.provider,
831
847
  model: model.model,
832
848
  official: model.official,
833
849
  ...aggregate,
834
- customCost: priced === null ? null : { cost: priced.cost, currency: priced.currency },
850
+ customCost: model.official || model.customCost === null ? null : { ...model.customCost },
835
851
  }
836
852
  })
837
853
  const customCosts = customCostTotals(publicModels)
@@ -848,6 +864,7 @@ export function historyView({ days = HISTORY_RETENTION_DAYS, date = null, sessio
848
864
  const window = historyWindow(atMs, days)
849
865
  const sourceEvents = store.history && store.history.events ? Object.values(store.history.events) : []
850
866
  const filtered = sourceEvents.filter((event) => event.day >= window.from && event.day <= window.to && (sessionId === null || event.sessionId === sessionId))
867
+ const customRules = customPriceRuleMap(store.customPrices)
851
868
  const byDay = new Map()
852
869
  for (const event of filtered) {
853
870
  if (!byDay.has(event.day)) byDay.set(event.day, [])
@@ -857,7 +874,7 @@ export function historyView({ days = HISTORY_RETENTION_DAYS, date = null, sessio
857
874
  for (let index = 0; index < window.days; index += 1) {
858
875
  const day = shiftHistoryDay(window.from, index)
859
876
  const events = byDay.get(day) || []
860
- const aggregate = historyAggregate(events)
877
+ const aggregate = historyAggregate(events, customRules)
861
878
  const dayStart = historyDayStartMs(day)
862
879
  daysOut.push({
863
880
  date: day,
@@ -868,12 +885,12 @@ export function historyView({ days = HISTORY_RETENTION_DAYS, date = null, sessio
868
885
  cacheHitRate: aggregate.cacheHitRate,
869
886
  })
870
887
  }
871
- const total = historyAggregate(filtered)
888
+ const total = historyAggregate(filtered, customRules)
872
889
  const todayEvents = byDay.get(window.to) || []
873
890
  const monthPrefix = window.to.slice(0, 7)
874
891
  const monthEvents = filtered.filter((event) => event.day.startsWith(monthPrefix))
875
892
  const selectedEvents = typeof date === 'string' ? (byDay.get(date) || []) : []
876
- const selectedAggregate = typeof date === 'string' && date >= window.from && date <= window.to ? historyAggregate(selectedEvents) : null
893
+ const selectedAggregate = typeof date === 'string' && date >= window.from && date <= window.to ? historyAggregate(selectedEvents, customRules) : null
877
894
  return {
878
895
  ok: true,
879
896
  timezone: HISTORY_TIMEZONE,
@@ -882,8 +899,8 @@ export function historyView({ days = HISTORY_RETENTION_DAYS, date = null, sessio
882
899
  window,
883
900
  summary: {
884
901
  total: total.total,
885
- today: historyAggregate(todayEvents).total,
886
- month: historyAggregate(monthEvents).total,
902
+ today: historyAggregate(todayEvents, customRules).total,
903
+ month: historyAggregate(monthEvents, customRules).total,
887
904
  cacheHitRate: total.cacheHitRate,
888
905
  },
889
906
  days: daysOut,
@@ -912,7 +929,9 @@ export function normalizeProviderList(value) {
912
929
  }
913
930
 
914
931
  export function isPlanProvider(provider) {
915
- return typeof provider === 'string' && PLAN_PROVIDER_IDS.has(provider)
932
+ if (typeof provider !== 'string') return false
933
+ const upstream = provider.startsWith(TRANSPARENT_PROVIDER_PREFIX) ? provider.slice(TRANSPARENT_PROVIDER_PREFIX.length) : provider
934
+ return PLAN_PROVIDER_IDS.has(upstream)
916
935
  }
917
936
 
918
937
  export function isOfficialProvider(provider, extraProviders) {
@@ -926,6 +945,107 @@ function customPriceRate(value) {
926
945
  return Math.round(value * 1_000_000) / 1_000_000
927
946
  }
928
947
 
948
+ function normalizeCustomTimezone(value) {
949
+ if (typeof value !== 'string' || value === '' || value.length > 80 || /[\u0000-\u001f\u007f]/.test(value)) return null
950
+ if (customTimezoneNames.has(value)) return customTimezoneNames.get(value)
951
+ try {
952
+ const timezone = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone
953
+ if (customTimezoneNames.size >= 200) customTimezoneNames.clear()
954
+ customTimezoneNames.set(value, timezone)
955
+ return timezone
956
+ } catch {
957
+ return null
958
+ }
959
+ }
960
+
961
+ function customTimeMinute(value) {
962
+ if (typeof value !== 'string') return null
963
+ const match = value.match(/^([01]\d|2[0-3]):([0-5]\d)$/)
964
+ return match === null ? null : Number(match[1]) * 60 + Number(match[2])
965
+ }
966
+
967
+ function normalizeCustomPriceWindow(value) {
968
+ const source = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
969
+ const label = boundedString(source.label, 40)
970
+ const start = customTimeMinute(source.start)
971
+ const end = customTimeMinute(source.end)
972
+ const rawDays = Array.isArray(source.days) ? source.days : []
973
+ const days = CUSTOM_WEEKDAY_ORDER.filter((day) => rawDays.includes(day))
974
+ const input = customPriceRate(source.input)
975
+ const cacheRead = customPriceRate(source.cacheRead)
976
+ const cacheWrite = customPriceRate(source.cacheWrite)
977
+ const output = customPriceRate(source.output)
978
+ if (label === null || start === null || end === null || start === end || days.length === 0) return null
979
+ if (rawDays.some((day) => !Number.isInteger(day) || day < 0 || day > 6)) return null
980
+ if ([input, cacheRead, cacheWrite, output].some((rate) => rate === null)) return null
981
+ return { label, days, start: source.start, end: source.end, input, cacheRead, cacheWrite, output }
982
+ }
983
+
984
+ function normalizeCustomPriceWindows(value) {
985
+ if (value === undefined) return []
986
+ if (!Array.isArray(value) || value.length > CUSTOM_PRICE_MAX_WINDOWS) return null
987
+ const windows = []
988
+ const intervals = []
989
+ for (const candidate of value) {
990
+ const window = normalizeCustomPriceWindow(candidate)
991
+ if (window === null) return null
992
+ const startMinute = customTimeMinute(window.start)
993
+ const endMinute = customTimeMinute(window.end)
994
+ const duration = (endMinute - startMinute + MINUTES_PER_DAY) % MINUTES_PER_DAY
995
+ for (const day of window.days) {
996
+ const start = day * MINUTES_PER_DAY + startMinute
997
+ const end = start + duration
998
+ if (end <= MINUTES_PER_WEEK) intervals.push({ start, end })
999
+ else {
1000
+ intervals.push({ start, end: MINUTES_PER_WEEK })
1001
+ intervals.push({ start: 0, end: end - MINUTES_PER_WEEK })
1002
+ }
1003
+ }
1004
+ windows.push(window)
1005
+ }
1006
+ intervals.sort((left, right) => left.start - right.start || left.end - right.end)
1007
+ for (let index = 1; index < intervals.length; index += 1) {
1008
+ if (intervals[index].start < intervals[index - 1].end) return null
1009
+ }
1010
+ return windows
1011
+ }
1012
+
1013
+ function customWallTime(atMs, timezone) {
1014
+ if (!Number.isFinite(atMs)) return null
1015
+ try {
1016
+ let formatter = customTimeFormatters.get(timezone)
1017
+ if (formatter === undefined) {
1018
+ formatter = new Intl.DateTimeFormat('en-US-u-ca-gregory', {
1019
+ timeZone: timezone,
1020
+ weekday: 'short',
1021
+ hour: '2-digit',
1022
+ minute: '2-digit',
1023
+ hourCycle: 'h23',
1024
+ })
1025
+ if (customTimeFormatters.size >= 200) customTimeFormatters.clear()
1026
+ customTimeFormatters.set(timezone, formatter)
1027
+ }
1028
+ const parts = Object.fromEntries(formatter.formatToParts(new Date(atMs)).map((part) => [part.type, part.value]))
1029
+ const weekday = WEEKDAY_NUMBER[parts.weekday]
1030
+ const hour = Number(parts.hour)
1031
+ const minute = Number(parts.minute)
1032
+ return Number.isInteger(weekday) && Number.isInteger(hour) && Number.isInteger(minute)
1033
+ ? { weekday, minute: hour * 60 + minute }
1034
+ : null
1035
+ } catch {
1036
+ return null
1037
+ }
1038
+ }
1039
+
1040
+ function customWindowMatches(window, wall) {
1041
+ const start = customTimeMinute(window.start)
1042
+ const end = customTimeMinute(window.end)
1043
+ if (start < end) return window.days.includes(wall.weekday) && wall.minute >= start && wall.minute < end
1044
+ if (wall.minute >= start) return window.days.includes(wall.weekday)
1045
+ const previousDay = (wall.weekday + 6) % 7
1046
+ return wall.minute < end && window.days.includes(previousDay)
1047
+ }
1048
+
929
1049
  /** Normalize one exact third-party provider/model price rule (rates are per 1M tokens). */
930
1050
  export function normalizeCustomPriceRule(value) {
931
1051
  const source = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
@@ -941,7 +1061,12 @@ export function normalizeCustomPriceRule(value) {
941
1061
  if (provider === '__proto__' || provider === 'prototype' || provider === 'constructor') return null
942
1062
  if (model === '__proto__' || model === 'prototype' || model === 'constructor') return null
943
1063
  if ([input, cacheRead, cacheWrite, output].some((rate) => rate === null)) return null
944
- return { provider, model, currency, input, cacheRead, cacheWrite, output }
1064
+ const windows = normalizeCustomPriceWindows(source.windows)
1065
+ if (windows === null) return null
1066
+ const timezone = normalizeCustomTimezone(source.timezone === undefined ? HISTORY_TIMEZONE : source.timezone)
1067
+ if (timezone === null) return null
1068
+ const rule = { provider, model, currency, input, cacheRead, cacheWrite, output }
1069
+ return windows.length === 0 ? rule : { ...rule, timezone, windows }
945
1070
  }
946
1071
 
947
1072
  /** Normalize, deduplicate, and bound persisted custom third-party price rules. */
@@ -959,19 +1084,105 @@ export function normalizeCustomPrices(value) {
959
1084
  return [...rules.values()]
960
1085
  }
961
1086
 
962
- /** Price one exact third-party route from user-entered per-million-token rates. */
963
- export function customCostOf(provider, model, usage, rules) {
964
- if (typeof provider !== 'string' || typeof model !== 'string') return null
965
- const rule = normalizeCustomPrices(rules).find((entry) => entry.provider === provider && entry.model === model)
966
- if (rule === undefined) return null
1087
+ function customPriceRuleMap(rules) {
1088
+ return new Map(normalizeCustomPrices(rules).map((rule) => [rule.provider + '\u0000' + rule.model, rule]))
1089
+ }
1090
+
1091
+ function customRuleFor(provider, model, rules) {
1092
+ if (typeof provider !== 'string' || typeof model !== 'string' || !Array.isArray(rules)) return null
1093
+ for (let index = rules.length - 1; index >= 0; index -= 1) {
1094
+ const candidate = rules[index]
1095
+ if (candidate && candidate.provider === provider && candidate.model === model) return normalizeCustomPriceRule(candidate)
1096
+ }
1097
+ return null
1098
+ }
1099
+
1100
+ function customRateSelection(rule, atMs) {
1101
+ if (Array.isArray(rule.windows) && rule.windows.length > 0 && Number.isFinite(atMs)) {
1102
+ const wall = customWallTime(atMs, rule.timezone)
1103
+ const window = wall === null ? null : rule.windows.find((candidate) => customWindowMatches(candidate, wall))
1104
+ if (window !== undefined && window !== null) {
1105
+ return {
1106
+ kind: 'window',
1107
+ label: window.label,
1108
+ rates: { input: window.input, cacheRead: window.cacheRead, cacheWrite: window.cacheWrite, output: window.output },
1109
+ window,
1110
+ }
1111
+ }
1112
+ }
1113
+ return {
1114
+ kind: 'base',
1115
+ label: '基础价',
1116
+ rates: { input: rule.input, cacheRead: rule.cacheRead, cacheWrite: rule.cacheWrite, output: rule.output },
1117
+ window: null,
1118
+ }
1119
+ }
1120
+
1121
+ export function customPriceStatus(value, atMs = Date.now()) {
1122
+ const rule = normalizeCustomPriceRule(value)
1123
+ if (rule === null) return null
1124
+ const selected = customRateSelection(rule, atMs)
1125
+ return {
1126
+ kind: selected.kind,
1127
+ label: selected.label,
1128
+ timezone: rule.timezone || null,
1129
+ rates: selected.rates,
1130
+ window: selected.window === null
1131
+ ? null
1132
+ : { label: selected.window.label, days: [...selected.window.days], start: selected.window.start, end: selected.window.end },
1133
+ }
1134
+ }
1135
+
1136
+ function customCostWithRule(rule, usage, atMs) {
1137
+ const selected = customRateSelection(rule, atMs)
967
1138
  const counters = historyUsageCounters(usage)
968
1139
  const cost = (
969
- counters.input * rule.input
970
- + counters.cacheRead * rule.cacheRead
971
- + counters.cacheWrite * rule.cacheWrite
972
- + counters.output * rule.output
1140
+ counters.input * selected.rates.input
1141
+ + counters.cacheRead * selected.rates.cacheRead
1142
+ + counters.cacheWrite * selected.rates.cacheWrite
1143
+ + counters.output * selected.rates.output
973
1144
  ) / 1e6
974
- return { cost, currency: rule.currency, rule }
1145
+ const result = { cost, currency: rule.currency, rule }
1146
+ if (Array.isArray(rule.windows) && rule.windows.length > 0) result.active = customPriceStatus(rule, atMs)
1147
+ return result
1148
+ }
1149
+
1150
+ /** Price one exact third-party route from user-entered per-million-token rates. */
1151
+ export function customCostOf(provider, model, usage, rules, atMs = null) {
1152
+ const rule = customRuleFor(provider, model, rules)
1153
+ return rule === null ? null : customCostWithRule(rule, usage, atMs)
1154
+ }
1155
+
1156
+ function customSessionCost(provider, model, usage, sessionId, rules) {
1157
+ const rule = customRuleFor(provider, model, rules)
1158
+ if (rule === null) return null
1159
+ const remaining = historyUsageCounters(usage)
1160
+ let cost = 0
1161
+ if (typeof sessionId === 'string') {
1162
+ const events = Object.values(store.history && store.history.events ? store.history.events : {})
1163
+ .filter((event) => event && !event.official && event.sessionId === sessionId && event.provider === provider && event.model === model)
1164
+ .sort((left, right) => right.occurredAt - left.occurredAt)
1165
+ for (const event of events) {
1166
+ const counters = historyUsageCounters(event.usage)
1167
+ // Clearing a session intentionally leaves historical events in the
1168
+ // ledger. Walk newest-first and only price events that still fit inside
1169
+ // the live aggregate, so pre-clear usage is not resurrected here.
1170
+ if (!['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning'].every((key) => counters[key] <= remaining[key])) continue
1171
+ for (const key of ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning']) remaining[key] -= counters[key]
1172
+ cost += customCostWithRule(rule, event.usage, event.occurredAt).cost
1173
+ }
1174
+ }
1175
+ // Session totals can outlive the 365-day event ledger or survive an explicit
1176
+ // history clear. Without an occurrence timestamp, charge only the configured
1177
+ // base rate instead of pretending the current time window applied.
1178
+ cost += customCostWithRule(rule, remaining, null).cost
1179
+ const result = { cost, currency: rule.currency, rule }
1180
+ if (Array.isArray(rule.windows) && rule.windows.length > 0) result.active = customPriceStatus(rule, Date.now())
1181
+ return result
1182
+ }
1183
+
1184
+ function customPriceRulesView(atMs = Date.now()) {
1185
+ return store.customPrices.map((rule) => ({ ...rule, active: customPriceStatus(rule, atMs) }))
975
1186
  }
976
1187
 
977
1188
  function normalizeThirdPartyRoutes(value) {
@@ -1947,19 +2158,22 @@ function balanceTotal() {
1947
2158
  return sumBalances(balance.balances)
1948
2159
  }
1949
2160
 
1950
- function thirdPartyRouteView(bucket) {
2161
+ function thirdPartyRouteView(bucket, sessionId = null) {
1951
2162
  const routes = []
1952
2163
  const source = normalizeThirdPartyRoutes(bucket && bucket.routes)
1953
2164
  for (const [provider, providerState] of Object.entries(source)) {
1954
2165
  if (isPlanProvider(provider) || isOfficialProvider(provider, store.officialProviders)) continue
1955
2166
  for (const [model, counters] of Object.entries(providerState.models)) {
1956
- const priced = customCostOf(provider, model, counters, store.customPrices)
2167
+ const priced = sessionId === null
2168
+ ? customCostOf(provider, model, counters, store.customPrices)
2169
+ : customSessionCost(provider, model, counters, sessionId, store.customPrices)
1957
2170
  routes.push({
1958
2171
  provider,
1959
2172
  model,
1960
2173
  tokens: counters,
1961
2174
  totalTokens: historyTokenTotal(counters),
1962
2175
  customCost: priced === null ? null : { cost: priced.cost, currency: priced.currency },
2176
+ activePrice: priced && priced.active ? priced.active : null,
1963
2177
  })
1964
2178
  }
1965
2179
  }
@@ -1999,7 +2213,7 @@ function sessionView(sessionId) {
1999
2213
  }
2000
2214
  const official = bucketTotals(session.official)
2001
2215
  const third = bucketTotals(session.third)
2002
- const thirdRoutes = thirdPartyRouteView(session.third)
2216
+ const thirdRoutes = thirdPartyRouteView(session.third, sessionId)
2003
2217
  return {
2004
2218
  official: {
2005
2219
  tokens: official,
@@ -2060,7 +2274,7 @@ function snapshotView(sessionId) {
2060
2274
  builtinOfficial: OFFICIAL_PROVIDER,
2061
2275
  official: [...store.officialProviders],
2062
2276
  known: store.knownProviders.filter((p) => p !== OFFICIAL_PROVIDER && !store.officialProviders.includes(p)),
2063
- customPrices: [...store.customPrices],
2277
+ customPrices: customPriceRulesView(now),
2064
2278
  knownRoutes: knownThirdPartyRoutes(),
2065
2279
  },
2066
2280
  plans: planView(now),
@@ -2359,7 +2573,7 @@ historyEventCount = store.history && store.history.events ? Object.keys(store.hi
2359
2573
  path: '/api/wallet/custom-prices',
2360
2574
  handler: async (req, res) => {
2361
2575
  if (req.method === 'GET') {
2362
- return json(res, 200, { ok: true, rules: [...store.customPrices], knownRoutes: knownThirdPartyRoutes() })
2576
+ return json(res, 200, { ok: true, rules: customPriceRulesView(), knownRoutes: knownThirdPartyRoutes() })
2363
2577
  }
2364
2578
  if (usageStorageLocked) return json(res, 423, { ok: false, error: 'usage-storage-locked' })
2365
2579
  if (req.method !== 'POST' && req.method !== 'DELETE') {
@@ -2378,14 +2592,14 @@ historyEventCount = store.history && store.history.events ? Object.keys(store.hi
2378
2592
  rule,
2379
2593
  ])
2380
2594
  scheduleSave(ctx.logger)
2381
- return json(res, 200, { ok: true, rules: [...store.customPrices], knownRoutes: knownThirdPartyRoutes() })
2595
+ return json(res, 200, { ok: true, rules: customPriceRulesView(), knownRoutes: knownThirdPartyRoutes() })
2382
2596
  }
2383
2597
  const provider = boundedString(body.provider, 100)
2384
2598
  const model = boundedString(body.model, 160)
2385
2599
  if (provider === null || model === null) return json(res, 400, { ok: false, error: 'provider-and-model-required' })
2386
2600
  store.customPrices = store.customPrices.filter((entry) => entry.provider !== provider || entry.model !== model)
2387
2601
  scheduleSave(ctx.logger)
2388
- return json(res, 200, { ok: true, rules: [...store.customPrices], knownRoutes: knownThirdPartyRoutes() })
2602
+ return json(res, 200, { ok: true, rules: customPriceRulesView(), knownRoutes: knownThirdPartyRoutes() })
2389
2603
  },
2390
2604
  })
2391
2605
  const disposeAccounts = ctx.webServer.register({