@rexvane/piweb 0.3.10 → 0.3.12

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.
Files changed (193) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +7 -6
  3. package/.next/build-manifest.json +6 -6
  4. package/.next/prerender-manifest.json +7 -7
  5. package/.next/react-loadable-manifest.json +7 -7
  6. package/.next/routes-manifest.json +6 -0
  7. package/.next/server/app/_global-error/page.js +2 -2
  8. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/_global-error.html +1 -1
  10. package/.next/server/app/_global-error.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/server/app/_not-found/page.js +1 -1
  15. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app/_not-found.html +1 -1
  17. package/.next/server/app/_not-found.rsc +2 -2
  18. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  19. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  20. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  21. package/.next/server/app/api/agent/[id]/events/route.js +2 -2
  22. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  23. package/.next/server/app/api/agent/[id]/route.js +1 -1
  24. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  25. package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
  26. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  27. package/.next/server/app/api/agent/new/route.js +1 -1
  28. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  29. package/.next/server/app/api/config/route.js +1 -1
  30. package/.next/server/app/api/files/route.js +1 -1
  31. package/.next/server/app/api/git/route.js +2 -2
  32. package/.next/server/app/api/growth/route.js +1 -1
  33. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  34. package/.next/server/app/api/health/route.js +1 -1
  35. package/.next/server/app/api/models/route.js +1 -1
  36. package/.next/server/app/api/models/route.js.nft.json +1 -1
  37. package/.next/server/app/api/pi-settings/route.js +1 -1
  38. package/.next/server/app/api/plugins/route.js +1 -1
  39. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  40. package/.next/server/app/api/prompts/route.js +1 -0
  41. package/.next/server/app/api/prompts/route.js.nft.json +1 -0
  42. package/.next/server/app/api/prompts/route_client-reference-manifest.js +1 -0
  43. package/.next/server/app/api/security/route.js +1 -1
  44. package/.next/server/app/api/security/route.js.nft.json +1 -1
  45. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  46. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  47. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  48. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  49. package/.next/server/app/api/sessions/route.js +1 -1
  50. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  51. package/.next/server/app/api/skills/route.js +2 -2
  52. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  53. package/.next/server/app/api/update/route.js +2 -2
  54. package/.next/server/app/api/update/route.js.nft.json +1 -1
  55. package/.next/server/app/api/version/route.js +1 -1
  56. package/.next/server/app/api/web-auth/route.js +1 -1
  57. package/.next/server/app/api/workspaces/route.js +1 -1
  58. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  59. package/.next/server/app/icon.svg/route.js +1 -1
  60. package/.next/server/app/index.html +1 -1
  61. package/.next/server/app/index.rsc +4 -4
  62. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  63. package/.next/server/app/index.segments/_full.segment.rsc +4 -4
  64. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  65. package/.next/server/app/login/page.js +1 -1
  66. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  67. package/.next/server/app/login.html +1 -1
  68. package/.next/server/app/login.rsc +3 -3
  69. package/.next/server/app/login.segments/_full.segment.rsc +3 -3
  70. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  71. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
  72. package/.next/server/app/page.js +1 -1
  73. package/.next/server/app/page_client-reference-manifest.js +1 -1
  74. package/.next/server/app-paths-manifest.json +7 -6
  75. package/.next/server/chunks/229.js +1 -0
  76. package/.next/server/chunks/496.js +1 -1
  77. package/.next/server/chunks/686.js +2 -2
  78. package/.next/server/middleware-build-manifest.js +1 -1
  79. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  80. package/.next/server/pages/404.html +1 -1
  81. package/.next/server/pages/500.html +1 -1
  82. package/.next/server/server-reference-manifest.json +1 -1
  83. package/.next/static/2kt2UiWjv_viXpNlfjvBT/_buildManifest.js +1 -0
  84. package/.next/static/chunks/2155.2f27fe7098987699.js +1 -0
  85. package/.next/static/chunks/2608.ec8ff1f5767c9056.js +1 -0
  86. package/.next/static/chunks/2716.a9e808190a4e2424.js +1 -0
  87. package/.next/static/chunks/{794-d1a40dcf24d651d9.js → 3794-3679f1c05160977c.js} +1 -1
  88. package/.next/static/chunks/4127.dff5c3b33c6bfd1b.js +1 -0
  89. package/.next/static/chunks/4673.d3d0a7f2599080fb.js +1 -0
  90. package/.next/static/chunks/4686-5bbee099126af797.js +1 -0
  91. package/.next/static/chunks/4bd1b696-93bf506b236d6248.js +1 -0
  92. package/.next/static/chunks/app/_global-error/page-961c055baebb3872.js +1 -0
  93. package/.next/static/chunks/app/_not-found/page-2d40d1d7d916666a.js +1 -0
  94. package/.next/static/chunks/app/api/agent/[id]/events/route-961c055baebb3872.js +1 -0
  95. package/.next/static/chunks/app/api/agent/[id]/route-961c055baebb3872.js +1 -0
  96. package/.next/static/chunks/app/api/agent/[id]/tree/route-961c055baebb3872.js +1 -0
  97. package/.next/static/chunks/app/api/agent/new/route-961c055baebb3872.js +1 -0
  98. package/.next/static/chunks/app/api/config/route-961c055baebb3872.js +1 -0
  99. package/.next/static/chunks/app/api/files/route-961c055baebb3872.js +1 -0
  100. package/.next/static/chunks/app/api/git/route-961c055baebb3872.js +1 -0
  101. package/.next/static/chunks/app/api/growth/route-961c055baebb3872.js +1 -0
  102. package/.next/static/chunks/app/api/health/route-961c055baebb3872.js +1 -0
  103. package/.next/static/chunks/app/api/models/route-961c055baebb3872.js +1 -0
  104. package/.next/static/chunks/app/api/pi-settings/route-961c055baebb3872.js +1 -0
  105. package/.next/static/chunks/app/api/plugins/route-961c055baebb3872.js +1 -0
  106. package/.next/static/chunks/app/api/prompts/route-961c055baebb3872.js +1 -0
  107. package/.next/static/chunks/app/api/security/route-961c055baebb3872.js +1 -0
  108. package/.next/static/chunks/app/api/sessions/[id]/export/route-961c055baebb3872.js +1 -0
  109. package/.next/static/chunks/app/api/sessions/[id]/route-961c055baebb3872.js +1 -0
  110. package/.next/static/chunks/app/api/sessions/route-961c055baebb3872.js +1 -0
  111. package/.next/static/chunks/app/api/skills/route-961c055baebb3872.js +1 -0
  112. package/.next/static/chunks/app/api/update/route-961c055baebb3872.js +1 -0
  113. package/.next/static/chunks/app/api/version/route-961c055baebb3872.js +1 -0
  114. package/.next/static/chunks/app/api/web-auth/route-961c055baebb3872.js +1 -0
  115. package/.next/static/chunks/app/api/workspaces/route-961c055baebb3872.js +1 -0
  116. package/.next/static/chunks/app/layout-50d06e2078d6ce76.js +1 -0
  117. package/.next/static/chunks/app/login/page-52ef4841cbd199a8.js +1 -0
  118. package/.next/static/chunks/app/page-00c8ebf40ec5eaca.js +1 -0
  119. package/.next/static/chunks/framework-c4e651fe89edfa91.js +1 -0
  120. package/.next/static/chunks/{main-a315d7c58b599962.js → main-6159b22a8cb0b7c5.js} +2 -2
  121. package/.next/static/chunks/main-app-e9b53f0e1c6591b2.js +1 -0
  122. package/.next/static/chunks/next/dist/client/components/builtin/app-error-961c055baebb3872.js +1 -0
  123. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-961c055baebb3872.js +1 -0
  124. package/.next/static/chunks/next/dist/client/components/builtin/global-error-a7e260f8cc2582d7.js +1 -0
  125. package/.next/static/chunks/next/dist/client/components/builtin/not-found-961c055baebb3872.js +1 -0
  126. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-961c055baebb3872.js +1 -0
  127. package/.next/static/chunks/webpack-05a9e85529a18b93.js +1 -0
  128. package/.next/static/css/b2d35abc828d4713.css +1 -0
  129. package/.next/types/app/api/prompts/route.ts +353 -0
  130. package/.next/types/routes.d.ts +2 -1
  131. package/.next/types/validator.ts +9 -0
  132. package/package.json +1 -1
  133. package/src/app/api/prompts/route.ts +51 -0
  134. package/src/components/AppShell.tsx +42 -19
  135. package/src/components/ChatWindow.tsx +63 -36
  136. package/src/components/PromptPanel.tsx +257 -0
  137. package/src/components/SettingsPanel.tsx +183 -55
  138. package/src/hooks/usePiWeb.ts +13 -1
  139. package/src/i18n.tsx +80 -2
  140. package/src/lib/agent-manager.ts +47 -8
  141. package/src/lib/clipboard.ts +27 -0
  142. package/src/lib/command-validation.ts +1 -0
  143. package/src/lib/event-batcher.ts +62 -0
  144. package/src/lib/models-service.ts +430 -19
  145. package/src/lib/prompt-sources.ts +201 -0
  146. package/src/lib/provider-usage.ts +91 -0
  147. package/src/lib/session-retention.ts +119 -0
  148. package/src/lib/types.ts +1 -0
  149. package/.next/server/chunks/780.js +0 -1
  150. package/.next/static/CLy0QZWDpEExcItT1yudl/_buildManifest.js +0 -1
  151. package/.next/static/chunks/155.a3c40ae481da1358.js +0 -1
  152. package/.next/static/chunks/18.8b025ee3303c3dc7.js +0 -1
  153. package/.next/static/chunks/4bd1b696-92152b0f5947070d.js +0 -1
  154. package/.next/static/chunks/608.5b8aae42a9998ee7.js +0 -1
  155. package/.next/static/chunks/673.93445c8600bc1dd9.js +0 -1
  156. package/.next/static/chunks/686-97c833615d1565c5.js +0 -1
  157. package/.next/static/chunks/716.5266fe10b5578591.js +0 -1
  158. package/.next/static/chunks/app/_global-error/page-1dac1b3ca8adb975.js +0 -1
  159. package/.next/static/chunks/app/_not-found/page-9de30f6f614fce02.js +0 -1
  160. package/.next/static/chunks/app/api/agent/[id]/events/route-1dac1b3ca8adb975.js +0 -1
  161. package/.next/static/chunks/app/api/agent/[id]/route-1dac1b3ca8adb975.js +0 -1
  162. package/.next/static/chunks/app/api/agent/[id]/tree/route-1dac1b3ca8adb975.js +0 -1
  163. package/.next/static/chunks/app/api/agent/new/route-1dac1b3ca8adb975.js +0 -1
  164. package/.next/static/chunks/app/api/config/route-1dac1b3ca8adb975.js +0 -1
  165. package/.next/static/chunks/app/api/files/route-1dac1b3ca8adb975.js +0 -1
  166. package/.next/static/chunks/app/api/git/route-1dac1b3ca8adb975.js +0 -1
  167. package/.next/static/chunks/app/api/growth/route-1dac1b3ca8adb975.js +0 -1
  168. package/.next/static/chunks/app/api/health/route-1dac1b3ca8adb975.js +0 -1
  169. package/.next/static/chunks/app/api/models/route-1dac1b3ca8adb975.js +0 -1
  170. package/.next/static/chunks/app/api/pi-settings/route-1dac1b3ca8adb975.js +0 -1
  171. package/.next/static/chunks/app/api/plugins/route-1dac1b3ca8adb975.js +0 -1
  172. package/.next/static/chunks/app/api/security/route-1dac1b3ca8adb975.js +0 -1
  173. package/.next/static/chunks/app/api/sessions/[id]/export/route-1dac1b3ca8adb975.js +0 -1
  174. package/.next/static/chunks/app/api/sessions/[id]/route-1dac1b3ca8adb975.js +0 -1
  175. package/.next/static/chunks/app/api/sessions/route-1dac1b3ca8adb975.js +0 -1
  176. package/.next/static/chunks/app/api/skills/route-1dac1b3ca8adb975.js +0 -1
  177. package/.next/static/chunks/app/api/update/route-1dac1b3ca8adb975.js +0 -1
  178. package/.next/static/chunks/app/api/version/route-1dac1b3ca8adb975.js +0 -1
  179. package/.next/static/chunks/app/api/web-auth/route-1dac1b3ca8adb975.js +0 -1
  180. package/.next/static/chunks/app/api/workspaces/route-1dac1b3ca8adb975.js +0 -1
  181. package/.next/static/chunks/app/layout-eb62bcd99abe79f9.js +0 -1
  182. package/.next/static/chunks/app/login/page-d2308b48d013d1a6.js +0 -1
  183. package/.next/static/chunks/app/page-19e37fe240ae3e20.js +0 -1
  184. package/.next/static/chunks/framework-0f597d6e68ffafed.js +0 -1
  185. package/.next/static/chunks/main-app-1a92e20ec190a32b.js +0 -1
  186. package/.next/static/chunks/next/dist/client/components/builtin/app-error-1dac1b3ca8adb975.js +0 -1
  187. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-1dac1b3ca8adb975.js +0 -1
  188. package/.next/static/chunks/next/dist/client/components/builtin/global-error-7bb5c4afcbc1772b.js +0 -1
  189. package/.next/static/chunks/next/dist/client/components/builtin/not-found-1dac1b3ca8adb975.js +0 -1
  190. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-1dac1b3ca8adb975.js +0 -1
  191. package/.next/static/chunks/webpack-eebc3d300017fa47.js +0 -1
  192. package/.next/static/css/080ec3dba8907e83.css +0 -1
  193. /package/.next/static/{CLy0QZWDpEExcItT1yudl → 2kt2UiWjv_viXpNlfjvBT}/_ssgManifest.js +0 -0
