dsh-simple-usage-info 0.1.0
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/LICENSE +21 -0
- package/README.md +269 -0
- package/cordis.patch.yml +35 -0
- package/lib/client.d.ts +85 -0
- package/lib/client.js +620 -0
- package/lib/holiday-source.d.ts +54 -0
- package/lib/holiday-source.js +154 -0
- package/lib/holidays.d.ts +19 -0
- package/lib/holidays.js +109 -0
- package/lib/index.d.ts +46 -0
- package/lib/index.js +263 -0
- package/lib/pricing.d.ts +105 -0
- package/lib/pricing.js +225 -0
- package/package.json +88 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chinese public holiday source.
|
|
3
|
+
*
|
|
4
|
+
* DeepSeek's peak window excludes Chinese public holidays, and the statutory
|
|
5
|
+
* arrangement is republished every November and is not derivable — the observed
|
|
6
|
+
* days move with the lunar calendar and the annual 调休 adjustments. So instead
|
|
7
|
+
* of maintaining a table by hand, this fetches one that is already maintained:
|
|
8
|
+
*
|
|
9
|
+
* https://github.com/NateScarlet/holiday-cn
|
|
10
|
+
*
|
|
11
|
+
* That repository scrapes the State Council announcements daily in CI and
|
|
12
|
+
* publishes one JSON file per year:
|
|
13
|
+
*
|
|
14
|
+
* { "year": 2026, "papers": ["https://www.gov.cn/..."],
|
|
15
|
+
* "days": [ { "name": "元旦", "date": "2026-01-01", "isOffDay": true }, ... ] }
|
|
16
|
+
*
|
|
17
|
+
* `isOffDay: true` is a statutory day off; `isOffDay: false` is a 调休 makeup
|
|
18
|
+
* workday. `lib/holidays.js` stays as an offline fallback for when the fetch
|
|
19
|
+
* fails. The fetcher is injected so it can be exercised without a network.
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-simple-usage-info/holiday-source
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Where the maintained data lives; `{year}` is substituted. */
|
|
25
|
+
export const DEFAULT_HOLIDAY_URL = 'https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/{year}.json'
|
|
26
|
+
|
|
27
|
+
/** How long one successful snapshot stays fresh. */
|
|
28
|
+
export const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000
|
|
29
|
+
|
|
30
|
+
/** Remote request timeout. */
|
|
31
|
+
export const DEFAULT_TIMEOUT_MS = 10000
|
|
32
|
+
|
|
33
|
+
/** Sorted, de-duplicated union. */
|
|
34
|
+
function union(lists) {
|
|
35
|
+
return [...new Set(lists.flat())].sort()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read one year's document.
|
|
40
|
+
* @param options - the injected fetch, template, timeout, and clock.
|
|
41
|
+
* @param year - the calendar year to read.
|
|
42
|
+
* @returns the year's days, or `undefined` when the notice is not published yet.
|
|
43
|
+
* @throws Error when the request fails or the body is unusable.
|
|
44
|
+
*/
|
|
45
|
+
async function loadYear(options, year) {
|
|
46
|
+
const url = options.urlTemplate.replace('{year}', String(year))
|
|
47
|
+
const response = await options.fetchImpl(url, {
|
|
48
|
+
headers: { accept: 'application/json' },
|
|
49
|
+
signal: AbortSignal.timeout(options.timeoutMs)
|
|
50
|
+
})
|
|
51
|
+
if (response.ok !== true) throw new Error(`${url} answered HTTP ${response.status}`)
|
|
52
|
+
const body = await response.json()
|
|
53
|
+
const days = Array.isArray(body?.days) ? body.days : []
|
|
54
|
+
// holiday-cn publishes an empty placeholder before the State Council notice
|
|
55
|
+
// lands, and an empty `days` means "not announced", never "no holidays".
|
|
56
|
+
if (days.length === 0) return undefined
|
|
57
|
+
return {
|
|
58
|
+
year: Number(body.year ?? year),
|
|
59
|
+
holidays: days.filter((day) => day.isOffDay === true).map((day) => String(day.date)),
|
|
60
|
+
makeupWorkdays: days.filter((day) => day.isOffDay !== true).map((day) => String(day.date)),
|
|
61
|
+
papers: Array.isArray(body.papers) ? body.papers.map(String) : []
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build a lazily-refreshed holiday source.
|
|
67
|
+
*
|
|
68
|
+
* `refresh()` never rejects: a failed year is recorded and the previous snapshot
|
|
69
|
+
* is kept, so the caller can always render something.
|
|
70
|
+
* @param options - configuration and injectable seams.
|
|
71
|
+
* @returns the source handle.
|
|
72
|
+
*/
|
|
73
|
+
export function createHolidaySource(options) {
|
|
74
|
+
const settings = {
|
|
75
|
+
urlTemplate: options?.urlTemplate ?? DEFAULT_HOLIDAY_URL,
|
|
76
|
+
ttlMs: options?.ttlMs ?? DEFAULT_TTL_MS,
|
|
77
|
+
timeoutMs: options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
78
|
+
fetchImpl: options?.fetchImpl ?? globalThis.fetch,
|
|
79
|
+
now: options?.now ?? Date.now,
|
|
80
|
+
log: options?.log ?? (() => {})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The last successful snapshot, if any. */
|
|
84
|
+
let snapshot
|
|
85
|
+
/** The in-flight refresh, so concurrent callers share one round trip. */
|
|
86
|
+
let inflight = null
|
|
87
|
+
/** When the last refresh finished, successful or not. */
|
|
88
|
+
let attemptedAt = 0
|
|
89
|
+
|
|
90
|
+
/** Whether a remote source is configured at all. */
|
|
91
|
+
const enabled = () => settings.urlTemplate !== '' && typeof settings.fetchImpl === 'function'
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether a refresh is worth starting for these years.
|
|
95
|
+
* @param years - the years the caller needs covered.
|
|
96
|
+
* @returns true when the snapshot is missing, stale, or short of a year.
|
|
97
|
+
*/
|
|
98
|
+
function stale(years) {
|
|
99
|
+
if (!enabled()) return false
|
|
100
|
+
if (snapshot === undefined) return true
|
|
101
|
+
if (settings.now() - attemptedAt > settings.ttlMs) return true
|
|
102
|
+
return years.some((year) => !snapshot.years.includes(year))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Fetch every requested year and publish one merged snapshot. A disabled
|
|
107
|
+
* source resolves to the current snapshot without touching the network, so a
|
|
108
|
+
* caller that forgets to check {@link stale} still cannot fetch.
|
|
109
|
+
* @param years - the years to cover.
|
|
110
|
+
* @returns the snapshot after the attempt (unchanged when every year failed).
|
|
111
|
+
*/
|
|
112
|
+
function refresh(years) {
|
|
113
|
+
if (!enabled()) return Promise.resolve(snapshot)
|
|
114
|
+
if (inflight !== null) return inflight
|
|
115
|
+
inflight = (async () => {
|
|
116
|
+
const loaded = []
|
|
117
|
+
const failures = []
|
|
118
|
+
for (const year of years) {
|
|
119
|
+
try {
|
|
120
|
+
const one = await loadYear(settings, year)
|
|
121
|
+
if (one !== undefined) loaded.push(one)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
failures.push(`${year}: ${String(error?.message ?? error)}`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
attemptedAt = settings.now()
|
|
127
|
+
if (loaded.length > 0) {
|
|
128
|
+
snapshot = {
|
|
129
|
+
holidays: union(loaded.map((one) => one.holidays)),
|
|
130
|
+
makeupWorkdays: union(loaded.map((one) => one.makeupWorkdays)),
|
|
131
|
+
years: [...new Set(loaded.map((one) => one.year))].sort((left, right) => left - right),
|
|
132
|
+
papers: union(loaded.map((one) => one.papers)),
|
|
133
|
+
fetchedAt: settings.now()
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (failures.length > 0) {
|
|
137
|
+
settings.log(
|
|
138
|
+
`usage-info: holiday refresh incomplete (${failures.join('; ')})` +
|
|
139
|
+
(snapshot === undefined ? '; falling back to the bundled table' : '; keeping the last snapshot')
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
inflight = null
|
|
143
|
+
return snapshot
|
|
144
|
+
})()
|
|
145
|
+
return inflight
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
/** The current snapshot, or undefined before the first success. */
|
|
150
|
+
peek: () => snapshot,
|
|
151
|
+
stale,
|
|
152
|
+
refresh
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-simple-usage-info — bundled Chinese public holiday table types.
|
|
3
|
+
* @module dsh-simple-usage-info/holidays
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Every calendar year the bundled table covers. */
|
|
7
|
+
export declare const COVERED_YEARS: readonly number[]
|
|
8
|
+
|
|
9
|
+
/** Every announced public holiday date across the covered years, sorted. */
|
|
10
|
+
export declare function bundledHolidays(): string[]
|
|
11
|
+
|
|
12
|
+
/** Every announced 调休 makeup workday across the covered years, sorted. */
|
|
13
|
+
export declare function bundledMakeupWorkdays(): string[]
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The State Council citation for one year's arrangement.
|
|
17
|
+
* @returns the notice reference, or undefined when the year is not covered.
|
|
18
|
+
*/
|
|
19
|
+
export declare function noticeFor(year: number): string | undefined
|
package/lib/holidays.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chinese public holidays — the offline fallback table.
|
|
3
|
+
*
|
|
4
|
+
* The primary source is fetched at runtime from a list that is already
|
|
5
|
+
* maintained (`lib/holiday-source.js` → NateScarlet/holiday-cn). This table is
|
|
6
|
+
* what the plugin uses when that fetch has never succeeded, so an offline host
|
|
7
|
+
* still prices holidays correctly instead of silently treating them as ordinary
|
|
8
|
+
* weekdays.
|
|
9
|
+
*
|
|
10
|
+
* The dates are Chinese calendar days; because every peak window (01:00–10:00
|
|
11
|
+
* UTC) sits inside one Chinese day (09:00–18:00 CST), looking them up by UTC
|
|
12
|
+
* date is equivalent.
|
|
13
|
+
*
|
|
14
|
+
* The 2026 entry below is the State Council announcement (Guo Ban Fa Ming Dian
|
|
15
|
+
* [2025] No. 7), verified date-for-date against holiday-cn's 2026.json: 33 days
|
|
16
|
+
* off and 6 makeup workdays, identical.
|
|
17
|
+
*
|
|
18
|
+
* Being a fallback rather than the source of truth, it needs no annual
|
|
19
|
+
* maintenance — a year it does not cover simply reports itself as uncovered when
|
|
20
|
+
* the fetch is also unavailable.
|
|
21
|
+
*
|
|
22
|
+
* @module dsh-simple-usage-info/holidays
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One year's announced arrangement.
|
|
27
|
+
* `ranges` are inclusive `YYYY-MM-DD` spans of days off; `makeupWorkdays` are
|
|
28
|
+
* weekend days the notice designates as working days.
|
|
29
|
+
*/
|
|
30
|
+
const NOTICES = {
|
|
31
|
+
2026: {
|
|
32
|
+
notice: 'Guo Ban Fa Ming Dian [2025] No. 7 (4 November 2025)',
|
|
33
|
+
ranges: [
|
|
34
|
+
// New Year's Day, 3 days.
|
|
35
|
+
['2026-01-01', '2026-01-03'],
|
|
36
|
+
// Spring Festival, 9 days.
|
|
37
|
+
['2026-02-15', '2026-02-23'],
|
|
38
|
+
// Qingming Festival, 3 days.
|
|
39
|
+
['2026-04-04', '2026-04-06'],
|
|
40
|
+
// Labor Day, 5 days.
|
|
41
|
+
['2026-05-01', '2026-05-05'],
|
|
42
|
+
// Dragon Boat Festival, 3 days.
|
|
43
|
+
['2026-06-19', '2026-06-21'],
|
|
44
|
+
// Mid-Autumn Festival, 3 days.
|
|
45
|
+
['2026-09-25', '2026-09-27'],
|
|
46
|
+
// National Day, 7 days.
|
|
47
|
+
['2026-10-01', '2026-10-07']
|
|
48
|
+
],
|
|
49
|
+
makeupWorkdays: [
|
|
50
|
+
'2026-01-04', // Sunday, for New Year's Day
|
|
51
|
+
'2026-02-14', // Saturday, for Spring Festival
|
|
52
|
+
'2026-02-28', // Saturday, for Spring Festival
|
|
53
|
+
'2026-05-09', // Saturday, for Labor Day
|
|
54
|
+
'2026-09-20', // Sunday, for National Day
|
|
55
|
+
'2026-10-10' // Saturday, for National Day
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Every calendar year this table covers. */
|
|
61
|
+
export const COVERED_YEARS = Object.keys(NOTICES).map(Number).sort()
|
|
62
|
+
|
|
63
|
+
/** `YYYY-MM-DD` for a UTC date. */
|
|
64
|
+
function dayKey(year, month, day) {
|
|
65
|
+
const pad = (value) => String(value).padStart(2, '0')
|
|
66
|
+
return `${year}-${pad(month + 1)}-${pad(day)}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Expand one inclusive `YYYY-MM-DD`..`YYYY-MM-DD` span.
|
|
71
|
+
* @param from - first day off.
|
|
72
|
+
* @param to - last day off.
|
|
73
|
+
* @returns every date in the span.
|
|
74
|
+
*/
|
|
75
|
+
function expandRange(from, to) {
|
|
76
|
+
const start = Date.parse(`${from}T00:00:00Z`)
|
|
77
|
+
const end = Date.parse(`${to}T00:00:00Z`)
|
|
78
|
+
const dates = []
|
|
79
|
+
for (let at = start; at <= end; at += 86400000) {
|
|
80
|
+
const date = new Date(at)
|
|
81
|
+
dates.push(dayKey(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
|
|
82
|
+
}
|
|
83
|
+
return dates
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Every announced public holiday date across the covered years.
|
|
88
|
+
* @returns a sorted list of `YYYY-MM-DD`.
|
|
89
|
+
*/
|
|
90
|
+
export function bundledHolidays() {
|
|
91
|
+
return COVERED_YEARS.flatMap((year) => NOTICES[year].ranges.flatMap(([from, to]) => expandRange(from, to))).sort()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Every announced makeup workday across the covered years.
|
|
96
|
+
* @returns a sorted list of `YYYY-MM-DD`.
|
|
97
|
+
*/
|
|
98
|
+
export function bundledMakeupWorkdays() {
|
|
99
|
+
return COVERED_YEARS.flatMap((year) => [...NOTICES[year].makeupWorkdays]).sort()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The citation for one year's arrangement.
|
|
104
|
+
* @param year - calendar year.
|
|
105
|
+
* @returns the notice reference, or undefined when uncovered.
|
|
106
|
+
*/
|
|
107
|
+
export function noticeFor(year) {
|
|
108
|
+
return NOTICES[year]?.notice
|
|
109
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-simple-usage-info — host (Node) half type surface.
|
|
3
|
+
* @module dsh-simple-usage-info
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
6
|
+
|
|
7
|
+
/** Cordis plugin name. */
|
|
8
|
+
export declare const name = 'dsh-simple-usage-info'
|
|
9
|
+
/** Host services required before the balance route can register. */
|
|
10
|
+
export declare const inject: string[]
|
|
11
|
+
/** The one route path this plugin owns, shared with the browser half. */
|
|
12
|
+
export declare const BALANCE_PATH = '/usage/balance'
|
|
13
|
+
/** Plugin configuration schema. */
|
|
14
|
+
export declare const Config: unknown
|
|
15
|
+
|
|
16
|
+
/** One currency's balance, exactly as the DeepSeek API reports it. */
|
|
17
|
+
export interface DeepSeekBalanceInfo {
|
|
18
|
+
readonly currency: 'CNY' | 'USD'
|
|
19
|
+
readonly total_balance: string
|
|
20
|
+
readonly granted_balance: string
|
|
21
|
+
readonly topped_up_balance: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The DeepSeek `GET /user/balance` document. */
|
|
25
|
+
export interface DeepSeekBalance {
|
|
26
|
+
readonly is_available: boolean
|
|
27
|
+
readonly balance_infos: readonly DeepSeekBalanceInfo[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What `GET /usage/balance` answers. Both branches carry the pricing block. */
|
|
31
|
+
export type BalancePayload =
|
|
32
|
+
| {
|
|
33
|
+
readonly ok: true
|
|
34
|
+
readonly balance: DeepSeekBalance
|
|
35
|
+
readonly pricing: import('./pricing.js').PricingReading
|
|
36
|
+
readonly fetchedAt: number
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
readonly ok: false
|
|
40
|
+
readonly error: { readonly code: string; readonly message: string }
|
|
41
|
+
readonly pricing: import('./pricing.js').PricingReading
|
|
42
|
+
readonly fetchedAt: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Host plugin body: one cached, read-only balance route. */
|
|
46
|
+
export declare function apply(ctx: Context, config: unknown): void
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-simple-usage-info — host (Node) half.
|
|
3
|
+
*
|
|
4
|
+
* Registers one exact read-only HTTP route on the Web GUI's server. The route
|
|
5
|
+
* resolves the same credential the shipped `deepseek-official` model route uses
|
|
6
|
+
* (`DEEPSEEK_API_KEY`, through the credentials service) and asks the public
|
|
7
|
+
* DeepSeek balance API for the account balance, then answers the browser half
|
|
8
|
+
* with plain JSON. The key never leaves this process.
|
|
9
|
+
*
|
|
10
|
+
* Route: GET /usage/balance
|
|
11
|
+
* 200 { ok: true, balance: { is_available, balance_infos: [...] }, pricing, fetchedAt }
|
|
12
|
+
* 200 { ok: false, error: { code, message }, pricing, fetchedAt }
|
|
13
|
+
* 403 when the request's Host header is not a loopback host
|
|
14
|
+
* 405 for any other method.
|
|
15
|
+
*
|
|
16
|
+
* `pricing` is computed fresh on every request from the server clock; only the
|
|
17
|
+
* upstream balance call is cached.
|
|
18
|
+
*
|
|
19
|
+
* The route is registered directly on the WebServer, so it sits outside the
|
|
20
|
+
* Web GUI's `/api` authentication fence. It answers loopback hosts only (and
|
|
21
|
+
* anything when the operator deliberately binds `0.0.0.0`), which blocks a
|
|
22
|
+
* malicious page from reading the balance through DNS rebinding, and it never
|
|
23
|
+
* returns key material — only the numbers the DeepSeek API reports.
|
|
24
|
+
*
|
|
25
|
+
* The route is deliberately cache-backed: the browser half polls it, and
|
|
26
|
+
* `refreshMs` bounds how often the upstream API is actually called.
|
|
27
|
+
*
|
|
28
|
+
* @module dsh-simple-usage-info
|
|
29
|
+
*/
|
|
30
|
+
import z from '@deepseek-ai/schemastery'
|
|
31
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
32
|
+
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
|
|
33
|
+
import { normalizeSchedule, readPricing, withHolidayData } from './pricing.js'
|
|
34
|
+
import { createHolidaySource, DEFAULT_HOLIDAY_URL } from './holiday-source.js'
|
|
35
|
+
|
|
36
|
+
/** Cordis plugin name. */
|
|
37
|
+
export const name = 'dsh-simple-usage-info'
|
|
38
|
+
|
|
39
|
+
/** The WebServer must exist before the balance route can register. */
|
|
40
|
+
export const inject = ['webServer']
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The one route path this plugin owns. The browser half hard-codes the same
|
|
44
|
+
* constant, so it is exported rather than configurable.
|
|
45
|
+
*/
|
|
46
|
+
export const BALANCE_PATH = '/usage/balance'
|
|
47
|
+
|
|
48
|
+
/** Plugin configuration, validated by Schemastery. */
|
|
49
|
+
export const Config = z.object({
|
|
50
|
+
/** DeepSeek API origin; the balance endpoint lives at its root. */
|
|
51
|
+
baseURL: z.string().default('https://api.deepseek.com'),
|
|
52
|
+
/** Credential-reference name holding the API key. */
|
|
53
|
+
apiKeyEnv: z.string().role('credential-ref').default('DEEPSEEK_API_KEY'),
|
|
54
|
+
/** How long one upstream balance answer stays fresh. */
|
|
55
|
+
refreshMs: z.number().default(60000),
|
|
56
|
+
/** Upstream request timeout. */
|
|
57
|
+
timeoutMs: z.number().default(15000),
|
|
58
|
+
/** Peak-rate windows as `HH:MM-HH:MM` UTC; everything else is off-peak. */
|
|
59
|
+
peakWindows: z.array(z.string()).default(['01:00-04:00', '06:00-10:00']),
|
|
60
|
+
/** Peak weekdays, 0 = Sunday through 6 = Saturday. */
|
|
61
|
+
peakDays: z.array(z.number()).default([1, 2, 3, 4, 5]),
|
|
62
|
+
/** Extra `YYYY-MM-DD` UTC dates priced off-peak in full, on top of the fetched holidays. */
|
|
63
|
+
holidays: z.array(z.string()).default([]),
|
|
64
|
+
/** Extra `YYYY-MM-DD` UTC makeup workdays; only meaningful with `makeupWorkdaysArePeak`. */
|
|
65
|
+
makeupWorkdays: z.array(z.string()).default([]),
|
|
66
|
+
/**
|
|
67
|
+
* Treat 调休 makeup workdays as peak days. The published rule says "Monday
|
|
68
|
+
* through Friday", which makes them off-peak, so this defaults off.
|
|
69
|
+
*/
|
|
70
|
+
makeupWorkdaysArePeak: z.boolean().default(false),
|
|
71
|
+
/**
|
|
72
|
+
* Where to read the maintained Chinese holiday data; `{year}` is substituted.
|
|
73
|
+
* Leave the URL empty to stay offline and use the bundled table.
|
|
74
|
+
*/
|
|
75
|
+
holidayURL: z.string().default(DEFAULT_HOLIDAY_URL),
|
|
76
|
+
/** How long one fetched holiday snapshot stays fresh. */
|
|
77
|
+
holidayRefreshMs: z.number().default(43200000),
|
|
78
|
+
/** Merge the bundled State Council holiday table as a fallback; false leaves only `holidays`. */
|
|
79
|
+
useBundledHolidays: z.boolean().default(true),
|
|
80
|
+
/** Off-peak discount, as a percentage of the peak rate. */
|
|
81
|
+
offPeakDiscountPercent: z.number().default(50)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
/** A forced refresh may never hit the network more often than this. */
|
|
85
|
+
const MIN_REFRESH_MS = 5000
|
|
86
|
+
|
|
87
|
+
/** Hostnames that can only mean the local machine. */
|
|
88
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]'])
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether a request's `Host` header is one this route answers.
|
|
92
|
+
*
|
|
93
|
+
* The route lives outside the GUI's `/api` auth fence, so without this check a
|
|
94
|
+
* malicious page could use DNS rebinding to read the balance from
|
|
95
|
+
* `http://127.0.0.1:<port>/usage/balance`. A deliberate `0.0.0.0` bind is the
|
|
96
|
+
* operator's stated intent to expose the server, so that posture answers any
|
|
97
|
+
* host.
|
|
98
|
+
* @param hostHeader - the raw `Host` header.
|
|
99
|
+
* @param bindHost - the WebServer's configured bind host.
|
|
100
|
+
* @returns true when the request may be answered.
|
|
101
|
+
*/
|
|
102
|
+
function hostAllowed(hostHeader, bindHost) {
|
|
103
|
+
if (bindHost === '0.0.0.0') return true
|
|
104
|
+
if (typeof hostHeader !== 'string' || hostHeader === '') return false
|
|
105
|
+
const closing = hostHeader.startsWith('[') ? hostHeader.indexOf(']') : -1
|
|
106
|
+
const hostname = closing >= 0 ? hostHeader.slice(0, closing + 1) : hostHeader.split(':', 1)[0]
|
|
107
|
+
return LOOPBACK_HOSTS.has(hostname.toLowerCase())
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Write one JSON response. `no-store` keeps browsers from caching a balance. */
|
|
111
|
+
function sendJson(res, status, body) {
|
|
112
|
+
res.statusCode = status
|
|
113
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
114
|
+
res.setHeader('cache-control', 'no-store')
|
|
115
|
+
res.end(JSON.stringify(body))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** An error carrying a stable machine-readable code for the browser half. */
|
|
119
|
+
function taggedError(code, message) {
|
|
120
|
+
const error = new Error(message)
|
|
121
|
+
error.code = code
|
|
122
|
+
return error
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve the API key exactly the way `@deepseek-ai/dsh-llm-deepseek-api-key`
|
|
127
|
+
* does: the credentials seam first (which itself layers the launch environment),
|
|
128
|
+
* then the raw launch environment when no seam is mounted.
|
|
129
|
+
* @param ctx - host plugin context.
|
|
130
|
+
* @param config - validated plugin configuration.
|
|
131
|
+
* @returns the secret key value.
|
|
132
|
+
*/
|
|
133
|
+
async function resolveApiKey(ctx, config) {
|
|
134
|
+
const ref = credentialRef(config.apiKeyEnv)
|
|
135
|
+
const credentials = ctx.get('credentials')
|
|
136
|
+
if (credentials !== undefined) {
|
|
137
|
+
const hit = await credentials.resolve(ref)
|
|
138
|
+
if (hit !== undefined && hit.value.length > 0) return hit.value
|
|
139
|
+
}
|
|
140
|
+
const ambient = launchEnvironmentOf(ctx).get(ref)
|
|
141
|
+
if (ambient !== undefined && ambient.value.length > 0) return ambient.value
|
|
142
|
+
throw taggedError(
|
|
143
|
+
'missing-credential',
|
|
144
|
+
`no API key behind ${String(ref)}; store it through the credentials service or export it before launching dsh`
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Read the account balance from the public DeepSeek API.
|
|
150
|
+
* @param ctx - host plugin context.
|
|
151
|
+
* @param config - validated plugin configuration.
|
|
152
|
+
* @returns the parsed balance document.
|
|
153
|
+
*/
|
|
154
|
+
async function fetchBalance(ctx, config) {
|
|
155
|
+
const key = await resolveApiKey(ctx, config)
|
|
156
|
+
const url = `${config.baseURL.replace(/\/+$/, '')}/user/balance`
|
|
157
|
+
const response = await fetch(url, {
|
|
158
|
+
method: 'GET',
|
|
159
|
+
headers: { authorization: `Bearer ${key}`, accept: 'application/json' },
|
|
160
|
+
signal: AbortSignal.timeout(config.timeoutMs)
|
|
161
|
+
})
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
const detail = await response.text().catch(() => '')
|
|
164
|
+
const code = response.status === 401 || response.status === 403 ? 'invalid-credential' : 'upstream-error'
|
|
165
|
+
throw taggedError(code, `DeepSeek answered HTTP ${response.status}${detail === '' ? '' : ` — ${detail.slice(0, 200)}`}`)
|
|
166
|
+
}
|
|
167
|
+
return await response.json()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Host plugin body: one cached, read-only balance route.
|
|
172
|
+
* @param ctx - host plugin context.
|
|
173
|
+
* @param config - validated plugin configuration.
|
|
174
|
+
*/
|
|
175
|
+
export function apply(ctx, config) {
|
|
176
|
+
// A malformed schedule is a configuration error, so fail here rather than
|
|
177
|
+
// answering every request with a broken pricing block.
|
|
178
|
+
const schedule = normalizeSchedule(config)
|
|
179
|
+
/** The maintained holiday data, refreshed in the background. */
|
|
180
|
+
const holidays = createHolidaySource({
|
|
181
|
+
urlTemplate: config.holidayURL,
|
|
182
|
+
ttlMs: config.holidayRefreshMs,
|
|
183
|
+
timeoutMs: config.timeoutMs,
|
|
184
|
+
log: (message) => ctx.logger?.warn?.(message)
|
|
185
|
+
})
|
|
186
|
+
/**
|
|
187
|
+
* The calendar years a reading can land in. holiday-cn files a date under the
|
|
188
|
+
* year of the State Council paper rather than the date, so December can depend
|
|
189
|
+
* on the next year's document and both must be present.
|
|
190
|
+
*/
|
|
191
|
+
const neededYears = () => {
|
|
192
|
+
const year = new Date().getUTCFullYear()
|
|
193
|
+
return [year, year + 1]
|
|
194
|
+
}
|
|
195
|
+
// Warm the cache at load time; a failure just leaves the bundled table in use.
|
|
196
|
+
if (holidays.stale(neededYears())) void holidays.refresh(neededYears())
|
|
197
|
+
|
|
198
|
+
/** Cache: last good payload, its timestamp, and the in-flight read. */
|
|
199
|
+
const cache = { payload: undefined, at: 0, inflight: undefined }
|
|
200
|
+
const ttl = Math.max(MIN_REFRESH_MS, config.refreshMs)
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Answer from cache, or re-read upstream. Concurrent callers share one read,
|
|
204
|
+
* and a failed read never replaces the last good payload.
|
|
205
|
+
* @param force - bypass the freshness window (still floored by MIN_REFRESH_MS).
|
|
206
|
+
* @returns the payload the route serializes.
|
|
207
|
+
*/
|
|
208
|
+
const read = async (force) => {
|
|
209
|
+
const age = Date.now() - cache.at
|
|
210
|
+
if (cache.payload !== undefined && age < (force ? MIN_REFRESH_MS : ttl)) return cache.payload
|
|
211
|
+
if (cache.inflight !== undefined) return cache.inflight
|
|
212
|
+
cache.inflight = (async () => {
|
|
213
|
+
try {
|
|
214
|
+
const balance = await fetchBalance(ctx, config)
|
|
215
|
+
cache.payload = { ok: true, balance, fetchedAt: Date.now() }
|
|
216
|
+
cache.at = Date.now()
|
|
217
|
+
return cache.payload
|
|
218
|
+
} catch (error) {
|
|
219
|
+
const code = typeof error?.code === 'string' ? error.code : 'request-failed'
|
|
220
|
+
const message = String(error?.message ?? error)
|
|
221
|
+
ctx.logger?.warn?.('usage-info: balance read failed (%s): %s', code, message)
|
|
222
|
+
return { ok: false, error: { code, message }, fetchedAt: Date.now() }
|
|
223
|
+
} finally {
|
|
224
|
+
cache.inflight = undefined
|
|
225
|
+
}
|
|
226
|
+
})()
|
|
227
|
+
return cache.inflight
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const route = {
|
|
231
|
+
kind: 'exact',
|
|
232
|
+
path: BALANCE_PATH,
|
|
233
|
+
handler: async (req, res) => {
|
|
234
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
235
|
+
res.setHeader('allow', 'GET, HEAD')
|
|
236
|
+
sendJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'use GET' } })
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
if (!hostAllowed(req.headers?.host, ctx.webServer?.host ?? '127.0.0.1')) {
|
|
240
|
+
sendJson(res, 403, { ok: false, error: { code: 'forbidden-host', message: 'this route answers loopback hosts only' } })
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
const force = new URL(req.url ?? '/', 'http://localhost').searchParams.get('refresh') === '1'
|
|
244
|
+
const payload = await read(force)
|
|
245
|
+
// Keep the holiday calendar warm without ever blocking a request on it: a
|
|
246
|
+
// stale snapshot is refreshed in the background, and this request answers
|
|
247
|
+
// from whatever is already cached (fetched data, else the bundled table).
|
|
248
|
+
const years = neededYears()
|
|
249
|
+
if (holidays.stale(years)) void holidays.refresh(years)
|
|
250
|
+
// The pricing window is arithmetic on the server clock, so it is read
|
|
251
|
+
// fresh here rather than cached with the upstream balance.
|
|
252
|
+
const body = { ...payload, pricing: readPricing(withHolidayData(schedule, holidays.peek()), Date.now()) }
|
|
253
|
+
if (req.method === 'HEAD') {
|
|
254
|
+
res.statusCode = 200
|
|
255
|
+
res.end()
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
sendJson(res, 200, body)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
ctx.effect(() => ctx.webServer.register(route), `usage-info: ${BALANCE_PATH}`)
|
|
263
|
+
}
|