deepseek-harness-wallet 0.3.8 → 0.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +8 -6
- package/index.js +244 -30
- package/lib/client.js +1 -5449
- package/package.json +20 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## 0.3.10 - 2026-09-05
|
|
6
|
+
|
|
7
|
+
- 拆分可读客户端源码并提交可复现的精简加载产物,使每个文件低于 DSH STORE 的 256 KiB 自动审核上限;安装仍不需要构建或运行依赖。 / Split readable client sources and commit a reproducible compact loader artifact below DSH STORE's 256 KiB per-file review bound, retaining build-free installation and zero runtime dependencies.
|
|
8
|
+
- 声明 alpha.3、alpha.4、alpha.5、rc.1 的精确 DSH 兼容状态;alpha.4/alpha.5/rc.1 均完成隔离 Profile 安装、冷启动、钱包接口与客户端资源验证、卸载和回退。 / Declare exact DSH compatibility for alpha.3, alpha.4, alpha.5, and rc.1; the latter three passed disposable-profile installation, cold start, wallet endpoints/client resource delivery, removal, and rollback.
|
|
9
|
+
- 增加产物复现与文件体积检查,保留全部钱包、账户、套餐及分时计价回归。 / Add artifact reproducibility and byte-bound checks while retaining wallet, account, plan and time-of-use pricing regressions.
|
|
10
|
+
|
|
11
|
+
## 0.3.9 - 2026-09-04
|
|
12
|
+
|
|
13
|
+
- 第三方 API 自定义价格新增分时计价(Issue #37):每个精确 Provider/模型可在基础价之上配置多个 IANA 时区、适用星期和起止时间规则,并为输入、缓存读、缓存写、输出分别定价;支持跨午夜时段,当前命中的价格段会在设置和钱包详情中显示。 / Added time-of-use custom third-party API pricing (Issue #37): each exact Provider/model route can define multiple IANA-timezone, weekday, and start/end windows over a base rate, with separate input, cache-read, cache-write, and output prices; cross-midnight windows are supported and the active price segment is shown in Settings and wallet details.
|
|
14
|
+
- 分时规则会拒绝无效时区、日期、时间和重叠范围;365 天账本与当前会话按每条保留用量的发生时间重新估算,缺少时间戳的旧聚合或已清除历史安全回退基础价,且 v0.3.8 固定价格规则无需迁移配置。 / Time-window validation rejects invalid timezones, weekdays, times, and overlaps; the 365-day ledger and live session reprice each retained usage event by occurrence time, while legacy aggregates or cleared history without timestamps safely use the base rate and existing v0.3.8 fixed-price rules remain compatible.
|
|
15
|
+
- 将峰谷时钟渲染测试固定到明确的工作日时间,避免测试结果随执行当天是否周末而波动。 / Pinned the peak-clock rendering test to an explicit weekday so its result no longer depends on the day the suite runs.
|
|
16
|
+
- Vision Toolkit 透明包装的 Z.ai 路由会还原为对应套餐 Provider,模型切换后继续显示 5 小时 Token 与月度 MCP 摘要,而不会误显示为普通第三方价格;DeepSeek 与其他包装路由的既有分桶规则保持不变。 / Z.ai routes transparently wrapped by Vision Toolkit now resolve back to their plan provider so model switches retain five-hour Token and monthly MCP summaries instead of looking like ordinary third-party pricing; existing DeepSeek and other wrapper classification remains unchanged.
|
|
17
|
+
|
|
5
18
|
## 0.3.8 - 2026-09-02
|
|
6
19
|
|
|
7
20
|
- 新增第三方 API 自定义价格(Issue #36):可按精确 Provider/模型设置币种以及每百万 Token 的输入、缓存读、缓存写和输出价格;本会话与 365 天账本会按当前规则重新估算,并始终标注为用户自定义估算。 / Added custom third-party API pricing (Issue #36) with exact Provider/model matching, currency, and per-million input/cache-read/cache-write/output rates; current-session and 365-day ledger totals are recalculated from the active user rule and always labeled as estimates.
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
> A local-first companion that keeps account status, per-conversation usage, completion reminders, official recharge, flexible layout, and host-gated session controls beside the DSH composer.
|
|
16
16
|
|
|
17
|
-
> **Version:** v0.3.
|
|
17
|
+
> **Version:** v0.3.10.
|
|
18
18
|
|
|
19
19
|
> If DeepSeek Harness Control Center helps you, please consider leaving a ⭐ Star. Thank you!
|
|
20
20
|
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
- **v4 peak/off-peak ring clock** — a resident 24-hour sidebar footer widget for `v4-flash`, `v4-pro`, and `v4-flash-vision-exp`. Weekday peak windows are 09:00–12:00 and 14:00–18:00 Beijing time. After Friday 18:00 the card previews “weekend all-day off-peak”; Saturday and Sunday name the current all-day off-peak rule; Monday before 09:00 shows the time remaining to enter peak. Optional notifications treat Friday 18:00 through Monday 09:00 as one continuous off-peak period.
|
|
30
30
|
- **Official pricing sync** — periodically checks the official DeepSeek pricing page and applies only a fully validated table. Network failures retain the last validated rule (or the built-in rule before the first successful sync); an unrecognized page structure is marked for review instead of silently changing billing.
|
|
31
31
|
- **Z.ai Coding Plan quotas** — a generic official-plan adapter monitors configured Global and China plans without exposing credentials. It separates the 5-hour model-token window from monthly MCP-tool usage, leads with quota remaining from 100% down while keeping usage as secondary context, retains the last successful snapshot on failure, and never converts subscription quota into CNY balance.
|
|
32
|
-
- **Provider-aware composer surfaces** — the chip and sidebar clock follow the session's selected provider/model. Z.ai replaces DeepSeek balance, recharge, and peak pricing with plan-window summaries; unrelated providers show only their own session tokens, and DeepSeek V4 restores the wallet and peak clock.
|
|
32
|
+
- **Provider-aware composer surfaces** — the chip and sidebar clock follow the session's selected provider/model. Z.ai—including transparent `vision-toolkit-` variants—replaces DeepSeek balance, recharge, and peak pricing with plan-window summaries; unrelated providers show only their own session tokens, and DeepSeek V4 restores the wallet and peak clock.
|
|
33
33
|
- **365-day local usage ledger** — Wallet settings keeps the heatmap visible, while compact wallet panels keep it collapsible. Stable request identities are deduplicated, official cost is locked at usage time, official and third-party data stay separate, and prompts or responses are never stored. Collection begins after upgrading to v0.3.2; older aggregate counters have no trustworthy dates and are not backfilled.
|
|
34
34
|
- **Third-party total** — current-session tokens (input / cache read / output) remain available with zero configuration.
|
|
35
|
-
- **Custom third-party pricing** — enter an exact Provider/model route, currency, and per-million input/cache-read/cache-write/output rates. Current-session and 365-day ledger costs are recalculated locally
|
|
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
|
|
77
|
+
From npm:
|
|
78
78
|
|
|
79
79
|
```sh
|
|
80
80
|
dsh plugin --profile web add deepseek-harness-wallet
|
|
@@ -145,12 +145,14 @@ For buildable DSH hosts, the npm package and repository include a versioned [Age
|
|
|
145
145
|
|
|
146
146
|
## Data & trust
|
|
147
147
|
|
|
148
|
+
Client development now uses five readable files under `src/client/` and a committed `lib/client.js` artifact; run `npm ci`, `npm run build:client`, and `npm run check:client` after source edits. Installation runs no build scripts. Exact-version smoke checks are documented in [0.3.10 compatibility evidence](https://github.com/feibi-mochi/deepseek-harness-control-center/blob/main/docs/compatibility-0.3.10.md).
|
|
149
|
+
|
|
148
150
|
| Item | Behavior |
|
|
149
151
|
| --- | --- |
|
|
150
152
|
| Token accounting | Listens to the `llm/stream` event and buckets per session and provider: `deepseek-official` plus explicitly opted-in wrapper routes use the official bucket; other providers stay third-party; each usage event also locks its contemporaneous official price, so multiple sessions and pricing windows never mix. |
|
|
151
153
|
| Balance | The wallet plugin itself sends the active key directly only to the official `/user/balance` endpoint. When multi-account switching is enabled, the selected key is also written into the DSH credentials seam; DSH may then use it for subsequent model requests. |
|
|
152
154
|
| Accounts | Keys live encrypted in `$DSH_HOME/storages/accounts.json`, with an encrypted `accounts.json.bak` fallback for a missing, corrupt, or undecryptable primary. Windows uses current-user DPAPI; other platforms use an owner-only AES-GCM key file, so move `accounts.json`, `.bak`, and `.key` together. If neither copy can be read, account writes fail closed. |
|
|
153
|
-
| Usage ledger | Local events and custom third-party price rules live in `$DSH_HOME/storages/wallet.json` with a `wallet.json.bak` recovery copy. Missing/corrupt primaries recover automatically; if neither copy is readable, wallet writes fail closed. Up to 365 days of session/provider/model/token metadata and official locked cost are kept—never prompts, tool arguments, response bodies, or API keys. Third-party estimates are recalculated from the current custom rule. |
|
|
155
|
+
| Usage ledger | Local events and custom third-party price rules live in `$DSH_HOME/storages/wallet.json` with a `wallet.json.bak` recovery copy. Missing/corrupt primaries recover automatically; if neither copy is readable, wallet writes fail closed. Up to 365 days of session/provider/model/token metadata and official locked cost are kept—never prompts, tool arguments, response bodies, or API keys. Third-party estimates are recalculated from the current custom rule and each retained event's occurrence time; aggregate usage without a retained timestamp safely falls back to the base rate. |
|
|
154
156
|
| Local settings | Layout, scale, visibility, reminder, and panel settings stay in browser-compatible local storage. |
|
|
155
157
|
| Permanent deletion | Opt-in and host-gated. The wallet never advertises the action unless the host implements the matching session deletion path. |
|
|
156
158
|
| Model surface | No tools registered, no prompt injection, zero token cost. |
|
|
@@ -174,7 +176,7 @@ Historical deepseek-chat and deepseek-reasoner records retain their original fla
|
|
|
174
176
|
|
|
175
177
|
- [x] 365-day Token heatmap and rebuildable local usage ledger
|
|
176
178
|
- [x] Z.ai Coding Plan Global/China monitoring on a generic official-plan adapter contract
|
|
177
|
-
- [x] User-defined third-party Provider/model pricing
|
|
179
|
+
- [x] User-defined third-party Provider/model base and time-of-use pricing
|
|
178
180
|
- [ ] Additional provider price/balance adapters only after real-account validation
|
|
179
181
|
|
|
180
182
|
## License
|
package/index.js
CHANGED
|
@@ -28,12 +28,13 @@ export const name = 'wallet'
|
|
|
28
28
|
export const inject = ['webServer', 'credentials']
|
|
29
29
|
|
|
30
30
|
const OFFICIAL_PROVIDER = 'deepseek-official'
|
|
31
|
+
const TRANSPARENT_PROVIDER_PREFIX = 'vision-toolkit-'
|
|
31
32
|
// Subscription-plan routes are independent providers. They may contribute to
|
|
32
33
|
// the generic third-party token ledger, but they must never be promoted into
|
|
33
34
|
// DeepSeek's official paid-API bucket by the wrapper-provider alias control.
|
|
34
35
|
const PLAN_PROVIDER_IDS = new Set(PLAN_ADAPTERS.map((adapter) => adapter.provider))
|
|
35
36
|
const RECHARGE_URL = 'https://platform.deepseek.com/top_up'
|
|
36
|
-
const PLUGIN_VERSION = '0.3.
|
|
37
|
+
const PLUGIN_VERSION = '0.3.10'
|
|
37
38
|
const PRICING_SOURCE_URL = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing/'
|
|
38
39
|
const PRICING_SYNC_INTERVAL_MS = 6 * 60 * 60_000
|
|
39
40
|
const PRICING_SYNC_TIMEOUT_MS = 8_000
|
|
@@ -49,14 +50,21 @@ const ACCOUNTS_CRYPTO_VERSION = 1
|
|
|
49
50
|
const CREDENTIAL_REF = 'DEEPSEEK_API_KEY'
|
|
50
51
|
const DEFAULT_THRESHOLD = 5
|
|
51
52
|
const BALANCE_REFRESH_MS = 60_000
|
|
52
|
-
const STORE_VERSION =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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 *
|
|
970
|
-
+ counters.cacheRead *
|
|
971
|
-
+ counters.cacheWrite *
|
|
972
|
-
+ counters.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
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
2602
|
+
return json(res, 200, { ok: true, rules: customPriceRulesView(), knownRoutes: knownThirdPartyRoutes() })
|
|
2389
2603
|
},
|
|
2390
2604
|
})
|
|
2391
2605
|
const disposeAccounts = ctx.webServer.register({
|