@@ -17,7 +17,10 @@ import {
17
17
  type AuthInteraction, type AuthPrompt, type AuthType,
18
18
  } from "@earendil-works/pi-ai";
19
19
  import { withExternalSettingsLock, withSettingsWriteLock } from "./settings-write-lock";
20
-
20
+ import { MONTHLY_WINDOW_SECONDS, WEEKLY_WINDOW_SECONDS, type BalanceAmount, type BalanceDetailKey, type BalanceUsage, type ProviderUsage, type QuotaUsage, type QuotaWindow, type XaiUsage } from "./provider-usage";
21
+ // 用量的纯类型/纯函数拆在 provider-usage(客户端安全:设置面板按值导入它们);这里重导出,路由与测试的导入不变。
22
+ export { MONTHLY_WINDOW_SECONDS, WEEKLY_WINDOW_SECONDS, quotaWindowKind, supportsUsageProbe } from "./provider-usage";
23
+ export type { BalanceAmount, BalanceDetailKey, BalanceUsage, ProviderUsage, QuotaUsage, QuotaWindow, QuotaWindowKind, XaiUsage } from "./provider-usage";
21
24
  export interface ModelView {
22
25
  provider: string;
23
26
  id: string;
@@ -222,7 +225,7 @@ export async function removeApiKey(providerId: string): Promise<void> {
222
225
  }
223
226
 
224
227
  /** 已存凭证回退:编辑表单的 key 框为空(「留空保持不变」)时,从这里取。oauth=true 表示来自 OAuth 令牌(部分端点不适用)。 */
225
- async function storedCredentialFor(providerId: string): Promise<{ key: string; oauth: boolean } | undefined> {
228
+ async function storedCredentialFor(providerId: string): Promise<{ key: string; oauth: boolean; accountId?: string } | undefined> {
226
229
  // 自定义 provider / 内置覆盖层:models.json providers[id].apiKey。
227
230
  // 损坏/权限失败不能静默回退到别的凭证;JSONC 与编辑器使用相同解析规则。
228
231
  const custom = await readCustomProvidersSnapshot(modelsJsonPath());
@@ -237,7 +240,10 @@ async function storedCredentialFor(providerId: string): Promise<{ key: string; o
237
240
  const entry = parsed?.[providerId] ?? parsed?.providers?.[providerId];
238
241
  const key = entry?.apiKey ?? entry?.api_key ?? entry?.key;
239
242
  if (typeof key === "string" && key.trim()) return { key: key.trim(), oauth: false };
240
- if (entry?.type === "oauth" && typeof entry?.access === "string" && entry.access.trim()) return { key: entry.access.trim(), oauth: true };
243
+ if (entry?.type === "oauth" && typeof entry?.access === "string" && entry.access.trim()) {
244
+ const accountId = typeof entry.accountId === "string" && entry.accountId.trim() ? entry.accountId.trim() : undefined;
245
+ return { key: entry.access.trim(), oauth: true, ...(accountId === undefined ? {} : { accountId }) };
246
+ }
241
247
  } catch {
242
248
  /* 同上 */
243
249
  }
@@ -457,35 +463,440 @@ export async function discoverModels(input: { baseUrl: string; api?: string; api
457
463
  }
458
464
  }
459
465
 
460
- // ---------- xAI 订阅配额(响应头 x-ratelimit-*) ----------
466
+ // ---------- 订阅配额 / 余额(各提供商各自的只读端点) ----------
461
467
 
462
- export interface ProviderUsage {
463
- model: string;
464
- requestLimit: number;
465
- requestRemaining: number;
466
- tokenLimit: number;
467
- tokenRemaining: number;
468
- probedAt: number;
469
- }
470
468
 
471
469
  const usageCache = new Map<string, { at: number; value: ProviderUsage | null }>();
472
470
  const USAGE_TTL_MS = 5 * 60_000;
473
471
 
474
472
  /**
475
- * 探测提供商的实时配额。xAI 不提供周限查询端点,只能从一次最小对话请求的
476
- * x-ratelimit-* 响应头读取 limit/remaining(窗口周期 xAI 未在头部标注)。
477
- * 目前仅 xai(OAuth 订阅)接入;探测结果缓存 5 分钟。
473
+ * 探测提供商的实时配额 / 余额。只有确实提供只读用量端点的提供商接入,
474
+ * 没有端点的宁可留空也不做假开关。结果统一缓存 5 分钟,force 跳过缓存。
478
475
  */
479
476
  export async function providerUsage(providerId: string, force = false): Promise<ProviderUsage | null> {
480
- if (providerId !== "xai") return null;
477
+ const probe = usageProbeFor(providerId);
478
+ if (!probe) return null;
481
479
  const cached = usageCache.get(providerId);
482
480
  if (!force && cached && Date.now() - cached.at < USAGE_TTL_MS) return cached.value;
483
- const value = await probeXaiUsage();
481
+ const value = await probe();
484
482
  usageCache.set(providerId, { at: Date.now(), value });
485
483
  return value;
486
484
  }
487
485
 
488
- async function probeXaiUsage(): Promise<ProviderUsage | null> {
486
+
487
+ function usageProbeFor(providerId: string): (() => Promise<ProviderUsage | null>) | null {
488
+ switch (providerId) {
489
+ case "xai": return probeXaiUsage;
490
+ case "openai-codex": return probeCodexUsage;
491
+ case "anthropic": return probeAnthropicUsage;
492
+ case "deepseek": return probeDeepSeekBalance;
493
+ case "openrouter": return probeOpenRouterBalance;
494
+ case "moonshotai": return () => probeMoonshotBalance("moonshotai");
495
+ case "moonshotai-cn": return () => probeMoonshotBalance("moonshotai-cn");
496
+ case "kimi-coding": return probeKimiCodingUsage;
497
+ case "github-copilot": return probeCopilotUsage;
498
+ case "vercel-ai-gateway": return probeVercelGatewayBalance;
499
+ case "opencode-go": return probeOpencodeUsage;
500
+ default: return null;
501
+ }
502
+ }
503
+
504
+ // ---------- Codex 周限(ChatGPT backend-api,只读) ----------
505
+
506
+ const CODEX_USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
507
+ const USAGE_TIMEOUT_MS = 10_000;
508
+
509
+
510
+ /**
511
+ * 共用的一次性只读探测:超时即放弃并取消响应体,任何失败都返回 undefined,
512
+ * 由各调用方决定降级方式(有的回退到本地可得的套餐,有的整体留空)。
513
+ */
514
+ async function fetchUsageJson(url: string, init: RequestInit, timeoutMs = USAGE_TIMEOUT_MS): Promise<unknown | undefined> {
515
+ const controller = new AbortController();
516
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
517
+ try {
518
+ const response = await fetch(url, { ...init, signal: controller.signal });
519
+ if (!response.ok) {
520
+ void response.body?.cancel().catch(() => {});
521
+ return undefined;
522
+ }
523
+ return await response.json();
524
+ } catch {
525
+ return undefined;
526
+ } finally {
527
+ clearTimeout(timeout);
528
+ }
529
+ }
530
+
531
+ /** 端点不可用时,只要本地能确定套餐就仍然给出套餐,避免整块空白。 */
532
+ function quotaWithPlanOnly(planType: string | undefined): QuotaUsage | null {
533
+ return planType === undefined ? null : { kind: "quota", planType, windows: [], probedAt: Date.now() };
534
+ }
535
+
536
+ /** 载荷里只认对象,数组与标量一律视为无数据。 */
537
+ function asRecord(input: unknown): Record<string, any> | undefined {
538
+ return input && typeof input === "object" && !Array.isArray(input) ? (input as Record<string, any>) : undefined;
539
+ }
540
+
541
+ /** ISO 字符串 → ISO 字符串;无法解析时返回 undefined 而不是把原始值透传出去。 */
542
+ function isoOrUndefined(value: unknown): string | undefined {
543
+ if (typeof value !== "string") return undefined;
544
+ const parsed = new Date(value);
545
+ return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : undefined;
546
+ }
547
+
548
+ /** 从 access token 的 JWT 本地读套餐声明:端点不可用时界面仍显示套餐。 */
549
+ export function planTypeFromAccessToken(accessToken: string): string | undefined {
550
+ try {
551
+ const payload = JSON.parse(Buffer.from(accessToken.split(".")[1] ?? "", "base64").toString("utf8")) as Record<string, any>;
552
+ const value = payload?.["https://api.openai.com/auth"]?.chatgpt_plan_type;
553
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
554
+ } catch {
555
+ return undefined;
556
+ }
557
+ }
558
+
559
+ /**
560
+ * /wham/usage 载荷里只取界面需要的部分。窗口组合随套餐不同:常见是 5 小时窗 +
561
+ * 7 天窗,有的套餐只有 7 天窗,Go 这类只有 30 天窗(secondary_window 直接是
562
+ * null)。所以不假设窗口数量与位置,把实际给出的窗口全列出来按时长升序排列,
563
+ * 由界面如实标注每一项的周期。字段缺失即未知,不做猜测。
564
+ */
565
+ export function parseCodexUsagePayload(value: unknown): Omit<QuotaUsage, "kind" | "probedAt"> {
566
+ const root = asRecord(value);
567
+ if (!root) return { windows: [] };
568
+ const rateLimit = asRecord(root.rate_limit);
569
+ const windows = [rateLimit?.primary_window, rateLimit?.secondary_window]
570
+ .map(asRecord)
571
+ .flatMap((candidate): QuotaWindow[] => {
572
+ if (!candidate) return [];
573
+ if (typeof candidate.limit_window_seconds !== "number" || candidate.limit_window_seconds <= 0) return [];
574
+ if (typeof candidate.used_percent !== "number" || !Number.isFinite(candidate.used_percent)) return [];
575
+ let resetAt: string | undefined;
576
+ if (Number.isSafeInteger(candidate.reset_at) && candidate.reset_at > 0) {
577
+ const reset = new Date(candidate.reset_at * 1000);
578
+ if (Number.isFinite(reset.getTime())) resetAt = reset.toISOString();
579
+ }
580
+ return [{
581
+ limitWindowSeconds: candidate.limit_window_seconds,
582
+ remainingPercent: Math.min(100, Math.max(0, Math.round(100 - candidate.used_percent))),
583
+ ...(resetAt === undefined ? {} : { resetAt }),
584
+ }];
585
+ })
586
+ .sort(byWindowLength);
587
+ const plan = root.plan_type;
588
+ return {
589
+ ...(typeof plan === "string" && plan.trim() ? { planType: plan.trim() } : {}),
590
+ windows,
591
+ };
592
+ }
593
+
594
+ async function probeCodexUsage(): Promise<QuotaUsage | null> {
595
+ const credential = await storedCredentialFor("openai-codex");
596
+ if (!credential) return null;
597
+ // JWT 里的套餐声明本地可得,端点失败时用它兜底,界面不至于整块空白
598
+ const fallbackPlan = planTypeFromAccessToken(credential.key);
599
+ const payload = await fetchUsageJson(CODEX_USAGE_ENDPOINT, {
600
+ headers: {
601
+ Authorization: `Bearer ${credential.key}`,
602
+ ...(credential.accountId ? { "chatgpt-account-id": credential.accountId } : {}),
603
+ },
604
+ });
605
+ if (payload === undefined) return quotaWithPlanOnly(fallbackPlan);
606
+ const parsed = parseCodexUsagePayload(payload);
607
+ const planType = parsed.planType ?? fallbackPlan;
608
+ return { kind: "quota", ...parsed, ...(planType === undefined ? {} : { planType }), probedAt: Date.now() };
609
+ }
610
+
611
+ // ---------- Claude 订阅(Anthropic 只读用量端点) ----------
612
+
613
+ const ANTHROPIC_USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage";
614
+ /** OAuth 令牌访问该端点需要显式带上的 beta 头。 */
615
+ const ANTHROPIC_USAGE_BETA = "oauth-2025-04-20";
616
+ const HOUR_SECONDS = 60 * 60;
617
+
618
+ /**
619
+ * /api/oauth/usage 把窗口放在 five_hour / seven_day / seven_day_opus 三个固定字段,
620
+ * utilization 是 0-100 的「已用」百分比(同名的 anthropic-ratelimit-unified-* 响应头
621
+ * 给的是 0-1 比例,两处口径不同)。字段缺失就是没有该窗口,不按 0 补齐。
622
+ */
623
+ export function parseAnthropicUsagePayload(value: unknown): Omit<QuotaUsage, "kind" | "probedAt"> {
624
+ const root = asRecord(value);
625
+ if (!root) return { windows: [] };
626
+ const pick = (key: string, seconds: number, variant?: string): QuotaWindow[] => {
627
+ const entry = asRecord(root[key]);
628
+ if (!entry || typeof entry.utilization !== "number" || !Number.isFinite(entry.utilization)) return [];
629
+ const resetAt = isoOrUndefined(entry.resets_at);
630
+ return [{
631
+ limitWindowSeconds: seconds,
632
+ remainingPercent: Math.min(100, Math.max(0, Math.round(100 - entry.utilization))),
633
+ ...(resetAt === undefined ? {} : { resetAt }),
634
+ ...(variant === undefined ? {} : { variant }),
635
+ }];
636
+ };
637
+ const windows = [
638
+ ...pick("five_hour", 5 * HOUR_SECONDS),
639
+ ...pick("seven_day", WEEKLY_WINDOW_SECONDS),
640
+ ...pick("seven_day_opus", WEEKLY_WINDOW_SECONDS, "opus"),
641
+ ].sort(byWindowLength);
642
+ return { windows };
643
+ }
644
+
645
+ async function probeAnthropicUsage(): Promise<QuotaUsage | null> {
646
+ const credential = await storedCredentialFor("anthropic");
647
+ // 该端点只认订阅 OAuth 令牌;API key 走的是按量计费,没有这组窗口
648
+ if (!credential?.oauth) return null;
649
+ const payload = await fetchUsageJson(ANTHROPIC_USAGE_ENDPOINT, {
650
+ headers: { Authorization: `Bearer ${credential.key}`, "anthropic-beta": ANTHROPIC_USAGE_BETA },
651
+ });
652
+ if (payload === undefined) return null;
653
+ const parsed = parseAnthropicUsagePayload(payload);
654
+ return parsed.windows.length === 0 ? null : { kind: "quota", ...parsed, probedAt: Date.now() };
655
+ }
656
+
657
+ // ---------- 余额制提供商(各家官方余额端点) ----------
658
+
659
+ /** 金额字段统一按「有限数字」校验后保留两位;其余一律视为无数据。 */
660
+ function amountOrUndefined(value: unknown, currency?: string): BalanceAmount | undefined {
661
+ if (typeof value === "number" && Number.isFinite(value)) return { amount: value.toFixed(2), ...(currency === undefined ? {} : { currency }) };
662
+ if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) {
663
+ return { amount: Number(value).toFixed(2), ...(currency === undefined ? {} : { currency }) };
664
+ }
665
+ return undefined;
666
+ }
667
+
668
+ /** DeepSeek:GET /user/balance,金额是字符串,币种跟着账户(CNY 或 USD)。 */
669
+ export function parseDeepSeekBalancePayload(value: unknown): Omit<BalanceUsage, "kind" | "probedAt"> {
670
+ const root = asRecord(value);
671
+ const infos = Array.isArray(root?.balance_infos) ? root.balance_infos.map(asRecord).filter(Boolean) : [];
672
+ const info = infos[0];
673
+ if (!info) return { details: [] };
674
+ const currency = typeof info.currency === "string" && info.currency.trim() ? info.currency.trim() : undefined;
675
+ const details: BalanceUsage["details"] = [];
676
+ const granted = amountOrUndefined(info.granted_balance, currency);
677
+ if (granted) details.push({ key: "granted", ...granted });
678
+ const toppedUp = amountOrUndefined(info.topped_up_balance, currency);
679
+ if (toppedUp) details.push({ key: "toppedUp", ...toppedUp });
680
+ const total = amountOrUndefined(info.total_balance, currency);
681
+ return { ...(total === undefined ? {} : { primary: total }), details };
682
+ }
683
+
684
+ /** OpenRouter:GET /api/v1/key,额度以美元计;无上限的 key 没有 limit_remaining。 */
685
+ export function parseOpenRouterKeyPayload(value: unknown): Omit<BalanceUsage, "kind" | "probedAt"> {
686
+ const data = asRecord(asRecord(value)?.data);
687
+ if (!data) return { details: [] };
688
+ const details: BalanceUsage["details"] = [];
689
+ const limit = amountOrUndefined(data.limit, "USD");
690
+ if (limit) details.push({ key: "limit", ...limit });
691
+ const used = amountOrUndefined(data.usage, "USD");
692
+ if (used) details.push({ key: "used", ...used });
693
+ const remaining = amountOrUndefined(data.limit_remaining, "USD");
694
+ if (remaining) return { primary: remaining, details };
695
+ // 无上限时没有剩余额度可显示,退回累计已用
696
+ return used ? { primaryKey: "used", primary: used, details } : { details };
697
+ }
698
+
699
+ /** Moonshot / Kimi:GET /v1/users/me/balance,国内站记 CNY、海外站记 USD,两者密钥不通用。 */
700
+ export function parseMoonshotBalancePayload(value: unknown, currency: string): Omit<BalanceUsage, "kind" | "probedAt"> {
701
+ const root = asRecord(value);
702
+ const data = asRecord(root?.data);
703
+ if (!data || root?.status === false) return { details: [] };
704
+ const details: BalanceUsage["details"] = [];
705
+ const voucher = amountOrUndefined(data.voucher_balance, currency);
706
+ if (voucher) details.push({ key: "voucher", ...voucher });
707
+ const cash = amountOrUndefined(data.cash_balance, currency);
708
+ if (cash) details.push({ key: "cash", ...cash });
709
+ const available = amountOrUndefined(data.available_balance, currency);
710
+ return { ...(available === undefined ? {} : { primary: available }), details };
711
+ }
712
+
713
+ const MOONSHOT_HOSTS: Record<string, { base: string; currency: string }> = {
714
+ moonshotai: { base: "https://api.moonshot.ai", currency: "USD" },
715
+ "moonshotai-cn": { base: "https://api.moonshot.cn", currency: "CNY" },
716
+ };
717
+
718
+ async function probeDeepSeekBalance(): Promise<BalanceUsage | null> {
719
+ const credential = await storedCredentialFor("deepseek");
720
+ if (!credential) return null;
721
+ const payload = await fetchUsageJson("https://api.deepseek.com/user/balance", {
722
+ headers: { Authorization: `Bearer ${credential.key}` },
723
+ });
724
+ if (payload === undefined) return null;
725
+ return { kind: "balance", ...parseDeepSeekBalancePayload(payload), probedAt: Date.now() };
726
+ }
727
+
728
+ async function probeOpenRouterBalance(): Promise<BalanceUsage | null> {
729
+ const credential = await storedCredentialFor("openrouter");
730
+ if (!credential) return null;
731
+ const payload = await fetchUsageJson("https://openrouter.ai/api/v1/key", {
732
+ headers: { Authorization: `Bearer ${credential.key}` },
733
+ });
734
+ if (payload === undefined) return null;
735
+ return { kind: "balance", ...parseOpenRouterKeyPayload(payload), probedAt: Date.now() };
736
+ }
737
+
738
+ async function probeMoonshotBalance(providerId: "moonshotai" | "moonshotai-cn"): Promise<BalanceUsage | null> {
739
+ const credential = await storedCredentialFor(providerId);
740
+ if (!credential) return null;
741
+ const host = MOONSHOT_HOSTS[providerId];
742
+ const payload = await fetchUsageJson(`${host.base}/v1/users/me/balance`, {
743
+ headers: { Authorization: `Bearer ${credential.key}` },
744
+ });
745
+ if (payload === undefined) return null;
746
+ return { kind: "balance", ...parseMoonshotBalancePayload(payload, host.currency), probedAt: Date.now() };
747
+ }
748
+
749
+ // ---------- 订阅制订阅额度(Kimi Code / GitHub Copilot / OpenCode Go) ----------
750
+
751
+ /** 0-1 的 used_ratio 或 0-100 的已用百分比统一转成剩余百分比。 */
752
+ function remainingFromUsed(used: number): number {
753
+ return Math.min(100, Math.max(0, Math.round(100 - used)));
754
+ }
755
+
756
+ /** 短周期在前;没有周期的窗口(Copilot 类别、rolling)排在最后。 */
757
+ function byWindowLength(left: QuotaWindow, right: QuotaWindow): number {
758
+ return (left.limitWindowSeconds ?? Number.POSITIVE_INFINITY) - (right.limitWindowSeconds ?? Number.POSITIVE_INFINITY);
759
+ }
760
+
761
+ /**
762
+ * Kimi Code:GET /coding/v1/usages。四个固定键给 used_ratio(0-1 的小数,与
763
+ * Claude 的 0-100 口径不同)和 reset_time;月度总额度与月度代码额度周期相同,
764
+ * 靠 variant 区分。
765
+ */
766
+ export function parseKimiCodingUsagePayload(value: unknown): Omit<QuotaUsage, "kind" | "probedAt"> {
767
+ const usages = asRecord(asRecord(value)?.usages);
768
+ if (!usages) return { windows: [] };
769
+ const pick = (key: string, seconds: number, variant?: string): QuotaWindow[] => {
770
+ const entry = asRecord(usages[key]);
771
+ if (!entry || typeof entry.used_ratio !== "number" || !Number.isFinite(entry.used_ratio)) return [];
772
+ const resetAt = isoOrUndefined(entry.reset_time);
773
+ return [{
774
+ limitWindowSeconds: seconds,
775
+ remainingPercent: remainingFromUsed(entry.used_ratio * 100),
776
+ ...(resetAt === undefined ? {} : { resetAt }),
777
+ ...(variant === undefined ? {} : { variant }),
778
+ }];
779
+ };
780
+ const windows = [
781
+ ...pick("limit_5h", 5 * HOUR_SECONDS),
782
+ ...pick("limit_7d", WEEKLY_WINDOW_SECONDS),
783
+ ...pick("limit_month_total", MONTHLY_WINDOW_SECONDS),
784
+ ...pick("limit_month_code", MONTHLY_WINDOW_SECONDS, "code"),
785
+ ].sort(byWindowLength);
786
+ return { windows };
787
+ }
788
+
789
+ /**
790
+ * GitHub Copilot:GET /copilot_internal/user(VS Code 自己就用这条,未进官方
791
+ * REST 文档)。quota_snapshots 按用途分档而不是按时间窗口,percent_remaining 是
792
+ * 0-100 的剩余百分比;unlimited 的档位没有可跟踪的额度,跳过不占位置。
793
+ */
794
+ export function parseCopilotUserPayload(value: unknown): Omit<QuotaUsage, "kind" | "probedAt"> {
795
+ const root = asRecord(value);
796
+ if (!root) return { windows: [] };
797
+ const snapshots = asRecord(root.quota_snapshots);
798
+ const plan = root.copilot_plan;
799
+ const resetAt = isoOrUndefined(root.quota_reset_date);
800
+ const names: Array<[string, string]> = [["premium_interactions", "premium"], ["chat", "chat"], ["completions", "completions"]];
801
+ const windows = names.flatMap(([raw, name]): QuotaWindow[] => {
802
+ const snapshot = asRecord(snapshots?.[raw]);
803
+ if (!snapshot) return [];
804
+ if (snapshot.has_quota === false || snapshot.unlimited === true) return [];
805
+ if (typeof snapshot.percent_remaining !== "number" || !Number.isFinite(snapshot.percent_remaining)) return [];
806
+ return [{
807
+ remainingPercent: Math.min(100, Math.max(0, Math.round(snapshot.percent_remaining))),
808
+ ...(resetAt === undefined ? {} : { resetAt }),
809
+ name,
810
+ }];
811
+ });
812
+ return {
813
+ ...(typeof plan === "string" && plan.trim() ? { planType: plan.trim() } : {}),
814
+ windows,
815
+ };
816
+ }
817
+
818
+ /** Vercel AI Gateway:GET /v1/credits,美元字符串形式的余额与累计已用。 */
819
+ export function parseVercelGatewayCreditsPayload(value: unknown): Omit<BalanceUsage, "kind" | "probedAt"> {
820
+ const root = asRecord(value);
821
+ if (!root) return { details: [] };
822
+ const details: BalanceUsage["details"] = [];
823
+ const used = amountOrUndefined(root.total_used, "USD");
824
+ if (used) details.push({ key: "used", ...used });
825
+ const balance = amountOrUndefined(root.balance, "USD");
826
+ return { ...(balance === undefined ? {} : { primary: balance }), details };
827
+ }
828
+
829
+ /**
830
+ * OpenCode Go:GET /zen/go/v1/usage。三个窗口的 percent 是「已用」百分比
831
+ * (服务端字段名就是 usagePercent),剩余要反算。rolling 的周期服务端不返回,
832
+ * 所以只给名字不给时长;weekly / monthly 按名字标注。
833
+ */
834
+ export function parseOpencodeUsagePayload(value: unknown): Omit<QuotaUsage, "kind" | "probedAt"> {
835
+ const usage = asRecord(asRecord(value)?.usage);
836
+ if (!usage) return { windows: [] };
837
+ const pick = (key: string, seconds?: number): QuotaWindow[] => {
838
+ const entry = asRecord(usage[key]);
839
+ if (!entry || typeof entry.percent !== "number" || !Number.isFinite(entry.percent)) return [];
840
+ const resetAt = isoOrUndefined(entry.resetsAt);
841
+ return [{
842
+ ...(seconds === undefined ? { name: key } : { limitWindowSeconds: seconds }),
843
+ remainingPercent: remainingFromUsed(entry.percent),
844
+ ...(resetAt === undefined ? {} : { resetAt }),
845
+ }];
846
+ };
847
+ const windows = [
848
+ ...pick("rolling"),
849
+ ...pick("weekly", WEEKLY_WINDOW_SECONDS),
850
+ ...pick("monthly", MONTHLY_WINDOW_SECONDS),
851
+ ].sort(byWindowLength);
852
+ return { windows };
853
+ }
854
+
855
+ async function probeKimiCodingUsage(): Promise<QuotaUsage | null> {
856
+ const credential = await storedCredentialFor("kimi-coding");
857
+ if (!credential) return null;
858
+ const payload = await fetchUsageJson("https://api.kimi.com/coding/v1/usages", {
859
+ headers: { Authorization: `Bearer ${credential.key}`, Accept: "application/json" },
860
+ });
861
+ if (payload === undefined) return null;
862
+ const parsed = parseKimiCodingUsagePayload(payload);
863
+ return parsed.windows.length === 0 ? null : { kind: "quota", ...parsed, probedAt: Date.now() };
864
+ }
865
+
866
+ async function probeCopilotUsage(): Promise<QuotaUsage | null> {
867
+ const credential = await storedCredentialFor("github-copilot");
868
+ if (!credential?.oauth) return null;
869
+ // 凭据本身就是 Copilot 令牌(tid=...),直接可用于这个内部端点
870
+ const payload = await fetchUsageJson("https://api.github.com/copilot_internal/user", {
871
+ headers: { Authorization: `Bearer ${credential.key}`, Accept: "application/json" },
872
+ });
873
+ if (payload === undefined) return null;
874
+ const parsed = parseCopilotUserPayload(payload);
875
+ return parsed.windows.length === 0 && parsed.planType === undefined ? null : { kind: "quota", ...parsed, probedAt: Date.now() };
876
+ }
877
+
878
+ async function probeVercelGatewayBalance(): Promise<BalanceUsage | null> {
879
+ const credential = await storedCredentialFor("vercel-ai-gateway");
880
+ if (!credential) return null;
881
+ const payload = await fetchUsageJson("https://ai-gateway.vercel.sh/v1/credits", {
882
+ headers: { Authorization: `Bearer ${credential.key}` },
883
+ });
884
+ if (payload === undefined) return null;
885
+ return { kind: "balance", ...parseVercelGatewayCreditsPayload(payload), probedAt: Date.now() };
886
+ }
887
+
888
+ async function probeOpencodeUsage(): Promise<QuotaUsage | null> {
889
+ const credential = await storedCredentialFor("opencode-go");
890
+ if (!credential) return null;
891
+ const payload = await fetchUsageJson("https://opencode.ai/zen/go/v1/usage", {
892
+ headers: { Authorization: `Bearer ${credential.key}`, Accept: "application/json" },
893
+ });
894
+ if (payload === undefined) return null;
895
+ const parsed = parseOpencodeUsagePayload(payload);
896
+ return parsed.windows.length === 0 ? null : { kind: "quota", ...parsed, probedAt: Date.now() };
897
+ }
898
+
899
+ async function probeXaiUsage(): Promise<XaiUsage | null> {
489
900
  const credential = await storedCredentialFor("xai");
490
901
  if (!credential) return null;
491
902
  const key = credential.key;
@@ -519,7 +930,7 @@ async function probeXaiUsage(): Promise<ProviderUsage | null> {
519
930
  const tokenLimit = get("x-ratelimit-limit-tokens");
520
931
  const tokenRemaining = get("x-ratelimit-remaining-tokens");
521
932
  if (![requestLimit, requestRemaining, tokenLimit, tokenRemaining].every(Number.isFinite)) return null;
522
- return { model, requestLimit, requestRemaining, tokenLimit, tokenRemaining, probedAt: Date.now() };
933
+ return { kind: "xai", model, requestLimit, requestRemaining, tokenLimit, tokenRemaining, probedAt: Date.now() };
523
934
  } catch {
524
935
  return null;
525
936
  } finally {