@leo000001/opencode-quota-sidebar 2.0.4 → 2.0.5

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/README.md CHANGED
@@ -41,14 +41,15 @@ On Windows, use forward slashes: `"file:///D:/Lab/opencode-quota-sidebar/dist/in
41
41
 
42
42
  ## Supported quota providers
43
43
 
44
- | Provider | Endpoint | Auth | Status |
45
- | -------------- | -------------------------------------- | --------------- | --------------------------------------- |
46
- | OpenAI Codex | `chatgpt.com/backend-api/wham/usage` | OAuth (ChatGPT) | Multi-window (short-term + weekly) |
47
- | GitHub Copilot | `api.github.com/copilot_internal/user` | OAuth | Monthly quota |
48
- | Kimi For Coding | `api.kimi.com/coding/v1/usages` | API key | Multi-window subscription (5h + weekly) |
49
- | RightCode | `www.right.codes/account/summary` | API key | Subscription or balance (by prefix) |
50
- | Buzz | `buzzai.cc/v1/dashboard/billing/*` | API key | Balance only (computed from total-used) |
51
- | Anthropic | `api.anthropic.com/api/oauth/usage` | OAuth | Multi-window (5h + weekly / plan-based) |
44
+ | Provider | Endpoint | Auth | Status |
45
+ | --------------- | -------------------------------------- | --------------------- | --------------------------------------- |
46
+ | OpenAI Codex | `chatgpt.com/backend-api/wham/usage` | OAuth (ChatGPT) | Multi-window (short-term + weekly) |
47
+ | GitHub Copilot | `api.github.com/copilot_internal/user` | OAuth | Monthly quota |
48
+ | Kimi For Coding | `api.kimi.com/coding/v1/usages` | API key | Multi-window subscription (5h + weekly) |
49
+ | RightCode | `www.right.codes/account/summary` | API key | Subscription or balance (by prefix) |
50
+ | Buzz | `buzzai.cc/v1/dashboard/billing/*` | API key | Balance only (computed from total-used) |
51
+ | Anthropic | `api.anthropic.com/api/oauth/usage` | OAuth | Multi-window (5h + weekly / plan-based) |
52
+ | XYAI Vibe | `new.xychatai.com/frontend-api/*` | Login -> session auth | Daily balance quota with reset time |
52
53
 
53
54
  Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware AI, etc.)? See [CONTRIBUTING.md](CONTRIBUTING.md).
54
55
 
@@ -63,9 +64,11 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
63
64
  - next lines: `Cache Coverage` (read/write cache models) and `Cache Read Coverage` (read-only cache models) when enough cache telemetry is available; mixed sessions can show both
64
65
  - next line: `$X.XX as API cost` (equivalent API billing for subscription-auth providers)
65
66
  - quota lines: quota text like `OpenAI 5h 80% Rst 16:20`; short windows (`5h`, `1d`, `Daily`) show `HH:MM` on same-day resets and `MM-DD HH:MM` when crossing days, while longer windows continue to show `MM-DD`
66
- - RightCode daily quota shows `$remaining/$dailyTotal` + expiry (e.g. `RC Daily $105/$60 Exp 02-27`, without trailing percent) and also shows balance on the next indented line when available; `Exp` remains date-only
67
+ - RightCode daily quota shows `$remaining/$dailyTotal` without trailing percent, and shows balance on the next indented line when available
68
+ - XYAI daily quota follows the same balance-style layout and prefers the real reset time (for example `XYAI Daily $70.2/$90 Rst 22:18`)
67
69
  - Session-scoped usage/quota can include descendant subagent sessions (enabled by default via `sidebar.includeChildren=true`). Traversal is bounded by `childrenMaxDepth` (default 6), `childrenMaxSessions` (default 128), and `childrenConcurrency` (default 5); truncation is logged when `OPENCODE_QUOTA_DEBUG=1`. Day/week/month ranges never merge children — only session scope does.
68
70
  - Toast message can include four sections: `Token Usage`, `Cost as API` (per provider), `Provider Cache` (when provider-level cache coverage is available), and `Quota`
71
+ - Expiry reminders are shown in a separate `Expiry Soon` toast section only for providers with real subscription expiry timestamps, and each session shows that auto-reminder at most once
69
72
  - `quota_summary` markdown / toast also include `Cache Coverage` and `Cache Read Coverage` summary lines when available
70
73
  - Quota snapshots are de-duplicated before rendering to avoid repeated provider lines
71
74
  - Custom tools:
@@ -79,6 +82,7 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
79
82
  - RightCode API key (`/account/summary`)
80
83
  - Buzz API key (`/v1/dashboard/billing/subscription` + `/v1/dashboard/billing/usage`)
81
84
  - Anthropic Claude OAuth (`/api/oauth/usage`, with beta header)
85
+ - XYAI Vibe account login (`/frontend-api/login` -> cached `share-session` -> `/frontend-api/vibe-code/quota`)
82
86
  - OpenAI OAuth quota checks auto-refresh expired access token (using refresh token)
83
87
  - API key providers still show usage aggregation (quota only applies to subscription providers)
84
88
  - Incremental usage aggregation — only processes new messages since last cursor
@@ -91,6 +95,14 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
91
95
  - Quota data is read from `GET https://api.kimi.com/coding/v1/usages`.
92
96
  - The current implementation maps the short rolling window in `limits[]` to `5h` and the top-level `usage` block to `Weekly`.
93
97
  - Rendering follows the same compact reset formatting as OpenAI: short windows show `Rst MM-DD HH:MM` when they cross days, and longer windows show `Rst MM-DD`.
98
+
99
+ ### XYAI Vibe notes
100
+
101
+ - Enable it explicitly under `quota.providers.xyai-vibe.enabled`; it is not enabled by default.
102
+ - Configure login credentials in `quota-sidebar.config.json`, not in source code.
103
+ - The adapter logs in via `POST https://new.xychatai.com/frontend-api/login`, caches the returned `share-session`, and retries quota fetches with that session.
104
+ - Quota data is read from `GET https://new.xychatai.com/frontend-api/vibe-code/quota`.
105
+ - Compact displays show the daily balance and the true reset time when present; expiry stays as secondary report/toast metadata.
94
106
 
95
107
  ## Storage layout
96
108
 
@@ -104,6 +116,7 @@ The plugin stores lightweight global state and date-partitioned session chunks.
104
116
  - per-session title state (`baseTitle`, `lastAppliedTitle`)
105
117
  - `createdAt`
106
118
  - `parentID` (when the session is a subagent child session)
119
+ - `expiryToastShown` (session-level dedupe for automatic expiry reminders)
107
120
  - cached usage summary used by `quota_summary`, including session-level and provider-level `cacheBuckets` for cache coverage reporting
108
121
  - incremental aggregation cursor
109
122
 
@@ -248,7 +261,7 @@ Quota defaults:
248
261
  - `quota.includeOpenAI`: `true`
249
262
  - `quota.includeCopilot`: `true`
250
263
  - `quota.includeAnthropic`: `true`
251
- - `quota.providers`: `{}` (per-adapter switches, for example `rightcode.enabled` or `buzz.enabled`)
264
+ - `quota.providers`: `{}` (per-adapter switches and adapter-specific config, for example `rightcode.enabled` or `xyai-vibe.login.username/password`)
252
265
  - `quota.refreshAccessToken`: `false`
253
266
  - `quota.requestTimeoutMs`: `8000` (clamped to `>=1000`)
254
267
 
package/dist/format.js CHANGED
@@ -444,6 +444,36 @@ function dateLine(iso) {
444
444
  return iso;
445
445
  return new Date(time).toLocaleString();
446
446
  }
447
+ function expiryAlertLine(iso, nowMs = Date.now()) {
448
+ if (!iso)
449
+ return undefined;
450
+ const timestamp = Date.parse(iso);
451
+ if (Number.isNaN(timestamp) || timestamp <= nowMs)
452
+ return undefined;
453
+ const remainingMs = timestamp - nowMs;
454
+ const thresholdMs = 3 * 24 * 60 * 60 * 1000;
455
+ if (remainingMs > thresholdMs)
456
+ return undefined;
457
+ const value = new Date(timestamp);
458
+ const now = new Date(nowMs);
459
+ const sameDay = value.getFullYear() === now.getFullYear() &&
460
+ value.getMonth() === now.getMonth() &&
461
+ value.getDate() === now.getDate();
462
+ const two = (num) => `${num}`.padStart(2, '0');
463
+ const hhmm = `${two(value.getHours())}:${two(value.getMinutes())}`;
464
+ if (sameDay)
465
+ return `Exp today ${hhmm}`;
466
+ return `Exp ${two(value.getMonth() + 1)}-${two(value.getDate())} ${hhmm}`;
467
+ }
468
+ function quotaExpiryPairs(quotas, nowMs = Date.now()) {
469
+ return collapseQuotaSnapshots(quotas)
470
+ .filter((item) => item.status === 'ok')
471
+ .map((item) => ({
472
+ label: quotaDisplayLabel(item),
473
+ value: expiryAlertLine(item.expiresAt, nowMs),
474
+ }))
475
+ .filter((item) => Boolean(item.value));
476
+ }
447
477
  function reportResetLine(iso, resetLabel, windowLabel) {
448
478
  const compact = compactReset(iso, resetLabel, windowLabel);
449
479
  if (compact)
@@ -582,13 +612,14 @@ export function renderMarkdownReport(period, usage, quotas, options) {
582
612
  // Multi-window detail
583
613
  if (quota.windows && quota.windows.length > 0 && quota.status === 'ok') {
584
614
  const windowLines = quota.windows.map((win) => {
615
+ const extraNote = win === quota.windows?.[0] && quota.note ? ` | ${quota.note}` : '';
585
616
  if (win.showPercent === false) {
586
617
  const winLabel = win.label ? ` (${win.label})` : '';
587
- return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
618
+ return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}${extraNote}`);
588
619
  }
589
620
  const remaining = formatQuotaPercent(win.remainingPercent);
590
621
  const winLabel = win.label ? ` (${win.label})` : '';
591
- return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
622
+ return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}${extraNote}`);
592
623
  });
593
624
  if (quota.balance) {
594
625
  windowLines.push(mdCell(`- ${displayLabel}: ${quota.status} | balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`));
@@ -785,5 +816,11 @@ export function renderToastMessage(period, usage, quotas, options) {
785
816
  lines.push(fitLine('Quota', width));
786
817
  lines.push(...alignPairs(quotaPairs).map((line) => fitLine(line, width)));
787
818
  }
819
+ const expiryPairs = quotaExpiryPairs(quotas);
820
+ if (expiryPairs.length > 0) {
821
+ lines.push('');
822
+ lines.push(fitLine('Expiry Soon', width));
823
+ lines.push(...alignPairs(expiryPairs).map((line) => fitLine(line, width)));
824
+ }
788
825
  return lines.join('\n');
789
826
  }
package/dist/index.js CHANGED
@@ -81,6 +81,7 @@ export async function QuotaSidebarPlugin(input) {
81
81
  baseTitle: normalizeBaseTitle(title),
82
82
  lastAppliedTitle: undefined,
83
83
  parentID: parentID ?? undefined,
84
+ expiryToastShown: false,
84
85
  usage: undefined,
85
86
  cursor: undefined,
86
87
  };
@@ -236,6 +237,66 @@ export async function QuotaSidebarPlugin(input) {
236
237
  })
237
238
  .catch(swallow('showToast'));
238
239
  };
240
+ const expiryAlertText = (iso, nowMs = Date.now()) => {
241
+ if (!iso)
242
+ return undefined;
243
+ const timestamp = Date.parse(iso);
244
+ if (Number.isNaN(timestamp) || timestamp <= nowMs)
245
+ return undefined;
246
+ const remainingMs = timestamp - nowMs;
247
+ const thresholdMs = 3 * 24 * 60 * 60 * 1000;
248
+ if (remainingMs > thresholdMs)
249
+ return undefined;
250
+ const value = new Date(timestamp);
251
+ const now = new Date(nowMs);
252
+ const two = (num) => `${num}`.padStart(2, '0');
253
+ const hhmm = `${two(value.getHours())}:${two(value.getMinutes())}`;
254
+ const sameDay = value.getFullYear() === now.getFullYear() &&
255
+ value.getMonth() === now.getMonth() &&
256
+ value.getDate() === now.getDate();
257
+ return sameDay
258
+ ? `Exp today ${hhmm}`
259
+ : `Exp ${two(value.getMonth() + 1)}-${two(value.getDate())} ${hhmm}`;
260
+ };
261
+ const expiryToastInflight = new Set();
262
+ const maybeShowExpiryToast = async (sessionID) => {
263
+ const sessionState = state.sessions[sessionID];
264
+ if (!sessionState)
265
+ return;
266
+ if (sessionState.expiryToastShown || expiryToastInflight.has(sessionID)) {
267
+ return;
268
+ }
269
+ expiryToastInflight.add(sessionID);
270
+ try {
271
+ const quotas = await getQuotaSnapshots([], { allowDefault: true });
272
+ const nowMs = Date.now();
273
+ const expiryLines = quotas
274
+ .filter((item) => item.status === 'ok')
275
+ .map((item) => ({
276
+ label: item.shortLabel || item.label,
277
+ value: expiryAlertText(item.expiresAt, nowMs),
278
+ }))
279
+ .filter((item) => Boolean(item.value));
280
+ if (expiryLines.length === 0)
281
+ return;
282
+ sessionState.expiryToastShown = true;
283
+ const dateKey = state.sessionDateMap[sessionID] || dateKeyFromTimestamp(sessionState.createdAt);
284
+ state.sessionDateMap[sessionID] = dateKey;
285
+ markDirty(dateKey);
286
+ scheduleSave();
287
+ const body = [
288
+ 'Expiry Soon',
289
+ ...expiryLines.map((item) => `${item.label} ${item.value}`),
290
+ ].join('\n');
291
+ await showToast('session', body);
292
+ }
293
+ catch (error) {
294
+ debug(`expiry toast check failed: ${String(error)}`);
295
+ }
296
+ finally {
297
+ expiryToastInflight.delete(sessionID);
298
+ }
299
+ };
239
300
  const dispatchEvent = createEventDispatcher({
240
301
  onSessionCreated: async (session) => {
241
302
  ensureSessionState(session.id, session.title, session.time.created, session.parentID ?? null);
@@ -295,6 +356,7 @@ export async function QuotaSidebarPlugin(input) {
295
356
  onAssistantMessageCompleted: async (message) => {
296
357
  usageService.markSessionDirty(message.sessionID);
297
358
  titleRefresh.schedule(message.sessionID);
359
+ void maybeShowExpiryToast(message.sessionID);
298
360
  },
299
361
  });
300
362
  return {
@@ -18,6 +18,7 @@ export declare function asRecord(value: unknown): Record<string, unknown> | unde
18
18
  export declare function configuredProviderEnabled(config: {
19
19
  providers?: Record<string, {
20
20
  enabled?: boolean;
21
+ [key: string]: unknown;
21
22
  }>;
22
23
  }, adapterID: string, fallback?: boolean): boolean;
23
24
  export declare function sanitizeBaseURL(value: unknown): string | undefined;
@@ -5,6 +5,7 @@ import { kimiForCodingAdapter } from './core/kimi_for_coding.js';
5
5
  import { openaiAdapter } from './core/openai.js';
6
6
  import { QuotaProviderRegistry } from './registry.js';
7
7
  import { rightCodeAdapter } from './third_party/rightcode.js';
8
+ import { xyaiVibeAdapter } from './third_party/xyai_vibe.js';
8
9
  export declare function createDefaultProviderRegistry(): QuotaProviderRegistry;
9
- export { anthropicAdapter, buzzAdapter, copilotAdapter, kimiForCodingAdapter, openaiAdapter, rightCodeAdapter, QuotaProviderRegistry, };
10
+ export { anthropicAdapter, buzzAdapter, copilotAdapter, kimiForCodingAdapter, openaiAdapter, rightCodeAdapter, xyaiVibeAdapter, QuotaProviderRegistry, };
10
11
  export type { AuthUpdate, AuthValue, ProviderResolveContext, QuotaFetchContext, QuotaProviderAdapter, RefreshedOAuthAuth, } from './types.js';
@@ -5,14 +5,16 @@ import { kimiForCodingAdapter } from './core/kimi_for_coding.js';
5
5
  import { openaiAdapter } from './core/openai.js';
6
6
  import { QuotaProviderRegistry } from './registry.js';
7
7
  import { rightCodeAdapter } from './third_party/rightcode.js';
8
+ import { xyaiVibeAdapter } from './third_party/xyai_vibe.js';
8
9
  export function createDefaultProviderRegistry() {
9
10
  const registry = new QuotaProviderRegistry();
10
11
  registry.register(rightCodeAdapter);
11
12
  registry.register(buzzAdapter);
13
+ registry.register(xyaiVibeAdapter);
12
14
  registry.register(kimiForCodingAdapter);
13
15
  registry.register(openaiAdapter);
14
16
  registry.register(copilotAdapter);
15
17
  registry.register(anthropicAdapter);
16
18
  return registry;
17
19
  }
18
- export { anthropicAdapter, buzzAdapter, copilotAdapter, kimiForCodingAdapter, openaiAdapter, rightCodeAdapter, QuotaProviderRegistry, };
20
+ export { anthropicAdapter, buzzAdapter, copilotAdapter, kimiForCodingAdapter, openaiAdapter, rightCodeAdapter, xyaiVibeAdapter, QuotaProviderRegistry, };
@@ -182,8 +182,6 @@ async function fetchRightCodeQuota(ctx) {
182
182
  label: `Daily $${formatQuotaValue(dailyRemaining)}/$${formatQuotaValue(dailyTotal)}`,
183
183
  showPercent: false,
184
184
  remainingPercent: dailyPercent,
185
- resetAt: expiry,
186
- resetLabel: hasMultipleExpiries ? 'Exp+' : 'Exp',
187
185
  },
188
186
  ];
189
187
  const names = matched.map((subscription) => subscription.name).join(', ');
@@ -192,6 +190,7 @@ async function fetchRightCodeQuota(ctx) {
192
190
  status: dailyPercent === undefined ? 'error' : 'ok',
193
191
  checkedAt,
194
192
  remainingPercent: dailyPercent,
193
+ expiresAt: expiry,
195
194
  balance: balance === undefined
196
195
  ? undefined
197
196
  : {
@@ -201,7 +200,7 @@ async function fetchRightCodeQuota(ctx) {
201
200
  windows,
202
201
  note: dailyPercent === undefined
203
202
  ? 'matched subscription has no daily quota fields'
204
- : `subscription daily quota: ${names}`,
203
+ : `subscription daily quota: ${names}${expiry ? ` | exp ${expiry.slice(5, 10)}` : ''}${hasMultipleExpiries ? '+' : ''}`,
205
204
  };
206
205
  }
207
206
  if (balance !== undefined) {
@@ -0,0 +1,2 @@
1
+ import type { QuotaProviderAdapter } from '../types.js';
2
+ export declare const xyaiVibeAdapter: QuotaProviderAdapter;
@@ -0,0 +1,314 @@
1
+ import { debugError, isRecord, swallow } from '../../helpers.js';
2
+ import { asNumber, configuredProviderEnabled, fetchWithTimeout, sanitizeBaseURL, toIso, } from '../common.js';
3
+ const XYAI_BASE_URL = 'https://new.xychatai.com';
4
+ function resolveSiteOrigin(value) {
5
+ const normalized = sanitizeBaseURL(value);
6
+ if (!normalized)
7
+ return XYAI_BASE_URL;
8
+ try {
9
+ return new URL(normalized).origin;
10
+ }
11
+ catch {
12
+ return XYAI_BASE_URL;
13
+ }
14
+ }
15
+ function isXyaiBaseURL(value) {
16
+ const normalized = sanitizeBaseURL(value);
17
+ if (!normalized)
18
+ return false;
19
+ try {
20
+ const parsed = new URL(normalized);
21
+ return parsed.protocol === 'https:' && parsed.host === 'new.xychatai.com';
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ function providerConfigFor(config, providerIDs) {
28
+ for (const providerID of providerIDs) {
29
+ if (!providerID)
30
+ continue;
31
+ const value = config.quota.providers?.[providerID];
32
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
33
+ return value;
34
+ }
35
+ }
36
+ return undefined;
37
+ }
38
+ function resolveSessionCookie(auth, providerConfig) {
39
+ if (typeof providerConfig?.sessionCookie === 'string' &&
40
+ providerConfig.sessionCookie) {
41
+ return providerConfig.sessionCookie;
42
+ }
43
+ if (!auth)
44
+ return undefined;
45
+ if (auth.type === 'wellknown') {
46
+ if (typeof auth.token === 'string' && auth.token)
47
+ return auth.token;
48
+ }
49
+ return undefined;
50
+ }
51
+ function resolveServiceType(providerConfig) {
52
+ return providerConfig?.serviceType === 'claudecode' ? 'claudecode' : 'codex';
53
+ }
54
+ function resolveLogin(providerConfig) {
55
+ const login = providerConfig?.login;
56
+ if (!login || typeof login !== 'object' || Array.isArray(login)) {
57
+ return undefined;
58
+ }
59
+ const username = typeof login.username === 'string' ? login.username.trim() : '';
60
+ const password = typeof login.password === 'string' ? login.password : '';
61
+ if (!username || !password)
62
+ return undefined;
63
+ return { username, password };
64
+ }
65
+ function headerCookies(response) {
66
+ const headers = response.headers;
67
+ if (typeof headers.getSetCookie === 'function') {
68
+ return headers.getSetCookie().filter(Boolean);
69
+ }
70
+ const joined = response.headers.get('set-cookie');
71
+ return joined ? [joined] : [];
72
+ }
73
+ function extractShareSession(response) {
74
+ for (const value of headerCookies(response)) {
75
+ const match = value.match(/share-session=("?)([^;"]+)\1/);
76
+ if (match?.[2])
77
+ return match[2];
78
+ }
79
+ return undefined;
80
+ }
81
+ async function loginAndPersistSession(siteOrigin, login, providerID, updateAuth, timeoutMs) {
82
+ const response = await fetchWithTimeout(`${siteOrigin}/frontend-api/login`, {
83
+ method: 'POST',
84
+ headers: {
85
+ Accept: 'application/json',
86
+ 'Content-Type': 'application/json',
87
+ 'User-Agent': 'opencode-quota-sidebar',
88
+ },
89
+ body: JSON.stringify({
90
+ userToken: login.username,
91
+ password: login.password,
92
+ token: '',
93
+ }),
94
+ }, timeoutMs).catch(swallow('fetchXyaiVibeQuota:login'));
95
+ if (!response) {
96
+ return { error: 'login request failed' };
97
+ }
98
+ if (!response.ok) {
99
+ return { error: `login http ${response.status}` };
100
+ }
101
+ const payload = await response.json().catch(swallow('fetchXyaiVibeQuota:loginJson'));
102
+ if (!isRecord(payload)) {
103
+ return { error: 'invalid login response' };
104
+ }
105
+ if (payload.code !== 1) {
106
+ const msg = typeof payload.msg === 'string' && payload.msg ? payload.msg : 'login failed';
107
+ return { error: msg };
108
+ }
109
+ const session = extractShareSession(response);
110
+ if (!session) {
111
+ return { error: 'missing share-session cookie' };
112
+ }
113
+ if (updateAuth) {
114
+ try {
115
+ await updateAuth(providerID, { type: 'wellknown', token: session });
116
+ }
117
+ catch (error) {
118
+ debugError('updateAuth:xyai-vibe', error);
119
+ return {
120
+ session,
121
+ warning: 'session refreshed but failed to persist; using in-memory session',
122
+ };
123
+ }
124
+ }
125
+ return { session };
126
+ }
127
+ async function fetchQuotaPayload(siteOrigin, session, timeoutMs) {
128
+ const response = await fetchWithTimeout(`${siteOrigin}/frontend-api/vibe-code/quota`, {
129
+ headers: {
130
+ Accept: 'application/json',
131
+ Cookie: `share-session=${session}`,
132
+ 'User-Agent': 'opencode-quota-sidebar',
133
+ },
134
+ }, timeoutMs).catch(swallow('fetchXyaiVibeQuota:quota'));
135
+ if (!response)
136
+ return { error: 'network request failed' };
137
+ if (!response.ok)
138
+ return { error: `http ${response.status}` };
139
+ const payload = await response.json().catch(swallow('fetchXyaiVibeQuota:quotaJson'));
140
+ if (!isRecord(payload))
141
+ return { error: 'invalid response' };
142
+ return { payload };
143
+ }
144
+ function isAuthFailure(payload) {
145
+ return payload.code === -1 || payload.msg === '认证失败,请重新登录';
146
+ }
147
+ function formatAmount(value) {
148
+ if (!Number.isFinite(value))
149
+ return '0';
150
+ if (Math.abs(value) >= 10) {
151
+ const one = value.toFixed(1);
152
+ return one.endsWith('.0') ? one.slice(0, -2) : one;
153
+ }
154
+ return value.toFixed(2);
155
+ }
156
+ function pickServicePayload(payload, preferred) {
157
+ const source = isRecord(payload.data) ? payload.data : payload;
158
+ const ordered = preferred === 'claudecode' ? ['claudecode', 'codex'] : ['codex', 'claudecode'];
159
+ for (const key of ordered) {
160
+ const value = source[key];
161
+ if (isRecord(value))
162
+ return { serviceType: key, value };
163
+ }
164
+ return undefined;
165
+ }
166
+ function parseQuotaSnapshot(args) {
167
+ const base = {
168
+ providerID: args.providerID,
169
+ adapterID: 'xyai-vibe',
170
+ label: 'XYAI Vibe',
171
+ shortLabel: 'XYAI',
172
+ sortOrder: 7,
173
+ };
174
+ const subscriptions = isRecord(args.payload.subscriptions)
175
+ ? args.payload.subscriptions
176
+ : undefined;
177
+ const usage = isRecord(args.payload.currentUsage) ? args.payload.currentUsage : undefined;
178
+ const amountLimit = asNumber(subscriptions?.amountLimit);
179
+ const remainingAmount = asNumber(subscriptions?.remainingAmount);
180
+ const periodResetTime = toIso(subscriptions?.periodResetTime);
181
+ const expireTime = toIso(subscriptions?.expireTime);
182
+ if (amountLimit === undefined || remainingAmount === undefined) {
183
+ return {
184
+ ...base,
185
+ status: 'error',
186
+ checkedAt: args.checkedAt,
187
+ note: 'missing quota fields',
188
+ };
189
+ }
190
+ const remainingPercent = amountLimit > 0 ? Math.max(0, Math.min(100, (remainingAmount / amountLimit) * 100)) : undefined;
191
+ const windows = [
192
+ {
193
+ label: `Daily $${formatAmount(remainingAmount)}/$${formatAmount(amountLimit)}`,
194
+ showPercent: false,
195
+ remainingPercent,
196
+ resetAt: periodResetTime,
197
+ resetLabel: 'Rst',
198
+ },
199
+ ];
200
+ const noteParts = [
201
+ expireTime ? `exp ${expireTime.slice(5, 10)}` : undefined,
202
+ args.serviceType === 'claudecode' ? 'service=claudecode' : undefined,
203
+ args.warning,
204
+ ].filter((value) => Boolean(value));
205
+ return {
206
+ ...base,
207
+ status: 'ok',
208
+ checkedAt: args.checkedAt,
209
+ remainingPercent,
210
+ resetAt: periodResetTime,
211
+ expiresAt: expireTime,
212
+ note: noteParts.join(' | ') || undefined,
213
+ windows,
214
+ };
215
+ }
216
+ async function fetchXyaiVibeQuota(ctx) {
217
+ const checkedAt = Date.now();
218
+ const runtimeProviderID = typeof ctx.sourceProviderID === 'string' && ctx.sourceProviderID
219
+ ? ctx.sourceProviderID
220
+ : ctx.providerID;
221
+ const providerConfig = providerConfigFor(ctx.config, [
222
+ runtimeProviderID,
223
+ ctx.providerID,
224
+ 'xyai-vibe',
225
+ ]);
226
+ const siteOrigin = resolveSiteOrigin(providerConfig?.baseURL ?? ctx.providerOptions?.baseURL);
227
+ const serviceType = resolveServiceType(providerConfig);
228
+ const base = {
229
+ providerID: runtimeProviderID,
230
+ adapterID: 'xyai-vibe',
231
+ label: 'XYAI Vibe',
232
+ shortLabel: 'XYAI',
233
+ sortOrder: 7,
234
+ };
235
+ let session = resolveSessionCookie(ctx.auth, providerConfig);
236
+ const login = resolveLogin(providerConfig);
237
+ let warning;
238
+ if (!session && login) {
239
+ const loginResult = await loginAndPersistSession(siteOrigin, login, ctx.providerID, ctx.updateAuth, ctx.config.quota.requestTimeoutMs);
240
+ if ('error' in loginResult) {
241
+ return {
242
+ ...base,
243
+ status: 'unavailable',
244
+ checkedAt,
245
+ note: loginResult.error,
246
+ };
247
+ }
248
+ session = loginResult.session;
249
+ warning = loginResult.warning;
250
+ }
251
+ if (!session) {
252
+ return {
253
+ ...base,
254
+ status: 'unavailable',
255
+ checkedAt,
256
+ note: 'missing share-session or login credentials',
257
+ };
258
+ }
259
+ let quotaResult = await fetchQuotaPayload(siteOrigin, session, ctx.config.quota.requestTimeoutMs);
260
+ if (!('error' in quotaResult) && isAuthFailure(quotaResult.payload) && login) {
261
+ const loginResult = await loginAndPersistSession(siteOrigin, login, ctx.providerID, ctx.updateAuth, ctx.config.quota.requestTimeoutMs);
262
+ if (!('error' in loginResult)) {
263
+ session = loginResult.session;
264
+ warning = loginResult.warning ?? warning;
265
+ quotaResult = await fetchQuotaPayload(siteOrigin, session, ctx.config.quota.requestTimeoutMs);
266
+ }
267
+ }
268
+ if ('error' in quotaResult) {
269
+ return {
270
+ ...base,
271
+ status: 'error',
272
+ checkedAt,
273
+ note: quotaResult.error,
274
+ };
275
+ }
276
+ if (isAuthFailure(quotaResult.payload)) {
277
+ return {
278
+ ...base,
279
+ status: 'unavailable',
280
+ checkedAt,
281
+ note: 'auth expired',
282
+ };
283
+ }
284
+ const service = pickServicePayload(quotaResult.payload, serviceType);
285
+ if (!service) {
286
+ return {
287
+ ...base,
288
+ status: 'error',
289
+ checkedAt,
290
+ note: 'missing service payload',
291
+ };
292
+ }
293
+ return parseQuotaSnapshot({
294
+ providerID: runtimeProviderID,
295
+ serviceType: service.serviceType,
296
+ payload: service.value,
297
+ checkedAt,
298
+ warning,
299
+ });
300
+ }
301
+ export const xyaiVibeAdapter = {
302
+ id: 'xyai-vibe',
303
+ label: 'XYAI Vibe',
304
+ shortLabel: 'XYAI',
305
+ sortOrder: 7,
306
+ normalizeID: (providerID) => (providerID === 'xyai-vibe' ? 'xyai-vibe' : undefined),
307
+ matchScore: ({ providerID, providerOptions }) => {
308
+ if (providerID === 'xyai-vibe')
309
+ return 100;
310
+ return isXyaiBaseURL(providerOptions?.baseURL) ? 95 : 0;
311
+ },
312
+ isEnabled: (config) => configuredProviderEnabled(config.quota, 'xyai-vibe', false),
313
+ fetch: fetchXyaiVibeQuota,
314
+ };
@@ -25,7 +25,7 @@ export type RefreshedOAuthAuth = {
25
25
  accountId?: string;
26
26
  enterpriseUrl?: string;
27
27
  };
28
- export type AuthUpdate = (providerID: string, auth: RefreshedOAuthAuth) => Promise<void>;
28
+ export type AuthUpdate = (providerID: string, auth: AuthValue) => Promise<void>;
29
29
  export type ProviderResolveContext = {
30
30
  providerID: string;
31
31
  providerOptions?: Record<string, unknown>;
package/dist/quota.d.ts CHANGED
@@ -17,6 +17,7 @@ export declare function createQuotaRuntime(): {
17
17
  remainingPercent?: number;
18
18
  usedPercent?: number;
19
19
  resetAt?: string;
20
+ expiresAt?: string;
20
21
  balance?: {
21
22
  amount: number;
22
23
  currency: string;
@@ -40,6 +41,7 @@ export declare function fetchQuotaSnapshot(providerID: string, authMap: Record<s
40
41
  remainingPercent?: number;
41
42
  usedPercent?: number;
42
43
  resetAt?: string;
44
+ expiresAt?: string;
43
45
  balance?: {
44
46
  amount: number;
45
47
  currency: string;
package/dist/storage.js CHANGED
@@ -56,9 +56,27 @@ export async function loadConfig(paths) {
56
56
  ...Object.entries(providers).reduce((acc, [id, value]) => {
57
57
  if (!isRecord(value))
58
58
  return acc;
59
- if (typeof value.enabled === 'boolean') {
60
- acc[id] = { enabled: value.enabled };
61
- }
59
+ const baseProvider = isRecord(base.quota.providers?.[id])
60
+ ? base.quota.providers?.[id]
61
+ : {};
62
+ const baseLogin = isRecord(baseProvider.login)
63
+ ? baseProvider.login
64
+ : undefined;
65
+ const nextLogin = isRecord(value.login)
66
+ ? value.login
67
+ : undefined;
68
+ acc[id] = {
69
+ ...baseProvider,
70
+ ...value,
71
+ ...(baseLogin || nextLogin
72
+ ? {
73
+ login: {
74
+ ...(baseLogin || {}),
75
+ ...(nextLogin || {}),
76
+ },
77
+ }
78
+ : {}),
79
+ };
62
80
  return acc;
63
81
  }, {}),
64
82
  };
@@ -116,6 +116,7 @@ export function parseSessionState(value) {
116
116
  ...title,
117
117
  createdAt,
118
118
  parentID: typeof value.parentID === 'string' ? value.parentID : undefined,
119
+ expiryToastShown: value.expiryToastShown === true,
119
120
  usage: parseCachedUsage(value.usage),
120
121
  dirty: value.dirty === true,
121
122
  cursor: parseCursor(value.cursor),
@@ -182,6 +183,7 @@ export function parseQuotaCache(value) {
182
183
  : undefined,
183
184
  usedPercent: typeof item.usedPercent === 'number' ? item.usedPercent : undefined,
184
185
  resetAt: typeof item.resetAt === 'string' ? item.resetAt : undefined,
186
+ expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined,
185
187
  balance,
186
188
  note: typeof item.note === 'string' ? item.note : undefined,
187
189
  windows,
package/dist/types.d.ts CHANGED
@@ -23,6 +23,7 @@ export type QuotaSnapshot = {
23
23
  remainingPercent?: number;
24
24
  usedPercent?: number;
25
25
  resetAt?: string;
26
+ expiresAt?: string;
26
27
  /** Balance-style quota (for providers that expose balance instead of percent). */
