deepseek-harness-wallet 0.3.6 → 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,23 @@
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
+
12
+ ## 0.3.8 - 2026-09-02
13
+
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.
15
+ - 自定义价格保存在本机 `wallet.json`,支持修改、删除、观察到的路由建议、输入边界与官方/套餐 Provider 隔离;不会保存 API Key,也不会更改历史 Token。 / Custom prices persist locally in `wallet.json`, support editing, deletion, observed-route suggestions, bounded inputs, and strict separation from official/plan providers; no API keys or historical token counts are changed.
16
+
17
+ ## 0.3.7 - 2026-09-01
18
+
19
+ - 适配 DeepSeek Harness `0.1.2-alpha.3` 的新客户端包结构:移除已被官方删除的 `@deepseek-ai/dsh-client-runtime` 清单边,继续通过 Cordis `sessions` 服务和现存 UI slot 合约工作;同时保留 `0.1.1-rc.2` 兼容。 / Adapted to the new DeepSeek Harness `0.1.2-alpha.3` client package layout by removing the retired `@deepseek-ai/dsh-client-runtime` manifest edge while continuing to consume the Cordis `sessions` service and surviving UI-slot contracts; `0.1.1-rc.2` compatibility is retained.
20
+ - 在隔离的 alpha.3 构建和数据目录中完成 npm 安装、完整 Harness 构建、Web 启动、插件后端、设置页、365 天账本、背景选项和健康检查验证,未向测试环境写入真实凭据。 / Verified npm installation, a full Harness build, Web startup, plugin host routes, settings UI, 365-day ledger, background controls, and health checks in isolated alpha.3 source/data directories without copying real credentials into the test environment.
21
+
5
22
  ## 0.3.6 - 2026-08-28
6
23
 
