deepseek-harness-wallet 0.3.8 → 0.3.9

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,13 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 0.3.9 - 2026-09-04
6
+
7
+ - 第三方 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.
8
+ - 分时规则会拒绝无效时区、日期、时间和重叠范围;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.
9
+ - 将峰谷时钟渲染测试固定到明确的工作日时间,避免测试结果随执行当天是否周末而波动。 / Pinned the peak-clock rendering test to an explicit weekday so its result no longer depends on the day the suite runs.
10
+ - 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.
11
+
5
12
  ## 0.3.8 - 2026-09-02
6
13
 
7
14
  - 新增第三方 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.9 is the current release on GitHub and npm.
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 (published stable v0.3.9):
78
78
 
79
79
  ```sh
80
80
  dsh plugin --profile web add deepseek-harness-wallet
@@ -150,7 +150,7 @@ For buildable DSH hosts, the npm package and repository include a versioned [Age
150
150
  | 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
151
  | 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
152
  | 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. |
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 and each retained event's occurrence time; aggregate usage without a retained timestamp safely falls back to the base rate. |
154
154
  | Local settings | Layout, scale, visibility, reminder, and panel settings stay in browser-compatible local storage. |
155
155
  | Permanent deletion | Opt-in and host-gated. The wallet never advertises the action unless the host implements the matching session deletion path. |
156
156
  | Model surface | No tools registered, no prompt injection, zero token cost. |
@@ -174,7 +174,7 @@ Historical deepseek-chat and deepseek-reasoner records retain their original fla
174
174
 
175
175
  - [x] 365-day Token heatmap and rebuildable local usage ledger
176
176
  - [x] Z.ai Coding Plan Global/China monitoring on a generic official-plan adapter contract
177
- - [x] User-defined third-party Provider/model pricing
177
+ - [x] User-defined third-party Provider/model base and time-of-use pricing
178
178
  - [ ] Additional provider price/balance adapters only after real-account validation
179
179
 
180
180
  ## 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.9'
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({
package/lib/client.js CHANGED
@@ -23,7 +23,11 @@ window.__ModuleLoader__.load({
23
23
 
24
24
  var POLL_MS = 15000
25
25
  // Keep in lockstep with package.json; a test enforces the sync.
26
- var WALLET_VERSION = '0.3.8'
26
+ var WALLET_VERSION = '0.3.9'
27
+ var CUSTOM_PRICE_WEEKDAYS = [
28
+ { value: 1, label: '一' }, { value: 2, label: '二' }, { value: 3, label: '三' },
29
+ { value: 4, label: '四' }, { value: 5, label: '五' }, { value: 6, label: '六' }, { value: 0, label: '日' }
30
+ ]
27
31
  var CONFIRM_KEY = 'dsh-wallet-recharge-confirmed'
28
32
  var CHIP_LAYOUT_KEY = 'dshw-chip-layout-v4'
29
33
  var PANEL_POS_KEY = 'dshw-panel-pos-v1'
@@ -610,15 +614,22 @@ window.__ModuleLoader__.load({
610
614
  '.dshw_settingChoiceCopy{min-width:0;display:flex;flex-direction:column;gap:1px}',
611
615
  '.dshw_settingChoiceCopy strong{font-size:11px;font-weight:550;white-space:nowrap;color:var(--dsw-alias-label-primary,inherit)}',
612
616
  '.dshw_settingChoiceCopy span{font-size:9.5px;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary,#667085));white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
613
- '.dshw_priceForm{display:grid;grid-template-columns:minmax(120px,1.25fr) minmax(140px,1.5fr) 72px repeat(4,minmax(74px,.7fr)) auto;gap:7px;align-items:end;padding:10px 12px}',
617
+ '.dshw_priceForm{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px;align-items:end;padding:10px 12px}',
614
618
  '.dshw_priceField{display:grid;gap:4px;min-width:0}',
615
619
  '.dshw_priceField>span{font-size:9.5px;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary,#667085))}',
616
620
  '.dshw_priceField .dshw_input,.dshw_priceField .dshw_select{box-sizing:border-box;width:100%;height:28px}',
621
+ '.dshw_priceWindowEditor{display:grid;gap:7px;margin:0 12px 10px;padding:9px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.15));border-radius:9px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1,rgba(127,127,127,.035)))}',
622
+ '.dshw_priceWindowToolbar{display:flex;align-items:end;justify-content:space-between;gap:8px;flex-wrap:wrap}.dshw_priceWindowToolbar .dshw_priceField{min-width:180px;flex:1}',
623
+ '.dshw_priceWindowList{display:grid;gap:7px}.dshw_priceWindow{display:grid;gap:7px;padding:8px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.14));border-radius:8px;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-base,rgba(127,127,127,.03)))}',
624
+ '.dshw_priceWindowHead{display:flex;align-items:end;gap:7px}.dshw_priceWindowHead .dshw_priceField{flex:1}',
625
+ '.dshw_priceDays{display:flex;align-items:center;gap:4px;flex-wrap:wrap}.dshw_priceDays>span{margin-right:2px;font-size:9.5px;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary,#667085))}',
626
+ '.dshw_priceDay{width:25px;height:24px;padding:0;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.18));border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary,inherit);font-size:10px;cursor:pointer}.dshw_priceDay[aria-pressed="true"]{border-color:var(--dsw-alias-brand-primary,#4aa3ff);background:var(--dsw-alias-brand-soft,rgba(74,163,255,.12));color:var(--dsw-alias-label-primary,inherit)}',
627
+ '.dshw_priceWindowRates{display:grid;grid-template-columns:repeat(6,minmax(72px,1fr));gap:7px}',
617
628
  '.dshw_priceRules{display:grid;gap:6px;padding:0 12px 11px}',
618
629
  '.dshw_priceRule{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:center;padding:7px 9px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.15));border-radius:8px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-layer-1,rgba(127,127,127,.04)))}',
619
630
  '.dshw_priceRule strong,.dshw_priceRule span{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
620
631
  '.dshw_priceRule strong{font-size:11px}.dshw_priceRule span{font-size:9.5px;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary,#667085))}',
621
- '@media(max-width:980px){.dshw_priceForm{grid-template-columns:repeat(2,minmax(0,1fr))}.dshw_priceForm>.dshw_btn{min-height:28px}}',
632
+ '@media(max-width:760px){.dshw_priceForm{grid-template-columns:repeat(2,minmax(0,1fr))}.dshw_priceForm>.dshw_btn{min-height:28px}.dshw_priceWindowRates{grid-template-columns:repeat(2,minmax(0,1fr))}}',
622
633
  '.dshw_switch{position:relative;flex:none;width:34px;height:20px;display:inline-flex;cursor:pointer}',
623
634
  '.dshw_switch>input{position:absolute;inset:0;opacity:0;margin:0;cursor:pointer}',
624
635
  '.dshw_track{position:absolute;inset:0;border-radius:999px;background:var(--dsw-alias-border-l3,rgba(127,127,127,.3));transition:background-color .15s}',
@@ -1597,7 +1608,7 @@ window.__ModuleLoader__.load({
1597
1608
  React.createElement('div', null, React.createElement('span', null, '今天'), React.createElement('strong', null, fmtTokens(today.totalTokens))),
1598
1609
  React.createElement('div', null, React.createElement('span', null, '本月'), React.createElement('strong', null, fmtTokens(month.totalTokens))),
1599
1610
  React.createElement('div', null, React.createElement('span', null, '缓存命中'), React.createElement('strong', null, history.summary.cacheHitRate === null || history.summary.cacheHitRate === undefined ? '--' : history.summary.cacheHitRate + '%'))),
1600
- customCostsText(total.customCosts) ? React.createElement('div', { className: 'dshw_historyLegend' }, '第三方按当前自定义价估算:' + customCostsText(total.customCosts)) : null,
1611
+ customCostsText(total.customCosts) ? React.createElement('div', { className: 'dshw_historyLegend' }, '第三方按当前自定义规则估算:' + customCostsText(total.customCosts)) : null,
1601
1612
  React.createElement('div', { className: 'dshw_historyMetricToggle', role: 'group', 'aria-label': '历史热力图指标' },
1602
1613
  React.createElement('button', { type: 'button', className: metric === 'tokens' ? 'dshw_historyMetricActive' : '', 'aria-pressed': metric === 'tokens', onClick: function () { setMetric('tokens') } }, 'Token'),
1603
1614
  React.createElement('button', { type: 'button', className: metric === 'cost' ? 'dshw_historyMetricActive' : '', 'aria-pressed': metric === 'cost', onClick: function () { setMetric('cost') } }, '费用')),
@@ -1709,7 +1720,8 @@ window.__ModuleLoader__.load({
1709
1720
  if (!selection || typeof selection.provider !== 'string') return { kind: 'loading', provider: null, model: null }
1710
1721
  var provider = selection.provider
1711
1722
  var model = typeof selection.model === 'string' ? selection.model : null
1712
- if (provider === 'zai' || provider === 'zai-coding-cn') return { kind: 'zai', provider: provider, model: model }
1723
+ var planProvider = provider.indexOf('vision-toolkit-') === 0 ? provider.slice('vision-toolkit-'.length) : provider
1724
+ if (planProvider === 'zai' || planProvider === 'zai-coding-cn') return { kind: 'zai', provider: planProvider, routeProvider: provider, model: model }
1713
1725
  var providers = snapshot && snapshot.providers ? snapshot.providers : {}
1714
1726
  var aliases = Array.isArray(providers.official) ? providers.official : []
1715
1727
  if (provider === (providers.builtinOfficial || 'deepseek-official') || aliases.indexOf(provider) >= 0) {
@@ -1893,7 +1905,8 @@ window.__ModuleLoader__.load({
1893
1905
  var [knownPriceRoutes, setKnownPriceRoutes] = React.useState([])
1894
1906
  var [priceNotice, setPriceNotice] = React.useState(null)
1895
1907
  var [priceDraft, setPriceDraft] = React.useState({
1896
- provider: '', model: '', currency: 'CNY', input: '', cacheRead: '', cacheWrite: '', output: ''
1908
+ provider: '', model: '', currency: 'CNY', input: '', cacheRead: '', cacheWrite: '', output: '',
1909
+ timezone: 'Asia/Shanghai', windows: []
1897
1910
  })
1898
1911
  var [visibility, setVisibility] = React.useState(function () {
1899
1912
  try {
@@ -2207,6 +2220,29 @@ window.__ModuleLoader__.load({
2207
2220
  setPriceNotice('请填写 Provider、模型及四项非负价格')
2208
2221
  return
2209
2222
  }
2223
+ var windows = (Array.isArray(priceDraft.windows) ? priceDraft.windows : []).map(function (window) {
2224
+ var rates = ['input', 'cacheRead', 'cacheWrite', 'output'].reduce(function (out, key) {
2225
+ var value = Number.parseFloat(window[key])
2226
+ out[key] = Number.isFinite(value) && value >= 0 ? value : null
2227
+ return out
2228
+ }, {})
2229
+ return {
2230
+ label: String(window.label || '').trim(),
2231
+ days: Array.isArray(window.days) ? window.days.slice() : [],
2232
+ start: window.start,
2233
+ end: window.end,
2234
+ input: rates.input,
2235
+ cacheRead: rates.cacheRead,
2236
+ cacheWrite: rates.cacheWrite,
2237
+ output: rates.output
2238
+ }
2239
+ })
2240
+ if (windows.some(function (window) {
2241
+ return !window.label || window.days.length === 0 || !/^([01]\d|2[0-3]):[0-5]\d$/.test(window.start || '') || !/^([01]\d|2[0-3]):[0-5]\d$/.test(window.end || '') || window.start === window.end || ['input', 'cacheRead', 'cacheWrite', 'output'].some(function (key) { return window[key] === null })
2242
+ })) {
2243
+ setPriceNotice('请完整填写分时段名称、星期、起止时间及四项价格')
2244
+ return
2245
+ }
2210
2246
  setPriceNotice('正在保存…')
2211
2247
  fetch('/api/wallet/custom-prices', {
2212
2248
  method: 'POST',
@@ -2218,13 +2254,15 @@ window.__ModuleLoader__.load({
2218
2254
  input: numeric.input,
2219
2255
  cacheRead: numeric.cacheRead,
2220
2256
  cacheWrite: numeric.cacheWrite,
2221
- output: numeric.output
2257
+ output: numeric.output,
2258
+ timezone: String(priceDraft.timezone || '').trim(),
2259
+ windows: windows
2222
2260
  } })
2223
2261
  }).then(function (resp) { return resp.json().then(function (json) { return { ok: resp.ok, json: json } }) }).then(function (result) {
2224
2262
  if (!result.ok || !applyCustomPriceResponse(result.json)) throw new Error(result.json && result.json.error ? result.json.error : 'save-failed')
2225
- setPriceNotice('已保存;本会话与历史账本按当前自定义价重新估算')
2263
+ setPriceNotice('已保存;本会话与历史账本按当前自定义规则重新估算')
2226
2264
  }).catch(function (error) {
2227
- setPriceNotice(error && error.message === 'official-provider-not-allowed' ? '官方计费 Provider 不能设置第三方价格' : '保存失败,请检查填写内容')
2265
+ setPriceNotice(error && error.message === 'official-provider-not-allowed' ? '官方计费 Provider 不能设置第三方价格' : '保存失败,请检查时区、时段是否有效或重叠')
2228
2266
  })
2229
2267
  }
2230
2268
 
@@ -2249,11 +2287,69 @@ window.__ModuleLoader__.load({
2249
2287
  input: String(rule.input),
2250
2288
  cacheRead: String(rule.cacheRead),
2251
2289
  cacheWrite: String(rule.cacheWrite),
2252
- output: String(rule.output)
2290
+ output: String(rule.output),
2291
+ timezone: rule.timezone || 'Asia/Shanghai',
2292
+ windows: (Array.isArray(rule.windows) ? rule.windows : []).map(function (window) {
2293
+ return {
2294
+ label: window.label,
2295
+ days: Array.isArray(window.days) ? window.days.slice() : [],
2296
+ start: window.start,
2297
+ end: window.end,
2298
+ input: String(window.input),
2299
+ cacheRead: String(window.cacheRead),
2300
+ cacheWrite: String(window.cacheWrite),
2301
+ output: String(window.output)
2302
+ }
2303
+ })
2253
2304
  })
2254
2305
  setPriceNotice('已载入,可修改后保存')
2255
2306
  }
2256
2307
 
2308
+ function addCustomPriceWindow() {
2309
+ setPriceDraft(function (current) {
2310
+ var windows = Array.isArray(current.windows) ? current.windows.slice() : []
2311
+ var fallback = function (key) { return current[key] === '' ? '' : String(current[key]) }
2312
+ windows.push({
2313
+ label: '分时段 ' + (windows.length + 1),
2314
+ days: [1, 2, 3, 4, 5],
2315
+ start: '00:00',
2316
+ end: '09:00',
2317
+ input: fallback('input'),
2318
+ cacheRead: fallback('cacheRead'),
2319
+ cacheWrite: fallback('cacheWrite'),
2320
+ output: fallback('output')
2321
+ })
2322
+ return Object.assign({}, current, { windows: windows })
2323
+ })
2324
+ }
2325
+
2326
+ function updateCustomPriceWindow(index, key, value) {
2327
+ setPriceDraft(function (current) {
2328
+ var windows = (Array.isArray(current.windows) ? current.windows : []).map(function (window, candidate) {
2329
+ return candidate === index ? Object.assign({}, window, (function () { var patch = {}; patch[key] = value; return patch })()) : window
2330
+ })
2331
+ return Object.assign({}, current, { windows: windows })
2332
+ })
2333
+ }
2334
+
2335
+ function toggleCustomPriceDay(index, day) {
2336
+ setPriceDraft(function (current) {
2337
+ var windows = (Array.isArray(current.windows) ? current.windows : []).map(function (window, candidate) {
2338
+ if (candidate !== index) return window
2339
+ var days = Array.isArray(window.days) ? window.days.slice() : []
2340
+ days = days.includes(day) ? days.filter(function (value) { return value !== day }) : days.concat([day])
2341
+ return Object.assign({}, window, { days: days })
2342
+ })
2343
+ return Object.assign({}, current, { windows: windows })
2344
+ })
2345
+ }
2346
+
2347
+ function removeCustomPriceWindow(index) {
2348
+ setPriceDraft(function (current) {
2349
+ return Object.assign({}, current, { windows: (Array.isArray(current.windows) ? current.windows : []).filter(function (_, candidate) { return candidate !== index }) })
2350
+ })
2351
+ }
2352
+
2257
2353
  function useCurrentRouteForPrice() {
2258
2354
  if (!settingsModelSelection || typeof settingsModelSelection.provider !== 'string' || typeof settingsModelSelection.model !== 'string') {
2259
2355
  setPriceNotice('当前会话尚未选择可识别的模型')
@@ -2748,17 +2844,78 @@ window.__ModuleLoader__.load({
2748
2844
  React.createElement('button', { type: 'button', className: 'dshw_btn dshw_btnPrimary', disabled: usageLocked, onClick: saveCustomPrice }, '保存'),
2749
2845
  React.createElement('datalist', { id: 'dshw-price-providers' }, providerOptions.map(function (provider) { return React.createElement('option', { key: provider, value: provider }) })),
2750
2846
  React.createElement('datalist', { id: 'dshw-price-models' }, modelOptions.map(function (model) { return React.createElement('option', { key: model, value: model }) })))
2847
+ function customWindowField(window, index, key, label, type) {
2848
+ return React.createElement('label', { key: key, className: 'dshw_priceField' },
2849
+ React.createElement('span', null, label),
2850
+ React.createElement('input', {
2851
+ className: 'dshw_input',
2852
+ type: type,
2853
+ min: type === 'number' ? '0' : undefined,
2854
+ max: type === 'number' ? '1000000' : undefined,
2855
+ step: type === 'number' ? '0.000001' : undefined,
2856
+ value: window[key],
2857
+ 'aria-label': '分时段 ' + (index + 1) + ' ' + label,
2858
+ onChange: function (event) { updateCustomPriceWindow(index, key, event.target.value) }
2859
+ }))
2860
+ }
2861
+ var draftPriceWindows = Array.isArray(priceDraft.windows) ? priceDraft.windows : []
2862
+ var priceWindowRows = draftPriceWindows.map(function (window, index) {
2863
+ return React.createElement('div', { key: index, className: 'dshw_priceWindow' },
2864
+ React.createElement('div', { className: 'dshw_priceWindowHead' },
2865
+ customWindowField(window, index, 'label', '时段名称', 'text'),
2866
+ React.createElement('button', { type: 'button', className: 'dshw_btn', disabled: usageLocked, 'aria-label': '删除分时段 ' + (index + 1), onClick: function () { removeCustomPriceWindow(index) } }, '删除时段')),
2867
+ React.createElement('div', { className: 'dshw_priceDays', role: 'group', 'aria-label': '分时段 ' + (index + 1) + ' 适用星期' },
2868
+ React.createElement('span', null, '星期'),
2869
+ CUSTOM_PRICE_WEEKDAYS.map(function (day) {
2870
+ var selected = Array.isArray(window.days) && window.days.includes(day.value)
2871
+ return React.createElement('button', {
2872
+ key: day.value,
2873
+ type: 'button',
2874
+ className: 'dshw_priceDay',
2875
+ 'aria-label': '星期' + day.label,
2876
+ 'aria-pressed': selected,
2877
+ onClick: function () { toggleCustomPriceDay(index, day.value) }
2878
+ }, day.label)
2879
+ })),
2880
+ React.createElement('div', { className: 'dshw_priceWindowRates' },
2881
+ customWindowField(window, index, 'start', '开始', 'time'),
2882
+ customWindowField(window, index, 'end', '结束', 'time'),
2883
+ customWindowField(window, index, 'input', '输入', 'number'),
2884
+ customWindowField(window, index, 'cacheRead', '缓存读', 'number'),
2885
+ customWindowField(window, index, 'cacheWrite', '缓存写', 'number'),
2886
+ customWindowField(window, index, 'output', '输出', 'number')))
2887
+ })
2888
+ var priceWindowEditor = React.createElement('div', { className: 'dshw_priceWindowEditor' },
2889
+ React.createElement('div', { className: 'dshw_priceWindowToolbar' },
2890
+ React.createElement('label', { className: 'dshw_priceField' },
2891
+ React.createElement('span', null, '计价时区(IANA)'),
2892
+ React.createElement('input', {
2893
+ className: 'dshw_input', type: 'text', list: 'dshw-price-timezones', value: priceDraft.timezone,
2894
+ 'aria-label': '第三方计价时区', onChange: function (event) { updatePriceDraft('timezone', event.target.value) }
2895
+ }),
2896
+ React.createElement('datalist', { id: 'dshw-price-timezones' }, ['Asia/Shanghai', 'UTC', 'America/New_York', 'Europe/London'].map(function (timezone) {
2897
+ return React.createElement('option', { key: timezone, value: timezone }, timezone)
2898
+ }))),
2899
+ React.createElement('button', { type: 'button', className: 'dshw_btn', disabled: usageLocked || draftPriceWindows.length >= 24, onClick: addCustomPriceWindow }, '+ 添加分时段')),
2900
+ priceWindowRows.length > 0
2901
+ ? React.createElement('div', { className: 'dshw_priceWindowList' }, priceWindowRows)
2902
+ : React.createElement('div', { className: 'dshw_muted' }, '未添加分时段时始终使用上方基础价格。'),
2903
+ React.createElement('div', { className: 'dshw_muted' }, '分时段可跨午夜;同一规则的时段不能重叠。'))
2751
2904
  var priceRuleRows = priceRules.map(function (rule) {
2905
+ var windows = Array.isArray(rule.windows) ? rule.windows : []
2906
+ var active = rule.active && windows.length > 0 ? rule.active : null
2752
2907
  return React.createElement('div', { key: rule.provider + ':' + rule.model, className: 'dshw_priceRule' },
2753
2908
  React.createElement('span', null,
2754
2909
  React.createElement('strong', { title: rule.provider + ' · ' + rule.model }, rule.provider + ' · ' + rule.model),
2755
- React.createElement('span', null, rule.currency + '/1M · ' + rule.input + ' · 缓读 ' + rule.cacheRead + ' · 缓写 ' + rule.cacheWrite + ' · 出 ' + rule.output)),
2910
+ React.createElement('span', null, rule.currency + '/1M · 基础:入 ' + rule.input + ' · 缓读 ' + rule.cacheRead + ' · 缓写 ' + rule.cacheWrite + ' · 出 ' + rule.output),
2911
+ windows.length > 0 ? React.createElement('span', null, '分时 ' + windows.length + ' 段 · ' + (rule.timezone || 'Asia/Shanghai') + ' · 当前:' + (active ? active.label : '基础价')) : null),
2756
2912
  React.createElement('span', { className: 'dshw_settingsInline' },
2757
2913
  React.createElement('button', { type: 'button', className: 'dshw_btn', onClick: function () { editCustomPrice(rule) } }, '编辑'),
2758
2914
  React.createElement('button', { type: 'button', className: 'dshw_btn', disabled: usageLocked, onClick: function () { removeCustomPrice(rule) } }, '删除')))
2759
2915
  })
2760
2916
  rows.push(React.createElement('div', { key: 'cp-card', className: 'dshw_setCard wide', style: { display: 'block' } },
2761
2917
  priceForm,
2918
+ priceWindowEditor,
2762
2919
  priceNotice ? React.createElement('div', { className: 'dshw_muted', role: 'status', style: { padding: '0 12px 8px' } }, priceNotice) : null,
2763
2920
  priceRuleRows.length > 0
2764
2921
  ? React.createElement('div', { className: 'dshw_priceRules' }, priceRuleRows)
@@ -4917,7 +5074,16 @@ window.__ModuleLoader__.load({
4917
5074
  if (thirdCustomText) {
4918
5075
  thirdRows.push(React.createElement('div', { key: 't-c', className: 'dshw_row' },
4919
5076
  React.createElement('span', { className: 'dshw_muted' }, '自定义估算'),
4920
- React.createElement('span', { title: '按当前自定义价格计算,不代表第三方账单' }, thirdCustomText)))
5077
+ React.createElement('span', { title: '按当前自定义规则及用量发生时间计算,不代表第三方账单' }, thirdCustomText)))
5078
+ }
5079
+ if (activeThirdRoute && activeThirdRoute.activePrice) {
5080
+ var activeCustomPrice = activeThirdRoute.activePrice
5081
+ var activeWindow = activeCustomPrice.window
5082
+ thirdRows.push(React.createElement('div', { key: 't-price-window', className: 'dshw_row' },
5083
+ React.createElement('span', { className: 'dshw_muted' }, '当前计价'),
5084
+ React.createElement('span', {
5085
+ title: activeWindow ? ('星期按所选日期 · ' + activeWindow.start + '–' + activeWindow.end + ' · ' + activeCustomPrice.timezone) : ('未命中分时段 · ' + activeCustomPrice.timezone)
5086
+ }, activeCustomPrice.label)))
4921
5087
  }
4922
5088
 
4923
5089
  var thresholdInput = React.createElement('input', {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "deepseek-harness-wallet",
3
- "description": "Local-first DeepSeek Harness control center for balance monitoring, usage accounting, custom third-party API pricing, a 365-day local token ledger, Z.ai Coding Plan quotas, official pricing sync, encrypted multi-account switching, reminders, recharge, and host-gated session controls.",
4
- "version": "0.3.8",
3
+ "description": "Local-first DeepSeek Harness control center for balance monitoring, usage accounting, time-aware custom third-party API pricing, a 365-day local token ledger, Z.ai Coding Plan quotas, official pricing sync, encrypted multi-account switching, reminders, recharge, and host-gated session controls.",
4
+ "version": "0.3.9",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {