From 247a6616cf2818fd4521dbf8705ce4c89253be0b Mon Sep 17 00:00:00 2001 From: Lance Vermilion Date: Sun, 23 Aug 2026 23:09:27 -0700 Subject: [PATCH 1/6] Added onpeak/offpeak tarrifs with visual/audio warnings. Added settings to enable/disable/tune them. --- package.json | 17 +++ package.nls.json | 3 + src/runtime/lifecycle.ts | 148 ++++++++++++++++++++ src/tariff.test.ts | 64 +++++++++ src/tariff.ts | 291 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 523 insertions(+) create mode 100644 src/tariff.test.ts create mode 100644 src/tariff.ts diff --git a/package.json b/package.json index ff483aa..d99bd37 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]; +} From 713aec58b8be51b4f203a5a668be9a82b3d4bbcc Mon Sep 17 00:00:00 2001 From: Lance Vermilion Date: Mon, 21 Sep 2026 22:50:12 -0700 Subject: [PATCH 2/6] chore(tariff): remove unused isInPeakWindow helper --- src/tariff.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/tariff.ts b/src/tariff.ts index add5feb..5c6e372 100644 --- a/src/tariff.ts +++ b/src/tariff.ts @@ -121,12 +121,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'; @@ -276,7 +270,7 @@ function extractPricingFootnote(html: string): string | undefined { .replace(/\s+/g, ' ') .trim(); - const markerIndex = plainText.search(/peak\s+hours?\s+are/i); + const markerIndex = plainText.search(/peak\s+hours?\s+are/i); if (markerIndex === -1) { return undefined; } From db59a285ba3939d285c402d72b3a6ba0e9e7de98 Mon Sep 17 00:00:00 2001 From: Lance Vermilion Date: Mon, 21 Sep 2026 22:53:57 -0700 Subject: [PATCH 3/6] test(tariff): pin the current pricing page footnote wording --- src/tariff.test.ts | 19 +++++++++++++++++++ src/tariff.ts | 12 ++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/tariff.test.ts b/src/tariff.test.ts index 5afeb65..dc394c0 100644 --- a/src/tariff.test.ts +++ b/src/tariff.test.ts @@ -47,6 +47,25 @@ describe('DeepSeek tariff logic', () => { ]); }); + 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( diff --git a/src/tariff.ts b/src/tariff.ts index 5c6e372..097244a 100644 --- a/src/tariff.ts +++ b/src/tariff.ts @@ -12,6 +12,18 @@ export interface DeepSeekTariffTransition { 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 not tracked here, so a holiday that + * falls on a weekday reports as peak even though the page bills it as off-peak. + */ const DEFAULT_PEAK_WINDOWS: readonly DeepSeekTariffWindow[] = [ { startHourUtc: 1, endHourUtc: 4 }, { startHourUtc: 6, endHourUtc: 10 }, From f3d1a07ccd7766388d96a08ed305cd1777ac6145 Mon Sep 17 00:00:00 2001 From: Lance Vermilion Date: Mon, 21 Sep 2026 22:59:40 -0700 Subject: [PATCH 4/6] feat(tariff): match the pricing page holiday rule The pricing page excludes Chinese public holidays from peak hours and bills them as off-peak in full. Track the published Beijing-calendar arrangement, refreshed daily and cached, with a built-in fallback for offline use. --- src/runtime/lifecycle.ts | 16 ++- src/tariff-holidays.test.ts | 113 +++++++++++++++++ src/tariff-holidays.ts | 245 ++++++++++++++++++++++++++++++++++++ src/tariff.test.ts | 20 +++ src/tariff.ts | 17 ++- 5 files changed, 400 insertions(+), 11 deletions(-) create mode 100644 src/tariff-holidays.test.ts create mode 100644 src/tariff-holidays.ts diff --git a/src/runtime/lifecycle.ts b/src/runtime/lifecycle.ts index 205f94d..cdef9d6 100644 --- a/src/runtime/lifecycle.ts +++ b/src/runtime/lifecycle.ts @@ -8,6 +8,10 @@ import { getNextDeepSeekTariffTransition, refreshDeepSeekTariffWindowsFromPricingPage, } from '../tariff'; +import { + isChinesePublicHoliday, + refreshChinesePublicHolidaysFromWeb, +} from '../tariff-holidays'; import { registerActionUrls } from './actions'; import { registerCommands } from './commands'; import { initializeDiagnostics } from './diagnostics'; @@ -86,7 +90,9 @@ export async function activate(context: vscode.ExtensionContext): Promise : 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.`; + 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) { @@ -161,6 +167,14 @@ export async function activate(context: vscode.ExtensionContext): Promise 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..73465f9 --- /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 index dc394c0..07539b2 100644 --- a/src/tariff.test.ts +++ b/src/tariff.test.ts @@ -19,6 +19,26 @@ describe('DeepSeek tariff logic', () => { 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); diff --git a/src/tariff.ts b/src/tariff.ts index 097244a..33a0cb9 100644 --- a/src/tariff.ts +++ b/src/tariff.ts @@ -1,3 +1,5 @@ +import { isChinesePublicHoliday, type TariffScheduleStorage } from './tariff-holidays'; + export type DeepSeekTariffState = 'peak' | 'offpeak'; export interface DeepSeekTariffWindow { @@ -21,8 +23,8 @@ export interface DeepSeekTariffTransition { * 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 not tracked here, so a holiday that - * falls on a weekday reports as peak even though the page bills it as off-peak. + * 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 }, @@ -134,7 +136,7 @@ function getUtcHourFraction(date: Date): number { } export function getDeepSeekTariffState(date: Date): DeepSeekTariffState { - if (isWeekendUtc(date)) { + if (isWeekendUtc(date) || isChinesePublicHoliday(date)) { return 'offpeak'; } @@ -218,10 +220,7 @@ export function getDeepSeekTariffStatusText(now: Date = new Date()): string { 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; - }, + storage?: TariffScheduleStorage, ): Promise { try { const response = await fetch(pageUrl, { @@ -264,9 +263,7 @@ export async function refreshDeepSeekTariffWindowsFromPricingPage( } export function getDeepSeekTariffScheduleSnapshot( - storage?: { - get(key: string): T | undefined; - }, + storage?: TariffScheduleStorage, ): DeepSeekTariffScheduleSnapshot | undefined { return storage?.get(DEEPSEEK_TARIFF_SCHEDULE_KEY); } From 3b91decc4ba46f756d2faa57f2a03b22ba0445ae Mon Sep 17 00:00:00 2001 From: Lance Vermilion Date: Mon, 21 Sep 2026 23:02:15 -0700 Subject: [PATCH 5/6] fixed and updated to match 0.9.2 --- src/runtime/lifecycle.ts | 12 ++++++------ src/tariff-holidays.test.ts | 16 ++++++++-------- src/tariff.test.ts | 8 ++++---- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/runtime/lifecycle.ts b/src/runtime/lifecycle.ts index cdef9d6..f8b2725 100644 --- a/src/runtime/lifecycle.ts +++ b/src/runtime/lifecycle.ts @@ -3,14 +3,14 @@ import vscode from 'vscode'; import { t } from '../i18n'; import { logger } from '../logger'; import { - getDeepSeekTariffState, - getDeepSeekTariffStatusText, - getNextDeepSeekTariffTransition, - refreshDeepSeekTariffWindowsFromPricingPage, + getDeepSeekTariffState, + getDeepSeekTariffStatusText, + getNextDeepSeekTariffTransition, + refreshDeepSeekTariffWindowsFromPricingPage, } from '../tariff'; import { - isChinesePublicHoliday, - refreshChinesePublicHolidaysFromWeb, + isChinesePublicHoliday, + refreshChinesePublicHolidaysFromWeb, } from '../tariff-holidays'; import { registerActionUrls } from './actions'; import { registerCommands } from './commands'; diff --git a/src/tariff-holidays.test.ts b/src/tariff-holidays.test.ts index 73465f9..c2fc589 100644 --- a/src/tariff-holidays.test.ts +++ b/src/tariff-holidays.test.ts @@ -2,14 +2,14 @@ import assert from 'node:assert/strict'; import { after, describe, it } from 'node:test'; import { - type TariffScheduleStorage, - getBeijingDateKey, - getChineseHolidayScheduleSnapshot, - getChinesePublicHolidays, - isChinesePublicHoliday, - parseChineseHolidayDataset, - refreshChinesePublicHolidaysFromWeb, - setChinesePublicHolidays, + type TariffScheduleStorage, + getBeijingDateKey, + getChineseHolidayScheduleSnapshot, + getChinesePublicHolidays, + isChinesePublicHoliday, + parseChineseHolidayDataset, + refreshChinesePublicHolidaysFromWeb, + setChinesePublicHolidays, } from './tariff-holidays'; function createStorage(): TariffScheduleStorage { diff --git a/src/tariff.test.ts b/src/tariff.test.ts index 07539b2..2da6415 100644 --- a/src/tariff.test.ts +++ b/src/tariff.test.ts @@ -2,10 +2,10 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { - getDeepSeekTariffState, - getDeepSeekTariffWindowsFromPricingFootnote, - getNextDeepSeekTariffTransition, - hasDeepSeekTariffScheduleChanged, + getDeepSeekTariffState, + getDeepSeekTariffWindowsFromPricingFootnote, + getNextDeepSeekTariffTransition, + hasDeepSeekTariffScheduleChanged, } from './tariff'; describe('DeepSeek tariff logic', () => { From b8352250db075403dba5cb6415ba5cc3d00111a7 Mon Sep 17 00:00:00 2001 From: Lance Vermilion Date: Thu, 24 Sep 2026 22:50:28 -0700 Subject: [PATCH 6/6] updated to 0.9.3 and mixed in my peak/offpeak --- .vscode/package-extension.ps1 | 97 +++++++++++++++++++++++++++++++++++ .vscode/package-extension.sh | 40 +++++++++++++++ .vscode/tasks.json | 46 +++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- 5 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 .vscode/package-extension.ps1 create mode 100644 .vscode/package-extension.sh diff --git a/.vscode/package-extension.ps1 b/.vscode/package-extension.ps1 new file mode 100644 index 0000000..7b3b7e5 --- /dev/null +++ b/.vscode/package-extension.ps1 @@ -0,0 +1,97 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet("stable", "insiders")] + [string] $Quality, + + [Parameter(Mandatory = $true)] + [string] $Workspace +) + +$ErrorActionPreference = "Stop" + +switch ($Quality) { + "stable" { + $cli = "code" + $installHint = "Shell Command: Install 'code' command in PATH" + } + "insiders" { + $cli = "code-insiders" + $installHint = "Shell Command: Install 'code-insiders' command in PATH" + } +} + +if (-not (Get-Command $cli -ErrorAction SilentlyContinue)) { + [Console]::Error.WriteLine("Missing '$cli' in PATH.`nInstall it from the target VS Code Command Palette:`n $installHint`nThen restart the terminal and try again.") + exit 127 +} + +# `npm run package` runs the `vscode:prepublish` script, which shells out to bash +# (.github/scripts/prepare-marketplace-readme.sh). Windows does not ship bash, so +# fall back to the interpreter bundled with Git for Windows. +if (-not (Get-Command bash -ErrorAction SilentlyContinue)) { + $candidates = @() + + if ($env:ProgramFiles) { + $candidates += (Join-Path $env:ProgramFiles "Git\bin") + } + + if (${env:ProgramFiles(x86)}) { + $candidates += (Join-Path ${env:ProgramFiles(x86)} "Git\bin") + } + + if ($env:LOCALAPPDATA) { + $candidates += (Join-Path $env:LOCALAPPDATA "Programs\Git\bin") + } + + $gitBash = $candidates | Where-Object { Test-Path (Join-Path $_ "bash.exe") } | Select-Object -First 1 + + if (-not $gitBash) { + [Console]::Error.WriteLine("Missing 'bash' in PATH and Git for Windows was not found.`nInstall Git for Windows, or run 'npm run package' from a shell that has bash.") + exit 127 + } + + $env:PATH = "$env:PATH;$gitBash" +} + +Push-Location $Workspace + +try { + # npm and the VS Code CLI write progress and warnings to stderr, and PowerShell 5.1 + # surfaces native stderr as errors while $ErrorActionPreference is 'Stop'. Relax it + # for these two invocations and rely on their exit codes instead. + $previousErrorAction = $ErrorActionPreference + $ErrorActionPreference = "Continue" + + try { + & npm run package + + if ($LASTEXITCODE) { + exit $LASTEXITCODE + } + + $manifest = Get-Content (Join-Path $Workspace "package.json") -Raw -Encoding utf8 | ConvertFrom-Json + $vsix = Join-Path $Workspace ("dist\{0}-{1}.vsix" -f $manifest.name, $manifest.version) + + if (-not (Test-Path $vsix)) { + [Console]::Error.WriteLine("Expected package output was not produced: $vsix") + exit 1 + } + + # --force replaces an already-installed copy of the same version with this build. + $installOutput = & $cli --install-extension $vsix --force 2>&1 + $installExitCode = $LASTEXITCODE + + if ($installExitCode) { + $installOutput | Write-Host + exit $installExitCode + } + } finally { + $ErrorActionPreference = $previousErrorAction + } + + Write-Host "" + Write-Host "Installed $($manifest.publisher).$($manifest.name) $($manifest.version) into $Quality VS Code." + Write-Host "Run 'Developer: Reload Window' in that window to activate the new build." +} finally { + Pop-Location +} diff --git a/.vscode/package-extension.sh b/.vscode/package-extension.sh new file mode 100644 index 0000000..0bf0112 --- /dev/null +++ b/.vscode/package-extension.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +quality="${1:?usage: package-extension.sh }" +workspace="${2:?usage: package-extension.sh }" + +case "$quality" in + stable) cli="code" ;; + insiders) cli="code-insiders" ;; + *) + echo "Unknown quality: $quality" >&2 + exit 2 + ;; +esac + +if ! command -v "$cli" >/dev/null 2>&1; then + echo "Missing '$cli' in PATH." >&2 + echo "Install it from the target VS Code Command Palette: Shell Command: Install '$cli' command in PATH" >&2 + exit 127 +fi + +cd "$workspace" + +npm run package + +name=$(node -p "require('./package.json').name") +version=$(node -p "require('./package.json').version") +vsix="dist/${name}-${version}.vsix" + +if [ ! -f "$vsix" ]; then + echo "Expected package output was not produced: $vsix" >&2 + exit 1 +fi + +# --force replaces an already-installed copy of the same version with this build. +"$cli" --install-extension "$vsix" --force + +echo +echo "Installed ${name} ${version} into $quality VS Code." +echo "Run 'Developer: Reload Window' in that window to activate the new build." diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b20d2d7..606b70e 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -64,6 +64,52 @@ "dependsOn": "npm: watch", "dependsOrder": "sequence", "problemMatcher": [] + }, + { + "label": "Package & Install Extension (Stable)", + "type": "shell", + "command": "bash", + "args": [ + "${workspaceFolder}/.vscode/package-extension.sh", + "stable", + "${workspaceFolder}" + ], + "windows": { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/.vscode/package-extension.ps1", + "stable", + "${workspaceFolder}" + ] + }, + "problemMatcher": [] + }, + { + "label": "Package & Install Extension (Insiders)", + "type": "shell", + "command": "bash", + "args": [ + "${workspaceFolder}/.vscode/package-extension.sh", + "insiders", + "${workspaceFolder}" + ], + "windows": { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + "${workspaceFolder}/.vscode/package-extension.ps1", + "insiders", + "${workspaceFolder}" + ] + }, + "problemMatcher": [] } ] } diff --git a/package-lock.json b/package-lock.json index fb83274..4aa098a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "deepseek-v4-for-copilot", - "version": "0.9.3", + "version": "0.9.3-local", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "deepseek-v4-for-copilot", - "version": "0.9.3", + "version": "0.9.3-local", "license": "MIT", "devDependencies": { "@types/node": "^25.6.0", diff --git a/package.json b/package.json index 8045983..ca1595c 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "DeepSeek V4 for Copilot Chat", "description": "Use DeepSeek V4.1 Flash in Copilot Chat with native vision, Vision Proxy, reasoning, and agent tools.", "icon": "resources/icon.png", - "version": "0.9.3", + "version": "0.9.3-local", "publisher": "Vizards", "license": "MIT", "repository": {