pi-deepseek-tariff 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Valderan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,129 @@
1
- # Temporary Holding Version
1
+ # pi-deepseek-tariff
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/pi-deepseek-tariff?color=blue)](https://www.npmjs.com/package/pi-deepseek-tariff)
4
+ [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
5
+ [![pi-package](https://img.shields.io/badge/pi-package-8a2be2)](https://pi.dev/packages)
6
+
7
+ A [pi](https://pi.dev) extension that shows the current **DeepSeek API tariff window** — peak or off-peak — with a countdown, and a panel with live prices parsed from the official DeepSeek pricing page.
8
+
9
+ DeepSeek charges double during peak hours, so knowing the current window is worth real money.
10
+
11
+ ```
12
+ Now OFF-PEAK
13
+ Computer: 14:43:52 26-10-2026(MSK) | UTC: 2026-10-26 11:43
14
+ Next window: 04:00 - 07:00 27-10-2026(MSK) in 13h 16m
15
+ DeepSeek prices: $ per 1M tokens, active: OFF-PEAK
16
+ deepseek:deepseek-flash ← active
17
+ PEAK hit 0.0060 miss 0.3000 out 1.2000
18
+ ▸ OFF-PEAK hit 0.0030 miss 0.1500 out 0.6000
19
+ deepseek:deepseek-v4-pro
20
+ PEAK hit 0.0440 miss 1.3200 out 3.9600
21
+ ▸ OFF-PEAK hit 0.0220 miss 0.6600 out 1.9800
22
+ Search & Fetch: $0.75 per 1,000 successful requests
23
+ ────────────────────────────────────────────────────────────
24
+ Peak windows (MSK): 04:00-07:00 and 09:00-13:00, Mon-Fri
25
+ Off-peak = night, weekends and Chinese holidays
26
+ Price source: api-docs.deepseek.com, updated 11:20 26-10 (next in 5h 59m)
27
+ ────────────────────────────────────────────────────────────
28
+ Commands: /deepseek-peak on|off|refresh|en|ru
29
+ ```
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ # from git (tags are pinned)
35
+ pi install git:github.com/valderan/pi-deepseek-tariff@v0.1.0
36
+
37
+ # from npm
38
+ pi install npm:pi-deepseek-tariff
39
+
40
+ # local checkout, for development
41
+ git clone git@github.com:valderan/pi-deepseek-tariff.git
42
+ pi install ./pi-deepseek-tariff
43
+
44
+ # try it for one run without touching settings
45
+ pi -e ./pi-deepseek-tariff
46
+ ```
47
+
48
+ Then run `/deepseek-peak`. Afterwards `pi list`, `pi update --extensions`, `pi remove …` manage it like any other package.
49
+
50
+ ## Commands
51
+
52
+ | Command | Effect |
53
+ |---|---|
54
+ | `/deepseek-peak` | toggle the panel |
55
+ | `/deepseek-peak on` / `off` | show or hide it |
56
+ | `/deepseek-peak refresh` | re-parse prices from the docs page now |
57
+ | `/deepseek-peak en` / `ru` | switch the panel language (persisted) |
58
+
59
+ ## What it does
60
+
61
+ - **Footer line** — `deepseek: PEAK → off-peak 13:00 (1h 40m)`. Shown only while a DeepSeek model is active; red for peak, green for off-peak.
62
+ - **Peak rules** — DeepSeek defines peak as `01:00-04:00` and `06:00-10:00` **UTC, Monday to Friday**; weekends and Chinese public holidays are off-peak all day.
63
+ - **Local time everywhere** — the UTC clock comes from the machine clock, so peak windows, weekday labels and countdowns are rendered in *your* zone (`(MSK)`, `(UTC-04:00)`, …). Weekday labels shift with the zone: at UTC−4 the windows land on Sun–Thu.
64
+ - **Prices** — parsed from `api-docs.deepseek.com/quick_start/pricing/`: cache hit, cache miss and output per model, plus the Search & Fetch request rates. The tariff in effect is highlighted.
65
+ - **Caching** — the page is parsed at most once per `pricingTtlHours` (default 6), and the result is cached on disk, so restarts stay offline. `PI_OFFLINE` disables network access entirely and the panel falls back to built-in values.
66
+
67
+ ## Configuration
68
+
69
+ `<agent-dir>/deepseek-peak.json` — `PI_CODING_AGENT_DIR`, default `~/.pi/agent`. The file is created only when you switch the language.
70
+
71
+ ```json
72
+ {
73
+ "language": "ru",
74
+ "intervalMs": 30000,
75
+ "pricingTtlHours": 6,
76
+ "alwaysShow": false,
77
+ "models": ["deepseek"],
78
+ "providerPrefix": "deepseek:",
79
+ "holidays": ["2026-10-01", "2026-10-02"]
80
+ }
81
+ ```
82
+
83
+ | Key | Default | Meaning |
84
+ |---|---|---|
85
+ | `language` | pi's `settings.json` language, else `en` | `"en"` or `"ru"` |
86
+ | `intervalMs` | `30000` | minimum refresh cadence (clamped to ≥ 5000) |
87
+ | `pricingTtlHours` | `6` | how long parsed prices stay valid |
88
+ | `alwaysShow` | `false` | show the footer line for non-DeepSeek models too |
89
+ | `models` | `["deepseek"]` | id substrings that count as DeepSeek |
90
+ | `providerPrefix` | `"deepseek:"` | prefix used when printing model ids |
91
+ | `holidays` | `[]` | Chinese public holidays as Beijing dates (`YYYY-MM-DD`), off-peak all day |
92
+
93
+ Chinese holidays are announced yearly and are not computed, so keep the list current. Prices are parsed automatically; if DeepSeek changes the page markup the parser returns nothing and the extension keeps the last good values.
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ git clone git@github.com:valderan/pi-deepseek-tariff.git
99
+ cd pi-deepseek-tariff
100
+ npm test # node:test, no dependencies required
101
+ npm run typecheck # needs `npm install` for the pi type packages
102
+ ```
103
+
104
+ Layout:
105
+
106
+ ```
107
+ extensions/deepseek-peak.ts pi entry point (UI, events, commands)
108
+ src/rules.ts peak rules, local time formatting (pure)
109
+ src/pricing.ts page parsing + TTL/disk cache (pure, injectable fetch)
110
+ src/view.ts footer line and panel rendering
111
+ src/config.ts config file, agent-dir paths
112
+ src/i18n.ts English and Russian strings
113
+ test/ node:test suites + captured page fixture
114
+ ```
115
+
116
+ The rules and parsing modules are pure and take injectable inputs, so tests never touch the network or a TUI. `test/fixtures/pricing.html` is a captured copy of the pricing tables; refresh it when DeepSeek changes the page.
117
+
118
+ ## Release
119
+
120
+ ```bash
121
+ git tag -a v0.1.0 -m "v0.1.0"
122
+ git push origin main --tags
123
+ ```
124
+
125
+ Tags are pinnable with `pi install git:…@v0.1.0`. Publishing to npm (`.github/workflows/ci.yml` runs it on a `v*` tag) puts the package into the [pi package gallery](https://pi.dev/packages).
126
+
127
+ ## License
128
+
129
+ MIT
@@ -0,0 +1,157 @@
1
+ /**
2
+ * DeepSeek Peak / Off-Peak tariff panel for pi.
3
+ *
4
+ * Footer status line: current tariff window and countdown to the next switch.
5
+ * `/deepseek-peak` opens a panel with the local/UTC clock, the next window,
6
+ * per-model prices and Search & Fetch request pricing parsed from the official
7
+ * DeepSeek pricing page, plus reference notes.
8
+ *
9
+ * https://api-docs.deepseek.com/quick_start/pricing/
10
+ */
11
+
12
+ import { truncateToWidth } from "@earendil-works/pi-tui";
13
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
14
+ import { loadConfig, PRICING_CACHE, saveConfig, type Config } from "../src/config.ts";
15
+ import { STRINGS, type Lang } from "../src/i18n.ts";
16
+ import { createPricingStore, isOffline, type Pricing } from "../src/pricing.ts";
17
+ import { SEPARATOR, panelLines, statusText } from "../src/view.ts";
18
+
19
+ const STATUS_KEY = "deepseek-peak";
20
+ const WIDGET_KEY = "deepseek-peak";
21
+
22
+ export default function deepseekPeak(pi: ExtensionAPI) {
23
+ let config = loadConfig();
24
+ let lang: Lang = config.language ?? "en";
25
+ let timer: ReturnType<typeof setInterval> | undefined;
26
+ let panelVisible = false;
27
+
28
+ const pricing = createPricingStore({
29
+ cachePath: PRICING_CACHE,
30
+ ttlMs: config.pricingTtlHours * 60 * 60 * 1000,
31
+ offline: isOffline(),
32
+ });
33
+
34
+ const ttlMs = () => config.pricingTtlHours * 60 * 60 * 1000;
35
+
36
+ const isDeepSeekModel = (ctx: ExtensionContext | ExtensionCommandContext) => {
37
+ const id = ctx.model?.id.toLowerCase();
38
+ if (!id) return false;
39
+ return config.models.some((needle) => id.includes(needle));
40
+ };
41
+
42
+ const renderFooter = (ctx: ExtensionContext | ExtensionCommandContext, current: Pricing = pricing.get()) => {
43
+ if (!config.alwaysShow && !isDeepSeekModel(ctx)) {
44
+ ctx.ui.setStatus(STATUS_KEY, undefined);
45
+ return;
46
+ }
47
+ const theme: Theme | undefined = (ctx.ui as { theme?: Theme }).theme;
48
+ if (!theme) return;
49
+ ctx.ui.setStatus(STATUS_KEY, statusText(theme, config, lang, current));
50
+ };
51
+
52
+ const renderPanel = (ctx: ExtensionContext | ExtensionCommandContext, current: Pricing = pricing.get()) => {
53
+ if (!panelVisible) return;
54
+ const currentModel = ctx.model?.id;
55
+ ctx.ui.setWidget(
56
+ WIDGET_KEY,
57
+ (_tui, theme) => {
58
+ const lines = panelLines(theme, {
59
+ config,
60
+ lang,
61
+ currentModel,
62
+ pricing: current,
63
+ ttlMs: ttlMs(),
64
+ });
65
+ return {
66
+ invalidate() {},
67
+ render(width: number) {
68
+ const rule = theme.fg("dim", "─".repeat(Math.max(0, width)));
69
+ return lines.map((line) => (line === SEPARATOR ? rule : truncateToWidth(line, width)));
70
+ },
71
+ };
72
+ },
73
+ { placement: "belowEditor" },
74
+ );
75
+ };
76
+
77
+ const render = (ctx: ExtensionContext | ExtensionCommandContext, current: Pricing = pricing.get()) => {
78
+ renderFooter(ctx, current);
79
+ renderPanel(ctx, current);
80
+ };
81
+
82
+ const stop = () => {
83
+ if (timer !== undefined) clearInterval(timer);
84
+ timer = undefined;
85
+ };
86
+
87
+ const start = (ctx: ExtensionContext) => {
88
+ stop();
89
+ render(ctx);
90
+ timer = setInterval(() => {
91
+ // Only hits the network when the TTL expired.
92
+ if (!pricing.expired()) {
93
+ render(ctx);
94
+ return;
95
+ }
96
+ void pricing.refresh().then((value) => render(ctx, value));
97
+ }, config.intervalMs);
98
+ };
99
+
100
+ pi.on("session_start", async (_event, ctx) => {
101
+ config = loadConfig();
102
+ lang = config.language ?? "en";
103
+ pricing.setTtl(ttlMs());
104
+ pricing.loadCache();
105
+ start(ctx);
106
+ void pricing.refresh().then((value) => render(ctx, value));
107
+ });
108
+
109
+ pi.on("session_shutdown", async (_event, ctx) => {
110
+ stop();
111
+ panelVisible = false;
112
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
113
+ ctx.ui.setStatus(STATUS_KEY, undefined);
114
+ });
115
+
116
+ // The model can change mid-session, so refresh on the cheap lifecycle hooks too.
117
+ pi.on("agent_start", async (_event, ctx) => render(ctx));
118
+ pi.on("turn_start", async (_event, ctx) => render(ctx));
119
+
120
+ pi.registerCommand("deepseek-peak", {
121
+ description: "DeepSeek peak/off-peak panel · Панель DeepSeek peak/off-peak",
122
+ handler: async (args, ctx) => {
123
+ config = loadConfig();
124
+ lang = config.language ?? "en";
125
+ pricing.setTtl(ttlMs());
126
+ const arg = args.trim().toLowerCase();
127
+
128
+ if (arg === "ru" || arg === "en") {
129
+ lang = arg;
130
+ config.language = arg;
131
+ saveConfig({ language: arg });
132
+ panelVisible = true;
133
+ render(ctx);
134
+ return;
135
+ }
136
+
137
+ if (arg === "off" || (arg === "" && panelVisible)) {
138
+ panelVisible = false;
139
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
140
+ renderFooter(ctx);
141
+ return;
142
+ }
143
+
144
+ if (arg === "refresh") {
145
+ const value = await pricing.refresh(true);
146
+ panelVisible = true;
147
+ render(ctx, value);
148
+ return;
149
+ }
150
+
151
+ panelVisible = true;
152
+ render(ctx);
153
+ },
154
+ });
155
+ }
156
+
157
+ export { STRINGS };
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "pi-deepseek-tariff",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "DeepSeek peak/off-peak tariff panel for pi: local clock, next window, live per-model prices and Search & Fetch rates",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "deepseek",
9
+ "pricing",
10
+ "tariff",
11
+ "statusline"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Valderan",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/valderan/pi-deepseek-tariff.git"
18
+ },
19
+ "homepage": "https://github.com/valderan/pi-deepseek-tariff#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/valderan/pi-deepseek-tariff/issues"
22
+ },
23
+ "type": "module",
24
+ "files": [
25
+ "extensions",
26
+ "src",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "pi": {
31
+ "extensions": [
32
+ "./extensions/deepseek-peak.ts"
33
+ ]
34
+ },
35
+ "peerDependencies": {
36
+ "@earendil-works/pi-coding-agent": "*",
37
+ "@earendil-works/pi-tui": "*"
38
+ },
39
+ "devDependencies": {
40
+ "@earendil-works/pi-coding-agent": "^1.4.0",
41
+ "@earendil-works/pi-tui": "^1.4.0",
42
+ "typescript": "^5.9.3"
43
+ },
44
+ "engines": {
45
+ "node": ">=22.6.0"
46
+ },
47
+ "scripts": {
48
+ "test": "node --test",
49
+ "typecheck": "tsc --noEmit"
50
+ }
51
+ }
package/src/config.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Configuration and well-known paths.
3
+ *
4
+ * Paths honour PI_CODING_AGENT_DIR so non-default Pi installations keep their
5
+ * own config and cache instead of writing into ~/.pi/agent.
6
+ */
7
+
8
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ import { normalizeLang, type Lang } from "./i18n.ts";
12
+ import { DEFAULT_TTL_HOURS } from "./pricing.ts";
13
+
14
+ export const AGENT_DIR = process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent");
15
+ export const CONFIG_PATH = join(AGENT_DIR, "deepseek-peak.json");
16
+ export const SETTINGS_PATH = join(AGENT_DIR, "settings.json");
17
+ export const PRICING_CACHE = join(AGENT_DIR, "cache", "deepseek-peak-pricing.json");
18
+
19
+ export type Config = {
20
+ language?: Lang;
21
+ intervalMs: number;
22
+ pricingTtlHours: number;
23
+ alwaysShow: boolean;
24
+ models: string[];
25
+ providerPrefix: string;
26
+ holidays: Set<string>;
27
+ };
28
+
29
+ /** Language from the pi settings, so the panel follows the interface language. */
30
+ export function settingsLanguage(): Lang {
31
+ try {
32
+ const raw = JSON.parse(readFileSync(SETTINGS_PATH, "utf8")) as { language?: unknown };
33
+ return normalizeLang(raw.language) ?? "en";
34
+ } catch {
35
+ return "en";
36
+ }
37
+ }
38
+
39
+ export function loadConfig(): Config {
40
+ const config: Config = {
41
+ intervalMs: 30_000,
42
+ pricingTtlHours: DEFAULT_TTL_HOURS,
43
+ alwaysShow: false,
44
+ models: ["deepseek"],
45
+ providerPrefix: "deepseek:",
46
+ holidays: new Set<string>(),
47
+ };
48
+
49
+ try {
50
+ const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Record<string, unknown>;
51
+ config.language = normalizeLang(raw.language);
52
+ if (typeof raw.intervalMs === "number" && raw.intervalMs >= 5_000) config.intervalMs = raw.intervalMs;
53
+ if (typeof raw.pricingTtlHours === "number" && raw.pricingTtlHours > 0) {
54
+ config.pricingTtlHours = raw.pricingTtlHours;
55
+ }
56
+ if (typeof raw.alwaysShow === "boolean") config.alwaysShow = raw.alwaysShow;
57
+ if (typeof raw.providerPrefix === "string" && raw.providerPrefix.length > 0) {
58
+ config.providerPrefix = raw.providerPrefix;
59
+ }
60
+ if (Array.isArray(raw.models)) {
61
+ const models = raw.models.filter((m): m is string => typeof m === "string" && m.length > 0);
62
+ if (models.length > 0) config.models = models.map((m) => m.toLowerCase());
63
+ }
64
+ if (Array.isArray(raw.holidays)) {
65
+ for (const day of raw.holidays) {
66
+ if (typeof day === "string" && /^\d{4}-\d{2}-\d{2}$/.test(day)) config.holidays.add(day);
67
+ }
68
+ }
69
+ } catch {
70
+ // A missing or malformed config keeps the defaults.
71
+ }
72
+
73
+ config.language ??= settingsLanguage();
74
+ return config;
75
+ }
76
+
77
+ /** Persist a single config key without dropping user-authored ones. */
78
+ export function saveConfig(patch: Record<string, unknown>) {
79
+ try {
80
+ let current: Record<string, unknown> = {};
81
+ try {
82
+ current = JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Record<string, unknown>;
83
+ } catch {
84
+ current = {};
85
+ }
86
+ const next = { ...current, ...patch };
87
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
88
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`);
89
+ } catch {
90
+ // Persisting is best effort; the session keeps the in-memory value.
91
+ }
92
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Localization for the DeepSeek tariff panel.
3
+ *
4
+ * Add a language by extending `Lang`, `STRINGS` and `DAY_NAMES`.
5
+ */
6
+
7
+ export type Lang = "en" | "ru";
8
+
9
+ export function normalizeLang(value: unknown): Lang | undefined {
10
+ if (typeof value !== "string") return undefined;
11
+ const lower = value.trim().toLowerCase();
12
+ if (lower.startsWith("ru")) return "ru";
13
+ if (lower.startsWith("en")) return "en";
14
+ return undefined;
15
+ }
16
+
17
+ type Strings = {
18
+ peakTitle: string;
19
+ offPeakTitle: string;
20
+ holiday: string;
21
+ computer: string;
22
+ nextWindow: string;
23
+ nextIn: (duration: string) => string;
24
+ and: string;
25
+ prices: string;
26
+ pricesState: (state: string) => string;
27
+ activeModel: string;
28
+ peakWindows: (zone: string) => string;
29
+ peakDays: string;
30
+ offPeak: string;
31
+ offPeakNote: string;
32
+ priceSource: string;
33
+ sourceLive: (when: string, next: string) => string;
34
+ sourceBuiltin: string;
35
+ requestPricing: string;
36
+ commands: string;
37
+ locale: string;
38
+ hourUnit: string;
39
+ minuteUnit: string;
40
+ };
41
+
42
+ export const STRINGS: Record<Lang, Strings> = {
43
+ en: {
44
+ peakTitle: "Warning: Now PEAK",
45
+ offPeakTitle: "Now OFF-PEAK",
46
+ holiday: " (Chinese holiday)",
47
+ computer: "Computer:",
48
+ nextWindow: "Next window:",
49
+ nextIn: (duration) => `in ${duration}`,
50
+ and: "and",
51
+ prices: "DeepSeek prices:",
52
+ pricesState: (state) => `$ per 1M tokens, active: ${state}`,
53
+ activeModel: "← active",
54
+ peakWindows: (zone) => `Peak windows (${zone}):`,
55
+ peakDays: "Mon-Fri",
56
+ offPeak: "Off-peak",
57
+ offPeakNote: "= night, weekends and Chinese holidays",
58
+ priceSource: "Price source:",
59
+ sourceLive: (when, next) => `api-docs.deepseek.com, updated ${when} (next in ${next})`,
60
+ sourceBuiltin: "built-in values (page unavailable)",
61
+ requestPricing: "Search & Fetch:",
62
+ commands: "Commands:",
63
+ locale: "en-GB",
64
+ hourUnit: "h",
65
+ minuteUnit: "m",
66
+ },
67
+ ru: {
68
+ peakTitle: "Warning: Сейчас: PEAK",
69
+ offPeakTitle: "Сейчас: OFF-PEAK",
70
+ holiday: " (праздник Китая)",
71
+ computer: "Компьютер:",
72
+ nextWindow: "Следующее окно:",
73
+ nextIn: (duration) => `через ${duration}`,
74
+ and: "и",
75
+ prices: "Цены DeepSeek:",
76
+ pricesState: (state) => `$ за 1M токенов, активен ${state}`,
77
+ activeModel: "← активна",
78
+ peakWindows: (zone) => `Окна peak (${zone}):`,
79
+ peakDays: "пн-пт",
80
+ offPeak: "Off-peak",
81
+ offPeakNote: "= ночь, выходные и праздники Китая",
82
+ priceSource: "Источник цен:",
83
+ sourceLive: (when, next) => `api-docs.deepseek.com, обновлено ${when} (следующее через ${next})`,
84
+ sourceBuiltin: "встроенные значения (страница недоступна)",
85
+ requestPricing: "Search & Fetch:",
86
+ commands: "Команды:",
87
+ locale: "ru-RU",
88
+ hourUnit: "ч",
89
+ minuteUnit: "м",
90
+ },
91
+ };
92
+
93
+ export const DAY_NAMES: Record<Lang, string[]> = {
94
+ en: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
95
+ ru: ["вс", "пн", "вт", "ср", "чт", "пт", "сб"],
96
+ };
97
+
98
+ /** Translate the docs unit cell ("Per 1,000 successful requests") for display. */
99
+ export function localizeUnit(unit: string, lang: Lang): string {
100
+ if (lang === "ru") {
101
+ if (/1,?000/.test(unit) && /request/i.test(unit)) return "за 1,000 успешных запросов";
102
+ return unit;
103
+ }
104
+ // The page capitalizes the cell; inline text reads better lower-case.
105
+ return unit.length > 0 ? unit.charAt(0).toLowerCase() + unit.slice(1) : unit;
106
+ }
package/src/pricing.ts ADDED
@@ -0,0 +1,289 @@
1
+ /**
2
+ * DeepSeek pricing: parsing of the official docs page plus a small TTL cache.
3
+ *
4
+ * The page is parsed at most once per TTL (default 6 hours). Parsed values are
5
+ * cached on disk so a restart does not hit the network either.
6
+ */
7
+
8
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { dirname } from "node:path";
10
+
11
+ export type Tariff = { cacheHit: number; cacheMiss: number; output: number };
12
+ export type ModelPricing = { peak: Tariff; offPeak: Tariff };
13
+ export type RequestPricing = { api: string; unit: string; price: number };
14
+ export type Pricing = {
15
+ models: Record<string, ModelPricing>;
16
+ requests: RequestPricing[];
17
+ fetchedAt: number;
18
+ source: "live" | "cache" | "builtin";
19
+ };
20
+
21
+ export const PRICING_URL = "https://api-docs.deepseek.com/quick_start/pricing/";
22
+ export const DEFAULT_TTL_HOURS = 6;
23
+
24
+ /** Values from the docs page at the time this package was written. */
25
+ export const BUILTIN_PRICING: Record<string, ModelPricing> = {
26
+ "deepseek-flash": {
27
+ peak: { cacheHit: 0.006, cacheMiss: 0.3, output: 1.2 },
28
+ offPeak: { cacheHit: 0.003, cacheMiss: 0.15, output: 0.6 },
29
+ },
30
+ "deepseek-v4-pro": {
31
+ peak: { cacheHit: 0.044, cacheMiss: 1.32, output: 3.96 },
32
+ offPeak: { cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 },
33
+ },
34
+ };
35
+
36
+ export const BUILTIN_REQUESTS: RequestPricing[] = [
37
+ { api: "Search", unit: "per 1,000 successful requests", price: 0.75 },
38
+ { api: "Fetch", unit: "per 1,000 successful requests", price: 0.75 },
39
+ ];
40
+
41
+ // ------------------------------------------------------------------- parsing
42
+
43
+ function textOf(html: string): string {
44
+ return html
45
+ .replace(/<[^>]*>/g, " ")
46
+ .replace(/&nbsp;/gi, " ")
47
+ .replace(/&amp;/gi, "&")
48
+ .replace(/&lt;/gi, "<")
49
+ .replace(/&gt;/gi, ">")
50
+ .replace(/&quot;/gi, '"')
51
+ .replace(/&#(\d+);/g, (_match, code: string) => String.fromCodePoint(Number(code)))
52
+ .replace(/\s+/g, " ")
53
+ .trim();
54
+ }
55
+
56
+ function money(text: string): number | undefined {
57
+ if (/free/i.test(text)) return 0;
58
+ const value = Number(text.replace(/[^0-9.]/g, ""));
59
+ return Number.isFinite(value) && value >= 0 ? value : undefined;
60
+ }
61
+
62
+ function tableRows(table: string): string[][] {
63
+ return [...table.matchAll(/<tr[\s\S]*?<\/tr>/gi)].map((row) =>
64
+ [...row[0].matchAll(/<t[hd][\s\S]*?<\/t[hd]>/gi)].map((cell) => textOf(cell[0])),
65
+ );
66
+ }
67
+
68
+ function tables(html: string): string[] {
69
+ return [...html.matchAll(/<table[\s\S]*?<\/table>/gi)].map((match) => match[0]);
70
+ }
71
+
72
+ const PRICE_FIELDS: ReadonlyArray<readonly [RegExp, keyof Tariff]> = [
73
+ [/input tokens \(cache hit\)/i, "cacheHit"],
74
+ [/input tokens \(cache miss\)/i, "cacheMiss"],
75
+ [/output tokens/i, "output"],
76
+ ];
77
+
78
+ /** Parse the per-model pricing table. Returns undefined when the table is absent. */
79
+ export function parsePricing(html: string): Record<string, ModelPricing> | undefined {
80
+ for (const table of tables(html)) {
81
+ const rows = tableRows(table);
82
+ const header = rows.find((cells) => cells.some((cell) => /^model$/i.test(cell)));
83
+ if (!header) continue;
84
+
85
+ const headerStart = header.findIndex((cell) => /^model$/i.test(cell)) + 1;
86
+ const names = header
87
+ .slice(headerStart)
88
+ .map((cell) => cell.replace(/\s*\(\d+\)\s*$/, "").trim())
89
+ .filter((name) => name.length > 0);
90
+ if (names.length === 0) continue;
91
+
92
+ const empty = (): Tariff => ({ cacheHit: Number.NaN, cacheMiss: Number.NaN, output: Number.NaN });
93
+ const parsed: Record<string, ModelPricing> = {};
94
+ for (const name of names) parsed[name] = { peak: empty(), offPeak: empty() };
95
+
96
+ let field: keyof Tariff | undefined;
97
+ for (const cells of rows) {
98
+ for (const cell of cells) {
99
+ const match = PRICE_FIELDS.find(([pattern]) => pattern.test(cell));
100
+ if (match) field = match[1];
101
+ }
102
+ const tariffIndex = cells.findIndex((cell) => /^(off[\s-]?peak|peak)$/i.test(cell));
103
+ if (!field || tariffIndex < 0) continue;
104
+
105
+ const currentField = field;
106
+ const tariff: keyof ModelPricing = /^off/i.test(cells[tariffIndex]) ? "offPeak" : "peak";
107
+ const values = cells.slice(tariffIndex + 1).map(money);
108
+ names.forEach((name, index) => {
109
+ const value = values[index];
110
+ if (value !== undefined) parsed[name][tariff][currentField] = value;
111
+ });
112
+ }
113
+
114
+ const complete = Object.entries(parsed).filter(([, model]) =>
115
+ [model.peak, model.offPeak].every(
116
+ (tariff) =>
117
+ Number.isFinite(tariff.cacheHit) &&
118
+ Number.isFinite(tariff.cacheMiss) &&
119
+ Number.isFinite(tariff.output),
120
+ ),
121
+ );
122
+ if (complete.length > 0) return Object.fromEntries(complete);
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ /** Parse the Search / Fetch request pricing table. */
128
+ export function parseRequestPricing(html: string): RequestPricing[] {
129
+ for (const table of tables(html)) {
130
+ const rows = tableRows(table);
131
+ const header = rows.find((cells) => cells.some((cell) => /^api$/i.test(cell)));
132
+ if (!header) continue;
133
+ const apiIndex = header.findIndex((cell) => /^api$/i.test(cell));
134
+ const unitIndex = header.findIndex((cell) => /^unit$/i.test(cell));
135
+ const priceIndex = header.findIndex((cell) => /^price$/i.test(cell));
136
+ if (unitIndex < 0 || priceIndex < 0) continue;
137
+
138
+ const entries: RequestPricing[] = [];
139
+ for (const cells of rows) {
140
+ const api = cells[apiIndex] ?? "";
141
+ if (!/^[A-Za-z][A-Za-z &/-]{1,30}$/.test(api) || /^api$/i.test(api)) continue;
142
+ const price = money(cells[priceIndex] ?? "");
143
+ if (price === undefined) continue;
144
+ entries.push({ api: api.trim(), unit: (cells[unitIndex] ?? "").trim(), price });
145
+ }
146
+ if (entries.length > 0) return entries;
147
+ }
148
+ return [];
149
+ }
150
+
151
+ // --------------------------------------------------------------------- cache
152
+
153
+ export type PricingStoreOptions = {
154
+ cachePath: string;
155
+ ttlMs?: number;
156
+ /** PI_OFFLINE and friends: never touch the network. */
157
+ offline?: boolean;
158
+ fetchImpl?: typeof fetch;
159
+ };
160
+
161
+ export type PricingStore = {
162
+ get(): Pricing;
163
+ /** True when the parsed values are missing or older than the TTL. */
164
+ expired(): boolean;
165
+ /** Load the on-disk cache once, so a restart does not need the network. */
166
+ loadCache(): Pricing;
167
+ /** Parse the page when the TTL expired, or always with `force`. */
168
+ refresh(force?: boolean): Promise<Pricing>;
169
+ setTtl(ms: number): void;
170
+ };
171
+
172
+ export function createPricingStore(options: PricingStoreOptions): PricingStore {
173
+ const { cachePath } = options;
174
+ const fetchImpl = options.fetchImpl ?? fetch;
175
+ const offline = options.offline ?? false;
176
+ let ttlMs = options.ttlMs ?? DEFAULT_TTL_HOURS * 60 * 60 * 1000;
177
+ let loading = false;
178
+ let pricing: Pricing = {
179
+ models: BUILTIN_PRICING,
180
+ requests: BUILTIN_REQUESTS,
181
+ fetchedAt: 0,
182
+ source: "builtin",
183
+ };
184
+
185
+ const readCache = (): Pricing | undefined => {
186
+ try {
187
+ const raw = JSON.parse(readFileSync(cachePath, "utf8")) as {
188
+ models?: unknown;
189
+ requests?: unknown;
190
+ fetchedAt?: unknown;
191
+ };
192
+ if (!raw.models || typeof raw.models !== "object") return undefined;
193
+ const models = raw.models as Record<string, ModelPricing>;
194
+ const valid = Object.values(models).every(
195
+ (model) =>
196
+ typeof model?.peak?.cacheMiss === "number" &&
197
+ typeof model?.offPeak?.cacheMiss === "number" &&
198
+ typeof model?.peak?.output === "number" &&
199
+ typeof model?.offPeak?.output === "number",
200
+ );
201
+ if (!valid) return undefined;
202
+ const requests = Array.isArray(raw.requests)
203
+ ? (raw.requests as RequestPricing[]).filter(
204
+ (entry) => typeof entry?.api === "string" && typeof entry?.price === "number",
205
+ )
206
+ : [];
207
+ return {
208
+ models,
209
+ requests: requests.length > 0 ? requests : BUILTIN_REQUESTS,
210
+ fetchedAt: typeof raw.fetchedAt === "number" ? raw.fetchedAt : 0,
211
+ source: "cache",
212
+ };
213
+ } catch {
214
+ return undefined;
215
+ }
216
+ };
217
+
218
+ const writeCache = (value: Pricing) => {
219
+ try {
220
+ mkdirSync(dirname(cachePath), { recursive: true });
221
+ writeFileSync(
222
+ cachePath,
223
+ `${JSON.stringify({ models: value.models, requests: value.requests, fetchedAt: value.fetchedAt }, null, 2)}\n`,
224
+ );
225
+ } catch {
226
+ // The cache is an optimization only.
227
+ }
228
+ };
229
+
230
+ const expired = () => pricing.source === "builtin" || Date.now() - pricing.fetchedAt >= ttlMs;
231
+
232
+ const refresh = async (force = false): Promise<Pricing> => {
233
+ const fresh = pricing.source !== "builtin" && Date.now() - pricing.fetchedAt < ttlMs;
234
+ if (!force && fresh) return pricing;
235
+ if (loading) return pricing;
236
+ if (offline) return pricing;
237
+
238
+ loading = true;
239
+ try {
240
+ const response = await fetchImpl(PRICING_URL, {
241
+ headers: { accept: "text/html", "user-agent": "pi-deepseek-tariff" },
242
+ signal: AbortSignal.timeout(15_000),
243
+ });
244
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
245
+ const html = await response.text();
246
+ const models = parsePricing(html);
247
+ if (!models) throw new Error("pricing table not found");
248
+ const requests = parseRequestPricing(html);
249
+ pricing = {
250
+ models,
251
+ requests: requests.length > 0 ? requests : BUILTIN_REQUESTS,
252
+ fetchedAt: Date.now(),
253
+ source: "live",
254
+ };
255
+ writeCache(pricing);
256
+ } catch {
257
+ if (pricing.source === "builtin") {
258
+ const cached = readCache();
259
+ if (cached) pricing = cached;
260
+ }
261
+ } finally {
262
+ loading = false;
263
+ }
264
+ return pricing;
265
+ };
266
+
267
+ return {
268
+ get: () => pricing,
269
+ expired,
270
+ loadCache: () => {
271
+ if (pricing.source === "builtin") {
272
+ const cached = readCache();
273
+ if (cached) pricing = cached;
274
+ }
275
+ return pricing;
276
+ },
277
+ refresh,
278
+ setTtl: (ms: number) => {
279
+ if (Number.isFinite(ms) && ms > 0) ttlMs = ms;
280
+ },
281
+ };
282
+ }
283
+
284
+ /** PI_OFFLINE disables automatic network activity; treat 0/false/no as "online". */
285
+ export function isOffline(env: NodeJS.ProcessEnv = process.env): boolean {
286
+ const raw = (env.PI_OFFLINE ?? "").trim().toLowerCase();
287
+ if (raw === "") return false;
288
+ return !["0", "false", "no", "off"].includes(raw);
289
+ }
package/src/rules.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * DeepSeek tariff clock rules and local-time formatting.
3
+ *
4
+ * https://api-docs.deepseek.com/quick_start/pricing/
5
+ * - Peak: 01:00-04:00 and 06:00-10:00 UTC, Monday-Friday
6
+ * - Off-peak: all other hours, weekends and Chinese public holidays
7
+ *
8
+ * Everything here is pure: pass a `Date`, get numbers or strings back. That
9
+ * keeps the rules testable without a TUI and without the network.
10
+ */
11
+
12
+ import { DAY_NAMES, type Lang } from "./i18n.ts";
13
+
14
+ /** Peak windows in UTC hours, [start, end). */
15
+ export const PEAK_WINDOWS_UTC: ReadonlyArray<readonly [number, number]> = [
16
+ [1, 4],
17
+ [6, 10],
18
+ ];
19
+
20
+ const MAX_SCAN_MINUTES = 8 * 24 * 60;
21
+
22
+ /** Short zone names, because ICU reports "GMT+3" for most of them. */
23
+ const TZ_ABBREV: Record<string, string> = {
24
+ "Europe/Moscow": "MSK",
25
+ "Europe/Minsk": "MSK",
26
+ "Europe/Kaliningrad": "KGD",
27
+ "Europe/Samara": "SAMT",
28
+ "Asia/Yekaterinburg": "YEKT",
29
+ "Asia/Omsk": "OMST",
30
+ "Asia/Novosibirsk": "NOVT",
31
+ "Asia/Krasnoyarsk": "KRAT",
32
+ "Asia/Irkutsk": "IRKT",
33
+ "Asia/Yakutsk": "YAKT",
34
+ "Asia/Vladivostok": "VLAT",
35
+ "Asia/Magadan": "MAGT",
36
+ "Asia/Sakhalin": "SAKT",
37
+ "Asia/Kamchatka": "PETT",
38
+ "Europe/Kyiv": "EET",
39
+ "Europe/Kiev": "EET",
40
+ UTC: "UTC",
41
+ "Etc/UTC": "UTC",
42
+ };
43
+
44
+ export const pad2 = (value: number) => String(value).padStart(2, "0");
45
+
46
+ /** Beijing calendar date (UTC+8) as YYYY-MM-DD. */
47
+ export function beijingDateKey(date: Date): string {
48
+ const shifted = new Date(date.getTime() + 8 * 60 * 60 * 1000);
49
+ return shifted.toISOString().slice(0, 10);
50
+ }
51
+
52
+ export function isHoliday(date: Date, holidays: ReadonlySet<string>): boolean {
53
+ return holidays.size > 0 && holidays.has(beijingDateKey(date));
54
+ }
55
+
56
+ export function isPeak(date: Date, holidays: ReadonlySet<string>): boolean {
57
+ if (isHoliday(date, holidays)) return false;
58
+ const weekday = date.getUTCDay();
59
+ if (weekday === 0 || weekday === 6) return false;
60
+ const hour = date.getUTCHours() + date.getUTCMinutes() / 60;
61
+ return PEAK_WINDOWS_UTC.some(([start, end]) => hour >= start && hour < end);
62
+ }
63
+
64
+ /** First minute boundary at which the peak state differs from the state at `from`. */
65
+ export function nextTransition(from: Date, holidays: ReadonlySet<string>): Date {
66
+ const current = isPeak(from, holidays);
67
+ const probe = new Date(from.getTime());
68
+ probe.setUTCSeconds(0, 0);
69
+ probe.setUTCMinutes(probe.getUTCMinutes() + 1);
70
+ for (let i = 0; i < MAX_SCAN_MINUTES; i++) {
71
+ if (isPeak(probe, holidays) !== current) return new Date(probe.getTime());
72
+ probe.setUTCMinutes(probe.getUTCMinutes() + 1);
73
+ }
74
+ return probe;
75
+ }
76
+
77
+ export type LocalParts = { time: string; hm: string; date: string; short: string };
78
+
79
+ export function localParts(date: Date): LocalParts {
80
+ return {
81
+ time: `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`,
82
+ hm: `${pad2(date.getHours())}:${pad2(date.getMinutes())}`,
83
+ date: `${pad2(date.getDate())}-${pad2(date.getMonth() + 1)}-${date.getFullYear()}`,
84
+ short: `${pad2(date.getDate())}-${pad2(date.getMonth() + 1)}`,
85
+ };
86
+ }
87
+
88
+ export function utcStamp(date: Date): string {
89
+ const day = `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;
90
+ return `${day} ${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}`;
91
+ }
92
+
93
+ export function tzLabel(date: Date): string {
94
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
95
+ const known = TZ_ABBREV[zone];
96
+ if (known) return known;
97
+ const part =
98
+ new Intl.DateTimeFormat("en-GB", { timeZoneName: "short" })
99
+ .formatToParts(date)
100
+ .find((item) => item.type === "timeZoneName")?.value ?? "";
101
+ const offset = part.match(/GMT([+-]\d{1,2})(?::(\d{2}))?/i);
102
+ if (offset) {
103
+ const sign = offset[1].startsWith("-") ? "-" : "+";
104
+ return `UTC${sign}${pad2(Math.abs(Number(offset[1])))}:${offset[2] ?? "00"}`;
105
+ }
106
+ return part || "LOCAL";
107
+ }
108
+
109
+ export function clockTime(date: Date, lang: Lang): string {
110
+ const locale = lang === "ru" ? "ru-RU" : "en-GB";
111
+ return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
112
+ }
113
+
114
+ export function humanize(ms: number, lang: Lang = "en"): string {
115
+ const minutes = Math.max(0, Math.round(ms / 60_000));
116
+ const hourUnit = lang === "ru" ? "ч" : "h";
117
+ const minuteUnit = lang === "ru" ? "м" : "m";
118
+ if (minutes < 60) return `${minutes}${minuteUnit}`;
119
+ const hours = Math.floor(minutes / 60);
120
+ const rest = minutes % 60;
121
+ return rest === 0 ? `${hours}${hourUnit}` : `${hours}${hourUnit} ${rest}${minuteUnit}`;
122
+ }
123
+
124
+ /** Local wall-clock time of a UTC hour on the machine's current date. */
125
+ export function localClock(date: Date, utcHour: number): string {
126
+ const instant = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), utcHour, 0, 0));
127
+ return `${pad2(instant.getHours())}:${pad2(instant.getMinutes())}`;
128
+ }
129
+
130
+ /** How many calendar days the local clock is ahead of UTC at that hour. */
131
+ export function localDayShift(date: Date, utcHour: number): number {
132
+ const instant = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), utcHour, 0, 0));
133
+ const utcDay = Date.UTC(instant.getUTCFullYear(), instant.getUTCMonth(), instant.getUTCDate());
134
+ const localDay = Date.UTC(instant.getFullYear(), instant.getMonth(), instant.getDate());
135
+ return Math.round((localDay - utcDay) / 86_400_000);
136
+ }
137
+
138
+ /** "Mon-Fri" shifted by `shift` days, so the label matches local weekdays. */
139
+ export function dayRangeLabel(shift: number, lang: Lang): string {
140
+ const names = DAY_NAMES[lang];
141
+ const start = (1 + shift + 7) % 7;
142
+ const end = (5 + shift + 7) % 7;
143
+ return `${names[start]}-${names[end]}`;
144
+ }
145
+
146
+ /** "04:00-07:00 and 09:00-13:00" for the machine's time zone. */
147
+ export function localPeakWindows(date: Date, lang: Lang): string {
148
+ const joiner = lang === "ru" ? " и " : " and ";
149
+ return PEAK_WINDOWS_UTC.map(([from, to]) => `${localClock(date, from)}-${localClock(date, to)}`).join(joiner);
150
+ }
package/src/view.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Presentation: the footer status line and the panel body.
3
+ *
4
+ * Colors follow the pi theme: labels in the warning token, values in the text
5
+ * token, active rows bold, inactive rows dim.
6
+ */
7
+
8
+ import type { Theme } from "@earendil-works/pi-coding-agent";
9
+ import type { Config } from "./config.ts";
10
+ import { localizeUnit, STRINGS, type Lang } from "./i18n.ts";
11
+ import type { Pricing, RequestPricing } from "./pricing.ts";
12
+ import {
13
+ clockTime,
14
+ dayRangeLabel,
15
+ humanize,
16
+ isHoliday,
17
+ isPeak,
18
+ localDayShift,
19
+ localParts,
20
+ localPeakWindows,
21
+ nextTransition,
22
+ PEAK_WINDOWS_UTC,
23
+ tzLabel,
24
+ utcStamp,
25
+ } from "./rules.ts";
26
+
27
+ /** Placeholder for a full-width rule; the renderer expands it to the panel width. */
28
+ export const SEPARATOR = "\u0000rule";
29
+
30
+ /** "Search & Fetch" value: a shared price is printed once. */
31
+ export function requestPricingText(entries: RequestPricing[], lang: Lang): string | undefined {
32
+ if (entries.length === 0) return undefined;
33
+
34
+ const groups = new Map<string, { price: number; unit: string; apis: string[] }>();
35
+ for (const entry of entries) {
36
+ const key = `${entry.price}|${entry.unit}`;
37
+ const group = groups.get(key) ?? { price: entry.price, unit: entry.unit, apis: [] };
38
+ group.apis.push(entry.api);
39
+ groups.set(key, group);
40
+ }
41
+
42
+ const localized = (unit: string) => localizeUnit(unit, lang);
43
+ const grouped = [...groups.values()];
44
+
45
+ if (grouped.length === 1) {
46
+ const group = grouped[0];
47
+ return `$${group.price.toFixed(2)} ${localized(group.unit)}`.trim();
48
+ }
49
+
50
+ const sharedUnit = grouped.every((group) => group.unit === grouped[0].unit) ? grouped[0].unit : undefined;
51
+ const parts = grouped.map(
52
+ (group) => `${group.apis.join(" / ")} $${group.price.toFixed(2)}${sharedUnit ? "" : ` ${localized(group.unit)}`}`,
53
+ );
54
+ return `${parts.join(" · ")}${sharedUnit ? ` ${localized(sharedUnit)}` : ""}`.trim();
55
+ }
56
+
57
+ export function statusText(theme: Theme, config: Config, lang: Lang, pricing: Pricing): string {
58
+ void pricing;
59
+ const now = new Date();
60
+ const peak = isPeak(now, config.holidays);
61
+ const next = nextTransition(now, config.holidays);
62
+ const label = peak ? "PEAK" : "off-peak";
63
+ const color = theme.fg(peak ? "error" : "success", label);
64
+ const hint = peak ? "→ off-peak" : "→ peak";
65
+ const when = `${clockTime(next, lang)} (${humanize(next.getTime() - now.getTime(), lang)})`;
66
+ return `${theme.fg("dim", "deepseek:")} ${color} ${theme.fg("dim", `${hint} ${when}`)}`;
67
+ }
68
+
69
+ export function panelLines(
70
+ theme: Theme,
71
+ options: { config: Config; lang: Lang; currentModel?: string; pricing: Pricing; ttlMs: number },
72
+ ): string[] {
73
+ const { config, lang, currentModel, pricing, ttlMs } = options;
74
+ const text = STRINGS[lang];
75
+ const now = new Date();
76
+ const peak = isPeak(now, config.holidays);
77
+ const holiday = isHoliday(now, config.holidays);
78
+ const next = nextTransition(now, config.holidays);
79
+ const after = nextTransition(next, config.holidays);
80
+ const zone = tzLabel(now);
81
+ const local = localParts(now);
82
+ const yellow = (value: string) => theme.fg("warning", value);
83
+ const white = (value: string) => theme.fg("text", value);
84
+ const state = peak ? "PEAK" : "OFF-PEAK";
85
+
86
+ const lines: string[] = [];
87
+
88
+ // 1. Current tariff.
89
+ lines.push(
90
+ peak
91
+ ? theme.fg("error", text.peakTitle)
92
+ : theme.fg("success", text.offPeakTitle) + (holiday ? theme.fg("dim", text.holiday) : ""),
93
+ );
94
+
95
+ // 2. Machine clock and UTC.
96
+ lines.push(`${yellow(text.computer)} ${white(`${local.time} ${local.date}(${zone}) | UTC: ${utcStamp(now)}`)}`);
97
+
98
+ // 3. Next window: start - end date(zone) in duration.
99
+ const start = localParts(next);
100
+ const end = localParts(after);
101
+ const windowRange =
102
+ start.date === end.date
103
+ ? `${start.hm} - ${end.hm} ${end.date}(${zone})`
104
+ : `${start.hm} ${start.date} - ${end.hm} ${end.date}(${zone})`;
105
+ lines.push(
106
+ `${yellow(text.nextWindow)} ${white(`${windowRange} ${text.nextIn(humanize(next.getTime() - now.getTime(), lang))}`)}`,
107
+ );
108
+
109
+ // 4. Model prices with the active tariff highlighted.
110
+ lines.push(`${yellow(text.prices)} ${white(text.pricesState(state))}`);
111
+ const value = (amount: number) => amount.toFixed(4).padStart(8);
112
+ for (const [name, model] of Object.entries(pricing.models)) {
113
+ const id = `${config.providerPrefix}${name}`;
114
+ const isCurrent = currentModel === id ? theme.fg("accent", ` ${text.activeModel}`) : "";
115
+ lines.push(` ${theme.fg("muted", id)}${isCurrent}`);
116
+ for (const tariff of ["peak", "offPeak"] as const) {
117
+ const row = model[tariff];
118
+ const label = tariff === "peak" ? "PEAK " : "OFF-PEAK";
119
+ const body = `${label} hit ${value(row.cacheHit)} miss ${value(row.cacheMiss)} out ${value(row.output)}`;
120
+ const active = tariff === (peak ? "peak" : "offPeak");
121
+ lines.push(
122
+ active
123
+ ? theme.bold(theme.fg(peak ? "error" : "success", ` ▸ ${body}`))
124
+ : theme.fg("dim", ` ${body}`),
125
+ );
126
+ }
127
+ }
128
+
129
+ // 5. Search & Fetch request pricing.
130
+ const requests = requestPricingText(pricing.requests, lang);
131
+ if (requests) lines.push(` ${yellow(text.requestPricing)} ${white(requests)}`);
132
+
133
+ // 6. Reference notes, framed by rules.
134
+ lines.push(SEPARATOR);
135
+ lines.push(
136
+ `${theme.fg("dim", text.peakWindows(zone))} ${white(
137
+ `${localPeakWindows(now, lang)}, ${dayRangeLabel(localDayShift(now, PEAK_WINDOWS_UTC[0][0]), lang)}`,
138
+ )}`,
139
+ );
140
+ lines.push(`${theme.fg("dim", text.offPeak)} ${white(text.offPeakNote)}`);
141
+
142
+ // 7. Price source and freshness.
143
+ let source: string;
144
+ if (pricing.fetchedAt === 0) {
145
+ source = text.sourceBuiltin;
146
+ } else {
147
+ const when = new Date(pricing.fetchedAt);
148
+ const stamp = `${localParts(when).hm} ${localParts(when).short}`;
149
+ const remaining = humanize(Math.max(0, pricing.fetchedAt + ttlMs - Date.now()), lang);
150
+ source = text.sourceLive(stamp, remaining);
151
+ }
152
+ lines.push(`${theme.fg("dim", text.priceSource)} ${white(source)}`);
153
+ lines.push(SEPARATOR);
154
+
155
+ // 8. Command hints.
156
+ lines.push(`${theme.fg("dim", text.commands)} ${white("/deepseek-peak on|off|refresh|en|ru")}`);
157
+
158
+ return lines;
159
+ }