@te-river/opencode-alibabatokenplan 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.
@@ -0,0 +1,407 @@
1
+ // buildStatus —— 纯函数(03-D1:`buildStatus(catalog, now)`),输出即 status.json payload。
2
+
3
+ import type { CatalogSource, DiscountEntry, ModelEntry, OfferOrigin, OfferRule, OfferUnit, StatusFile, WindowView } from "./types";
4
+ import { CREDITS_PER_YUAN, TOKEN_PLAN_TEXT_MODELS, BAILIAN_WINDOW_RULES, BAILIAN_SNAPSHOT_AT } from "./bailian-snapshot";
5
+ import { qoderSnapshotOffers, QODER_SNAPSHOT_DATE, QODER_INSALE_FACTORS } from "./qoder-snapshot";
6
+ import { catalogToOffers, mergeOffers, isCrossVerified } from "./merge";
7
+ import { windowState, fmtCountdown } from "./rules";
8
+ import { fmtFactor } from "./text";
9
+
10
+ export const WRITTEN_BY = "@te-river/opencode-alibabatokenplan/server@0.1.0";
11
+ export const STALE_AFTER_SEC = 600;
12
+
13
+ export interface CatalogInput {
14
+ source: CatalogSource; // live | cli | snapshot
15
+ models: ModelEntry[];
16
+ fetchedAt?: string;
17
+ error?: string;
18
+ }
19
+
20
+ export interface QuotaInput {
21
+ available: boolean;
22
+ checkedAt?: string;
23
+ data?: unknown;
24
+ hint?: string;
25
+ }
26
+
27
+ const CATALOG_ORIGIN: Record<CatalogSource, OfferOrigin> = {
28
+ live: "bailian-live",
29
+ cli: "bailian-cli",
30
+ snapshot: "bailian-snapshot",
31
+ };
32
+
33
+ /** m5:id 保留 HHMM(去冒号),同小时不同分钟不再碰撞;offpeak 主窗沿用契约 §2 命名。 */
34
+ export function windowId(start: string, end: string): string {
35
+ if (start === "22:00" && end === "08:00") return "offpeak-22-08"; // 契约 §2 示例命名
36
+ return `win-${start.replace(":", "")}-${end.replace(":", "")}`;
37
+ }
38
+
39
+ /** 全部 OfferRule:Qoder 快照 ∪ 百炼文档窗口规则 ∪ 目录派生(合并去重+排序)。 */
40
+ export function buildOffers(catalog: CatalogInput): OfferRule[] {
41
+ const origin = CATALOG_ORIGIN[catalog.source] ?? "bailian-snapshot";
42
+ // m9:BAILIAN_WINDOW_RULES 是文档快照常量,来源恒标 bailian-snapshot——不随目录源
43
+ // (live/cli)抬升 originRank/双源判定;与目录同键行仍由 mergeOffers 做 origins 并集。
44
+ const legacy: OfferRule[] = BAILIAN_WINDOW_RULES.map((r) => ({
45
+ id: r.id,
46
+ kind: "time-window" as const,
47
+ label: r.label,
48
+ models: [...r.models],
49
+ unit: "cny-price" as const,
50
+ factor: r.factor,
51
+ window: { start: r.start, end: r.end, tz: "Asia/Shanghai" },
52
+ note: r.source,
53
+ links: [],
54
+ origins: ["bailian-snapshot" as const],
55
+ snapshotDate: BAILIAN_SNAPSHOT_AT,
56
+ }));
57
+ return mergeOffers([...qoderSnapshotOffers(), ...legacy, ...catalogToOffers(catalog.models, origin, catalog.fetchedAt?.slice(0, 10))]);
58
+ }
59
+
60
+ function buildWindows(offers: OfferRule[], now: Date): WindowView[] {
61
+ const byWindow = new Map<string, { start: string; end: string; tz: string; rules: OfferRule[] }>();
62
+ for (const r of offers) {
63
+ if (r.kind !== "time-window" || !r.window) continue;
64
+ const key = `${r.window.start}-${r.window.end}@${r.window.tz}`;
65
+ let g = byWindow.get(key);
66
+ if (!g) { g = { ...r.window, rules: [] }; byWindow.set(key, g); }
67
+ g.rules.push(r);
68
+ }
69
+ const out: WindowView[] = [];
70
+ for (const g of byWindow.values()) {
71
+ const w = windowState(g, now);
72
+ const models: WindowView["models"] = [];
73
+ for (const r of g.rules) {
74
+ const cross = isCrossVerified(r);
75
+ for (const m of r.models) {
76
+ models.push({
77
+ model: m,
78
+ regular: fmtFactor(r.baseFactor),
79
+ effective: fmtFactor(r.factor),
80
+ badge: r.label + (cross ? " · 双源印证" : ""),
81
+ origins: r.origins,
82
+ links: r.links,
83
+ });
84
+ }
85
+ }
86
+ models.sort((a, b) => {
87
+ const fa = parseFloat(a.effective ?? "1"), fb = parseFloat(b.effective ?? "1");
88
+ if (fa !== fb) return fa - fb; // 价低先(D2c)
89
+ return a.model.localeCompare(b.model);
90
+ });
91
+ out.push({
92
+ id: windowId(g.start, g.end),
93
+ label: g.rules[0].label,
94
+ start: g.start,
95
+ end: g.end,
96
+ tz: g.tz,
97
+ activeNow: w.active,
98
+ boundaryKind: w.boundaryKind,
99
+ nextBoundaryAt: w.nextBoundaryAt,
100
+ models,
101
+ });
102
+ }
103
+ // offpeak 主窗排前
104
+ out.sort((a, b) => Number(b.id.startsWith("offpeak")) - Number(a.id.startsWith("offpeak")) || a.start.localeCompare(b.start));
105
+ return out;
106
+ }
107
+
108
+ function buildDiscounts(offers: OfferRule[]): DiscountEntry[] {
109
+ const byModel = new Map<string, OfferRule[]>();
110
+ for (const r of offers) {
111
+ if (r.kind === "free-calls" || r.kind === "policy") continue;
112
+ for (const m of r.models) {
113
+ const arr = byModel.get(m) ?? [];
114
+ arr.push(r);
115
+ byModel.set(m, arr);
116
+ }
117
+ }
118
+ const out: DiscountEntry[] = [];
119
+ for (const [model, applies] of byModel) {
120
+ // m3:按 unit 分桶各取数值最小——credits-factor 与 cny-price 跨单位比大小无意义
121
+ const bestFactors: Partial<Record<OfferUnit, number>> = {};
122
+ for (const r of applies) {
123
+ if (r.factor === undefined) continue;
124
+ const cur = bestFactors[r.unit];
125
+ if (cur === undefined || r.factor < cur) bestFactors[r.unit] = r.factor;
126
+ }
127
+ out.push({
128
+ model,
129
+ applies,
130
+ ...(Object.keys(bestFactors).length ? { bestFactors } : {}),
131
+ });
132
+ }
133
+ // 与 offers 合并序一致:价低先(取各 unit 桶最小值中的最小作为展示序)、系列序(qwen→deepseek/glm→其他)沿用旧排序精神
134
+ const lowest = (d: DiscountEntry) => {
135
+ const vs = Object.values(d.bestFactors ?? {});
136
+ return vs.length ? Math.min(...vs) : 1;
137
+ };
138
+ const seriesOrder = (n: string) => (/^qwen/.test(n) ? 0 : /^(deepseek|glm|kimi|minimax)/.test(n) ? 1 : 2);
139
+ out.sort((a, b) => {
140
+ const fa = lowest(a), fb = lowest(b);
141
+ if (fa !== fb) return fa - fb;
142
+ const sd = seriesOrder(a.model) - seriesOrder(b.model);
143
+ if (sd) return sd;
144
+ return a.model.localeCompare(b.model);
145
+ });
146
+ return out;
147
+ }
148
+
149
+ function pickPrice(prices: ModelEntry["prices"], type: string, band?: string) {
150
+ return prices.find((p) => p.type === type && (band ? p.band === band : !p.band || p.band === "standard"));
151
+ }
152
+
153
+ function creditsPerM(price: number): string {
154
+ const v = price * CREDITS_PER_YUAN;
155
+ return Number.isInteger(v) ? v.toLocaleString("en-US") : String(Number(v.toFixed(1)));
156
+ }
157
+
158
+ function buildTextModels(models: ModelEntry[], windows: WindowView[], now: Date): StatusFile["textModels"] {
159
+ return TOKEN_PLAN_TEXT_MODELS.map((name) => {
160
+ const m = models.find((x) => x.model === name) || models.find((x) => x.family === name);
161
+ let badge: StatusFile["textModels"][number]["badge"];
162
+ for (const w of windows) {
163
+ if (w.models.some((x) => x.model === name)) {
164
+ const ws = windowState(w, now);
165
+ badge = {
166
+ label: w.label + (w.models.find((x) => x.model === name)?.badge?.includes("双源") ? " · 双源印证" : ""),
167
+ countdown: ws.active
168
+ ? `${fmtCountdown(ws.minutesToNextBoundary)}后结束(${w.end})`
169
+ : `${fmtCountdown(ws.minutesToNextBoundary)}后开始(${w.start})`,
170
+ };
171
+ break;
172
+ }
173
+ }
174
+ const insale = QODER_INSALE_FACTORS[name];
175
+ if (!m) {
176
+ return { model: name, input: "—", cache: "—", output: "—", ...(insale ? { creditsFactor: fmtFactor(insale.factor) } : {}), ...(badge ? { badge } : {}) };
177
+ }
178
+ const peakIn = pickPrice(m.prices, "input_token", "peak");
179
+ const offIn = pickPrice(m.prices, "input_token", "offpeak");
180
+ const stdIn = pickPrice(m.prices, "input_token");
181
+ const stdOut = pickPrice(m.prices, "output_token", "peak") || pickPrice(m.prices, "output_token");
182
+ const offOut = pickPrice(m.prices, "output_token", "offpeak");
183
+ const stdCache = pickPrice(m.prices, "input_token_cache", "peak") || pickPrice(m.prices, "input_token_cache") || pickPrice(m.prices, "input_token_cache_read");
184
+ const offCache = pickPrice(m.prices, "input_token_cache", "offpeak") || pickPrice(m.prices, "input_token_cache_read", "offpeak");
185
+ const dual = !!(peakIn && offIn);
186
+ const pair = (a?: { price: number }, b?: { price: number }) => a ? (b ? `${creditsPerM(a.price)} / ${creditsPerM(b.price)}` : creditsPerM(a.price)) : "—";
187
+ return {
188
+ model: name,
189
+ input: dual ? pair(peakIn, offIn) : pair(stdIn),
190
+ cache: dual ? pair(stdCache, offCache) : pair(stdCache),
191
+ output: dual ? pair(stdOut, offOut) : pair(stdOut),
192
+ ...(dual ? { bands: "峰/谷" as const } : {}),
193
+ ...(insale ? { creditsFactor: fmtFactor(insale.factor) } : {}),
194
+ ...(badge ? { badge } : {}),
195
+ };
196
+ });
197
+ }
198
+
199
+ function buildNotices(offers: OfferRule[], now: Date): StatusFile["notices"] {
200
+ return offers
201
+ .filter((r) => r.kind === "free-calls" || r.kind === "policy")
202
+ .map((r) => ({
203
+ kind: r.kind as "free-calls" | "policy",
204
+ text: `${r.label}${r.note ? " — " + r.note : ""}`,
205
+ source: r.origins.includes("qoder-snapshot") ? `Qoder 快照 ${r.snapshotDate ?? QODER_SNAPSHOT_DATE}` : (r.origins[0] ?? "unknown"),
206
+ expired: r.expiry ? new Date(r.expiry).getTime() < now.getTime() : undefined,
207
+ }));
208
+ }
209
+
210
+ /** 纯函数:目录 + now → status.json payload(schema 见契约 §2)。 */
211
+ export function buildStatus(catalog: CatalogInput, now: Date = new Date(), quota?: QuotaInput): StatusFile {
212
+ const offers = buildOffers(catalog);
213
+ const windows = buildWindows(offers, now);
214
+ return {
215
+ schemaVersion: 1,
216
+ writtenBy: WRITTEN_BY,
217
+ updatedAt: now.toISOString(),
218
+ staleAfterSec: STALE_AFTER_SEC,
219
+ timezone: "Asia/Shanghai",
220
+ creditsPerYuan: CREDITS_PER_YUAN,
221
+ creditsRateNote: "据百炼计费示例换算,非官方汇率",
222
+ sources: {
223
+ catalog: CATALOG_ORIGIN[catalog.source] ?? "bailian-snapshot",
224
+ qoderSnapshot: QODER_SNAPSHOT_DATE,
225
+ ...(catalog.error ? { error: catalog.error } : {}),
226
+ },
227
+ windows,
228
+ offers,
229
+ discounts: buildDiscounts(offers),
230
+ textModels: buildTextModels(catalog.models, windows, now),
231
+ quota: quota ?? { available: false, hint: "实时额度需: bl auth login --console --console-site domestic" },
232
+ notices: buildNotices(offers, now),
233
+ };
234
+ }
235
+
236
+ /** status.json 陈旧判定(契约 §2:TUI 侧复用)。 */
237
+ export function isStale(st: StatusFile, now = new Date()): boolean {
238
+ const t = new Date(st.updatedAt).getTime();
239
+ if (Number.isNaN(t)) return true;
240
+ return now.getTime() - t > st.staleAfterSec * 1000;
241
+ }
242
+
243
+ // ───────────────────────── 运行时契约(03-D3 / 契约 §2 §4) ─────────────────────────
244
+ // 路径、配置与 status.json 原子读写。放本文件(而非新增 core 模块)以贴合 03 包结构树;
245
+ // 只用 node 内建,仍满足「core 不 import opencode,可 node 直测」。
246
+
247
+ import * as fs from "node:fs";
248
+ import * as path from "node:path";
249
+ import * as os from "node:os";
250
+
251
+ export const STATUS_SIZE_CAP = 256 * 1024; // 目标 ≤256KB(超限 server 截断)
252
+
253
+ export function stateDir(): string {
254
+ return process.env.ATP_STATE_DIR || path.join(os.homedir(), ".cache", "opencode", "alibabatokenplan");
255
+ }
256
+ export function statusFilePath(): string {
257
+ return path.join(stateDir(), "status.json");
258
+ }
259
+ export function cacheFilePath(): string {
260
+ return path.join(stateDir(), "cache.json");
261
+ }
262
+ export function portFilePath(): string {
263
+ return path.join(stateDir(), ".port");
264
+ }
265
+ /** 旧缓存路径(一次性迁移读源)。 */
266
+ export function legacyCacheFile(): string {
267
+ return path.join(os.homedir(), ".cache", "opencode", "bailian-discount-cache.json");
268
+ }
269
+ export function legacyPortFile(): string {
270
+ return path.join(os.homedir(), ".cache", "opencode", "bailian-discount-panel.port");
271
+ }
272
+ export function configFilePath(): string {
273
+ return process.env.ATP_CONFIG_DIR
274
+ ? path.join(process.env.ATP_CONFIG_DIR, "config.jsonc")
275
+ : path.join(os.homedir(), ".config", "opencode", "alibabatokenplan", "config.jsonc");
276
+ }
277
+
278
+ /** 最小 JSONC 解析(扫描器实现,字符串内的 // 与 , 不受影响)。 */
279
+ export function parseJsonc(text: string): unknown {
280
+ // pass 1: 去注释(跟踪字符串态)
281
+ let out = "";
282
+ let i = 0;
283
+ let inStr = false;
284
+ let esc = false;
285
+ while (i < text.length) {
286
+ const c = text[i];
287
+ const n = text[i + 1];
288
+ if (inStr) {
289
+ out += c;
290
+ if (esc) esc = false;
291
+ else if (c === "\\") esc = true;
292
+ else if (c === '"') inStr = false;
293
+ i++;
294
+ continue;
295
+ }
296
+ if (c === '"') { inStr = true; out += c; i++; continue; }
297
+ if (c === "/" && n === "/") { while (i < text.length && text[i] !== "\n") i++; continue; }
298
+ if (c === "/" && n === "*") {
299
+ i += 2;
300
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
301
+ i += 2;
302
+ continue;
303
+ }
304
+ out += c;
305
+ i++;
306
+ }
307
+ // pass 2: 去尾逗号(扫描逗号后仅空白即为 }/] 的情况,同样跟踪字符串态)
308
+ let res = "";
309
+ inStr = false; esc = false; i = 0;
310
+ while (i < out.length) {
311
+ const c = out[i];
312
+ if (inStr) {
313
+ res += c;
314
+ if (esc) esc = false;
315
+ else if (c === "\\") esc = true;
316
+ else if (c === '"') inStr = false;
317
+ i++;
318
+ continue;
319
+ }
320
+ if (c === '"') { inStr = true; res += c; i++; continue; }
321
+ if (c === ",") {
322
+ let j = i + 1;
323
+ while (j < out.length && /\s/.test(out[j])) j++;
324
+ if (out[j] === "}" || out[j] === "]") { i++; continue; } // 丢弃尾逗号
325
+ }
326
+ res += c;
327
+ i++;
328
+ }
329
+ return JSON.parse(res);
330
+ }
331
+
332
+ export interface AtpConfig {
333
+ webpanel: { enabled: boolean; port: number };
334
+ sidebar: { order: number; showNonTokenPlan: boolean };
335
+ toast: boolean;
336
+ refresh: { catalogMin: number; quotaMin: number };
337
+ }
338
+
339
+ export const DEFAULT_CONFIG: AtpConfig = {
340
+ webpanel: { enabled: false, port: 7777 },
341
+ sidebar: { order: 160, showNonTokenPlan: false },
342
+ toast: true,
343
+ refresh: { catalogMin: 10, quotaMin: 5 },
344
+ };
345
+
346
+ export function loadConfig(): AtpConfig {
347
+ const d = structuredClone(DEFAULT_CONFIG);
348
+ try {
349
+ const raw = parseJsonc(fs.readFileSync(configFilePath(), "utf8")) as any;
350
+ if (raw && typeof raw === "object") {
351
+ if (raw.webpanel?.enabled !== undefined) d.webpanel.enabled = !!raw.webpanel.enabled;
352
+ // Nit: 端口须落在可绑定的非特权区间 [1024,65535],否则回退默认(避免 bind EACCES/无效端口)
353
+ if (Number.isInteger(raw.webpanel?.port) && raw.webpanel.port >= 1024 && raw.webpanel.port <= 65535) d.webpanel.port = raw.webpanel.port;
354
+ if (Number.isInteger(raw.sidebar?.order)) d.sidebar.order = raw.sidebar.order;
355
+ if (raw.sidebar?.showNonTokenPlan !== undefined) d.sidebar.showNonTokenPlan = !!raw.sidebar.showNonTokenPlan;
356
+ if (raw.toast !== undefined) d.toast = !!raw.toast;
357
+ if (Number.isInteger(raw.refresh?.catalogMin) && raw.refresh.catalogMin > 0) d.refresh.catalogMin = raw.refresh.catalogMin;
358
+ if (Number.isInteger(raw.refresh?.quotaMin) && raw.refresh.quotaMin > 0) d.refresh.quotaMin = raw.refresh.quotaMin;
359
+ }
360
+ } catch {
361
+ /* 缺失/坏 JSON = 全默认(契约 §4) */
362
+ }
363
+ return d;
364
+ }
365
+
366
+ /** 原子写:status.json.tmp → renameSync(同卷);≤256KB,超限截断大数组段;写失败静默。 */
367
+ export function writeStatusAtomic(st: StatusFile): boolean {
368
+ try {
369
+ const dir = stateDir();
370
+ fs.mkdirSync(dir, { recursive: true });
371
+ let payload = JSON.stringify(st);
372
+ if (Buffer.byteLength(payload, "utf8") > STATUS_SIZE_CAP) {
373
+ const shrunk: StatusFile = { ...st, textModels: st.textModels.slice(0, 20), discounts: st.discounts.slice(0, 60), offers: st.offers.slice(0, 60), windows: st.windows.slice(0, 8) };
374
+ payload = JSON.stringify(shrunk);
375
+ if (Buffer.byteLength(payload, "utf8") > STATUS_SIZE_CAP) payload = JSON.stringify({ ...shrunk, textModels: [], discounts: [] });
376
+ }
377
+ const tmp = path.join(dir, "status.json.tmp");
378
+ fs.writeFileSync(tmp, payload);
379
+ fs.renameSync(tmp, path.join(dir, "status.json"));
380
+ return true;
381
+ } catch {
382
+ return false; // 静默重试下轮(03-D3)
383
+ }
384
+ }
385
+
386
+ export type StatusRead =
387
+ | { kind: "live"; status: StatusFile }
388
+ | { kind: "missing" }
389
+ | { kind: "stale"; status: StatusFile }
390
+ | { kind: "corrupt"; error: string };
391
+
392
+ /** TUI 只读侧:ENOENT/坏 JSON/schemaVersion≠1/超 staleAfterSec → 非 live。 */
393
+ export function readStatusFile(now = new Date()): StatusRead {
394
+ let text: string;
395
+ try {
396
+ text = fs.readFileSync(statusFilePath(), "utf8");
397
+ } catch {
398
+ return { kind: "missing" };
399
+ }
400
+ try {
401
+ const j = JSON.parse(text) as StatusFile;
402
+ if (!j || j.schemaVersion !== 1 || !Array.isArray(j.windows)) return { kind: "corrupt", error: "schema" };
403
+ return isStale(j, now) ? { kind: "stale", status: j } : { kind: "live", status: j };
404
+ } catch (e: any) {
405
+ return { kind: "corrupt", error: String(e?.message || e) };
406
+ }
407
+ }
@@ -0,0 +1,68 @@
1
+ // 展示层格式化工具(契约 §3 展示规则 E 表转录)。纯函数,可 node 直测。
2
+
3
+ import type { StatusFile } from "./types";
4
+ import { fmtCountdown, fmtShortDur, windowState } from "./rules";
5
+
6
+ /** 倍率串:f.toFixed(2) 去尾 0 + "x"(E 表原文规则)。 */
7
+ export function fmtFactor(f?: number): string | undefined {
8
+ if (f === undefined || !Number.isFinite(f)) return undefined;
9
+ let s = f.toFixed(2);
10
+ s = s.replace(/0+$/, "").replace(/\.$/, ".0");
11
+ return s + "x";
12
+ }
13
+
14
+ /** 划线价仅当 base 与 cur 字符串不等才划线(E 表原文规则)。 */
15
+ export function showStrike(base?: number, cur?: number): boolean {
16
+ const b = fmtFactor(base), c = fmtFactor(cur);
17
+ return !!b && !!c && b !== c;
18
+ }
19
+
20
+ /** 用组合删除线字符模拟终端删除线(03-D4「删除线文本模拟」)。 */
21
+ export function strikethrough(s: string): string {
22
+ return s.replace(/[^\u0300-\u036f]/g, (c) => c + "\u0336");
23
+ }
24
+
25
+ /** 「N 后开始(22:00)」/「N 后结束(08:00)」(旧 countdown 文案语义)。 */
26
+ export function windowCountdownText(start: string, end: string, now = new Date()): string {
27
+ const w = windowState({ start, end }, now);
28
+ return w.active
29
+ ? `${fmtCountdown(w.minutesToNextBoundary)}后结束(${end})`
30
+ : `${fmtCountdown(w.minutesToNextBoundary)}后开始(${start})`;
31
+ }
32
+
33
+ /** 来源徽标行(sidebar 首行 / 工具头部)。 */
34
+ export function sourceBadgeLine(st: StatusFile): string {
35
+ const cat = st.sources.catalog;
36
+ const catLabel = cat === "qoder-snapshot" ? "Qoder快照" : cat === "bailian-live" ? "实时" : cat === "bailian-cli" ? "CLI" : "快照";
37
+ const t = st.updatedAt ? new Date(st.updatedAt) : undefined;
38
+ const hhmm = t && !Number.isNaN(t.getTime())
39
+ ? t.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "Asia/Shanghai" })
40
+ : "";
41
+ return `源:${catLabel}${hhmm ? " " + hhmm : ""} · Qoder快照${st.sources.qoderSnapshot.slice(5)}`;
42
+ }
43
+
44
+ /**
45
+ * home_bottom 一行摘要:
46
+ * `错峰 22:00–08:00 · qwen3.8-max×0.5/×0.25 · 距开始 5h23m · 源:Qoder快照08-31`
47
+ */
48
+ export function homeSummaryLine(st: StatusFile, now = new Date()): string {
49
+ const w = st.windows.find((x) => x.id.startsWith("offpeak")) ?? st.windows[0];
50
+ if (!w) return "Token Plan 暂无已知优惠";
51
+ const starModel =
52
+ w.models.find((m) => m.model === "qwen3.8-max") ??
53
+ w.models.find((m) => m.model.startsWith("qwen")) ??
54
+ w.models[0];
55
+ const parts: string[] = [`错峰 ${w.start}–${w.end}`];
56
+ if (starModel) {
57
+ const seg = starModel.regular && starModel.effective && starModel.regular !== starModel.effective
58
+ ? `${starModel.model}×${starModel.regular.replace("x", "")}/×${starModel.effective.replace("x", "")}`
59
+ : `${starModel.model}×${(starModel.effective ?? starModel.regular ?? "").replace("x", "")}`;
60
+ parts.push(seg);
61
+ }
62
+ // m4:本地按 start/end(HH:MM, tz) 重算下一边界——stale 文件里的绝对 nextBoundaryAt
63
+ // 已冻结(旧值),重算保证 home 摘要倒计时持续走字;active 判定同步重算。
64
+ const ws = windowState(w, now);
65
+ parts.push(`${ws.active ? "距结束" : "距开始"} ${fmtShortDur(ws.nextBoundaryAtMs - now.getTime())}`);
66
+ parts.push(sourceBadgeLine(st));
67
+ return parts.join(" · ");
68
+ }
@@ -0,0 +1,102 @@
1
+ // 核心类型 —— 原文照 04 号契约 §1。core 层不 import opencode,可 node 直测。
2
+
3
+ export type OfferOrigin = "bailian-live" | "bailian-cli" | "bailian-snapshot" | "qoder-snapshot";
4
+ export type OfferKind = "time-window" | "flat-cut" | "free-calls" | "policy";
5
+ export type OfferUnit = "credits-factor" | "cny-price"; // 两单位永不互算
6
+
7
+ export interface OfferRule {
8
+ id: string;
9
+ kind: OfferKind;
10
+ label: string; // label 如 "错峰 5 折"
11
+ models: string[]; // 生态内 canonical id, 小写
12
+ unit: OfferUnit;
13
+ factor?: number;
14
+ baseFactor?: number; // baseFactor=划线原价倍率
15
+ window?: { start: string; end: string; tz: string }; // "HH:MM", 跨午夜=start>end
16
+ batchStack?: number; // 仅百炼 Batch 叠加(03/D2c)
17
+ note?: string;
18
+ expiry?: string; // free-calls/policy 文案与截止
19
+ links: string[];
20
+ origins: OfferOrigin[]; // origins 并集=双源印证
21
+ snapshotDate?: string;
22
+ }
23
+
24
+ export interface DiscountEntry {
25
+ model: string;
26
+ applies: OfferRule[];
27
+ /** m3:按 unit 分桶各取数值最小(两套单位不互算,跨单位比较无意义,故弃单值 bestFactor/bestRuleId) */
28
+ bestFactors?: Partial<Record<OfferUnit, number>>;
29
+ }
30
+
31
+ export interface WindowView {
32
+ id: string;
33
+ label: string;
34
+ start: string;
35
+ end: string;
36
+ tz: string;
37
+ activeNow: boolean;
38
+ boundaryKind: "start" | "end";
39
+ nextBoundaryAt: string; // ISO 绝对时刻, TUI 每秒本地 tick
40
+ models: {
41
+ model: string;
42
+ regular?: string;
43
+ effective?: string;
44
+ badge?: string;
45
+ origins: OfferOrigin[];
46
+ links: string[];
47
+ }[];
48
+ }
49
+
50
+ export interface StatusFile {
51
+ schemaVersion: 1;
52
+ writtenBy: string;
53
+ updatedAt: string;
54
+ staleAfterSec: number;
55
+ timezone: "Asia/Shanghai";
56
+ creditsPerYuan: 250;
57
+ creditsRateNote: "据百炼计费示例换算,非官方汇率";
58
+ sources: { catalog: OfferOrigin; qoderSnapshot: "2026-08-31"; error?: string };
59
+ windows: WindowView[];
60
+ offers: OfferRule[];
61
+ discounts: DiscountEntry[];
62
+ textModels: {
63
+ model: string;
64
+ input: string;
65
+ cache: string;
66
+ output: string;
67
+ bands?: "峰/谷";
68
+ creditsFactor?: string;
69
+ badge?: { label: string; countdown: string };
70
+ }[];
71
+ quota: { available: boolean; checkedAt?: string; data?: unknown; hint?: string };
72
+ notices: { kind: "free-calls" | "policy"; text: string; source: string; expired?: boolean }[];
73
+ }
74
+
75
+ // ── 数据层辅助类型(移植自 bailian-discount-panel.ts) ──
76
+
77
+ export interface PriceEntry {
78
+ name: string;
79
+ type: string;
80
+ price: number;
81
+ unit: string;
82
+ band?: string;
83
+ discount?: number;
84
+ range?: string;
85
+ }
86
+
87
+ export interface ModelEntry {
88
+ family: string;
89
+ model: string;
90
+ provider: string;
91
+ context?: number;
92
+ prices: PriceEntry[];
93
+ }
94
+
95
+ export type CatalogSource = "live" | "cli" | "snapshot";
96
+
97
+ export interface CatalogModel {
98
+ source: CatalogSource;
99
+ fetchedAt: string;
100
+ models: ModelEntry[];
101
+ error?: string;
102
+ }