@leo000001/opencode-quota-sidebar 2.0.2 → 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/CHANGELOG.md +3 -0
- package/CONTRIBUTING.md +6 -1
- package/README.md +51 -26
- package/SECURITY.md +1 -1
- package/dist/format.js +39 -2
- package/dist/index.js +62 -0
- package/dist/providers/common.d.ts +1 -0
- package/dist/providers/core/kimi_for_coding.d.ts +2 -0
- package/dist/providers/core/kimi_for_coding.js +219 -0
- package/dist/providers/index.d.ts +3 -1
- package/dist/providers/index.js +5 -1
- package/dist/providers/third_party/rightcode.js +2 -3
- package/dist/providers/third_party/xyai_vibe.d.ts +2 -0
- package/dist/providers/third_party/xyai_vibe.js +314 -0
- package/dist/providers/types.d.ts +1 -1
- package/dist/quota.d.ts +2 -0
- package/dist/quota.js +1 -1
- package/dist/quota_render.js +1 -0
- package/dist/quota_service.js +28 -6
- package/dist/storage.js +21 -3
- package/dist/storage_parse.js +2 -0
- package/dist/types.d.ts +8 -3
- package/package.json +1 -1
- package/quota-sidebar.config.example.json +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Add built-in `kimi-for-coding` subscription quota support via `GET https://api.kimi.com/coding/v1/usages`.
|
|
6
|
+
- Parse Kimi's `5h` and `Weekly` windows, including reset timestamps, and render them like other subscription providers.
|
|
7
|
+
- Accept OpenCode provider discovery responses that expose Kimi API keys through provider `key` fields.
|
|
5
8
|
- Add Buzz API balance support for OpenAI-compatible providers that use a Buzz `baseURL`.
|
|
6
9
|
- Document Buzz configuration, rendering, and outbound billing endpoints.
|
|
7
10
|
- Keep session measured cost aligned with OpenCode root-session `message.cost` while still including descendant subagent usage in API-equivalent cost.
|
package/CONTRIBUTING.md
CHANGED
|
@@ -19,6 +19,7 @@ The plugin now uses a provider adapter registry, so adding a new provider does n
|
|
|
19
19
|
- `baseURL` match: best for OpenAI-compatible relays such as RightCode or Buzz
|
|
20
20
|
- Prefix/variant normalization: best when one provider has multiple runtime IDs
|
|
21
21
|
- Balance-only providers should prefer `balance` over inventing fake percent windows
|
|
22
|
+
- Built-in API-key providers such as `kimi-for-coding` may need both: direct ID matching for the canonical provider and support for OpenCode's discovered `key -> options.apiKey` bridge
|
|
22
23
|
|
|
23
24
|
## Add a new provider
|
|
24
25
|
|
|
@@ -70,6 +71,10 @@ If your provider is an OpenAI-compatible relay, prefer matching on
|
|
|
70
71
|
`providerOptions.baseURL` instead of the runtime `providerID`; that keeps custom
|
|
71
72
|
aliases working without extra user config.
|
|
72
73
|
|
|
74
|
+
If your provider is built into OpenCode and already has a stable runtime ID
|
|
75
|
+
(for example `kimi-for-coding`), prefer a direct provider-ID match first, then
|
|
76
|
+
add a `baseURL` fallback only when it helps older/custom runtime shapes.
|
|
77
|
+
|
|
73
78
|
If the new provider should appear in default `quota_summary` reports even when
|
|
74
79
|
it has not yet been used in the current session, also update
|
|
75
80
|
`listDefaultQuotaProviderIDs()` in `src/quota.ts`.
|
|
@@ -98,7 +103,7 @@ At minimum:
|
|
|
98
103
|
- format output if using special fields (e.g. `balance`)
|
|
99
104
|
- cache compatibility if the change replaces an older snapshot shape
|
|
100
105
|
- mixed-provider rendering if the new provider will commonly appear next to
|
|
101
|
-
OpenAI/Copilot/RightCode in sidebar or toast output
|
|
106
|
+
OpenAI/Copilot/Kimi/RightCode in sidebar or toast output
|
|
102
107
|
|
|
103
108
|
If the provider introduces new rendering rules or multi-window behavior, add
|
|
104
109
|
coverage in both `src/__tests__/quota.test.ts` and `src/__tests__/format.test.ts`.
|
package/README.md
CHANGED
|
@@ -41,13 +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
|
|
45
|
-
|
|
|
46
|
-
| OpenAI Codex
|
|
47
|
-
| GitHub Copilot
|
|
48
|
-
|
|
|
49
|
-
|
|
|
50
|
-
|
|
|
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 |
|
|
51
53
|
|
|
52
54
|
Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware AI, etc.)? See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
53
55
|
|
|
@@ -62,25 +64,45 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
|
|
|
62
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
|
|
63
65
|
- next line: `$X.XX as API cost` (equivalent API billing for subscription-auth providers)
|
|
64
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`
|
|
65
|
-
- RightCode daily quota shows `$remaining/$dailyTotal`
|
|
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`)
|
|
66
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.
|
|
67
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
|
|
68
72
|
- `quota_summary` markdown / toast also include `Cache Coverage` and `Cache Read Coverage` summary lines when available
|
|
69
73
|
- Quota snapshots are de-duplicated before rendering to avoid repeated provider lines
|
|
70
74
|
- Custom tools:
|
|
71
75
|
- `quota_summary` — generate usage report for session/day/week/month (markdown + toast)
|
|
72
76
|
- `quota_show` — toggle sidebar title display on/off (state persists across sessions)
|
|
73
77
|
- After startup, titles are restored immediately when persisted display mode is OFF; when persisted display mode is ON, touched titles refresh on startup and the rest update on the next relevant session/message event or when `quota_show` is toggled
|
|
74
|
-
- Quota connectors:
|
|
75
|
-
- OpenAI Codex OAuth (`/backend-api/wham/usage`)
|
|
76
|
-
- GitHub Copilot OAuth (`/copilot_internal/user`)
|
|
77
|
-
-
|
|
78
|
-
-
|
|
79
|
-
-
|
|
78
|
+
- Quota connectors:
|
|
79
|
+
- OpenAI Codex OAuth (`/backend-api/wham/usage`)
|
|
80
|
+
- GitHub Copilot OAuth (`/copilot_internal/user`)
|
|
81
|
+
- Kimi For Coding API key (`/usages`, built-in `kimi-for-coding` provider)
|
|
82
|
+
- RightCode API key (`/account/summary`)
|
|
83
|
+
- Buzz API key (`/v1/dashboard/billing/subscription` + `/v1/dashboard/billing/usage`)
|
|
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`)
|
|
80
86
|
- OpenAI OAuth quota checks auto-refresh expired access token (using refresh token)
|
|
81
|
-
- API key providers still show usage aggregation (quota only applies to subscription providers)
|
|
82
|
-
- Incremental usage aggregation — only processes new messages since last cursor
|
|
83
|
-
- Sidebar token units are adaptive (`k`/`m` with one decimal where applicable)
|
|
87
|
+
- API key providers still show usage aggregation (quota only applies to subscription providers)
|
|
88
|
+
- Incremental usage aggregation — only processes new messages since last cursor
|
|
89
|
+
- Sidebar token units are adaptive (`k`/`m` with one decimal where applicable)
|
|
90
|
+
|
|
91
|
+
### Kimi For Coding notes
|
|
92
|
+
|
|
93
|
+
- OpenCode's built-in provider ID is `kimi-for-coding` and its runtime base URL is `https://api.kimi.com/coding/v1`.
|
|
94
|
+
- The plugin treats Kimi as a subscription quota source, not a balance source.
|
|
95
|
+
- Quota data is read from `GET https://api.kimi.com/coding/v1/usages`.
|
|
96
|
+
- The current implementation maps the short rolling window in `limits[]` to `5h` and the top-level `usage` block to `Weekly`.
|
|
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.
|
|
84
106
|
|
|
85
107
|
## Storage layout
|
|
86
108
|
|
|
@@ -94,6 +116,7 @@ The plugin stores lightweight global state and date-partitioned session chunks.
|
|
|
94
116
|
- per-session title state (`baseTitle`, `lastAppliedTitle`)
|
|
95
117
|
- `createdAt`
|
|
96
118
|
- `parentID` (when the session is a subagent child session)
|
|
119
|
+
- `expiryToastShown` (session-level dedupe for automatic expiry reminders)
|
|
97
120
|
- cached usage summary used by `quota_summary`, including session-level and provider-level `cacheBuckets` for cache coverage reporting
|
|
98
121
|
- incremental aggregation cursor
|
|
99
122
|
|
|
@@ -238,7 +261,7 @@ Quota defaults:
|
|
|
238
261
|
- `quota.includeOpenAI`: `true`
|
|
239
262
|
- `quota.includeCopilot`: `true`
|
|
240
263
|
- `quota.includeAnthropic`: `true`
|
|
241
|
-
- `quota.providers`: `{}` (per-adapter switches, for example `rightcode.enabled` or `
|
|
264
|
+
- `quota.providers`: `{}` (per-adapter switches and adapter-specific config, for example `rightcode.enabled` or `xyai-vibe.login.username/password`)
|
|
242
265
|
- `quota.refreshAccessToken`: `false`
|
|
243
266
|
- `quota.requestTimeoutMs`: `8000` (clamped to `>=1000`)
|
|
244
267
|
|
|
@@ -455,12 +478,13 @@ Set `OPENCODE_QUOTA_DEBUG=1` to enable debug logging to stderr. This logs:
|
|
|
455
478
|
## Security & privacy notes
|
|
456
479
|
|
|
457
480
|
- The plugin reads OpenCode credentials from `<opencode-data>/auth.json`.
|
|
458
|
-
- If enabled, quota checks call external endpoints:
|
|
459
|
-
- OpenAI Codex: `https://chatgpt.com/backend-api/wham/usage`
|
|
460
|
-
- GitHub Copilot: `https://api.github.com/copilot_internal/user`
|
|
461
|
-
-
|
|
462
|
-
-
|
|
463
|
-
-
|
|
481
|
+
- If enabled, quota checks call external endpoints:
|
|
482
|
+
- OpenAI Codex: `https://chatgpt.com/backend-api/wham/usage`
|
|
483
|
+
- GitHub Copilot: `https://api.github.com/copilot_internal/user`
|
|
484
|
+
- Kimi For Coding: `https://api.kimi.com/coding/v1/usages`
|
|
485
|
+
- RightCode: `https://www.right.codes/account/summary`
|
|
486
|
+
- Buzz: `https://buzzai.cc/v1/dashboard/billing/subscription` and `https://buzzai.cc/v1/dashboard/billing/usage`
|
|
487
|
+
- Anthropic: `https://api.anthropic.com/api/oauth/usage`
|
|
464
488
|
- **Screen-sharing warning**: Session titles and toasts surface usage/quota
|
|
465
489
|
information. If you are screen-sharing or recording, consider toggling the
|
|
466
490
|
sidebar display off (`/qtoggle` or `quota_show` tool) to avoid leaking
|
|
@@ -470,8 +494,9 @@ Set `OPENCODE_QUOTA_DEBUG=1` to enable debug logging to stderr. This logs:
|
|
|
470
494
|
- OpenAI OAuth token refresh is disabled by default; set
|
|
471
495
|
`quota.refreshAccessToken=true` if you want the plugin to refresh access
|
|
472
496
|
tokens when expired.
|
|
473
|
-
- Anthropic quota currently uses a beta/internal-style OAuth usage endpoint and
|
|
474
|
-
request header; response fields may change without notice.
|
|
497
|
+
- Anthropic quota currently uses a beta/internal-style OAuth usage endpoint and
|
|
498
|
+
request header; response fields may change without notice.
|
|
499
|
+
- Kimi For Coding quota uses the current `/usages` response shape exposed by the Kimi coding service; if Kimi changes that payload, window parsing may need to be updated.
|
|
475
500
|
- State/chunk file writes refuse to write through symlinked targets (best-effort defense-in-depth).
|
|
476
501
|
- The `OPENCODE_QUOTA_DATA_HOME` env var overrides the OpenCode data directory
|
|
477
502
|
path (for testing); do not set this in production.
|
package/SECURITY.md
CHANGED
|
@@ -25,7 +25,7 @@ We will acknowledge reports as quickly as possible and provide a remediation tim
|
|
|
25
25
|
- Keep debug logs free of secrets.
|
|
26
26
|
- Prefer fail-closed behavior for writes (already enforced via symlink checks and atomic writes).
|
|
27
27
|
- Quota fetching may contact provider-operated endpoints such as OpenAI, GitHub,
|
|
28
|
-
RightCode, Buzz, and Anthropic; review any new provider integration for
|
|
28
|
+
Kimi, RightCode, Buzz, and Anthropic; review any new provider integration for
|
|
29
29
|
outbound data exposure and header/token handling.
|
|
30
30
|
- Some quota integrations rely on beta or internal-style endpoints; document
|
|
31
31
|
instability risks clearly and avoid assuming long-term API compatibility.
|
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;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { isRecord, swallow } from '../../helpers.js';
|
|
2
|
+
import { asNumber, configuredProviderEnabled, fetchWithTimeout, sanitizeBaseURL, toIso, } from '../common.js';
|
|
3
|
+
const KIMI_FOR_CODING_BASE_URL = 'https://api.kimi.com/coding/v1';
|
|
4
|
+
function resolveApiKey(auth, providerOptions) {
|
|
5
|
+
const optionKey = providerOptions?.apiKey;
|
|
6
|
+
if (typeof optionKey === 'string' && optionKey)
|
|
7
|
+
return optionKey;
|
|
8
|
+
if (!auth)
|
|
9
|
+
return undefined;
|
|
10
|
+
if (auth.type === 'api' && typeof auth.key === 'string' && auth.key) {
|
|
11
|
+
return auth.key;
|
|
12
|
+
}
|
|
13
|
+
if (auth.type === 'wellknown') {
|
|
14
|
+
if (typeof auth.key === 'string' && auth.key)
|
|
15
|
+
return auth.key;
|
|
16
|
+
if (typeof auth.token === 'string' && auth.token)
|
|
17
|
+
return auth.token;
|
|
18
|
+
}
|
|
19
|
+
if (auth.type === 'oauth' && typeof auth.access === 'string' && auth.access) {
|
|
20
|
+
return auth.access;
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
function isKimiCodingBaseURL(value) {
|
|
25
|
+
const normalized = sanitizeBaseURL(value);
|
|
26
|
+
if (!normalized)
|
|
27
|
+
return false;
|
|
28
|
+
try {
|
|
29
|
+
const parsed = new URL(normalized);
|
|
30
|
+
if (parsed.protocol !== 'https:')
|
|
31
|
+
return false;
|
|
32
|
+
const pathname = parsed.pathname.replace(/\/+$/, '');
|
|
33
|
+
return parsed.host === 'api.kimi.com' && pathname === '/coding/v1';
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function usagesUrl(baseURL) {
|
|
40
|
+
const normalized = sanitizeBaseURL(baseURL);
|
|
41
|
+
if (isKimiCodingBaseURL(normalized)) {
|
|
42
|
+
return `${normalized}/usages`;
|
|
43
|
+
}
|
|
44
|
+
return `${KIMI_FOR_CODING_BASE_URL}/usages`;
|
|
45
|
+
}
|
|
46
|
+
function percentFromQuota(limit, remaining) {
|
|
47
|
+
const total = asNumber(limit) ??
|
|
48
|
+
(typeof limit === 'string' && limit.trim() ? Number(limit) : undefined);
|
|
49
|
+
const left = asNumber(remaining) ??
|
|
50
|
+
(typeof remaining === 'string' && remaining.trim()
|
|
51
|
+
? Number(remaining)
|
|
52
|
+
: undefined);
|
|
53
|
+
if (total === undefined || left === undefined || total <= 0)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (!Number.isFinite(total) || !Number.isFinite(left))
|
|
56
|
+
return undefined;
|
|
57
|
+
return Math.max(0, Math.min(100, (left / total) * 100));
|
|
58
|
+
}
|
|
59
|
+
function windowLabel(duration, timeUnit) {
|
|
60
|
+
if (timeUnit === 'TIME_UNIT_MINUTE' && duration === 300)
|
|
61
|
+
return '5h';
|
|
62
|
+
if (timeUnit === 'TIME_UNIT_DAY' && duration === 7)
|
|
63
|
+
return 'Weekly';
|
|
64
|
+
if (timeUnit === 'TIME_UNIT_MINUTE' && duration && duration > 0) {
|
|
65
|
+
const hours = duration / 60;
|
|
66
|
+
if (hours <= 24)
|
|
67
|
+
return `${Math.round(hours)}h`;
|
|
68
|
+
}
|
|
69
|
+
if (timeUnit === 'TIME_UNIT_HOUR' && duration && duration > 0) {
|
|
70
|
+
if (duration <= 24)
|
|
71
|
+
return `${Math.round(duration)}h`;
|
|
72
|
+
const days = duration / 24;
|
|
73
|
+
if (days <= 6)
|
|
74
|
+
return `${Math.round(days)}d`;
|
|
75
|
+
}
|
|
76
|
+
if (timeUnit === 'TIME_UNIT_DAY' && duration && duration > 0) {
|
|
77
|
+
if (duration <= 6)
|
|
78
|
+
return `${Math.round(duration)}d`;
|
|
79
|
+
if (duration === 7)
|
|
80
|
+
return 'Weekly';
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
function parseWindow(value) {
|
|
85
|
+
if (!isRecord(value))
|
|
86
|
+
return undefined;
|
|
87
|
+
const window = isRecord(value.window) ? value.window : undefined;
|
|
88
|
+
const detail = isRecord(value.detail) ? value.detail : undefined;
|
|
89
|
+
if (!window || !detail)
|
|
90
|
+
return undefined;
|
|
91
|
+
const duration = asNumber(window.duration);
|
|
92
|
+
const timeUnit = typeof window.timeUnit === 'string' ? window.timeUnit : undefined;
|
|
93
|
+
const label = windowLabel(duration, timeUnit);
|
|
94
|
+
const remainingPercent = percentFromQuota(detail.limit, detail.remaining);
|
|
95
|
+
if (!label || remainingPercent === undefined)
|
|
96
|
+
return undefined;
|
|
97
|
+
return {
|
|
98
|
+
label,
|
|
99
|
+
remainingPercent,
|
|
100
|
+
resetAt: toIso(detail.resetTime),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function dedupeWindows(windows) {
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const deduped = [];
|
|
106
|
+
for (const window of windows) {
|
|
107
|
+
const key = `${window.label}|${window.resetAt || ''}|${window.remainingPercent ?? ''}`;
|
|
108
|
+
if (seen.has(key))
|
|
109
|
+
continue;
|
|
110
|
+
seen.add(key);
|
|
111
|
+
deduped.push(window);
|
|
112
|
+
}
|
|
113
|
+
return deduped;
|
|
114
|
+
}
|
|
115
|
+
async function fetchKimiForCodingQuota({ providerID, providerOptions, auth, config, }) {
|
|
116
|
+
const checkedAt = Date.now();
|
|
117
|
+
const base = {
|
|
118
|
+
providerID,
|
|
119
|
+
adapterID: 'kimi-for-coding',
|
|
120
|
+
label: 'Kimi For Coding',
|
|
121
|
+
shortLabel: 'Kimi',
|
|
122
|
+
sortOrder: 15,
|
|
123
|
+
};
|
|
124
|
+
const apiKey = resolveApiKey(auth, providerOptions);
|
|
125
|
+
if (!apiKey) {
|
|
126
|
+
return {
|
|
127
|
+
...base,
|
|
128
|
+
status: 'unavailable',
|
|
129
|
+
checkedAt,
|
|
130
|
+
note: 'missing api key',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const response = await fetchWithTimeout(usagesUrl(providerOptions?.baseURL), {
|
|
134
|
+
method: 'GET',
|
|
135
|
+
headers: {
|
|
136
|
+
Accept: 'application/json',
|
|
137
|
+
Authorization: `Bearer ${apiKey}`,
|
|
138
|
+
'User-Agent': 'opencode-quota-sidebar',
|
|
139
|
+
},
|
|
140
|
+
}, config.quota.requestTimeoutMs).catch(swallow('fetchKimiForCodingQuota:usage'));
|
|
141
|
+
if (!response) {
|
|
142
|
+
return {
|
|
143
|
+
...base,
|
|
144
|
+
status: 'error',
|
|
145
|
+
checkedAt,
|
|
146
|
+
note: 'network request failed',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
return {
|
|
151
|
+
...base,
|
|
152
|
+
status: 'error',
|
|
153
|
+
checkedAt,
|
|
154
|
+
note: `http ${response.status}`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const payload = await response
|
|
158
|
+
.json()
|
|
159
|
+
.catch(swallow('fetchKimiForCodingQuota:json'));
|
|
160
|
+
if (!isRecord(payload)) {
|
|
161
|
+
return {
|
|
162
|
+
...base,
|
|
163
|
+
status: 'error',
|
|
164
|
+
checkedAt,
|
|
165
|
+
note: 'invalid response',
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const windows = Array.isArray(payload.limits)
|
|
169
|
+
? payload.limits.map((item) => parseWindow(item)).filter(Boolean)
|
|
170
|
+
: [];
|
|
171
|
+
const usage = isRecord(payload.usage) ? payload.usage : undefined;
|
|
172
|
+
const topLevelRemainingPercent = usage
|
|
173
|
+
? percentFromQuota(usage.limit, usage.remaining)
|
|
174
|
+
: undefined;
|
|
175
|
+
const topLevelResetAt = usage ? toIso(usage.resetTime) : undefined;
|
|
176
|
+
const allWindows = dedupeWindows([
|
|
177
|
+
...windows,
|
|
178
|
+
topLevelRemainingPercent !== undefined
|
|
179
|
+
? {
|
|
180
|
+
label: 'Weekly',
|
|
181
|
+
remainingPercent: topLevelRemainingPercent,
|
|
182
|
+
resetAt: topLevelResetAt,
|
|
183
|
+
}
|
|
184
|
+
: undefined,
|
|
185
|
+
].filter((value) => Boolean(value))).sort((left, right) => {
|
|
186
|
+
const order = (label) => {
|
|
187
|
+
if (label === '5h')
|
|
188
|
+
return 0;
|
|
189
|
+
if (label === 'Weekly')
|
|
190
|
+
return 1;
|
|
191
|
+
return 2;
|
|
192
|
+
};
|
|
193
|
+
return order(left.label) - order(right.label);
|
|
194
|
+
});
|
|
195
|
+
const primary = allWindows[0];
|
|
196
|
+
return {
|
|
197
|
+
...base,
|
|
198
|
+
status: primary ? 'ok' : 'error',
|
|
199
|
+
checkedAt,
|
|
200
|
+
remainingPercent: primary?.remainingPercent,
|
|
201
|
+
resetAt: primary?.resetAt,
|
|
202
|
+
note: primary ? undefined : 'missing quota fields',
|
|
203
|
+
windows: allWindows.length > 0 ? allWindows : undefined,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
export const kimiForCodingAdapter = {
|
|
207
|
+
id: 'kimi-for-coding',
|
|
208
|
+
label: 'Kimi For Coding',
|
|
209
|
+
shortLabel: 'Kimi',
|
|
210
|
+
sortOrder: 15,
|
|
211
|
+
normalizeID: (providerID) => providerID === 'kimi-for-coding' ? 'kimi-for-coding' : undefined,
|
|
212
|
+
matchScore: ({ providerID, providerOptions }) => {
|
|
213
|
+
if (providerID === 'kimi-for-coding')
|
|
214
|
+
return 100;
|
|
215
|
+
return isKimiCodingBaseURL(providerOptions?.baseURL) ? 95 : 0;
|
|
216
|
+
},
|
|
217
|
+
isEnabled: (config) => configuredProviderEnabled(config.quota, 'kimi-for-coding', true),
|
|
218
|
+
fetch: fetchKimiForCodingQuota,
|
|
219
|
+
};
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { anthropicAdapter } from './core/anthropic.js';
|
|
2
2
|
import { buzzAdapter } from './third_party/buzz.js';
|
|
3
3
|
import { copilotAdapter } from './core/copilot.js';
|
|
4
|
+
import { kimiForCodingAdapter } from './core/kimi_for_coding.js';
|
|
4
5
|
import { openaiAdapter } from './core/openai.js';
|
|
5
6
|
import { QuotaProviderRegistry } from './registry.js';
|
|
6
7
|
import { rightCodeAdapter } from './third_party/rightcode.js';
|
|
8
|
+
import { xyaiVibeAdapter } from './third_party/xyai_vibe.js';
|
|
7
9
|
export declare function createDefaultProviderRegistry(): QuotaProviderRegistry;
|
|
8
|
-
export { anthropicAdapter, buzzAdapter, copilotAdapter, openaiAdapter, rightCodeAdapter, QuotaProviderRegistry, };
|
|
10
|
+
export { anthropicAdapter, buzzAdapter, copilotAdapter, kimiForCodingAdapter, openaiAdapter, rightCodeAdapter, xyaiVibeAdapter, QuotaProviderRegistry, };
|
|
9
11
|
export type { AuthUpdate, AuthValue, ProviderResolveContext, QuotaFetchContext, QuotaProviderAdapter, RefreshedOAuthAuth, } from './types.js';
|
package/dist/providers/index.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { anthropicAdapter } from './core/anthropic.js';
|
|
2
2
|
import { buzzAdapter } from './third_party/buzz.js';
|
|
3
3
|
import { copilotAdapter } from './core/copilot.js';
|
|
4
|
+
import { kimiForCodingAdapter } from './core/kimi_for_coding.js';
|
|
4
5
|
import { openaiAdapter } from './core/openai.js';
|
|
5
6
|
import { QuotaProviderRegistry } from './registry.js';
|
|
6
7
|
import { rightCodeAdapter } from './third_party/rightcode.js';
|
|
8
|
+
import { xyaiVibeAdapter } from './third_party/xyai_vibe.js';
|
|
7
9
|
export function createDefaultProviderRegistry() {
|
|
8
10
|
const registry = new QuotaProviderRegistry();
|
|
9
11
|
registry.register(rightCodeAdapter);
|
|
10
12
|
registry.register(buzzAdapter);
|
|
13
|
+
registry.register(xyaiVibeAdapter);
|
|
14
|
+
registry.register(kimiForCodingAdapter);
|
|
11
15
|
registry.register(openaiAdapter);
|
|
12
16
|
registry.register(copilotAdapter);
|
|
13
17
|
registry.register(anthropicAdapter);
|
|
14
18
|
return registry;
|
|
15
19
|
}
|
|
16
|
-
export { anthropicAdapter, buzzAdapter, copilotAdapter, 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,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:
|
|
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/quota.js
CHANGED
|
@@ -33,7 +33,7 @@ export function quotaSort(left, right) {
|
|
|
33
33
|
}
|
|
34
34
|
export function listDefaultQuotaProviderIDs() {
|
|
35
35
|
// Keep default report behavior stable for built-in subscription providers.
|
|
36
|
-
return ['openai', 'github-copilot', 'anthropic'];
|
|
36
|
+
return ['openai', 'kimi-for-coding', 'github-copilot', 'anthropic'];
|
|
37
37
|
}
|
|
38
38
|
export function createQuotaRuntime() {
|
|
39
39
|
const providerRegistry = createDefaultProviderRegistry();
|
package/dist/quota_render.js
CHANGED
package/dist/quota_service.js
CHANGED
|
@@ -107,13 +107,21 @@ export function createQuotaService(deps) {
|
|
|
107
107
|
const record = item;
|
|
108
108
|
const id = record.id;
|
|
109
109
|
const options = record.options;
|
|
110
|
+
const key = record.key;
|
|
110
111
|
if (typeof id !== 'string')
|
|
111
112
|
return acc;
|
|
112
113
|
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
113
|
-
acc[id] =
|
|
114
|
+
acc[id] =
|
|
115
|
+
typeof key === 'string' && key ? { apiKey: key } : {};
|
|
114
116
|
return acc;
|
|
115
117
|
}
|
|
116
|
-
|
|
118
|
+
const optionsRecord = options;
|
|
119
|
+
acc[id] = {
|
|
120
|
+
...optionsRecord,
|
|
121
|
+
...(typeof key === 'string' && key && optionsRecord.apiKey === undefined
|
|
122
|
+
? { apiKey: key }
|
|
123
|
+
: {}),
|
|
124
|
+
};
|
|
117
125
|
return acc;
|
|
118
126
|
}, {})
|
|
119
127
|
: {};
|
|
@@ -159,13 +167,20 @@ export function createQuotaService(deps) {
|
|
|
159
167
|
const record = item;
|
|
160
168
|
const id = record.id;
|
|
161
169
|
const options = record.options;
|
|
170
|
+
const key = record.key;
|
|
162
171
|
if (typeof id !== 'string')
|
|
163
172
|
return acc;
|
|
164
173
|
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
165
|
-
acc[id] = {};
|
|
174
|
+
acc[id] = typeof key === 'string' && key ? { apiKey: key } : {};
|
|
166
175
|
return acc;
|
|
167
176
|
}
|
|
168
|
-
|
|
177
|
+
const optionsRecord = options;
|
|
178
|
+
acc[id] = {
|
|
179
|
+
...optionsRecord,
|
|
180
|
+
...(typeof key === 'string' && key && optionsRecord.apiKey === undefined
|
|
181
|
+
? { apiKey: key }
|
|
182
|
+
: {}),
|
|
183
|
+
};
|
|
169
184
|
return acc;
|
|
170
185
|
}, {})
|
|
171
186
|
: {};
|
|
@@ -187,15 +202,22 @@ export function createQuotaService(deps) {
|
|
|
187
202
|
const record = item;
|
|
188
203
|
const id = record.id;
|
|
189
204
|
const options = record.options;
|
|
205
|
+
const key = record.key;
|
|
190
206
|
if (typeof id !== 'string')
|
|
191
207
|
return acc;
|
|
192
208
|
if (!options ||
|
|
193
209
|
typeof options !== 'object' ||
|
|
194
210
|
Array.isArray(options)) {
|
|
195
|
-
acc[id] = {};
|
|
211
|
+
acc[id] = typeof key === 'string' && key ? { apiKey: key } : {};
|
|
196
212
|
return acc;
|
|
197
213
|
}
|
|
198
|
-
|
|
214
|
+
const optionsRecord = options;
|
|
215
|
+
acc[id] = {
|
|
216
|
+
...optionsRecord,
|
|
217
|
+
...(typeof key === 'string' && key && optionsRecord.apiKey === undefined
|
|
218
|
+
? { apiKey: key }
|
|
219
|
+
: {}),
|
|
220
|
+
};
|
|
199
221
|
return acc;
|
|
200
222
|
}, {})
|
|
201
223
|
: {};
|
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
|
-
|
|
60
|
-
|
|
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
|
};
|
package/dist/storage_parse.js
CHANGED
|
@@ -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
|
@@ -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,
|