diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index cf3111bc1a..5ad98f8c65 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -1,4 +1,6 @@ use super::*; +use chrono::{Local, Utc}; +use serde::Serialize; use std::sync::Arc; const MAX_CONCURRENT_PROVIDER_FETCHES: usize = 8; @@ -891,6 +893,49 @@ fn predictive_warning_identity( Some(format!("{source}:{account}")) } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeepSeekPricingStatus { + pub period: &'static str, + pub current_local_time: String, + pub next_transition_local_time: Option, + pub effective_local_time: String, +} + +#[tauri::command] +pub fn get_deepseek_pricing_status( + state: tauri::State<'_, Mutex>, +) -> Option { + let settings = Settings::load(); + if !settings.enabled_providers.contains("deepseek") { + return None; + } + let now = Utc::now(); + let schedule = codexbar::providers::deepseek::pricing::status_at(now); + let period = match schedule.period { + codexbar::providers::deepseek::pricing::PricingPeriod::Standard => "standard", + codexbar::providers::deepseek::pricing::PricingPeriod::Peak => "peak", + codexbar::providers::deepseek::pricing::PricingPeriod::OffPeak => "offPeak", + }; + if let Ok(mut app_state) = state.lock() { + app_state + .notification_manager + .notify_pricing_transition(period, &settings); + } + let local = |instant: chrono::DateTime| { + instant + .with_timezone(&Local) + .format("%Y-%m-%d %H:%M:%S %Z") + .to_string() + }; + Some(DeepSeekPricingStatus { + period, + current_local_time: Local::now().format("%Y-%m-%d %H:%M:%S %Z").to_string(), + next_transition_local_time: schedule.next_transition.map(local), + effective_local_time: local(codexbar::providers::deepseek::pricing::EFFECTIVE_AT), + }) +} + #[tauri::command] pub async fn refresh_providers(app: tauri::AppHandle) -> Result<(), String> { do_refresh_providers(&app).await diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 0602135c1d..ead371a1ff 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -156,6 +156,7 @@ fn main() { commands::refresh_providers, commands::refresh_providers_if_stale, commands::get_cached_providers, + commands::get_deepseek_pricing_status, commands::codex_accounts_list, commands::codex_account_add, commands::codex_account_remove, diff --git a/apps/desktop-tauri/src/App.test.tsx b/apps/desktop-tauri/src/App.test.tsx index efaed84e72..fbd64dbf4c 100644 --- a/apps/desktop-tauri/src/App.test.tsx +++ b/apps/desktop-tauri/src/App.test.tsx @@ -24,6 +24,7 @@ const tauriMocks = vi.hoisted(() => ({ getLocaleStrings: vi.fn(), setUiLanguage: vi.fn(), getCurrentSurfaceState: vi.fn(), + getDeepSeekPricingStatus: vi.fn().mockResolvedValue(null), })); vi.mock("./lib/tauri", () => tauriMocks); diff --git a/apps/desktop-tauri/src/App.tsx b/apps/desktop-tauri/src/App.tsx index 565a611ac7..aa775058ad 100644 --- a/apps/desktop-tauri/src/App.tsx +++ b/apps/desktop-tauri/src/App.tsx @@ -16,6 +16,7 @@ import { FLOATBAR_WINDOW_LABEL } from "./floatbar/api"; import { LocaleProvider } from "./i18n/LocaleProvider"; import type { BootstrapState, ThemePreference } from "./types/bridge"; import type { SurfaceSnapshot } from "./hooks/useSurfaceSnapshot"; +import { useDeepSeekPricingStatus } from "./hooks/useDeepSeekPricingStatus"; const Settings = lazy(() => import("./surfaces/Settings")); const PopOutPanel = lazy(() => import("./surfaces/PopOutPanel")); @@ -62,6 +63,7 @@ function AppInner() { const [themePreference, setThemePreference] = useState("dark"); useTheme(themePreference); + useDeepSeekPricingStatus(); const reloadBootstrapState = useCallback( () => getBootstrapState(), diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index 663f26a3de..cb602dc417 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const tauriMocks = vi.hoisted(() => ({ getProviderChartData: vi.fn(), + getDeepSeekPricingStatus: vi.fn(), getLocaleStrings: vi.fn(), setUiLanguage: vi.fn(), })); @@ -133,8 +134,17 @@ describe("MenuCard", () => { WayfinderOffline: "Gateway offline", WayfinderDryRun: "Dry run", WayfinderMissingKeys: "Missing keys", + DeepSeekPricingTitle: "DeepSeek pricing", + DeepSeekPricingStandard: "Standard / pre-schedule", + DeepSeekPricingPeak: "Peak hours", + DeepSeekPricingOffPeak: "Off-peak hours", + DeepSeekPricingCurrent: "Current local time:", + DeepSeekPricingNext: "Next transition:", + DeepSeekPricingEffective: "Effective local time:", + DeepSeekPricingAdvice: "Official schedule", }), ); + tauriMocks.getDeepSeekPricingStatus.mockResolvedValue(null); tauriMocks.getProviderChartData.mockResolvedValue({ providerId: "claude", costHistory: [{ date: "2026-05-24", value: 1.23 }], @@ -173,6 +183,24 @@ describe("MenuCard", () => { expect(screen.queryByText("Estimated from local logs")).not.toBeInTheDocument(); }); + it("shows DeepSeek peak/off-peak pricing status", async () => { + tauriMocks.getDeepSeekPricingStatus.mockResolvedValue({ + period: "offPeak", + currentLocalTime: "2026-08-17 05:00:00 UTC", + nextTransitionLocalTime: "2026-08-17 06:00:00 UTC", + effectiveLocalTime: "2026-08-16 18:00:00 EDT", + }); + const snapshot = provider(null); + snapshot.providerId = "deepseek"; + snapshot.displayName = "DeepSeek"; + + renderCard(snapshot); + + expect(await screen.findByText("DeepSeek pricing: Off-peak hours")).toBeInTheDocument(); + expect(screen.getByText("Current local time: 2026-08-17 05:00:00 UTC")).toBeInTheDocument(); + expect(screen.getByText("Next transition: 2026-08-17 06:00:00 UTC")).toBeInTheDocument(); + }); + it("can render metric bars as used instead of remaining", async () => { renderCard(provider(null, 35), { showAsUsed: true }); diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 3b72160f98..348b5349b0 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -10,6 +10,9 @@ import type { LocaleKey } from "../i18n/keys"; import { providerSupportsChartData } from "../lib/providerCharts"; import MenuCardDetails, { describeCard, type MetricEntry } from "./MenuCardDetails"; import CodexAccountsMenu from "./CodexAccountsMenu"; +import { DEEPSEEK_PRICING_EVENT } from "../hooks/useDeepSeekPricingStatus"; +import { getDeepSeekPricingStatus } from "../lib/tauri"; +import type { DeepSeekPricingStatus } from "../types/bridge"; /** Small copy-to-clipboard button matching macOS CopyIconButton (doc.on.doc → checkmark). */ function CopyIconButton({ text }: { text: string }) { @@ -117,6 +120,16 @@ export default function MenuCard({ } = display; const { t } = useLocale(); const [chartData, setChartData] = useState(null); + const [pricingStatus, setPricingStatus] = useState(null); + + useEffect(() => { + if (provider.providerId !== "deepseek") return; + const onPricing = (event: Event) => + setPricingStatus((event as CustomEvent).detail); + window.addEventListener(DEEPSEEK_PRICING_EVENT, onPricing); + void getDeepSeekPricingStatus().then(setPricingStatus).catch(() => {}); + return () => window.removeEventListener(DEEPSEEK_PRICING_EVENT, onPricing); + }, [provider.providerId]); useEffect(() => { if (!providerSupportsChartData(provider.providerId)) { @@ -249,6 +262,33 @@ export default function MenuCard({ /> )} + {provider.providerId === "deepseek" && pricingStatus && ( +
+ + {t("DeepSeekPricingTitle")}: {t( + pricingStatus.period === "peak" + ? "DeepSeekPricingPeak" + : pricingStatus.period === "offPeak" + ? "DeepSeekPricingOffPeak" + : "DeepSeekPricingStandard", + )} + + + {t("DeepSeekPricingCurrent")} {pricingStatus.currentLocalTime} + + + {t("DeepSeekPricingNext")} {pricingStatus.nextTransitionLocalTime ?? "—"} + + + {t("DeepSeekPricingEffective")} {pricingStatus.effectiveLocalTime} + + {t("DeepSeekPricingAdvice")} +
+ )} + {provider.providerId === "codex" && ( )} diff --git a/apps/desktop-tauri/src/hooks/useDeepSeekPricingStatus.ts b/apps/desktop-tauri/src/hooks/useDeepSeekPricingStatus.ts new file mode 100644 index 0000000000..63b642d232 --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useDeepSeekPricingStatus.ts @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { getDeepSeekPricingStatus } from "../lib/tauri"; +import type { DeepSeekPricingStatus } from "../types/bridge"; + +export const DEEPSEEK_PRICING_EVENT = "codexbar:deepseek-pricing"; + +export function useDeepSeekPricingStatus(): void { + useEffect(() => { + let cancelled = false; + const poll = () => { + getDeepSeekPricingStatus() + .then((status) => { + if (!cancelled && status) { + window.dispatchEvent( + new CustomEvent(DEEPSEEK_PRICING_EVENT, { + detail: status, + }), + ); + } + }) + .catch(() => {}); + }; + poll(); + const timer = window.setInterval(poll, 60_000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, []); +} diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 9360e61f8b..f4ff708a20 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -164,6 +164,14 @@ export const ALL_LOCALE_KEYS = [ "ProviderSession", "ProviderWeekly", "ProviderMonthly", + "DeepSeekPricingTitle", + "DeepSeekPricingStandard", + "DeepSeekPricingPeak", + "DeepSeekPricingOffPeak", + "DeepSeekPricingCurrent", + "DeepSeekPricingNext", + "DeepSeekPricingEffective", + "DeepSeekPricingAdvice", "ProviderModel", "ProviderPlan", "ProviderNextReset", diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index b51844fb58..ea39b554da 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -38,6 +38,7 @@ import type { CodexAccountUsageSnapshot, CodexAccountsStateBridge, CodexSwitchResult, + DeepSeekPricingStatus, } from "../types/bridge"; export function getBootstrapState(): Promise { @@ -124,6 +125,10 @@ export function getCachedProviders(): Promise { return invoke("get_cached_providers"); } +export function getDeepSeekPricingStatus(): Promise { + return invoke("get_deepseek_pricing_status"); +} + export function getWorkAreaRect(): Promise { return invoke("get_work_area_rect"); } diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 2e802f5498..6e4cd60151 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -4171,6 +4171,31 @@ html:has(.menu-surface--tray) { margin: 4px 0; } +.menu-card__pricing-status { + display: flex; + flex-direction: column; + gap: 3px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--divider, var(--provider-row-divider, rgba(128, 128, 128, 0.18))); + color: var(--text-secondary); + font-size: 10px; + line-height: 1.35; + font-variant-numeric: tabular-nums; +} + +.menu-card__pricing-status strong { + color: var(--text-primary); + font-size: 11px; + font-weight: 600; +} + +.menu-card__pricing-status small { + margin-top: 2px; + color: var(--text-tertiary, var(--text-secondary)); + line-height: 1.4; +} + .menu-card__content { display: flex; flex-direction: column; diff --git a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx index cec062a350..7d50ea0606 100644 --- a/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/PopOutPanel.test.tsx @@ -18,6 +18,7 @@ const tauriMocks = vi.hoisted(() => ({ getProviderChartData: vi.fn(), getLocaleStrings: vi.fn(), setUiLanguage: vi.fn(), + getDeepSeekPricingStatus: vi.fn().mockResolvedValue(null), })); const eventMocks = vi.hoisted(() => ({ diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index 5b6d1ee05f..55dc4e1cda 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -30,6 +30,7 @@ const tauriMocks = vi.hoisted(() => ({ getCurrentSurfaceState: vi.fn(), getLocaleStrings: vi.fn(), setUiLanguage: vi.fn(), + getDeepSeekPricingStatus: vi.fn().mockResolvedValue(null), })); const eventMocks = vi.hoisted(() => ({ @@ -201,6 +202,7 @@ describe("TrayPanel provider grid", () => { beforeEach(() => { vi.clearAllMocks(); eventMocks.listeners.clear(); + tauriMocks.getDeepSeekPricingStatus.mockResolvedValue(null); tauriMocks.flyoutStoredSize.mockResolvedValue(null); tauriMocks.refreshProviders.mockResolvedValue(undefined); tauriMocks.refreshProvidersIfStale.mockResolvedValue(undefined); diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index c87eb91a64..e0bbe1bde5 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -73,6 +73,15 @@ export type FloatBarStyle = "floating" | "taskbar"; export type TrayVisibilitySupport = "supported" | "unsupportedOs"; export type TrayVisibilityState = "promoted" | "notPromoted" | "entryNotFound" | "unknown"; +export type DeepSeekPricingPeriod = "standard" | "peak" | "offPeak"; + +export interface DeepSeekPricingStatus { + period: DeepSeekPricingPeriod; + currentLocalTime: string; + nextTransitionLocalTime: string | null; + effectiveLocalTime: string; +} + export interface TrayVisibilityStatusDto { support: TrayVisibilitySupport; state: TrayVisibilityState; diff --git a/rust/src/locale.rs b/rust/src/locale.rs index d23f5ce22f..423bfb71b8 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -342,6 +342,14 @@ locale_keys! { ProviderSession, ProviderWeekly, ProviderMonthly, + DeepSeekPricingTitle, + DeepSeekPricingStandard, + DeepSeekPricingPeak, + DeepSeekPricingOffPeak, + DeepSeekPricingCurrent, + DeepSeekPricingNext, + DeepSeekPricingEffective, + DeepSeekPricingAdvice, ProviderModel, ProviderPlan, ProviderNextReset, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index c0c3c08df1..cb0ab1ddbd 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -749,3 +749,12 @@ NetworkProxyUserLabel = Username (optional) NetworkProxyPasswordLabel = Password (optional) NetworkProxyPasswordHelper = Stored in local settings.json. Prefer a local proxy without auth when possible. NetworkProxyInvalidUrl = Invalid proxy URL. Use http://host:port. + +DeepSeekPricingTitle = DeepSeek pricing +DeepSeekPricingStandard = Standard / pre-schedule +DeepSeekPricingPeak = Peak hours +DeepSeekPricingOffPeak = Off-peak hours +DeepSeekPricingCurrent = Current local time: +DeepSeekPricingNext = Next transition: +DeepSeekPricingEffective = Effective local time: +DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 3d2a95ca21..88f5cc2e08 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -670,3 +670,12 @@ NetworkProxyUserLabel = Username (optional) NetworkProxyPasswordLabel = Password (optional) NetworkProxyPasswordHelper = Stored in local settings.json. Prefer a local proxy without auth when possible. NetworkProxyInvalidUrl = Invalid proxy URL. Use http://host:port. + +DeepSeekPricingTitle = DeepSeek pricing +DeepSeekPricingStandard = Standard / pre-schedule +DeepSeekPricingPeak = Peak hours +DeepSeekPricingOffPeak = Off-peak hours +DeepSeekPricingCurrent = Current local time: +DeepSeekPricingNext = Next transition: +DeepSeekPricingEffective = Effective local time: +DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index d359a96fa3..d319fed896 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -736,3 +736,12 @@ NetworkProxyUserLabel = Username (optional) NetworkProxyPasswordLabel = Password (optional) NetworkProxyPasswordHelper = Stored in local settings.json. Prefer a local proxy without auth when possible. NetworkProxyInvalidUrl = Invalid proxy URL. Use http://host:port. + +DeepSeekPricingTitle = DeepSeek pricing +DeepSeekPricingStandard = Standard / pre-schedule +DeepSeekPricingPeak = Peak hours +DeepSeekPricingOffPeak = Off-peak hours +DeepSeekPricingCurrent = Current local time: +DeepSeekPricingNext = Next transition: +DeepSeekPricingEffective = Effective local time: +DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index fc27cbabb6..7f71c4d019 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -657,3 +657,12 @@ NetworkProxyUserLabel = Username (optional) NetworkProxyPasswordLabel = Password (optional) NetworkProxyPasswordHelper = Stored in local settings.json. Prefer a local proxy without auth when possible. NetworkProxyInvalidUrl = Invalid proxy URL. Use http://host:port. + +DeepSeekPricingTitle = DeepSeek pricing +DeepSeekPricingStandard = Standard / pre-schedule +DeepSeekPricingPeak = Peak hours +DeepSeekPricingOffPeak = Off-peak hours +DeepSeekPricingCurrent = Current local time: +DeepSeekPricingNext = Next transition: +DeepSeekPricingEffective = Effective local time: +DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 98541316bb..97d879d7ea 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -714,3 +714,12 @@ NetworkProxyUserLabel = Имя пользователя (необязатель NetworkProxyPasswordLabel = Пароль (необязательно) NetworkProxyPasswordHelper = Хранится в локальном файле settings.json. По возможности предпочитайте локальный прокси без аутентификации. NetworkProxyInvalidUrl = Неверный URL-адрес прокси. Используйте http://хост:порт. + +DeepSeekPricingTitle = DeepSeek pricing +DeepSeekPricingStandard = Standard / pre-schedule +DeepSeekPricingPeak = Peak hours +DeepSeekPricingOffPeak = Off-peak hours +DeepSeekPricingCurrent = Current local time: +DeepSeekPricingNext = Next transition: +DeepSeekPricingEffective = Effective local time: +DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 7f22d6a9de..3b866b9e56 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -735,3 +735,12 @@ NetworkProxyUserLabel = Username (optional) NetworkProxyPasswordLabel = Password (optional) NetworkProxyPasswordHelper = Stored in local settings.json. Prefer a local proxy without auth when possible. NetworkProxyInvalidUrl = Invalid proxy URL. Use http://host:port. + +DeepSeekPricingTitle = DeepSeek pricing +DeepSeekPricingStandard = Standard / pre-schedule +DeepSeekPricingPeak = Peak hours +DeepSeekPricingOffPeak = Off-peak hours +DeepSeekPricingCurrent = Current local time: +DeepSeekPricingNext = Next transition: +DeepSeekPricingEffective = Effective local time: +DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 791f2ed955..22c4142d3b 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -735,3 +735,12 @@ NetworkProxyUserLabel = Username (optional) NetworkProxyPasswordLabel = Password (optional) NetworkProxyPasswordHelper = Stored in local settings.json. Prefer a local proxy without auth when possible. NetworkProxyInvalidUrl = Invalid proxy URL. Use http://host:port. + +DeepSeekPricingTitle = DeepSeek pricing +DeepSeekPricingStandard = Standard / pre-schedule +DeepSeekPricingPeak = Peak hours +DeepSeekPricingOffPeak = Off-peak hours +DeepSeekPricingCurrent = Current local time: +DeepSeekPricingNext = Next transition: +DeepSeekPricingEffective = Effective local time: +DeepSeekPricingAdvice = Official schedule: peak 01:00-04:00 and 06:00-10:00 UTC; off-peak is half-price. diff --git a/rust/src/notifications.rs b/rust/src/notifications.rs index cae6dbd301..3f663ae4bf 100755 --- a/rust/src/notifications.rs +++ b/rust/src/notifications.rs @@ -128,6 +128,7 @@ pub struct NotificationManager { /// Track previous session percent for depleted/restored transitions (per account) previous_session_percent: std::collections::HashMap, predictive_warning_keys: std::collections::HashSet, + deepseek_pricing_period: Option, } impl NotificationManager { @@ -136,9 +137,32 @@ impl NotificationManager { sent_notifications: std::collections::HashSet::new(), previous_session_percent: std::collections::HashMap::new(), predictive_warning_keys: std::collections::HashSet::new(), + deepseek_pricing_period: None, } } + /// Observe a DeepSeek pricing period and notify once per observed transition. + /// Intentionally silent; this advisory must not play a notification sound. + pub fn notify_pricing_transition(&mut self, period: &str, settings: &Settings) { + let changed = self + .deepseek_pricing_period + .as_deref() + .is_some_and(|previous| previous != period); + self.deepseek_pricing_period = Some(period.to_string()); + if !settings.show_notifications || !changed { + return; + } + let label = match period { + "peak" => "peak", + "offPeak" => "off-peak", + _ => "standard/pre-schedule", + }; + self.show_toast( + "DeepSeek pricing schedule", + &format!("DeepSeek is currently in {label} hours."), + ); + } + pub fn record_predictive_observation( &mut self, enabled: bool, diff --git a/rust/src/providers/deepseek/mod.rs b/rust/src/providers/deepseek/mod.rs index 33484d3164..581509863c 100644 --- a/rust/src/providers/deepseek/mod.rs +++ b/rust/src/providers/deepseek/mod.rs @@ -8,6 +8,8 @@ use std::collections::HashMap; use serde::Deserialize; +pub mod pricing; + use crate::core::{ CostSnapshot, FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, diff --git a/rust/src/providers/deepseek/pricing.rs b/rust/src/providers/deepseek/pricing.rs new file mode 100644 index 0000000000..3f16b3c8db --- /dev/null +++ b/rust/src/providers/deepseek/pricing.rs @@ -0,0 +1,109 @@ +//! DeepSeek peak/off-peak pricing schedule. +use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc}; + +pub const EFFECTIVE_AT: DateTime = DateTime::::from_naive_utc_and_offset( + NaiveDateTime::new( + NaiveDate::from_ymd_opt(2026, 8, 16).unwrap(), + NaiveTime::from_hms_opt(16, 0, 0).unwrap(), + ), + Utc, +); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PricingPeriod { + Standard, + Peak, + OffPeak, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PricingScheduleStatus { + pub period: PricingPeriod, + pub next_transition: Option>, +} + +/// Evaluate the official UTC schedule. Intervals are half-open: [start, end). +pub fn status_at(now: DateTime) -> PricingScheduleStatus { + if now < EFFECTIVE_AT { + return PricingScheduleStatus { + period: PricingPeriod::Standard, + next_transition: Some(EFFECTIVE_AT), + }; + } + let today = [ + ( + NaiveTime::from_hms_opt(1, 0, 0).unwrap(), + NaiveTime::from_hms_opt(4, 0, 0).unwrap(), + PricingPeriod::Peak, + ), + ( + NaiveTime::from_hms_opt(4, 0, 0).unwrap(), + NaiveTime::from_hms_opt(6, 0, 0).unwrap(), + PricingPeriod::OffPeak, + ), + ( + NaiveTime::from_hms_opt(6, 0, 0).unwrap(), + NaiveTime::from_hms_opt(10, 0, 0).unwrap(), + PricingPeriod::Peak, + ), + ]; + for (start, end, period) in today { + let start = + DateTime::::from_naive_utc_and_offset(now.date_naive().and_time(start), Utc); + let end = DateTime::::from_naive_utc_and_offset(now.date_naive().and_time(end), Utc); + if now >= start && now < end { + return PricingScheduleStatus { + period, + next_transition: Some(end), + }; + } + } + + // 00:00-01:00 and 10:00-24:00 are off-peak. Use the next day's 01:00 + // as the transition so the reported instant always changes the period. + let next = DateTime::::from_naive_utc_and_offset( + (now.date_naive() + chrono::Days::new(1)) + .and_time(NaiveTime::from_hms_opt(1, 0, 0).unwrap()), + Utc, + ); + PricingScheduleStatus { + period: PricingPeriod::OffPeak, + next_transition: Some(next), + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn at(h: u32, m: u32) -> DateTime { + DateTime::::from_naive_utc_and_offset( + NaiveDate::from_ymd_opt(2026, 8, 17) + .unwrap() + .and_hms_opt(h, m, 0) + .unwrap(), + Utc, + ) + } + #[test] + fn pre_effective_is_standard() { + let s = status_at(EFFECTIVE_AT - chrono::Duration::seconds(1)); + assert_eq!(s.period, PricingPeriod::Standard); + assert_eq!(s.next_transition, Some(EFFECTIVE_AT)); + } + #[test] + fn peak_and_off_peak_boundaries_are_half_open() { + assert_eq!(status_at(at(1, 0)).period, PricingPeriod::Peak); + assert_eq!(status_at(at(4, 0)).period, PricingPeriod::OffPeak); + assert_eq!(status_at(at(6, 0)).period, PricingPeriod::Peak); + assert_eq!(status_at(at(10, 0)).period, PricingPeriod::OffPeak); + } + #[test] + fn next_transition_is_returned() { + assert_eq!(status_at(at(2, 0)).next_transition, Some(at(4, 0))); + assert_eq!(status_at(at(5, 0)).next_transition, Some(at(6, 0))); + assert_eq!( + status_at(at(23, 59)).next_transition, + Some(at(1, 0) + chrono::Days::new(1)) + ); + } +}