diff --git a/package.json b/package.json index ba28e6d..7fee9c8 100644 --- a/package.json +++ b/package.json @@ -209,6 +209,23 @@ "%deepseek-copilot.config.debugMode.verbose.description%" ], "markdownDescription": "%deepseek-copilot.config.debugMode.description%" + }, + "deepseek-copilot.tariff.transitionAlerts": { + "type": "boolean", + "default": true, + "markdownDescription": "Show a visible warning when a DeepSeek tariff transition is within the configured threshold." + }, + "deepseek-copilot.tariff.transitionAudio": { + "type": "boolean", + "default": true, + "markdownDescription": "Play a system beep when a DeepSeek tariff transition warning triggers. Disable this if you do not want audio alerts." + }, + "deepseek-copilot.tariff.transitionWarningMinutes": { + "type": "number", + "default": 5, + "minimum": 1, + "maximum": 60, + "markdownDescription": "How many minutes before a tariff transition to raise the warning." } } } diff --git a/package.nls.json b/package.nls.json index 5f5a88f..7a30208 100644 --- a/package.nls.json +++ b/package.nls.json @@ -27,6 +27,9 @@ "deepseek-copilot.config.debugMode.metadata.description": "Privacy-safe metadata. Safe to share publicly. View with `DeepSeek: Show Logs`.", "deepseek-copilot.config.debugMode.verbose.label": "Verbose", "deepseek-copilot.config.debugMode.verbose.description": "⚠️ Contains sensitive prompt content. For local debugging only.", + "deepseek-copilot.config.tariff.transitionAlerts.description": "Show a visible warning when a DeepSeek tariff transition is within the configured threshold.", + "deepseek-copilot.config.tariff.transitionAudio.description": "Play a system beep when a DeepSeek tariff transition warning triggers.", + "deepseek-copilot.config.tariff.transitionWarningMinutes.description": "How many minutes before a tariff transition to raise the warning.", "deepseek-copilot.config.modelIdOverrides.description": "Override the API model ID sent for each DeepSeek model. Defaults are prefilled with official DeepSeek IDs; change them only when using a compatible third-party API that uses different model names.", "deepseek-copilot.config.modelIdOverrides.deepseek-flash.description": "API model ID for DeepSeek V4.1 Flash", "deepseek-copilot.config.modelIdOverrides.deepseek-v4-flash.description": "API model ID for DeepSeek V4 Flash", diff --git a/src/runtime/lifecycle.ts b/src/runtime/lifecycle.ts index cba87ae..f8b2725 100644 --- a/src/runtime/lifecycle.ts +++ b/src/runtime/lifecycle.ts @@ -1,17 +1,180 @@ +import { spawnSync } from 'node:child_process'; import vscode from 'vscode'; import { t } from '../i18n'; import { logger } from '../logger'; +import { + getDeepSeekTariffState, + getDeepSeekTariffStatusText, + getNextDeepSeekTariffTransition, + refreshDeepSeekTariffWindowsFromPricingPage, +} from '../tariff'; +import { + isChinesePublicHoliday, + refreshChinesePublicHolidaysFromWeb, +} from '../tariff-holidays'; import { registerActionUrls } from './actions'; import { registerCommands } from './commands'; import { initializeDiagnostics } from './diagnostics'; import { registerProvider } from './provider'; import { showWelcomeIfNeeded } from './welcome'; +let lastTransitionWarningKey: string | undefined; +let lastTransitionFiredKey: string | undefined; + +function playTariffWarningAudio(): void { + if (process.platform === 'win32') { + spawnSync('powershell', [ + '-NoProfile', + '-Command', + '[Console]::Beep(880, 180)', + ], { + stdio: 'ignore', + windowsHide: true, + }); + return; + } + process.stdout.write('\u0007'); +} + +function playTariffTransitionAudio(): void { + if (process.platform === 'win32') { + spawnSync('powershell', [ + '-NoProfile', + '-Command', + '[Console]::Beep(880, 220); Start-Sleep -Milliseconds 80; [Console]::Beep(1040, 260)', + ], { + stdio: 'ignore', + windowsHide: true, + }); + return; + } + process.stdout.write('\u0007\u0007'); +} + export async function activate(context: vscode.ExtensionContext): Promise { await initializeDiagnostics(context); registerCommands(context); registerActionUrls(context); + const tariffStatusItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 100, + ); + tariffStatusItem.name = 'DeepSeek Tariff'; + tariffStatusItem.command = 'deepseek-copilot.openSettings'; + context.subscriptions.push(tariffStatusItem); + + const updateTariffStatus = () => { + const now = new Date(); + const state = getDeepSeekTariffState(now); + const config = vscode.workspace.getConfiguration('deepseek-copilot'); + const warningThresholdMinutes = config.get('tariff.transitionWarningMinutes', 5); + const warningThresholdMs = Math.max(1, warningThresholdMinutes) * 60 * 1000; + const nextTransition = getNextDeepSeekTariffTransition(now); + const warningWindowActive = + nextTransition !== undefined && + nextTransition.remainingMs <= warningThresholdMs && + nextTransition.remainingMs > 0; + const transitionHappenedNow = + nextTransition !== undefined && nextTransition.remainingMs <= 1000 && nextTransition.remainingMs >= 0; + const warningPrefix = warningWindowActive ? '$(alert) ' : ''; + const statusLabel = state === 'peak' ? '$(flame)' : '$(pulse)'; + tariffStatusItem.text = `${warningPrefix}${statusLabel} DeepSeek: ${getDeepSeekTariffStatusText()}`; + tariffStatusItem.backgroundColor = warningWindowActive + ? new vscode.ThemeColor('statusBarItem.warningBackground') + : transitionHappenedNow + ? new vscode.ThemeColor('statusBarItem.prominentBackground') + : undefined; + tariffStatusItem.color = warningWindowActive + ? new vscode.ThemeColor('statusBarItem.warningForeground') + : transitionHappenedNow + ? new vscode.ThemeColor('statusBarItem.prominentForeground') + : undefined; + const offPeakReason = + state === 'offpeak' && isChinesePublicHoliday(now) ? ' (Chinese public holiday)' : ''; + tariffStatusItem.tooltip = `DeepSeek model tariff: ${state === 'peak' ? 'Peak pricing is active (2x)' : 'Off-peak pricing is active (1/2 price)'}${offPeakReason} for the selected DeepSeek model.`; + tariffStatusItem.show(); + + if (!warningWindowActive && !transitionHappenedNow) { + lastTransitionWarningKey = undefined; + lastTransitionFiredKey = undefined; + return; + } + + const key = `${nextTransition.at.toISOString()}-${nextTransition.from}->${nextTransition.to}`; + if (warningWindowActive) { + if (lastTransitionWarningKey === key) { + return; + } + lastTransitionWarningKey = key; + const alertsEnabled = config.get('tariff.transitionAlerts', true); + if (!alertsEnabled) { + return; + } + const audioEnabled = config.get('tariff.transitionAudio', true); + const direction = nextTransition.to === 'peak' ? 'on-peak' : 'off-peak'; + const remainingMinutes = Math.max(1, Math.ceil(nextTransition.remainingMs / 60000)); + void vscode.window.showWarningMessage( + `DeepSeek tariff change in ${remainingMinutes} minute${remainingMinutes === 1 ? '' : 's'}: ${direction} begins at ${nextTransition.at.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'UTC', hour12: false })} UTC.`, + ); + if (audioEnabled) { + playTariffWarningAudio(); + } + return; + } + + if (transitionHappenedNow && lastTransitionFiredKey !== key) { + lastTransitionFiredKey = key; + const alertsEnabled = config.get('tariff.transitionAlerts', true); + const audioEnabled = config.get('tariff.transitionAudio', true); + const direction = nextTransition.to === 'peak' ? 'on-peak' : 'off-peak'; + if (alertsEnabled) { + void vscode.window.showInformationMessage( + `DeepSeek tariff transition: ${direction} has started. Current mode is ${nextTransition.to.toUpperCase()}.`, + ); + } + if (audioEnabled) { + playTariffTransitionAudio(); + } + } + }; + + updateTariffStatus(); + const timer = setInterval(updateTariffStatus, 1000); + context.subscriptions.push({ dispose: () => clearInterval(timer) }); + + const refreshTariffSchedule = () => + void refreshDeepSeekTariffWindowsFromPricingPage( + 'https://api-docs.deepseek.com/quick_start/pricing', + context.globalState, + ).then((windows) => { + const previous = context.globalState.get<{ windows: typeof windows; footnote?: string }>( + 'deepseek-copilot.tariff.schedule', + ); + if (previous && previous.windows && previous.windows.length > 0) { + const changed = !previous.windows.every((window, index) => { + const next = windows[index]; + return next && window.startHourUtc === next.startHourUtc && window.endHourUtc === next.endHourUtc; + }); + if (changed) { + logger.info(`DeepSeek tariff schedule changed, refreshed windows=${JSON.stringify(windows)}`); + } + } + }).catch((error) => { + logger.warn('Failed to refresh DeepSeek tariff schedule from pricing page', error); + }); + refreshTariffSchedule(); + const tariffRefreshTimer = setInterval(refreshTariffSchedule, 60 * 60 * 1000); + context.subscriptions.push({ dispose: () => clearInterval(tariffRefreshTimer) }); + + const refreshHolidaySchedule = () => + void refreshChinesePublicHolidaysFromWeb(context.globalState).then((dates) => { + logger.info(`Chinese public holiday calendar refreshed, dates=${dates.length}`); + }); + refreshHolidaySchedule(); + const holidayRefreshTimer = setInterval(refreshHolidaySchedule, 24 * 60 * 60 * 1000); + context.subscriptions.push({ dispose: () => clearInterval(holidayRefreshTimer) }); + try { const provider = registerProvider(context); diff --git a/src/tariff-holidays.test.ts b/src/tariff-holidays.test.ts new file mode 100644 index 0000000..c2fc589 --- /dev/null +++ b/src/tariff-holidays.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import { after, describe, it } from 'node:test'; + +import { + type TariffScheduleStorage, + getBeijingDateKey, + getChineseHolidayScheduleSnapshot, + getChinesePublicHolidays, + isChinesePublicHoliday, + parseChineseHolidayDataset, + refreshChinesePublicHolidaysFromWeb, + setChinesePublicHolidays, +} from './tariff-holidays'; + +function createStorage(): TariffScheduleStorage { + const values = new Map(); + return { + get: (key: string): T | undefined => values.get(key) as T | undefined, + update: async (key: string, value: T): Promise => { + values.set(key, value); + }, + }; +} + +function stubFetch(datasets: Record): typeof fetch { + return (async (input: string | URL | Request) => { + const url = input.toString(); + const year = /(\d{4})\.json$/.exec(url)?.[1] ?? ''; + const dates = datasets[year]; + if (!dates) { + return new Response('not found', { status: 404 }); + } + + const days = dates.map((date) => ({ name: 'holiday', date, isOffDay: true })); + return new Response(JSON.stringify({ year: Number(year), days }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; +} + +function failingFetch(): typeof fetch { + return (async () => { + throw new Error('offline'); + }) as unknown as typeof fetch; +} + +describe('DeepSeek tariff holiday calendar', () => { + const builtIn = [...getChinesePublicHolidays()]; + after(() => { + setChinesePublicHolidays(builtIn); + }); + + it('resolves the Beijing calendar day', () => { + assert.equal(getBeijingDateKey(new Date('2026-09-30T16:00:00Z')), '2026-10-01'); + assert.equal(getBeijingDateKey(new Date('2026-09-30T15:59:00Z')), '2026-09-30'); + }); + + it('recognizes built-in holiday dates', () => { + assert.equal(isChinesePublicHoliday(new Date('2026-02-18T12:00:00Z')), true); + assert.equal(isChinesePublicHoliday(new Date('2026-03-18T12:00:00Z')), false); + }); + + it('keeps only off days from a published dataset', () => { + assert.deepEqual( + parseChineseHolidayDataset({ + year: 2026, + days: [ + { name: '国庆节', date: '2026-10-02', isOffDay: true }, + { name: '调休', date: '2026-10-10', isOffDay: false }, + { name: 'bad date', date: 'nope', isOffDay: true }, + { date: '2026-10-01', isOffDay: true }, + ], + }), + ['2026-10-01', '2026-10-02'], + ); + assert.deepEqual(parseChineseHolidayDataset(null), []); + assert.deepEqual(parseChineseHolidayDataset({ days: 'nope' }), []); + assert.deepEqual(parseChineseHolidayDataset({ days: [null, 42] }), []); + }); + + it('replaces a built-in year with the fetched dataset', async () => { + const storage = createStorage(); + const dates = await refreshChinesePublicHolidaysFromWeb( + storage, + new Date('2027-01-05T00:00:00Z'), + stubFetch({ 2026: ['2026-01-01'], 2027: ['2027-02-06', '2027-02-07'] }), + ); + + assert.ok(dates.includes('2027-02-06')); + assert.ok(!dates.includes('2026-10-01')); + assert.equal(isChinesePublicHoliday(new Date('2027-02-06T02:30:00Z')), true); + assert.deepEqual(getChineseHolidayScheduleSnapshot(storage)?.dates, dates); + }); + + it('falls back to the cached calendar when every request fails', async () => { + const storage = createStorage(); + await refreshChinesePublicHolidaysFromWeb( + storage, + new Date('2028-01-05T00:00:00Z'), + stubFetch({ 2028: ['2028-05-01'] }), + ); + + const dates = await refreshChinesePublicHolidaysFromWeb( + storage, + new Date('2028-01-05T00:00:00Z'), + failingFetch(), + ); + + assert.ok(dates.includes('2028-05-01')); + assert.equal(isChinesePublicHoliday(new Date('2028-05-01T02:30:00Z')), true); + }); +}); diff --git a/src/tariff-holidays.ts b/src/tariff-holidays.ts new file mode 100644 index 0000000..b6b0e42 --- /dev/null +++ b/src/tariff-holidays.ts @@ -0,0 +1,245 @@ +/** + * Chinese public holidays, which the DeepSeek pricing page excludes from peak + * billing hours. + * + * Pricing page footnote, retrieved 2026-09-21: "Off-peak rates are half of the + * peak rates. Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through + * Friday, excluding Chinese public holidays. All other hours are off-peak, + * including weekends and Chinese public holidays in full." + * + * A holiday is a Beijing calendar day, and both peak windows (01:00-04:00 and + * 06:00-10:00 UTC) fall inside a single Beijing day, so the Beijing date is the + * one to test. + * + * Makeup workdays are deliberately ignored. The page keeps weekends off-peak in + * full, and every published makeup workday is a weekend day, so the weekend rule + * already reports them as off-peak. + * + * The built-in list is a fallback for offline use. The published arrangement is + * fetched so later years work without a code change. + */ + +/** Application-wide memento surface used by the tariff modules. */ +export interface TariffScheduleStorage { + get(key: string): T | undefined; + update(key: string, value: T): Thenable; +} + +export interface ChineseHolidayScheduleSnapshot { + readonly dates: readonly string[]; + readonly updatedAt: number; +} + +const HOLIDAY_SCHEDULE_KEY = 'deepseek-copilot.tariff.holidays'; + +/** + * Community mirror of the State Council holiday arrangement. Each file lists + * every day of that year with `isOffDay`, so makeup workdays are visible too. + */ +const HOLIDAY_DATASET_BASE_URL = 'https://raw.githubusercontent.com/NateScarlet/holiday-cn/master'; + +const BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +/** Published holiday days per year. A year fetched at runtime replaces its entry. */ +const BUILT_IN_HOLIDAYS: Readonly> = { + '2025': [ + '2025-01-01', + '2025-01-28', + '2025-01-29', + '2025-01-30', + '2025-01-31', + '2025-02-01', + '2025-02-02', + '2025-02-03', + '2025-02-04', + '2025-04-04', + '2025-04-05', + '2025-04-06', + '2025-05-01', + '2025-05-02', + '2025-05-03', + '2025-05-04', + '2025-05-05', + '2025-05-31', + '2025-06-01', + '2025-06-02', + '2025-10-01', + '2025-10-02', + '2025-10-03', + '2025-10-04', + '2025-10-05', + '2025-10-06', + '2025-10-07', + '2025-10-08', + ], + '2026': [ + '2026-01-01', + '2026-01-02', + '2026-01-03', + '2026-02-15', + '2026-02-16', + '2026-02-17', + '2026-02-18', + '2026-02-19', + '2026-02-20', + '2026-02-21', + '2026-02-22', + '2026-02-23', + '2026-04-04', + '2026-04-05', + '2026-04-06', + '2026-05-01', + '2026-05-02', + '2026-05-03', + '2026-05-04', + '2026-05-05', + '2026-06-19', + '2026-06-20', + '2026-06-21', + '2026-09-25', + '2026-09-26', + '2026-09-27', + '2026-10-01', + '2026-10-02', + '2026-10-03', + '2026-10-04', + '2026-10-05', + '2026-10-06', + '2026-10-07', + ], +}; + +let activeHolidays: ReadonlySet = new Set(mergeHolidayYears(BUILT_IN_HOLIDAYS, new Map())); + +export function getChinesePublicHolidays(): ReadonlySet { + return activeHolidays; +} + +export function setChinesePublicHolidays(dates: Iterable): void { + const valid = [...dates].filter((date) => DATE_PATTERN.test(date)); + if (valid.length === 0) { + return; + } + activeHolidays = new Set(valid); +} + +/** Calendar day in Beijing, the timezone the published holiday arrangement uses. */ +export function getBeijingDateKey(date: Date): string { + return new Date(date.getTime() + BEIJING_OFFSET_MS).toISOString().slice(0, 10); +} + +export function isChinesePublicHoliday(date: Date): boolean { + return activeHolidays.has(getBeijingDateKey(date)); +} + +/** Reads the `days[]` array of a holiday-cn dataset, keeping off days only. */ +export function parseChineseHolidayDataset(payload: unknown): string[] { + if (typeof payload !== 'object' || payload === null) { + return []; + } + + const days = (payload as { days?: unknown }).days; + if (!Array.isArray(days)) { + return []; + } + + const dates: string[] = []; + for (const day of days) { + if (typeof day !== 'object' || day === null) { + continue; + } + const { date, isOffDay } = day as { date?: unknown; isOffDay?: unknown }; + if (isOffDay === true && typeof date === 'string' && DATE_PATTERN.test(date)) { + dates.push(date); + } + } + + return dates.sort(); +} + +export function getChineseHolidayScheduleSnapshot( + storage?: TariffScheduleStorage, +): ChineseHolidayScheduleSnapshot | undefined { + return storage?.get(HOLIDAY_SCHEDULE_KEY); +} + +/** + * Fetches last, current, and next year's arrangement and caches the result. + * + * Individual years that cannot be fetched keep their cached or built-in dates, + * so a failed request never makes the schedule less accurate than it already is. + */ +export async function refreshChinesePublicHolidaysFromWeb( + storage?: TariffScheduleStorage, + now: Date = new Date(), + fetchImpl: typeof fetch = fetch, +): Promise { + const thisYear = now.getUTCFullYear(); + const fetched = new Map(); + + await Promise.all( + [thisYear - 1, thisYear, thisYear + 1].map(async (year) => { + try { + const response = await fetchImpl(`${HOLIDAY_DATASET_BASE_URL}/${year}.json`, { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + return; + } + const dates = parseChineseHolidayDataset(await response.json()); + if (dates.length > 0) { + fetched.set(String(year), dates); + } + } catch { + // Keep the cached or built-in dates for this year. + } + }), + ); + + const dates = + fetched.size > 0 + ? mergeHolidayYears(BUILT_IN_HOLIDAYS, fetched) + : mergeHolidayYears(BUILT_IN_HOLIDAYS, cachedHolidayYears(storage)); + + setChinesePublicHolidays(dates); + if (fetched.size > 0 && storage) { + void storage.update(HOLIDAY_SCHEDULE_KEY, { dates, updatedAt: Date.now() }); + } + + return dates; +} + +function cachedHolidayYears( + storage?: TariffScheduleStorage, +): ReadonlyMap { + const snapshot = getChineseHolidayScheduleSnapshot(storage); + const years = new Map(); + if (!snapshot) { + return years; + } + + for (const date of snapshot.dates) { + const year = date.slice(0, 4); + years.set(year, [...(years.get(year) ?? []), date]); + } + + return years; +} + +function mergeHolidayYears( + builtIn: Readonly>, + fetched: ReadonlyMap, +): string[] { + const dates = new Set(); + + for (const year of new Set([...Object.keys(builtIn), ...fetched.keys()])) { + for (const date of fetched.get(year) ?? builtIn[year] ?? []) { + if (DATE_PATTERN.test(date)) { + dates.add(date); + } + } + } + + return [...dates].sort(); +} diff --git a/src/tariff.test.ts b/src/tariff.test.ts new file mode 100644 index 0000000..2da6415 --- /dev/null +++ b/src/tariff.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + getDeepSeekTariffState, + getDeepSeekTariffWindowsFromPricingFootnote, + getNextDeepSeekTariffTransition, + hasDeepSeekTariffScheduleChanged, +} from './tariff'; + +describe('DeepSeek tariff logic', () => { + it('marks weekdays peak windows as 2x pricing', () => { + const peak = new Date('2026-08-24T02:30:00Z'); + assert.equal(getDeepSeekTariffState(peak), 'peak'); + }); + + it('marks non-peak times as half price', () => { + const offPeak = new Date('2026-08-24T05:00:00Z'); + assert.equal(getDeepSeekTariffState(offPeak), 'offpeak'); + }); + + it('treats Chinese public holidays as off-peak on weekdays', () => { + // 2026-09-25 (Friday) and 2026-10-01 (Thursday) are both inside peak windows. + assert.equal(getDeepSeekTariffState(new Date('2026-09-25T02:30:00Z')), 'offpeak'); + assert.equal(getDeepSeekTariffState(new Date('2026-10-01T07:00:00Z')), 'offpeak'); + }); + + it('keeps the weekday schedule unchanged outside holidays', () => { + assert.equal(getDeepSeekTariffState(new Date('2026-09-24T02:30:00Z')), 'peak'); + // 2026-09-20 is a published makeup workday, but it is a Sunday, and the + // pricing page keeps weekends off-peak in full. + assert.equal(getDeepSeekTariffState(new Date('2026-09-20T02:30:00Z')), 'offpeak'); + }); + + it('skips holiday and weekend days when finding the next transition', () => { + const next = getNextDeepSeekTariffTransition(new Date('2026-09-25T03:30:00Z')); + assert.ok(next); + assert.equal(next.to, 'peak'); + assert.equal(next.at.toISOString(), '2026-09-28T01:00:00.000Z'); + }); + + it('finds the next transition for a state change', () => { + const now = new Date('2026-08-24T03:30:00Z'); + const next = getNextDeepSeekTariffTransition(now); + assert.ok(next); + assert.equal(next.to, 'offpeak'); + assert.equal(next.at.getUTCHours(), 4); + }); + + it('parses the pricing page footnote window schedule', () => { + const windows = getDeepSeekTariffWindowsFromPricingFootnote( + '(1) Off-peak rates are half of the peak rates. Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday (all other hours are off-peak).', + ); + assert.deepEqual(windows, [ + { startHourUtc: 1, endHourUtc: 4 }, + { startHourUtc: 6, endHourUtc: 10 }, + ]); + }); + + it('parses the pricing page when the note appears elsewhere on the page', () => { + const windows = getDeepSeekTariffWindowsFromPricingFootnote( + 'Pricing details box\nImportant note: Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday (all other hours are off-peak).\nMore pricing tables below.', + ); + assert.deepEqual(windows, [ + { startHourUtc: 1, endHourUtc: 4 }, + { startHourUtc: 6, endHourUtc: 10 }, + ]); + }); + + it('parses the current live pricing page footnote', () => { + const windows = getDeepSeekTariffWindowsFromPricingFootnote( + '(2) Off-peak rates are half of the peak rates. Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese public holidays. All other hours are off-peak, including weekends and Chinese public holidays in full.', + ); + assert.deepEqual(windows, [ + { startHourUtc: 1, endHourUtc: 4 }, + { startHourUtc: 6, endHourUtc: 10 }, + ]); + }); + + it('matches the built-in schedule so no false change is reported', () => { + assert.equal( + hasDeepSeekTariffScheduleChanged( + '(2) Off-peak rates are half of the peak rates. Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese public holidays. All other hours are off-peak, including weekends and Chinese public holidays in full.', + ), + false, + ); + }); + + it('detects when the pricing website schedule changes from the footnote', () => { + assert.equal( + hasDeepSeekTariffScheduleChanged( + '(1) Off-peak rates are half of the peak rates. Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday (all other hours are off-peak).', + ), + false, + ); + assert.equal( + hasDeepSeekTariffScheduleChanged( + '(1) Off-peak rates are half of the peak rates. Peak hours are 00:00 - 03:00 and 05:00 - 09:00 UTC, Monday through Friday (all other hours are off-peak).', + ), + true, + ); + }); +}); diff --git a/src/tariff.ts b/src/tariff.ts new file mode 100644 index 0000000..33a0cb9 --- /dev/null +++ b/src/tariff.ts @@ -0,0 +1,294 @@ +import { isChinesePublicHoliday, type TariffScheduleStorage } from './tariff-holidays'; + +export type DeepSeekTariffState = 'peak' | 'offpeak'; + +export interface DeepSeekTariffWindow { + readonly startHourUtc: number; + readonly endHourUtc: number; +} + +export interface DeepSeekTariffTransition { + readonly from: DeepSeekTariffState; + readonly to: DeepSeekTariffState; + readonly at: Date; + readonly remainingMs: number; +} + +/** + * Fallback schedule used until the pricing page has been fetched. + * + * Pricing page footnote, retrieved 2026-09-21: "Peak hours are 01:00 - 04:00 + * and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese public + * holidays. All other hours are off-peak, including weekends and Chinese public + * holidays in full." + * + * The hours and the weekday rule are unchanged, and off-peak is still half of + * the peak rate. Chinese public holidays are off-peak in full, so a holiday that + * falls on a weekday reports as off-peak here as well. + */ +const DEFAULT_PEAK_WINDOWS: readonly DeepSeekTariffWindow[] = [ + { startHourUtc: 1, endHourUtc: 4 }, + { startHourUtc: 6, endHourUtc: 10 }, +] as const; + +export interface DeepSeekTariffScheduleSnapshot { + readonly windows: readonly DeepSeekTariffWindow[]; + readonly footnote: string; + readonly updatedAt: number; +} + +const DEEPSEEK_TARIFF_SCHEDULE_KEY = 'deepseek-copilot.tariff.schedule'; + +let activePeakWindows: readonly DeepSeekTariffWindow[] = DEFAULT_PEAK_WINDOWS; + +export function getDeepSeekTariffWindows(): readonly DeepSeekTariffWindow[] { + return activePeakWindows; +} + +export function setDeepSeekTariffWindows(windows: readonly DeepSeekTariffWindow[]): void { + if (windows.length === 0) { + return; + } + activePeakWindows = [...windows].sort((a, b) => a.startHourUtc - b.startHourUtc); +} + +export function getDeepSeekTariffWindowsFromPricingFootnote( + text: string | undefined | null, +): DeepSeekTariffWindow[] { + if (!text) { + return []; + } + + const normalized = text.replace(/\s+/g, ' ').trim(); + const match = normalized.match( + /peak hours are\s+(.+?)(?:\s+utc|$)/i, + ); + if (!match) { + return []; + } + + const ranges = match[1].matchAll(/(\d{1,2})(?::(\d{2}))?\s*-\s*(\d{1,2})(?::(\d{2}))?/g); + const windows: DeepSeekTariffWindow[] = []; + for (const range of ranges) { + const startHour = parseHourUtc(range[1], range[2]); + const endHour = parseHourUtc(range[3], range[4]); + if (Number.isFinite(startHour) && Number.isFinite(endHour)) { + windows.push({ + startHourUtc: startHour, + endHourUtc: endHour, + }); + } + } + + return windows.sort((a, b) => a.startHourUtc - b.startHourUtc); +} + +export function hasDeepSeekTariffScheduleChanged(text: string | undefined | null): boolean { + const parsed = getDeepSeekTariffWindowsFromPricingFootnote(text); + if (parsed.length === 0) { + return false; + } + + return !isDeepSeekTariffWindowListEqual(parsed, getDeepSeekTariffWindows()); +} + +function parseHourUtc(hourText: string | undefined, minuteText: string | undefined): number { + const hour = Number.parseInt(hourText ?? '', 10); + if (!Number.isFinite(hour) || hour < 0 || hour > 23) { + return Number.NaN; + } + + const minute = Number.parseInt(minuteText ?? '', 10); + if (minuteText !== undefined && (!Number.isFinite(minute) || minute < 0 || minute > 59)) { + return Number.NaN; + } + + return hour + (minuteText === undefined ? 0 : minute / 60); +} + +function isDeepSeekTariffWindowListEqual( + left: readonly DeepSeekTariffWindow[], + right: readonly DeepSeekTariffWindow[], +): boolean { + if (left.length !== right.length) { + return false; + } + + for (let index = 0; index < left.length; index += 1) { + if ( + left[index].startHourUtc !== right[index].startHourUtc || + left[index].endHourUtc !== right[index].endHourUtc + ) { + return false; + } + } + + return true; +} + +function isWeekendUtc(date: Date): boolean { + const day = date.getUTCDay(); + return day === 0 || day === 6; +} + +function getUtcHourFraction(date: Date): number { + return date.getUTCHours() + date.getUTCMinutes() / 60 + date.getUTCSeconds() / 3600; +} + +export function getDeepSeekTariffState(date: Date): DeepSeekTariffState { + if (isWeekendUtc(date) || isChinesePublicHoliday(date)) { + return 'offpeak'; + } + + const windows = getDeepSeekTariffWindows(); + const fraction = getUtcHourFraction(date); + const isPeak = windows.some((window) => fraction >= window.startHourUtc && fraction < window.endHourUtc); + return isPeak ? 'peak' : 'offpeak'; +} + +export function getNextDeepSeekTariffTransition(now: Date): DeepSeekTariffTransition | undefined { + const candidates: Date[] = []; + const startOfDay = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), + ); + const windows = getDeepSeekTariffWindows(); + + for (let dayOffset = 0; dayOffset <= 10; dayOffset += 1) { + const day = new Date(startOfDay.getTime() + dayOffset * 24 * 60 * 60 * 1000); + candidates.push(new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate(), 0, 0, 0))); + for (const window of windows) { + candidates.push( + new Date( + Date.UTC( + day.getUTCFullYear(), + day.getUTCMonth(), + day.getUTCDate(), + window.startHourUtc, + 0, + 0, + ), + ), + ); + candidates.push( + new Date( + Date.UTC( + day.getUTCFullYear(), + day.getUTCMonth(), + day.getUTCDate(), + window.endHourUtc, + 0, + 0, + ), + ), + ); + } + } + + for (const candidate of [...candidates] + .filter((item) => item.getTime() > now.getTime()) + .sort((a, b) => a.getTime() - b.getTime())) { + const before = getDeepSeekTariffState(new Date(candidate.getTime() - 60_000)); + const after = getDeepSeekTariffState(new Date(candidate.getTime() + 60_000)); + if (before !== after) { + return { + from: before, + to: after, + at: candidate, + remainingMs: Math.max(candidate.getTime() - now.getTime(), 0), + }; + } + } + + return undefined; +} + +export function formatDeepSeekTariffRemaining(ms: number): string { + const totalSeconds = Math.max(0, Math.ceil(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return `${hours}h ${minutes}m ${seconds}s`; +} + +export function getDeepSeekTariffStatusText(now: Date = new Date()): string { + const state = getDeepSeekTariffState(now); + const nextTransition = getNextDeepSeekTariffTransition(now); + const billing = state === 'peak' ? '2x price' : '1/2 price'; + const countdown = nextTransition ? formatDeepSeekTariffRemaining(nextTransition.remainingMs) : '—'; + return `${state === 'peak' ? 'PEAK' : 'OFF-PEAK'} (${billing}) • ${countdown}`; +} + +export async function refreshDeepSeekTariffWindowsFromPricingPage( + pageUrl: string = 'https://api-docs.deepseek.com/quick_start/pricing', + storage?: TariffScheduleStorage, +): Promise { + try { + const response = await fetch(pageUrl, { + headers: { + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const html = await response.text(); + const footnote = extractPricingFootnote(html); + const parsed = getDeepSeekTariffWindowsFromPricingFootnote(footnote); + const snapshot = storage?.get(DEEPSEEK_TARIFF_SCHEDULE_KEY); + if (snapshot && snapshot.footnote && snapshot.windows.length > 0) { + if (!isDeepSeekTariffWindowListEqual(snapshot.windows, parsed)) { + setDeepSeekTariffWindows(parsed); + } + } + if (parsed.length > 0 && hasDeepSeekTariffScheduleChanged(footnote)) { + setDeepSeekTariffWindows(parsed); + } + if (storage && footnote && parsed.length > 0) { + void storage.update(DEEPSEEK_TARIFF_SCHEDULE_KEY, { + windows: parsed, + footnote, + updatedAt: Date.now(), + }); + } + return getDeepSeekTariffWindows(); + } catch { + if (storage) { + const snapshot = storage.get(DEEPSEEK_TARIFF_SCHEDULE_KEY); + if (snapshot && snapshot.windows.length > 0) { + setDeepSeekTariffWindows(snapshot.windows); + } + } + return getDeepSeekTariffWindows(); + } +} + +export function getDeepSeekTariffScheduleSnapshot( + storage?: TariffScheduleStorage, +): DeepSeekTariffScheduleSnapshot | undefined { + return storage?.get(DEEPSEEK_TARIFF_SCHEDULE_KEY); +} + +function extractPricingFootnote(html: string): string | undefined { + const text = html.replace(//gi, ' '); + const body = text.replace(//gi, ' '); + const plainText = body + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/'/gi, "'") + .replace(/\s+/g, ' ') + .trim(); + + const markerIndex = plainText.search(/peak\s+hours?\s+are/i); + if (markerIndex === -1) { + return undefined; + } + + const fromMarker = plainText.slice(markerIndex); + const sentenceMatch = fromMarker.match(/peak\s+hours?\s+are\s+([^\n]+?)(?:\.|\)|\]|$)/i); + if (!sentenceMatch) { + return undefined; + } + + return sentenceMatch[0]; +}