27
28
  balance?: {
28
29
  amount: number;
@@ -32,6 +33,10 @@ export type QuotaSnapshot = {
32
33
  /** Multi-window quota (e.g. OpenAI short-term + weekly). */
33
34
  windows?: QuotaWindow[];
34
35
  };
36
+ export type QuotaProviderConfig = {
37
+ enabled?: boolean;
38
+ [key: string]: unknown;
39
+ };
35
40
  export type SessionTitleState = {
36
41
  baseTitle: string;
37
42
  lastAppliedTitle?: string;
@@ -111,6 +116,8 @@ export type SessionState = SessionTitleState & {
111
116
  createdAt: number;
112
117
  /** Parent session ID for subagent child sessions. */
113
118
  parentID?: string;
119
+ /** Whether this session has already shown an auto expiry toast. */
120
+ expiryToastShown?: boolean;
114
121
  usage?: CachedSessionUsage;
115
122
  /** Persisted dirtiness flag so descendant aggregation survives restart. */
116
123
  dirty?: boolean;
@@ -161,9 +168,7 @@ export type QuotaSidebarConfig = {
161
168
  includeCopilot: boolean;
162
169
  includeAnthropic: boolean;
163
170
  /** Generic per-adapter switches (e.g. rightcode). */
164
- providers?: Record<string, {
165
- enabled?: boolean;
166
- }>;
171
+ providers?: Record<string, QuotaProviderConfig>;
167
172
  /** When true, refreshes OpenAI OAuth access token using refresh token */
168
173
  refreshAccessToken: boolean;
169
174
  /** Timeout for external quota fetches */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo000001/opencode-quota-sidebar",
3
- "version": "2.0.4",
3
+ "version": "2.0.5",
4
4
  "description": "OpenCode plugin that shows quota and token usage in session titles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -19,6 +19,15 @@
19
19
  "providers": {
20
20
  "rightcode": {
21
21
  "enabled": true
22
+ },
23
+ "xyai-vibe": {
24
+ "enabled": false,
25
+ "baseURL": "https://new.xychatai.com",
26
+ "serviceType": "codex",
27
+ "login": {
28
+ "username": "your-account@example.com",
29
+ "password": "your-password"
30
+ }
22
31
  }
23
32
  },
24
33
  "refreshAccessToken": false,