@leo000001/opencode-quota-sidebar 2.0.1 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,10 @@
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';
7
8
  export declare function createDefaultProviderRegistry(): QuotaProviderRegistry;
8
- export { anthropicAdapter, buzzAdapter, copilotAdapter, openaiAdapter, rightCodeAdapter, QuotaProviderRegistry, };
9
+ export { anthropicAdapter, buzzAdapter, copilotAdapter, kimiForCodingAdapter, openaiAdapter, rightCodeAdapter, QuotaProviderRegistry, };
9
10
  export type { AuthUpdate, AuthValue, ProviderResolveContext, QuotaFetchContext, QuotaProviderAdapter, RefreshedOAuthAuth, } from './types.js';
@@ -1,6 +1,7 @@
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,9 +9,10 @@ export function createDefaultProviderRegistry() {
8
9
  const registry = new QuotaProviderRegistry();
9
10
  registry.register(rightCodeAdapter);
10
11
  registry.register(buzzAdapter);
12
+ registry.register(kimiForCodingAdapter);
11
13
  registry.register(openaiAdapter);
12
14
  registry.register(copilotAdapter);
13
15
  registry.register(anthropicAdapter);
14
16
  return registry;
15
17
  }
16
- export { anthropicAdapter, buzzAdapter, copilotAdapter, openaiAdapter, rightCodeAdapter, QuotaProviderRegistry, };
18
+ export { anthropicAdapter, buzzAdapter, copilotAdapter, kimiForCodingAdapter, openaiAdapter, rightCodeAdapter, QuotaProviderRegistry, };
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();
@@ -1,5 +1,6 @@
1
1
  const PROVIDER_SHORT_LABELS = {
2
2
  openai: 'OpenAI',
3
+ 'kimi-for-coding': 'Kimi',
3
4
  'github-copilot': 'Copilot',
4
5
  anthropic: 'Anthropic',
5
6
  rightcode: 'RC',
@@ -11,6 +11,7 @@ export function createQuotaService(deps) {
11
11
  const authCache = new TtlValueCache();
12
12
  const providerOptionsCache = new TtlValueCache();
13
13
  const inFlight = new Map();
14
+ let lastSuccessfulProviderOptionsMap = {};
14
15
  const authFingerprint = (auth) => {
15
16
  if (!auth || typeof auth !== 'object')
16
17
  return undefined;
@@ -46,7 +47,7 @@ export function createQuotaService(deps) {
46
47
  if (cached)
47
48
  return cached;
48
49
  const value = await loadAuthMap(deps.authPath);
49
- return authCache.set(value, 30_000);
50
+ return authCache.set(value, 5_000);
50
51
  };
51
52
  const getProviderOptionsMap = async () => {
52
53
  const cached = providerOptionsCache.get();
@@ -58,23 +59,142 @@ export function createQuotaService(deps) {
58
59
  }
59
60
  // Newer runtimes expose config.providers; older clients may only expose
60
61
  // provider.list with a slightly different response shape.
61
- const response = await (client.config?.providers
62
- ? client.config.providers({
62
+ let response;
63
+ let fromConfigProviders = false;
64
+ if (client.config?.providers) {
65
+ fromConfigProviders = true;
66
+ response = await client.config
67
+ .providers({
63
68
  query: { directory: deps.directory },
64
69
  throwOnError: true,
65
70
  })
66
- : client.provider.list({
71
+ .catch(swallow('getProviderOptionsMap:configProviders'));
72
+ }
73
+ if (!response && client.provider?.list) {
74
+ response = await client.provider
75
+ .list({
67
76
  query: { directory: deps.directory },
68
77
  throwOnError: true,
69
- })).catch(swallow('getProviderOptionsMap'));
70
- const data = isRecord(response) && isRecord(response.data) ? response.data : undefined;
71
- const list = Array.isArray(data?.providers)
72
- ? data.providers
73
- : Array.isArray(data?.all)
74
- ? data.all
78
+ })
79
+ .catch(swallow('getProviderOptionsMap:providerList'));
80
+ }
81
+ const data = isRecord(response) && Object.prototype.hasOwnProperty.call(response, 'data')
82
+ ? response.data
83
+ : undefined;
84
+ if (!response || data === undefined) {
85
+ if (client.provider?.list && fromConfigProviders) {
86
+ response = await client.provider
87
+ .list({
88
+ query: { directory: deps.directory },
89
+ throwOnError: true,
90
+ })
91
+ .catch(swallow('getProviderOptionsMap:providerListNoDataFallback'));
92
+ const fallbackData = isRecord(response) && Object.prototype.hasOwnProperty.call(response, 'data')
93
+ ? response.data
94
+ : undefined;
95
+ const fallbackRecord = isRecord(fallbackData) ? fallbackData : undefined;
96
+ const fallbackList = Array.isArray(fallbackRecord?.providers)
97
+ ? fallbackRecord.providers
98
+ : Array.isArray(fallbackRecord?.all)
99
+ ? fallbackRecord.all
100
+ : Array.isArray(fallbackData)
101
+ ? fallbackData
102
+ : undefined;
103
+ const map = Array.isArray(fallbackList)
104
+ ? fallbackList.reduce((acc, item) => {
105
+ if (!item || typeof item !== 'object')
106
+ return acc;
107
+ const record = item;
108
+ const id = record.id;
109
+ const options = record.options;
110
+ const key = record.key;
111
+ if (typeof id !== 'string')
112
+ return acc;
113
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
114
+ acc[id] =
115
+ typeof key === 'string' && key ? { apiKey: key } : {};
116
+ return acc;
117
+ }
118
+ const optionsRecord = options;
119
+ acc[id] = {
120
+ ...optionsRecord,
121
+ ...(typeof key === 'string' && key && optionsRecord.apiKey === undefined
122
+ ? { apiKey: key }
123
+ : {}),
124
+ };
125
+ return acc;
126
+ }, {})
127
+ : {};
128
+ if (Object.keys(map).length > 0) {
129
+ lastSuccessfulProviderOptionsMap = map;
130
+ return providerOptionsCache.set(map, 5_000);
131
+ }
132
+ }
133
+ return Object.keys(lastSuccessfulProviderOptionsMap).length > 0
134
+ ? lastSuccessfulProviderOptionsMap
135
+ : {};
136
+ }
137
+ const dataRecord = isRecord(data) ? data : undefined;
138
+ const list = Array.isArray(dataRecord?.providers)
139
+ ? dataRecord.providers
140
+ : Array.isArray(dataRecord?.all)
141
+ ? dataRecord.all
75
142
  : Array.isArray(data)
76
143
  ? data
77
144
  : undefined;
145
+ if (!list && fromConfigProviders && client.provider?.list) {
146
+ response = await client.provider
147
+ .list({
148
+ query: { directory: deps.directory },
149
+ throwOnError: true,
150
+ })
151
+ .catch(swallow('getProviderOptionsMap:providerListFallback'));
152
+ const fallbackData = isRecord(response) && Object.prototype.hasOwnProperty.call(response, 'data')
153
+ ? response.data
154
+ : undefined;
155
+ const fallbackRecord = isRecord(fallbackData) ? fallbackData : undefined;
156
+ const fallbackList = Array.isArray(fallbackRecord?.providers)
157
+ ? fallbackRecord.providers
158
+ : Array.isArray(fallbackRecord?.all)
159
+ ? fallbackRecord.all
160
+ : Array.isArray(fallbackData)
161
+ ? fallbackData
162
+ : undefined;
163
+ const map = Array.isArray(fallbackList)
164
+ ? fallbackList.reduce((acc, item) => {
165
+ if (!item || typeof item !== 'object')
166
+ return acc;
167
+ const record = item;
168
+ const id = record.id;
169
+ const options = record.options;
170
+ const key = record.key;
171
+ if (typeof id !== 'string')
172
+ return acc;
173
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
174
+ acc[id] = typeof key === 'string' && key ? { apiKey: key } : {};
175
+ return acc;
176
+ }
177
+ const optionsRecord = options;
178
+ acc[id] = {
179
+ ...optionsRecord,
180
+ ...(typeof key === 'string' && key && optionsRecord.apiKey === undefined
181
+ ? { apiKey: key }
182
+ : {}),
183
+ };
184
+ return acc;
185
+ }, {})
186
+ : {};
187
+ if (Object.keys(map).length > 0) {
188
+ lastSuccessfulProviderOptionsMap = map;
189
+ return providerOptionsCache.set(map, 5_000);
190
+ }
191
+ if (!Array.isArray(fallbackList)) {
192
+ return Object.keys(lastSuccessfulProviderOptionsMap).length > 0
193
+ ? lastSuccessfulProviderOptionsMap
194
+ : {};
195
+ }
196
+ return providerOptionsCache.set(map, 5_000);
197
+ }
78
198
  const map = Array.isArray(list)
79
199
  ? list.reduce((acc, item) => {
80
200
  if (!item || typeof item !== 'object')
@@ -82,18 +202,34 @@ export function createQuotaService(deps) {
82
202
  const record = item;
83
203
  const id = record.id;
84
204
  const options = record.options;
205
+ const key = record.key;
85
206
  if (typeof id !== 'string')
86
207
  return acc;
87
208
  if (!options ||
88
209
  typeof options !== 'object' ||
89
210
  Array.isArray(options)) {
90
- acc[id] = {};
211
+ acc[id] = typeof key === 'string' && key ? { apiKey: key } : {};
91
212
  return acc;
92
213
  }
93
- acc[id] = options;
214
+ const optionsRecord = options;
215
+ acc[id] = {
216
+ ...optionsRecord,
217
+ ...(typeof key === 'string' && key && optionsRecord.apiKey === undefined
218
+ ? { apiKey: key }
219
+ : {}),
220
+ };
94
221
  return acc;
95
222
  }, {})
96
223
  : {};
224
+ if (Object.keys(map).length > 0) {
225
+ lastSuccessfulProviderOptionsMap = map;
226
+ return providerOptionsCache.set(map, 5_000);
227
+ }
228
+ if (!Array.isArray(list)) {
229
+ return Object.keys(lastSuccessfulProviderOptionsMap).length > 0
230
+ ? lastSuccessfulProviderOptionsMap
231
+ : providerOptionsCache.set(map, 5_000);
232
+ }
97
233
  return providerOptionsCache.set(map, 5_000);
98
234
  };
99
235
  const isValidQuotaCache = (snapshot) => {
@@ -276,7 +412,11 @@ export function createQuotaService(deps) {
276
412
  body: next,
277
413
  throwOnError: true,
278
414
  })
279
- .catch(swallow('getQuotaSnapshots:authSet'));
415
+ .catch((error) => {
416
+ swallow('getQuotaSnapshots:authSet')(error);
417
+ throw error;
418
+ });
419
+ authCache.clear();
280
420
  }, providerOptions)
281
421
  .then((latest) => {
282
422
  if (!latest)
@@ -27,6 +27,7 @@ function parseProviderUsage(value) {
27
27
  cost: asNumber(value.cost, 0),
28
28
  apiCost: asNumber(value.apiCost, 0),
29
29
  assistantMessages: asNumber(value.assistantMessages, 0),
30
+ cacheBuckets: parseCacheUsageBuckets(value.cacheBuckets),
30
31
  };
31
32
  }
32
33
  function parseCacheUsageBucket(value) {
@@ -17,16 +17,32 @@ export declare function createTitleApplicator(deps: {
17
17
  scheduleParentRefreshIfSafe: (sessionID: string, parentID?: string) => void;
18
18
  restoreConcurrency: number;
19
19
  }): {
20
- applyTitle: (sessionID: string) => Promise<void>;
20
+ applyTitle: (sessionID: string) => Promise<boolean>;
21
21
  handleSessionUpdatedTitle: (args: {
22
22
  sessionID: string;
23
23
  incomingTitle: string;
24
24
  sessionState: SessionState;
25
25
  scheduleRefresh: (sessionID: string, delay?: number) => void;
26
26
  }) => Promise<void>;
27
- restoreSessionTitle: (sessionID: string) => Promise<void>;
28
- restoreAllVisibleTitles: () => Promise<void>;
29
- refreshAllTouchedTitles: () => Promise<void>;
30
- refreshAllVisibleTitles: () => Promise<void>;
27
+ restoreSessionTitle: (sessionID: string, options?: {
28
+ abortIfEnabled?: boolean;
29
+ }) => Promise<boolean>;
30
+ restoreAllVisibleTitles: (options?: {
31
+ abortIfEnabled?: boolean;
32
+ }) => Promise<{
33
+ attempted: number;
34
+ restored: number;
35
+ listFailed: boolean;
36
+ }>;
37
+ refreshAllTouchedTitles: () => Promise<{
38
+ attempted: number;
39
+ refreshed: number;
40
+ listFailed: boolean;
41
+ }>;
42
+ refreshAllVisibleTitles: () => Promise<{
43
+ attempted: number;
44
+ refreshed: number;
45
+ listFailed: boolean;
46
+ }>;
31
47
  forgetSession: (sessionID: string) => void;
32
48
  };