diff --git a/package.json b/package.json index 417a5da..3bcde78 100644 --- a/package.json +++ b/package.json @@ -192,6 +192,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 af9acdb..30909e9 100644 --- a/package.nls.json +++ b/package.nls.json @@ -25,6 +25,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-v4-flash.description": "API model ID for DeepSeek V4 Flash", "deepseek-copilot.config.modelIdOverrides.deepseek-v4-pro.description": "API model ID for DeepSeek V4 Pro", diff --git a/src/runtime/lifecycle.ts b/src/runtime/lifecycle.ts index 707aa0c..f2bb53d 100644 --- a/src/runtime/lifecycle.ts +++ b/src/runtime/lifecycle.ts @@ -1,7 +1,14 @@ +import { spawnSync } from 'node:child_process'; import vscode from 'vscode'; import { t } from '../i18n'; import { logger } from '../logger'; import { DeepSeekChatProvider } from '../provider'; +import { + getDeepSeekTariffState, + getDeepSeekTariffStatusText, + getNextDeepSeekTariffTransition, + refreshDeepSeekTariffWindowsFromPricingPage, +} from '../tariff'; import { registerActionUrls } from './actions'; import { registerCommands } from './commands'; import { initializeDiagnostics } from './diagnostics'; @@ -9,12 +16,153 @@ import { registerProvider } from './provider'; import { showWelcomeIfNeeded } from './welcome'; let activeProvider: DeepSeekChatProvider | undefined; +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; + tariffStatusItem.tooltip = `DeepSeek model tariff: ${state === 'peak' ? 'Peak pricing is active (2x)' : 'Off-peak pricing is active (1/2 price)'} 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) }); + try { const provider = await registerProvider(context); activeProvider = provider; diff --git a/src/tariff.test.ts b/src/tariff.test.ts new file mode 100644 index 0000000..5afeb65 --- /dev/null +++ b/src/tariff.test.ts @@ -0,0 +1,64 @@ +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('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('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..add5feb --- /dev/null +++ b/src/tariff.ts @@ -0,0 +1,291 @@ +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; +} + +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; +} + +function isInPeakWindow(hour: number): boolean { + return getDeepSeekTariffWindows().some( + (window) => hour >= window.startHourUtc && hour < window.endHourUtc, + ); +} + +export function getDeepSeekTariffState(date: Date): DeepSeekTariffState { + if (isWeekendUtc(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?: { + get(key: string): T | undefined; + update(key: string, value: T): Thenable; + }, +): 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?: { + get(key: string): T | undefined; + }, +): 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]; +}