7
24
  - 峰谷时钟新增明确的「透明 / 实色」背景选择,不加入自动模式;透明模式常态不铺底,悬停时使用主题实色,实色模式保持原有卡片背景。 / Added an explicit peak-clock background choice between transparent and solid, without an automatic mode; transparent mode removes the normal fill and uses the themed solid hover surface, while solid mode keeps the existing card background.
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.6 is the current local patch candidate; v0.3.5 remains the published stable release on GitHub and npm until release approval.
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,9 +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
- - **Third-party total** — current-session tokens (input / cache read / output). No balance guessing, no cost math, zero configuration.
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 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.
35
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.
36
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).
37
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.
@@ -73,13 +74,13 @@ Details: [compatibility](#browser-desktop-and-os-compatibility) · [data and tru
73
74
 
74
75
  ## Install
75
76
 
76
- From npm (published stable v0.3.5; v0.3.6 is the local patch candidate until release approval):
77
+ From npm (published stable v0.3.9):
77
78
 
78
79
  ```sh
79
80
  dsh plugin --profile web add deepseek-harness-wallet
80
81
  ```
81
82
 
82
- or from GitHub `main` (published v0.3.5 source until release approval):
83
+ or from GitHub `main`:
83
84
 
84
85
  ```sh
85
86
  dsh plugin --profile web add github:feibi-mochi/deepseek-harness-control-center
@@ -116,6 +117,7 @@ The client contains no operating-system-specific feature branch; it checks the W
116
117
  | Verification level | Coverage |
117
118
  | --- | --- |
118
119
  | Real environment checked for this release | Windows + current Edge + DSH Web |
120
+ | Verified Harness versions | `0.1.1-rc.2` and isolated `0.1.2-alpha.3` Web profiles; the alpha profile was built from its official tag and started with no real credentials |
119
121
  | Automated compatibility checks | Browser notification failure, in-page fallback, cross-tab fallback, storage fallback, CSS-scale fallback, and synchronous/asynchronous desktop adapters |
120
122
  | Capability-compatible targets | Current Chrome, Edge, and Firefox on Windows/macOS/Linux; Safari on macOS; Electron/Tauri-style DSH wrappers that provide the requirements below |
121
123
 
@@ -148,7 +150,7 @@ For buildable DSH hosts, the npm package and repository include a versioned [Age
148
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. |
149
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. |
150
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. |
151
- | Usage ledger | Local events 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 locked cost are kept—never prompts, tool arguments, or response bodies. |
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. |
152
154
  | Local settings | Layout, scale, visibility, reminder, and panel settings stay in browser-compatible local storage. |
153
155
  | Permanent deletion | Opt-in and host-gated. The wallet never advertises the action unless the host implements the matching session deletion path. |
154
156
  | Model surface | No tools registered, no prompt injection, zero token cost. |
@@ -172,6 +174,7 @@ Historical deepseek-chat and deepseek-reasoner records retain their original fla
172
174
 
173
175
  - [x] 365-day Token heatmap and rebuildable local usage ledger
174
176
  - [x] Z.ai Coding Plan Global/China monitoring on a generic official-plan adapter contract
177
+ - [x] User-defined third-party Provider/model base and time-of-use pricing
175
178
  - [ ] Additional provider price/balance adapters only after real-account validation
176
179
 
177
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.6'
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,12 +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 = 5
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
57
+ const CUSTOM_PRICE_MAX_RULES = 100
58
+ const CUSTOM_PRICE_MAX_RATE = 1_000_000
59
+ const CUSTOM_PRICE_MAX_WINDOWS = 24
56
60
  const HISTORY_TIMEZONE = 'Asia/Shanghai'
57
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()
58
68
  const PLAN_REFRESH_MS = 5 * 60_000
59
69
  const PLAN_STALE_MS = 15 * 60_000
60
70
  const PLAN_REFRESH_TIMEOUT_MS = 12_000
@@ -733,6 +743,15 @@ function removeSessionContribution(event) {
733
743
  bucket.unpriced = Math.max(0, currentUnpriced - 1)
734
744
  bucket.priced = bucket.unpriced === 0
735
745
  }
746
+ } else if (bucket.routes && bucket.routes[event.provider] && bucket.routes[event.provider].models) {
747
+ const routeCounters = bucket.routes[event.provider].models[event.model]
748
+ if (routeCounters !== null && typeof routeCounters === 'object') {
749
+ subtractUsage(routeCounters, event.usage)
750
+ if (Object.values(routeCounters).every((value) => finiteCounter(value) === 0)) {
751
+ delete bucket.routes[event.provider].models[event.model]
752
+ }
753
+ if (Object.keys(bucket.routes[event.provider].models).length === 0) delete bucket.routes[event.provider]
754
+ }
736
755
  }
737
756
  }
738
757
 
@@ -786,7 +805,8 @@ function publicHistoryAggregate(aggregate, hasOfficial = true) {
786
805
  }
787
806
  }
788
807
 
789
- function historyAggregate(events) {
808
+ function historyAggregate(events, customRules = null) {
809
+ const ruleMap = customRules || customPriceRuleMap(store.customPrices)
790
810
  const official = emptyHistoryAggregate()
791
811
  const third = emptyHistoryAggregate()
792
812
  const models = new Map()
@@ -800,10 +820,18 @@ function historyAggregate(events) {
800
820
  const key = event.provider + '\u0000' + event.model
801
821
  let model = models.get(key)
802
822
  if (model === undefined) {
803
- 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() }
804
824
  models.set(key, model)
805
825
  }
806
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
+ }
807
835
  }
808
836
  const total = emptyHistoryAggregate()
809
837
  for (const source of [official, third]) {
@@ -812,17 +840,23 @@ function historyAggregate(events) {
812
840
  }
813
841
  total.cost = official.cost
814
842
  total.priced = official.priced
815
- return {
816
- official: publicHistoryAggregate(official, true),
817
- third: publicHistoryAggregate(third, false),
818
- total: publicHistoryAggregate(total, official.calls > 0),
819
- cacheHitRate: cacheInput + cacheRead > 0 ? Math.round(cacheRead / (cacheInput + cacheRead) * 1000) / 10 : null,
820
- models: [...models.values()].map((model) => ({
843
+ const publicModels = [...models.values()].map((model) => {
844
+ const aggregate = publicHistoryAggregate(model, model.official)
845
+ return {
821
846
  provider: model.provider,
822
847
  model: model.model,
823
848
  official: model.official,
824
- ...publicHistoryAggregate(model, model.official),
825
- })),
849
+ ...aggregate,
850
+ customCost: model.official || model.customCost === null ? null : { ...model.customCost },
851
+ }
852
+ })
853
+ const customCosts = customCostTotals(publicModels)
854
+ return {
855
+ official: publicHistoryAggregate(official, true),
856
+ third: { ...publicHistoryAggregate(third, false), customCosts },
857
+ total: { ...publicHistoryAggregate(total, official.calls > 0), customCosts },
858
+ cacheHitRate: cacheInput + cacheRead > 0 ? Math.round(cacheRead / (cacheInput + cacheRead) * 1000) / 10 : null,
859
+ models: publicModels,
826
860
  }
827
861
  }
828
862
 
@@ -830,6 +864,7 @@ export function historyView({ days = HISTORY_RETENTION_DAYS, date = null, sessio
830
864
  const window = historyWindow(atMs, days)
831
865
  const sourceEvents = store.history && store.history.events ? Object.values(store.history.events) : []
832
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)
833
868
  const byDay = new Map()
834
869
  for (const event of filtered) {
835
870
  if (!byDay.has(event.day)) byDay.set(event.day, [])
@@ -839,7 +874,7 @@ export function historyView({ days = HISTORY_RETENTION_DAYS, date = null, sessio
839
874
  for (let index = 0; index < window.days; index += 1) {
840
875
  const day = shiftHistoryDay(window.from, index)
841
876
  const events = byDay.get(day) || []
842
- const aggregate = historyAggregate(events)
877
+ const aggregate = historyAggregate(events, customRules)
843
878
  const dayStart = historyDayStartMs(day)
844
879
  daysOut.push({
845
880
  date: day,
@@ -850,12 +885,12 @@ export function historyView({ days = HISTORY_RETENTION_DAYS, date = null, sessio
850
885
  cacheHitRate: aggregate.cacheHitRate,
851
886
  })
852
887
  }
853
- const total = historyAggregate(filtered)
888
+ const total = historyAggregate(filtered, customRules)
854
889
  const todayEvents = byDay.get(window.to) || []
855
890
  const monthPrefix = window.to.slice(0, 7)
856
891
  const monthEvents = filtered.filter((event) => event.day.startsWith(monthPrefix))
857
892
  const selectedEvents = typeof date === 'string' ? (byDay.get(date) || []) : []
858
- 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
859
894
  return {
860
895
  ok: true,
861
896
  timezone: HISTORY_TIMEZONE,
@@ -864,8 +899,8 @@ export function historyView({ days = HISTORY_RETENTION_DAYS, date = null, sessio
864
899
  window,
865
900
  summary: {
866
901
  total: total.total,
867
- today: historyAggregate(todayEvents).total,
868
- month: historyAggregate(monthEvents).total,
902
+ today: historyAggregate(todayEvents, customRules).total,
903
+ month: historyAggregate(monthEvents, customRules).total,
869
904
  cacheHitRate: total.cacheHitRate,
870
905
  },
871
906
  days: daysOut,
@@ -894,7 +929,9 @@ export function normalizeProviderList(value) {
894
929
  }
895
930
 
896
931
  export function isPlanProvider(provider) {
897
- 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)
898
935
  }
899
936
 
900
937
  export function isOfficialProvider(provider, extraProviders) {
@@ -903,6 +940,282 @@ export function isOfficialProvider(provider, extraProviders) {
903
940
  return Array.isArray(extraProviders) && extraProviders.includes(provider)
904
941
  }
905
942
 
943
+ function customPriceRate(value) {
944
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > CUSTOM_PRICE_MAX_RATE) return null
945
+ return Math.round(value * 1_000_000) / 1_000_000
946
+ }
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
+
1049
+ /** Normalize one exact third-party provider/model price rule (rates are per 1M tokens). */
1050
+ export function normalizeCustomPriceRule(value) {
1051
+ const source = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
1052
+ const provider = boundedString(source.provider, 100)
1053
+ const model = boundedString(source.model, 160)
1054
+ const currency = typeof source.currency === 'string' ? source.currency.toUpperCase() : ''
1055
+ const input = customPriceRate(source.input)
1056
+ const cacheRead = customPriceRate(source.cacheRead)
1057
+ const cacheWrite = customPriceRate(source.cacheWrite)
1058
+ const output = customPriceRate(source.output)
1059
+ if (provider === null || model === null || !/^[A-Z]{3}$/.test(currency)) return null
1060
+ if (provider === OFFICIAL_PROVIDER || isPlanProvider(provider)) return null
1061
+ if (provider === '__proto__' || provider === 'prototype' || provider === 'constructor') return null
1062
+ if (model === '__proto__' || model === 'prototype' || model === 'constructor') return null
1063
+ if ([input, cacheRead, cacheWrite, output].some((rate) => rate === null)) return null
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 }
1070
+ }
1071
+
1072
+ /** Normalize, deduplicate, and bound persisted custom third-party price rules. */
1073
+ export function normalizeCustomPrices(value) {
1074
+ if (!Array.isArray(value)) return []
1075
+ const rules = new Map()
1076
+ for (const candidate of value) {
1077
+ const rule = normalizeCustomPriceRule(candidate)
1078
+ if (rule === null) continue
1079
+ const key = rule.provider + '\u0000' + rule.model
1080
+ if (rules.has(key)) rules.delete(key)
1081
+ rules.set(key, rule)
1082
+ if (rules.size > CUSTOM_PRICE_MAX_RULES) rules.delete(rules.keys().next().value)
1083
+ }
1084
+ return [...rules.values()]
1085
+ }
1086
+
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)
1138
+ const counters = historyUsageCounters(usage)
1139
+ const cost = (
1140
+ counters.input * selected.rates.input
1141
+ + counters.cacheRead * selected.rates.cacheRead
1142
+ + counters.cacheWrite * selected.rates.cacheWrite
1143
+ + counters.output * selected.rates.output
1144
+ ) / 1e6
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) }))
1186
+ }
1187
+
1188
+ function normalizeThirdPartyRoutes(value) {
1189
+ const source = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
1190
+ const routes = {}
1191
+ let providers = 0
1192
+ let models = 0
1193
+ for (const [provider, rawProvider] of Object.entries(source)) {
1194
+ if (boundedString(provider, 100) === null || provider === OFFICIAL_PROVIDER || isPlanProvider(provider)) continue
1195
+ if (provider === '__proto__' || provider === 'prototype' || provider === 'constructor') continue
1196
+ const rawModels = rawProvider !== null && typeof rawProvider === 'object' && !Array.isArray(rawProvider)
1197
+ ? rawProvider.models
1198
+ : null
1199
+ const normalizedModels = normalizeModels(rawModels)
1200
+ if (Object.keys(normalizedModels).length === 0) continue
1201
+ routes[provider] = { models: normalizedModels }
1202
+ providers += 1
1203
+ models += Object.keys(normalizedModels).length
1204
+ if (providers >= 100 || models >= 1_000) break
1205
+ }
1206
+ return routes
1207
+ }
1208
+
1209
+ function customCostTotals(rows) {
1210
+ const totals = {}
1211
+ for (const row of rows) {
1212
+ if (row === null || typeof row !== 'object' || !row.customCost) continue
1213
+ const currency = row.customCost.currency
1214
+ totals[currency] = (totals[currency] || 0) + row.customCost.cost
1215
+ }
1216
+ return totals
1217
+ }
1218
+
906
1219
  export function normalizeUiPreferences(value) {
907
1220
  const source = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
908
1221
  const normalized = {}
@@ -964,7 +1277,10 @@ export function normalizeStoreData(value, atMs = Date.now()) {
964
1277
  const rawSession = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {}
965
1278
  sessions[sessionId] = {
966
1279
  official: migratedOfficialBucket(rawSession.official, atMs),
967
- third: { models: normalizeModels(rawSession.third && rawSession.third.models) },
1280
+ third: {
1281
+ models: normalizeModels(rawSession.third && rawSession.third.models),
1282
+ routes: normalizeThirdPartyRoutes(rawSession.third && rawSession.third.routes),
1283
+ },
968
1284
  }
969
1285
  sessionCount += 1
970
1286
  if (sessionCount >= 5_000) break
@@ -976,6 +1292,7 @@ export function normalizeStoreData(value, atMs = Date.now()) {
976
1292
  sessions,
977
1293
  officialProviders: normalizeProviderList(source.officialProviders),
978
1294
  knownProviders: normalizeProviderList(source.knownProviders),
1295
+ customPrices: normalizeCustomPrices(source.customPrices),
979
1296
  plans: normalizePlanSnapshotCache(source.plans, atMs),
980
1297
  preferences: normalizeUiPreferences(source.preferences),
981
1298
  }
@@ -993,7 +1310,7 @@ let usageStorageRecovered = false
993
1310
  let usageStoreSkipBackupOnce = false
994
1311
 
995
1312
  function emptyStoreData() {
996
- return { version: STORE_VERSION, thresholds: { CNY: DEFAULT_THRESHOLD }, accountThresholds: {}, sessions: {}, officialProviders: [], knownProviders: [], plans: {}, preferences: {}, history: emptyHistory() }
1313
+ return { version: STORE_VERSION, thresholds: { CNY: DEFAULT_THRESHOLD }, accountThresholds: {}, sessions: {}, officialProviders: [], knownProviders: [], customPrices: [], plans: {}, preferences: {}, history: emptyHistory() }
997
1314
  }
998
1315
 
999
1316
  function readStoreFile(path) {
@@ -1703,7 +2020,7 @@ function recordUsage(options, usage, atMs = Date.now()) {
1703
2020
  store.knownProviders = normalizeProviderList([...store.knownProviders, provider])
1704
2021
  }
1705
2022
  if (!Object.hasOwn(store.sessions, sessionId)) {
1706
- store.sessions[sessionId] = { official: emptyBucket(true), third: emptyBucket(false) }
2023
+ store.sessions[sessionId] = { official: emptyBucket(true), third: { models: {}, routes: {} } }
1707
2024
  }
1708
2025
  const event = createHistoryEvent(request, usage, atMs, store.officialProviders)
1709
2026
  const previousEvent = event !== null && store.history && store.history.events ? store.history.events[event.eventId] : undefined
@@ -1716,6 +2033,10 @@ function recordUsage(options, usage, atMs = Date.now()) {
1716
2033
  else {
1717
2034
  if (!Object.hasOwn(bucket.models, model)) bucket.models[model] = emptyCounters()
1718
2035
  addUsage(bucket.models[model], usage)
2036
+ if (bucket.routes === null || typeof bucket.routes !== 'object' || Array.isArray(bucket.routes)) bucket.routes = {}
2037
+ if (!Object.hasOwn(bucket.routes, provider)) bucket.routes[provider] = { models: {} }
2038
+ if (!Object.hasOwn(bucket.routes[provider].models, model)) bucket.routes[provider].models[model] = emptyCounters()
2039
+ addUsage(bucket.routes[provider].models[model], usage)
1719
2040
  }
1720
2041
  recordHistoryEvent(event)
1721
2042
  }
@@ -1837,6 +2158,45 @@ function balanceTotal() {
1837
2158
  return sumBalances(balance.balances)
1838
2159
  }
1839
2160
 
2161
+ function thirdPartyRouteView(bucket, sessionId = null) {
2162
+ const routes = []
2163
+ const source = normalizeThirdPartyRoutes(bucket && bucket.routes)
2164
+ for (const [provider, providerState] of Object.entries(source)) {
2165
+ if (isPlanProvider(provider) || isOfficialProvider(provider, store.officialProviders)) continue
2166
+ for (const [model, counters] of Object.entries(providerState.models)) {
2167
+ const priced = sessionId === null
2168
+ ? customCostOf(provider, model, counters, store.customPrices)
2169
+ : customSessionCost(provider, model, counters, sessionId, store.customPrices)
2170
+ routes.push({
2171
+ provider,
2172
+ model,
2173
+ tokens: counters,
2174
+ totalTokens: historyTokenTotal(counters),
2175
+ customCost: priced === null ? null : { cost: priced.cost, currency: priced.currency },
2176
+ activePrice: priced && priced.active ? priced.active : null,
2177
+ })
2178
+ }
2179
+ }
2180
+ return routes
2181
+ }
2182
+
2183
+ function knownThirdPartyRoutes() {
2184
+ const found = new Map()
2185
+ for (const session of Object.values(store.sessions)) {
2186
+ for (const route of thirdPartyRouteView(session && session.third)) {
2187
+ found.set(route.provider + '\u0000' + route.model, { provider: route.provider, model: route.model })
2188
+ }
2189
+ }
2190
+ for (const event of Object.values(store.history && store.history.events ? store.history.events : {})) {
2191
+ if (!event || event.official || isPlanProvider(event.provider) || isOfficialProvider(event.provider, store.officialProviders)) continue
2192
+ const provider = boundedString(event.provider, 100)
2193
+ const model = boundedString(event.model, 160)
2194
+ if (provider !== null && model !== null) found.set(provider + '\u0000' + model, { provider, model })
2195
+ if (found.size >= 500) break
2196
+ }
2197
+ return [...found.values()].slice(0, 500)
2198
+ }
2199
+
1840
2200
  function sessionView(sessionId) {
1841
2201
  if (sessionId === undefined || sessionId === '') return { official: null, third: null }
1842
2202
  // Own-property lookup only: session ids like `__proto__`, `constructor` or
@@ -1853,6 +2213,7 @@ function sessionView(sessionId) {
1853
2213
  }
1854
2214
  const official = bucketTotals(session.official)
1855
2215
  const third = bucketTotals(session.third)
2216
+ const thirdRoutes = thirdPartyRouteView(session.third, sessionId)
1856
2217
  return {
1857
2218
  official: {
1858
2219
  tokens: official,
@@ -1860,7 +2221,12 @@ function sessionView(sessionId) {
1860
2221
  priced: session.official.priced === true,
1861
2222
  models: session.official.models,
1862
2223
  },
1863
- third: { tokens: third, models: session.third.models },
2224
+ third: {
2225
+ tokens: third,
2226
+ models: session.third.models,
2227
+ routes: thirdRoutes,
2228
+ customCosts: customCostTotals(thirdRoutes),
2229
+ },
1864
2230
  }
1865
2231
  }
1866
2232
 
@@ -1908,6 +2274,8 @@ function snapshotView(sessionId) {
1908
2274
  builtinOfficial: OFFICIAL_PROVIDER,
1909
2275
  official: [...store.officialProviders],
1910
2276
  known: store.knownProviders.filter((p) => p !== OFFICIAL_PROVIDER && !store.officialProviders.includes(p)),
2277
+ customPrices: customPriceRulesView(now),
2278
+ knownRoutes: knownThirdPartyRoutes(),
1911
2279
  },
1912
2280
  plans: planView(now),
1913
2281
  }
@@ -2191,6 +2559,7 @@ historyEventCount = store.history && store.history.events ? Object.keys(store.hi
2191
2559
  const body = await readBody(req)
2192
2560
  if (body === null || !Array.isArray(body.providers)) return json(res, 400, { ok: false, error: 'providers array is required' })
2193
2561
  store.officialProviders = normalizeProviderList(body.providers)
2562
+ store.customPrices = store.customPrices.filter((rule) => !store.officialProviders.includes(rule.provider))
2194
2563
  scheduleSave(ctx.logger)
2195
2564
  return json(res, 200, {
2196
2565
  ok: true,
@@ -2199,6 +2568,40 @@ historyEventCount = store.history && store.history.events ? Object.keys(store.hi
2199
2568
  })
2200
2569
  },
2201
2570
  })
2571
+ const disposeCustomPrices = ctx.webServer.register({
2572
+ kind: 'exact',
2573
+ path: '/api/wallet/custom-prices',
2574
+ handler: async (req, res) => {
2575
+ if (req.method === 'GET') {
2576
+ return json(res, 200, { ok: true, rules: customPriceRulesView(), knownRoutes: knownThirdPartyRoutes() })
2577
+ }
2578
+ if (usageStorageLocked) return json(res, 423, { ok: false, error: 'usage-storage-locked' })
2579
+ if (req.method !== 'POST' && req.method !== 'DELETE') {
2580
+ return json(res, 405, { ok: false, error: 'method-not-allowed' })
2581
+ }
2582
+ const body = await readBody(req, 16_384)
2583
+ if (body === null) return json(res, 400, { ok: false, error: 'invalid-body' })
2584
+ if (req.method === 'POST') {
2585
+ const rule = normalizeCustomPriceRule(body.rule)
2586
+ if (rule === null) return json(res, 400, { ok: false, error: 'invalid-custom-price' })
2587
+ if (isOfficialProvider(rule.provider, store.officialProviders)) {
2588
+ return json(res, 400, { ok: false, error: 'official-provider-not-allowed' })
2589
+ }
2590
+ store.customPrices = normalizeCustomPrices([
2591
+ ...store.customPrices.filter((entry) => entry.provider !== rule.provider || entry.model !== rule.model),
2592
+ rule,
2593
+ ])
2594
+ scheduleSave(ctx.logger)
2595
+ return json(res, 200, { ok: true, rules: customPriceRulesView(), knownRoutes: knownThirdPartyRoutes() })
2596
+ }
2597
+ const provider = boundedString(body.provider, 100)
2598
+ const model = boundedString(body.model, 160)
2599
+ if (provider === null || model === null) return json(res, 400, { ok: false, error: 'provider-and-model-required' })
2600
+ store.customPrices = store.customPrices.filter((entry) => entry.provider !== provider || entry.model !== model)
2601
+ scheduleSave(ctx.logger)
2602
+ return json(res, 200, { ok: true, rules: customPriceRulesView(), knownRoutes: knownThirdPartyRoutes() })
2603
+ },
2604
+ })
2202
2605
  const disposeAccounts = ctx.webServer.register({
2203
2606
  kind: 'exact',
2204
2607
  path: '/api/wallet/accounts',
@@ -2285,6 +2688,7 @@ historyEventCount = store.history && store.history.events ? Object.keys(store.hi
2285
2688
  disposePlans()
2286
2689
  disposeAccounts()
2287
2690
  disposeProviders()
2691
+ disposeCustomPrices()
2288
2692
  disposeActivate()
2289
2693
  disposeRemove()
2290
2694
  clearInterval(balanceTimer)
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.6'
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,6 +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}',
617
+ '.dshw_priceForm{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px;align-items:end;padding:10px 12px}',
618
+ '.dshw_priceField{display:grid;gap:4px;min-width:0}',
619
+ '.dshw_priceField>span{font-size:9.5px;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary,#667085))}',
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}',
628
+ '.dshw_priceRules{display:grid;gap:6px;padding:0 12px 11px}',
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)))}',
630
+ '.dshw_priceRule strong,.dshw_priceRule span{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
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))}',
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))}}',
613
633
  '.dshw_switch{position:relative;flex:none;width:34px;height:20px;display:inline-flex;cursor:pointer}',
614
634
  '.dshw_switch>input{position:absolute;inset:0;opacity:0;margin:0;cursor:pointer}',
615
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}',
@@ -908,6 +928,15 @@ window.__ModuleLoader__.load({
908
928
  }
909
929
  }
910
930
 
931
+ function customCostsText(costs) {
932
+ if (!costs || typeof costs !== 'object') return ''
933
+ return Object.keys(costs).sort().filter(function (currency) {
934
+ return Number.isFinite(costs[currency])
935
+ }).map(function (currency) {
936
+ return fmtCurrency(costs[currency], currency)
937
+ }).join(' + ')
938
+ }
939
+
911
940
  function selectBalanceInfo(balance) {
912
941
  if (!balance || !Array.isArray(balance.balances) || balance.balances.length === 0) return null
913
942
  var currency = typeof balance.currency === 'string' ? balance.currency.toUpperCase() : null
@@ -1400,7 +1429,8 @@ window.__ModuleLoader__.load({
1400
1429
 
1401
1430
  function historyHeatValue(day, metric) {
1402
1431
  if (!day) return 0
1403
- if (metric === 'cost') return Number.isFinite(day.cost) ? day.cost : 0
1432
+ if (metric === 'cost') return (Number.isFinite(day.cost) ? day.cost : 0)
1433
+ + (day.customCosts && Number.isFinite(day.customCosts.CNY) ? day.customCosts.CNY : 0)
1404
1434
  return Number.isFinite(day.totalTokens) ? day.totalTokens : 0
1405
1435
  }
1406
1436
 
@@ -1552,8 +1582,8 @@ window.__ModuleLoader__.load({
1552
1582
  tabIndex: day.calls > 0 || isSelected || isToday ? 0 : -1,
1553
1583
  className: 'dshw_historyCell dshw_historyCell' + level + (isToday ? ' dshw_historyCellToday' : '') + (isSelected ? ' dshw_historyCellSelected' : ''),
1554
1584
  style: { gridColumn: String(Math.floor((index + firstWeekday) / 7) + 1), gridRow: String(weekday + 1) },
1555
- title: day.date + ' · ' + fmtTokens(day.totalTokens) + ' token · ' + (day.cost === null ? '未定价' : historyMoney(day.cost)) + ' · ' + day.calls + ' 次调用',
1556
- 'aria-label': day.date + ',' + (metric === 'cost' ? (day.cost === null ? '未定价' : historyMoney(day.cost)) : fmtTokens(day.totalTokens) + ' token') + ',' + day.calls + ' 次调用',
1585
+ title: day.date + ' · ' + fmtTokens(day.totalTokens) + ' token · 官方 ' + (day.cost === null ? '未定价' : historyMoney(day.cost)) + (customCostsText(day.customCosts) ? ' · 三方估 ' + customCostsText(day.customCosts) : '') + ' · ' + day.calls + ' 次调用',
1586
+ 'aria-label': day.date + ',' + (metric === 'cost' ? ((day.cost === null ? '官方未定价' : historyMoney(day.cost)) + (customCostsText(day.customCosts) ? ',第三方估算 ' + customCostsText(day.customCosts) : '')) : fmtTokens(day.totalTokens) + ' token') + ',' + day.calls + ' 次调用',
1557
1587
  'aria-selected': isSelected,
1558
1588
  'aria-current': isToday ? 'date' : undefined,
1559
1589
  onClick: function () { loadHistory(day.date) }
@@ -1570,7 +1600,7 @@ window.__ModuleLoader__.load({
1570
1600
  : breakdown.map(function (row) {
1571
1601
  return React.createElement('div', { key: row.provider + ':' + row.model, className: 'dshw_historyDetailRow' },
1572
1602
  React.createElement('span', { className: 'dshw_historyDetailName', title: row.provider + ' · ' + row.model }, row.model),
1573
- React.createElement('span', null, fmtTokens(row.totalTokens) + ' · ' + row.calls + ' 次' + (row.cost === null ? '' : ' · ' + historyMoney(row.cost))))
1603
+ React.createElement('span', null, fmtTokens(row.totalTokens) + ' · ' + row.calls + ' 次' + (row.cost === null ? '' : ' · ' + historyMoney(row.cost)) + (row.customCost ? ' · 估 ' + fmtCurrency(row.customCost.cost, row.customCost.currency) : '')))
1574
1604
  })
1575
1605
  body = React.createElement(React.Fragment, null,
1576
1606
  React.createElement('div', { className: 'dshw_historySummary' },
@@ -1578,6 +1608,7 @@ window.__ModuleLoader__.load({
1578
1608
  React.createElement('div', null, React.createElement('span', null, '今天'), React.createElement('strong', null, fmtTokens(today.totalTokens))),
1579
1609
  React.createElement('div', null, React.createElement('span', null, '本月'), React.createElement('strong', null, fmtTokens(month.totalTokens))),
1580
1610
  React.createElement('div', null, React.createElement('span', null, '缓存命中'), React.createElement('strong', null, history.summary.cacheHitRate === null || history.summary.cacheHitRate === undefined ? '--' : history.summary.cacheHitRate + '%'))),
1611
+ customCostsText(total.customCosts) ? React.createElement('div', { className: 'dshw_historyLegend' }, '第三方按当前自定义规则估算:' + customCostsText(total.customCosts)) : null,
1581
1612
  React.createElement('div', { className: 'dshw_historyMetricToggle', role: 'group', 'aria-label': '历史热力图指标' },
1582
1613
  React.createElement('button', { type: 'button', className: metric === 'tokens' ? 'dshw_historyMetricActive' : '', 'aria-pressed': metric === 'tokens', onClick: function () { setMetric('tokens') } }, 'Token'),
1583
1614
  React.createElement('button', { type: 'button', className: metric === 'cost' ? 'dshw_historyMetricActive' : '', 'aria-pressed': metric === 'cost', onClick: function () { setMetric('cost') } }, '费用')),
@@ -1689,7 +1720,8 @@ window.__ModuleLoader__.load({
1689
1720
  if (!selection || typeof selection.provider !== 'string') return { kind: 'loading', provider: null, model: null }
1690
1721
  var provider = selection.provider
1691
1722
  var model = typeof selection.model === 'string' ? selection.model : null
1692
- 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 }
1693
1725
  var providers = snapshot && snapshot.providers ? snapshot.providers : {}
1694
1726
  var aliases = Array.isArray(providers.official) ? providers.official : []
1695
1727
  if (provider === (providers.builtinOfficial || 'deepseek-official') || aliases.indexOf(provider) >= 0) {
@@ -1869,6 +1901,13 @@ window.__ModuleLoader__.load({
1869
1901
  var [nameDraft, setNameDraft] = React.useState('')
1870
1902
  var [keyDraft, setKeyDraft] = React.useState('')
1871
1903
  var [switchingId, setSwitchingId] = React.useState(null)
1904
+ var [priceRules, setPriceRules] = React.useState([])
1905
+ var [knownPriceRoutes, setKnownPriceRoutes] = React.useState([])
1906
+ var [priceNotice, setPriceNotice] = React.useState(null)
1907
+ var [priceDraft, setPriceDraft] = React.useState({
1908
+ provider: '', model: '', currency: 'CNY', input: '', cacheRead: '', cacheWrite: '', output: '',
1909
+ timezone: 'Asia/Shanghai', windows: []
1910
+ })
1872
1911
  var [visibility, setVisibility] = React.useState(function () {
1873
1912
  try {
1874
1913
  var saved = compatibility.storage.getItem(DATA_VISIBILITY_KEY)
@@ -1974,6 +2013,9 @@ window.__ModuleLoader__.load({
1974
2013
  fetch('/api/wallet/snapshot').then(function (resp) { return resp.json() }).then(function (json) {
1975
2014
  if (!stopped && json && json.ok) {
1976
2015
  setSnapshot(json)
2016
+ var providerState = json.providers || {}
2017
+ setPriceRules(Array.isArray(providerState.customPrices) ? providerState.customPrices : [])
2018
+ setKnownPriceRoutes(Array.isArray(providerState.knownRoutes) ? providerState.knownRoutes : [])
1977
2019
  setThresholdDraft(json.threshold !== undefined && json.threshold !== null ? json.threshold.toFixed(2) : '')
1978
2020
  }
1979
2021
  }).catch(function () { /* ignore */ })
@@ -2160,6 +2202,168 @@ window.__ModuleLoader__.load({
2160
2202
  }).catch(function () { setThresholdNotice('\u4fdd\u5b58\u5931\u8d25') })
2161
2203
  }
2162
2204
 
2205
+ function applyCustomPriceResponse(json) {
2206
+ if (!json || json.ok !== true) return false
2207
+ setPriceRules(Array.isArray(json.rules) ? json.rules : [])
2208
+ setKnownPriceRoutes(Array.isArray(json.knownRoutes) ? json.knownRoutes : [])
2209
+ return true
2210
+ }
2211
+
2212
+ function saveCustomPrice() {
2213
+ if (usageLocked) { setPriceNotice('用量账本已锁定,无法保存'); return }
2214
+ var numeric = ['input', 'cacheRead', 'cacheWrite', 'output'].reduce(function (out, key) {
2215
+ var value = Number.parseFloat(priceDraft[key])
2216
+ out[key] = Number.isFinite(value) && value >= 0 ? value : null
2217
+ return out
2218
+ }, {})
2219
+ if (!priceDraft.provider.trim() || !priceDraft.model.trim() || Object.values(numeric).some(function (value) { return value === null })) {
2220
+ setPriceNotice('请填写 Provider、模型及四项非负价格')
2221
+ return
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
+ }
2246
+ setPriceNotice('正在保存…')
2247
+ fetch('/api/wallet/custom-prices', {
2248
+ method: 'POST',
2249
+ headers: { 'content-type': 'application/json' },
2250
+ body: JSON.stringify({ rule: {
2251
+ provider: priceDraft.provider.trim(),
2252
+ model: priceDraft.model.trim(),
2253
+ currency: priceDraft.currency,
2254
+ input: numeric.input,
2255
+ cacheRead: numeric.cacheRead,
2256
+ cacheWrite: numeric.cacheWrite,
2257
+ output: numeric.output,
2258
+ timezone: String(priceDraft.timezone || '').trim(),
2259
+ windows: windows
2260
+ } })
2261
+ }).then(function (resp) { return resp.json().then(function (json) { return { ok: resp.ok, json: json } }) }).then(function (result) {
2262
+ if (!result.ok || !applyCustomPriceResponse(result.json)) throw new Error(result.json && result.json.error ? result.json.error : 'save-failed')
2263
+ setPriceNotice('已保存;本会话与历史账本按当前自定义规则重新估算')
2264
+ }).catch(function (error) {
2265
+ setPriceNotice(error && error.message === 'official-provider-not-allowed' ? '官方计费 Provider 不能设置第三方价格' : '保存失败,请检查时区、时段是否有效或重叠')
2266
+ })
2267
+ }
2268
+
2269
+ function removeCustomPrice(rule) {
2270
+ if (usageLocked) return
2271
+ setPriceNotice('正在删除…')
2272
+ fetch('/api/wallet/custom-prices', {
2273
+ method: 'DELETE',
2274
+ headers: { 'content-type': 'application/json' },
2275
+ body: JSON.stringify({ provider: rule.provider, model: rule.model })
2276
+ }).then(function (resp) { return resp.json().then(function (json) { return { ok: resp.ok, json: json } }) }).then(function (result) {
2277
+ if (!result.ok || !applyCustomPriceResponse(result.json)) throw new Error('delete-failed')
2278
+ setPriceNotice('已删除价格规则')
2279
+ }).catch(function () { setPriceNotice('删除失败') })
2280
+ }
2281
+
2282
+ function editCustomPrice(rule) {
2283
+ setPriceDraft({
2284
+ provider: rule.provider,
2285
+ model: rule.model,
2286
+ currency: rule.currency,
2287
+ input: String(rule.input),
2288
+ cacheRead: String(rule.cacheRead),
2289
+ cacheWrite: String(rule.cacheWrite),
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
+ })
2304
+ })
2305
+ setPriceNotice('已载入,可修改后保存')
2306
+ }
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
+
2353
+ function useCurrentRouteForPrice() {
2354
+ if (!settingsModelSelection || typeof settingsModelSelection.provider !== 'string' || typeof settingsModelSelection.model !== 'string') {
2355
+ setPriceNotice('当前会话尚未选择可识别的模型')
2356
+ return
2357
+ }
2358
+ setPriceDraft(function (current) {
2359
+ return Object.assign({}, current, {
2360
+ provider: settingsModelSelection.provider,
2361
+ model: settingsModelSelection.model
2362
+ })
2363
+ })
2364
+ setPriceNotice('已填入当前会话的 Provider 与模型')
2365
+ }
2366
+
2163
2367
  function reloadAccounts() {
2164
2368
  fetch('/api/wallet/accounts').then(function (resp) { return resp.json() }).then(function (json) {
2165
2369
  if (json && json.ok) {
@@ -2597,6 +2801,126 @@ window.__ModuleLoader__.load({
2597
2801
  rows.push(React.createElement('div', { key: 'pr-card', className: 'dshw_setCard', style: { display: 'block' } }, providerRows))
2598
2802
  }
2599
2803
 
2804
+ // —— 第三方 API 自定义价格(Issue #36) ——
2805
+ rows.push(React.createElement('div', { key: 'cp-head', className: 'dshw_accountHeader', style: { marginTop: '8px' } },
2806
+ React.createElement('span', null,
2807
+ React.createElement('span', { className: 'dshw_title' }, '第三方 API 自定义价格'),
2808
+ React.createElement('span', { className: 'dshw_muted', style: { display: 'block', marginTop: '2px', fontSize: '10px' } }, '每 100 万 Token;仅作本机估算,不代表第三方账单')),
2809
+ React.createElement('button', {
2810
+ type: 'button', className: 'dshw_btn',
2811
+ disabled: settingsProviderMode.kind !== 'third',
2812
+ title: settingsProviderMode.kind === 'third' ? '填入当前会话选择的 Provider 与模型' : '请先切换到第三方模型',
2813
+ onClick: useCurrentRouteForPrice
2814
+ }, '使用当前模型')))
2815
+ var providerOptions = Array.from(new Set(knownPriceRoutes.map(function (route) { return route && route.provider }).filter(Boolean)))
2816
+ var modelOptions = Array.from(new Set(knownPriceRoutes.filter(function (route) {
2817
+ return route && (!priceDraft.provider || route.provider === priceDraft.provider)
2818
+ }).map(function (route) { return route.model }).filter(Boolean)))
2819
+ function updatePriceDraft(key, value) {
2820
+ setPriceDraft(function (current) { var next = Object.assign({}, current); next[key] = key === 'currency' ? String(value).toUpperCase().slice(0, 3) : value; return next })
2821
+ }
2822
+ function priceField(key, label, type, options) {
2823
+ return React.createElement('label', { key: key, className: 'dshw_priceField' },
2824
+ React.createElement('span', null, label),
2825
+ type === 'select'
2826
+ ? React.createElement('select', { className: 'dshw_select', value: priceDraft[key], 'aria-label': label, onChange: function (event) { updatePriceDraft(key, event.target.value) } },
2827
+ options.map(function (option) { return React.createElement('option', { key: option, value: option }, option) }))
2828
+ : React.createElement('input', {
2829
+ className: 'dshw_input', type: type, min: type === 'number' ? '0' : undefined,
2830
+ max: type === 'number' ? '1000000' : undefined, step: type === 'number' ? '0.000001' : undefined,
2831
+ list: key === 'provider' ? 'dshw-price-providers' : key === 'model' ? 'dshw-price-models' : undefined,
2832
+ value: priceDraft[key], 'aria-label': label,
2833
+ onChange: function (event) { updatePriceDraft(key, event.target.value) }
2834
+ }))
2835
+ }
2836
+ var priceForm = React.createElement('div', { className: 'dshw_priceForm' },
2837
+ priceField('provider', 'Provider ID', 'text'),
2838
+ priceField('model', '模型 ID', 'text'),
2839
+ priceField('currency', '币种', 'text'),
2840
+ priceField('input', '输入', 'number'),
2841
+ priceField('cacheRead', '缓存读', 'number'),
2842
+ priceField('cacheWrite', '缓存写', 'number'),
2843
+ priceField('output', '输出', 'number'),
2844
+ React.createElement('button', { type: 'button', className: 'dshw_btn dshw_btnPrimary', disabled: usageLocked, onClick: saveCustomPrice }, '保存'),
2845
+ React.createElement('datalist', { id: 'dshw-price-providers' }, providerOptions.map(function (provider) { return React.createElement('option', { key: provider, value: provider }) })),
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' }, '分时段可跨午夜;同一规则的时段不能重叠。'))
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
2907
+ return React.createElement('div', { key: rule.provider + ':' + rule.model, className: 'dshw_priceRule' },
2908
+ React.createElement('span', null,
2909
+ React.createElement('strong', { title: rule.provider + ' · ' + rule.model }, rule.provider + ' · ' + rule.model),
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),
2912
+ React.createElement('span', { className: 'dshw_settingsInline' },
2913
+ React.createElement('button', { type: 'button', className: 'dshw_btn', onClick: function () { editCustomPrice(rule) } }, '编辑'),
2914
+ React.createElement('button', { type: 'button', className: 'dshw_btn', disabled: usageLocked, onClick: function () { removeCustomPrice(rule) } }, '删除')))
2915
+ })
2916
+ rows.push(React.createElement('div', { key: 'cp-card', className: 'dshw_setCard wide', style: { display: 'block' } },
2917
+ priceForm,
2918
+ priceWindowEditor,
2919
+ priceNotice ? React.createElement('div', { className: 'dshw_muted', role: 'status', style: { padding: '0 12px 8px' } }, priceNotice) : null,
2920
+ priceRuleRows.length > 0
2921
+ ? React.createElement('div', { className: 'dshw_priceRules' }, priceRuleRows)
2922
+ : React.createElement('div', { className: 'dshw_muted', style: { padding: '0 12px 11px' } }, '暂无规则;填写后即可看到第三方费用估算')))
2923
+
2600
2924
  rows.push(React.createElement(PlanUsagePanel, { key: 'plans', compact: false }))
2601
2925
  rows.push(React.createElement(UsageHistoryPanel, { key: 'history', sessionId: null, alwaysOpen: true }))
2602
2926
 
@@ -4541,6 +4865,13 @@ window.__ModuleLoader__.load({
4541
4865
  var third = session.third || {}
4542
4866
  var officialTokens = official.tokens ? totalTokens(official.tokens) : 0
4543
4867
  var thirdTokens = third.tokens ? totalTokens(third.tokens) : 0
4868
+ var thirdCustomText = customCostsText(third.customCosts)
4869
+ var activeThirdRoute = activeProviderMode.kind === 'third' && Array.isArray(third.routes)
4870
+ ? third.routes.find(function (route) { return route.provider === activeProviderMode.provider && route.model === activeProviderMode.model })
4871
+ : null
4872
+ var activeThirdCostText = activeThirdRoute && activeThirdRoute.customCost
4873
+ ? fmtCurrency(activeThirdRoute.customCost.cost, activeThirdRoute.customCost.currency)
4874
+ : ''
4544
4875
  var activePlanSource = activeProviderMode.kind === 'zai'
4545
4876
  ? planSourceForProvider(snapshot, activeProviderMode.provider)
4546
4877
  : null
@@ -4589,10 +4920,10 @@ window.__ModuleLoader__.load({
4589
4920
  React.createElement('span', { className: 'dshw_homePrimaryLabel' }, '5h 剩 '),
4590
4921
  React.createElement('span', { className: 'dshw_homePrimaryValue' }, planTokenRemaining)))
4591
4922
  } else if (activeProviderMode.kind === 'third') {
4592
- if (chipVertical) chipTextParts.push(verticalMetric('third', '本场', fmtTokens(thirdTokens)))
4923
+ if (chipVertical) chipTextParts.push(verticalMetric('third', activeThirdCostText ? '本估' : '本场', activeThirdCostText || fmtTokens(thirdTokens)))
4593
4924
  else chipTextParts.push(React.createElement('span', { key: 'third', className: 'dshw_homePrimary' },
4594
- React.createElement('span', { className: 'dshw_homePrimaryLabel' }, '本场 '),
4595
- React.createElement('span', { className: 'dshw_homePrimaryValue' }, fmtTokens(thirdTokens))))
4925
+ React.createElement('span', { className: 'dshw_homePrimaryLabel' }, activeThirdCostText ? '本估 ' : '本场 '),
4926
+ React.createElement('span', { className: 'dshw_homePrimaryValue' }, activeThirdCostText || fmtTokens(thirdTokens))))
4596
4927
  } else {
4597
4928
  if (chipVertical) chipTextParts.push(verticalMetric('bal', '余额', balanceText))
4598
4929
  else chipTextParts.push(React.createElement('span', { key: 'bal', className: 'dshw_balanceText dshw_homePrimary' },
@@ -4617,10 +4948,12 @@ window.__ModuleLoader__.load({
4617
4948
  if (chipVertical) {
4618
4949
  chipTextParts.push(verticalMetric('provider', '模型', thirdProviderName))
4619
4950
  chipTextParts.push(verticalMetric('third', '本场', fmtTokens(thirdTokens)))
4951
+ if (activeThirdCostText) chipTextParts.push(verticalMetric('third-cost', '估算', activeThirdCostText))
4620
4952
  } else {
4621
4953
  chipTextParts.push(React.createElement('span', { key: 'provider', className: 'dshw_homePrimary' },
4622
4954
  React.createElement('span', { className: 'dshw_homePrimaryValue' }, thirdProviderName)))
4623
4955
  chipTextParts.push(React.createElement('span', { key: 'third' }, '本场 ' + fmtTokens(thirdTokens)))
4956
+ if (activeThirdCostText) chipTextParts.push(React.createElement('span', { key: 'third-cost' }, '估 ' + activeThirdCostText))
4624
4957
  }
4625
4958
  } else if (chipVertical) {
4626
4959
  if (showDeepSeek) {
@@ -4738,6 +5071,20 @@ window.__ModuleLoader__.load({
4738
5071
  thirdRows.push(React.createElement('div', { key: 't-t', className: 'dshw_row' },
4739
5072
  React.createElement('span', { className: 'dshw_muted' }, '\u7b2c\u4e09\u65b9 token'),
4740
5073
  React.createElement('span', null, '\u8f93\u5165 ' + fmtTokens(third.tokens && third.tokens.input) + ' \u00b7 \u7f13\u5b58\u8bfb ' + fmtTokens(third.tokens && third.tokens.cacheRead) + ' \u00b7 \u8f93\u51fa ' + fmtTokens(third.tokens && third.tokens.output))))
5074
+ if (thirdCustomText) {
5075
+ thirdRows.push(React.createElement('div', { key: 't-c', className: 'dshw_row' },
5076
+ React.createElement('span', { className: 'dshw_muted' }, '自定义估算'),
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)))
5087
+ }
4741
5088
 
4742
5089
  var thresholdInput = React.createElement('input', {
4743
5090
  className: 'dshw_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, a 365-day local token ledger, Z.ai Coding Plan quota monitoring, official pricing sync, peak/off-peak status, encrypted multi-account switching, health checks, completion reminders, recharge, and host-gated session controls.",
4
- "version": "0.3.6",
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": {
@@ -56,7 +56,6 @@
56
56
  "client": {
57
57
  "platform": "web",
58
58
  "inject": [
59
- "@deepseek-ai/dsh-client-runtime",
60
59
  "@deepseek-ai/dsh-client-ui-slots",
61
60
  "@deepseek-ai/dsh-client-ui-conversation",
62
61
  "@deepseek-ai/dsh-client-ui-model-selection"