dsh-subscriptions 0.1.5 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +212 -154
  2. package/lib/auth/claude-code-creds.js +31 -9
  3. package/lib/auth/device-flow.js +16 -9
  4. package/lib/auth/oauth-flow.js +13 -10
  5. package/lib/auth/rpc.d.ts +73 -2
  6. package/lib/auth/rpc.js +124 -23
  7. package/lib/auth/store.js +80 -31
  8. package/lib/client/CursorCard.d.ts +1 -1
  9. package/lib/client/CursorCard.js +50 -16
  10. package/lib/client/ExternalUsageCards.d.ts +1 -1
  11. package/lib/client/ExternalUsageCards.js +37 -16
  12. package/lib/client/ImageGallery.d.ts +2 -2
  13. package/lib/client/ImageGallery.js +92 -33
  14. package/lib/client/ImageGenerateToolview.js +50 -14
  15. package/lib/client/OllamaCard.d.ts +13 -0
  16. package/lib/client/OllamaCard.js +94 -0
  17. package/lib/client/OllamaSettingsManager.d.ts +24 -0
  18. package/lib/client/OllamaSettingsManager.js +329 -0
  19. package/lib/client/ProviderAccountManager.d.ts +1 -1
  20. package/lib/client/ProviderAccountManager.js +164 -49
  21. package/lib/client/ProviderModelEditor.js +109 -26
  22. package/lib/client/SpeedSelect.d.ts +62 -14
  23. package/lib/client/SpeedSelect.js +257 -46
  24. package/lib/client/SubscriptionUsageBadge.d.ts +5 -11
  25. package/lib/client/SubscriptionUsageBadge.js +301 -112
  26. package/lib/client/SubscriptionsSection.d.ts +2 -0
  27. package/lib/client/SubscriptionsSection.js +259 -85
  28. package/lib/client/UsageBadgeDisplaySetting.js +35 -12
  29. package/lib/client/UsageMeter.d.ts +1 -1
  30. package/lib/client/UsageMeter.js +69 -27
  31. package/lib/client/VideoGenerateToolview.js +69 -17
  32. package/lib/client/account-preferences.d.ts +8 -0
  33. package/lib/client/account-preferences.js +20 -5
  34. package/lib/client/host-contract.d.ts +11 -2
  35. package/lib/client/host-contract.js +20 -3
  36. package/lib/client/index.d.ts +5 -5
  37. package/lib/client/index.js +34 -12
  38. package/lib/client/locales.d.ts +78 -0
  39. package/lib/client/locales.js +121 -34
  40. package/lib/client/ollama-draft.d.ts +229 -0
  41. package/lib/client/ollama-draft.js +255 -0
  42. package/lib/client/reset-action.js +6 -4
  43. package/lib/client/reset-credits-view.d.ts +1 -1
  44. package/lib/client/reset-credits-view.js +66 -17
  45. package/lib/client/subscription-card-styles.js +94 -24
  46. package/lib/client/usage-color-preferences.js +4 -2
  47. package/lib/client/usage-pace.d.ts +7 -0
  48. package/lib/client/usage-pace.js +43 -10
  49. package/lib/client.js +1647 -266
  50. package/lib/client.js.map +1 -1
  51. package/lib/http.js +1 -1
  52. package/lib/index.d.ts +28 -3
  53. package/lib/index.js +9329 -8217
  54. package/lib/model-defaults.js +4 -2
  55. package/lib/provider-settings.d.ts +14 -1
  56. package/lib/provider-settings.js +64 -20
  57. package/lib/providers/account-preferences.js +51 -22
  58. package/lib/providers/accounts.js +15 -9
  59. package/lib/providers/antigravity-oauth-client.js +2 -2
  60. package/lib/providers/antigravity.js +141 -68
  61. package/lib/providers/catalog-store.js +120 -41
  62. package/lib/providers/claude-cli-version.js +3 -3
  63. package/lib/providers/claude.d.ts +23 -3
  64. package/lib/providers/claude.js +167 -92
  65. package/lib/providers/codex-client-version.js +3 -3
  66. package/lib/providers/codex-search.js +40 -13
  67. package/lib/providers/codex.js +196 -112
  68. package/lib/providers/common.d.ts +14 -0
  69. package/lib/providers/common.js +118 -38
  70. package/lib/providers/copilot.js +143 -82
  71. package/lib/providers/cursor-adapter.js +42 -18
  72. package/lib/providers/cursor-auth.d.ts +1 -1
  73. package/lib/providers/cursor-auth.js +69 -24
  74. package/lib/providers/cursor-model-settings.js +14 -5
  75. package/lib/providers/cursor-usage.js +27 -13
  76. package/lib/providers/external-usage-controller.d.ts +9 -2
  77. package/lib/providers/external-usage-controller.js +30 -7
  78. package/lib/providers/external-usage.d.ts +8 -0
  79. package/lib/providers/external-usage.js +140 -20
  80. package/lib/providers/grok.js +152 -89
  81. package/lib/providers/image-pool.js +29 -11
  82. package/lib/providers/minimax-usage.js +31 -11
  83. package/lib/providers/npm-cli-version.js +10 -3
  84. package/lib/providers/ollama-settings.d.ts +90 -0
  85. package/lib/providers/ollama-settings.js +213 -0
  86. package/lib/providers/ollama-web.d.ts +46 -0
  87. package/lib/providers/ollama-web.js +231 -0
  88. package/lib/providers/ollama.d.ts +107 -0
  89. package/lib/providers/ollama.js +608 -0
  90. package/lib/providers/pool-family.js +11 -3
  91. package/lib/providers/pool-health.js +28 -8
  92. package/lib/providers/pool-usage.js +16 -9
  93. package/lib/providers/pool.d.ts +8 -0
  94. package/lib/providers/pool.js +81 -42
  95. package/lib/providers/rate-limit.js +8 -2
  96. package/lib/providers/replay.js +40 -17
  97. package/lib/providers/reset-redemption.js +18 -6
  98. package/lib/tools/image-generate.js +123 -65
  99. package/lib/tools/registration.js +5 -2
  100. package/lib/tools/video-generate.js +51 -31
  101. package/lib/tools/x-search.js +50 -22
  102. package/lib/translate/anthropic.js +85 -37
  103. package/lib/translate/antigravity-schema.js +71 -28
  104. package/lib/translate/antigravity-thinking.js +22 -9
  105. package/lib/translate/antigravity.js +141 -50
  106. package/lib/translate/chat-completions.js +67 -23
  107. package/lib/translate/resolved.js +55 -21
  108. package/lib/translate/responses.js +69 -29
  109. package/lib/translate/sse.js +4 -1
  110. package/package.json +26 -4
@@ -18,11 +18,20 @@ async function parseCredential(value) {
18
18
  if (parsed === null || typeof parsed !== 'object')
19
19
  throw new Error('Cursor credential is malformed');
20
20
  const item = parsed;
21
- if (item.type !== 'oauth' || typeof item.access !== 'string' || item.access.length === 0
22
- || typeof item.refresh !== 'string' || typeof item.expires !== 'number' || !Number.isFinite(item.expires)) {
21
+ if (item.type !== 'oauth' ||
22
+ typeof item.access !== 'string' ||
23
+ item.access.length === 0 ||
24
+ typeof item.refresh !== 'string' ||
25
+ typeof item.expires !== 'number' ||
26
+ !Number.isFinite(item.expires)) {
23
27
  throw new Error('Cursor credential is malformed');
24
28
  }
25
- return { type: 'oauth', access: item.access, refresh: item.refresh, expires: item.expires };
29
+ return {
30
+ type: 'oauth',
31
+ access: item.access,
32
+ refresh: item.refresh,
33
+ expires: item.expires,
34
+ };
26
35
  }
27
36
  function tokenExpiry(token, now) {
28
37
  try {
@@ -34,15 +43,23 @@ function tokenExpiry(token, now) {
34
43
  if (typeof exp === 'number' && Number.isFinite(exp))
35
44
  return exp * 1000;
36
45
  }
37
- catch { /* JWTs without exp still receive a bounded lifetime. */ }
46
+ catch {
47
+ /* JWTs without exp still receive a bounded lifetime. */
48
+ }
38
49
  return now + 24 * 60 * 60_000;
39
50
  }
40
51
  function abortableDelay(ms, signal) {
41
52
  if (signal.aborted)
42
53
  return Promise.reject(signal.reason);
43
54
  return new Promise((resolve, reject) => {
44
- const timer = setTimeout(() => { signal.removeEventListener('abort', aborted); resolve(); }, ms);
45
- function aborted() { clearTimeout(timer); reject(signal.reason); }
55
+ const timer = setTimeout(() => {
56
+ signal.removeEventListener('abort', aborted);
57
+ resolve();
58
+ }, ms);
59
+ function aborted() {
60
+ clearTimeout(timer);
61
+ reject(signal.reason);
62
+ }
46
63
  signal.addEventListener('abort', aborted, { once: true });
47
64
  });
48
65
  }
@@ -70,7 +87,7 @@ export class CursorAuth {
70
87
  ...(this.error === undefined ? {} : { error: this.error }),
71
88
  };
72
89
  }
73
- async accessToken({ signal } = {}) {
90
+ async accessToken({ signal, } = {}) {
74
91
  signal?.throwIfAborted();
75
92
  const current = await this.read();
76
93
  if (current === undefined)
@@ -82,27 +99,39 @@ export class CursorAuth {
82
99
  if (this.refreshTask === undefined) {
83
100
  const pending = this.refresh(current, signal);
84
101
  this.refreshTask = pending;
85
- void pending.finally(() => { if (this.refreshTask === pending)
86
- this.refreshTask = undefined; }).catch(() => undefined);
102
+ void pending
103
+ .finally(() => {
104
+ if (this.refreshTask === pending)
105
+ this.refreshTask = undefined;
106
+ })
107
+ .catch(() => undefined);
87
108
  }
88
109
  return (await this.refreshTask).access;
89
110
  }
90
111
  async refresh(current, signal) {
91
112
  const response = await this.http(`${API_ORIGIN}/auth/exchange_user_api_key`, {
92
- method: 'POST', redirect: 'error',
93
- headers: { authorization: `Bearer ${current.refresh}`, accept: 'application/json', 'content-type': 'application/json' },
113
+ method: 'POST',
114
+ redirect: 'error',
115
+ headers: {
116
+ authorization: `Bearer ${current.refresh}`,
117
+ accept: 'application/json',
118
+ 'content-type': 'application/json',
119
+ },
94
120
  body: '{}',
95
121
  ...(signal === undefined ? {} : { signal }),
96
122
  });
97
123
  if (!response.ok)
98
124
  throw new Error(`Cursor token refresh failed (HTTP ${response.status})`);
99
- const body = await parseProviderJson(response, 'Cursor token refresh');
125
+ const body = (await parseProviderJson(response, 'Cursor token refresh'));
100
126
  if (typeof body.accessToken !== 'string' || body.accessToken.length === 0) {
101
127
  throw new Error('Cursor token refresh returned no access token');
102
128
  }
103
129
  const next = {
104
- type: 'oauth', access: body.accessToken,
105
- refresh: typeof body.refreshToken === 'string' && body.refreshToken.length > 0 ? body.refreshToken : current.refresh,
130
+ type: 'oauth',
131
+ access: body.accessToken,
132
+ refresh: typeof body.refreshToken === 'string' && body.refreshToken.length > 0
133
+ ? body.refreshToken
134
+ : current.refresh,
106
135
  expires: tokenExpiry(body.accessToken, this.now()),
107
136
  };
108
137
  const latest = await this.read();
@@ -121,13 +150,22 @@ export class CursorAuth {
121
150
  const challenge = createHash('sha256').update(verifier).digest('base64url');
122
151
  const uuid = randomUUID();
123
152
  const url = new URL('https://cursor.com/loginDeepControl');
124
- url.search = new URLSearchParams({ challenge, uuid, mode: 'login', redirectTarget: 'cli' }).toString();
153
+ url.search = new URLSearchParams({
154
+ challenge,
155
+ uuid,
156
+ mode: 'login',
157
+ redirectTarget: 'cli',
158
+ }).toString();
125
159
  const abort = new AbortController();
126
160
  const promise = this.pollLogin(uuid, verifier, abort.signal)
127
- .catch(error => { if (!abort.signal.aborted)
128
- this.error = error instanceof Error ? error.message : String(error); })
129
- .finally(() => { if (this.loginTask?.abort === abort)
130
- this.loginTask = undefined; });
161
+ .catch((error) => {
162
+ if (!abort.signal.aborted)
163
+ this.error = error instanceof Error ? error.message : String(error);
164
+ })
165
+ .finally(() => {
166
+ if (this.loginTask?.abort === abort)
167
+ this.loginTask = undefined;
168
+ });
131
169
  this.loginTask = { abort, promise };
132
170
  return { authorizeUrl: url.href };
133
171
  }
@@ -136,23 +174,30 @@ export class CursorAuth {
136
174
  for (let attempt = 0; attempt < 150; attempt++) {
137
175
  await abortableDelay(delay, signal);
138
176
  const url = `${API_ORIGIN}/auth/poll?uuid=${encodeURIComponent(uuid)}&verifier=${encodeURIComponent(verifier)}`;
139
- const response = await this.http(url, { headers: { accept: 'application/json' }, redirect: 'error', signal });
177
+ const response = await this.http(url, {
178
+ headers: { accept: 'application/json' },
179
+ redirect: 'error',
180
+ signal,
181
+ });
140
182
  if (response.status === 404) {
141
183
  delay = Math.min(Math.ceil(delay * 1.2), 10_000);
142
184
  continue;
143
185
  }
144
186
  if (!response.ok)
145
187
  throw new Error(`Cursor login failed (HTTP ${response.status})`);
146
- const body = await parseProviderJson(response, 'Cursor login poll');
188
+ const body = (await parseProviderJson(response, 'Cursor login poll'));
147
189
  signal.throwIfAborted();
148
- if (typeof body.accessToken !== 'string' || body.accessToken.length === 0) {
190
+ if (typeof body.accessToken !== 'string' ||
191
+ body.accessToken.length === 0) {
149
192
  throw new Error('Cursor login returned no access token');
150
193
  }
151
- if (typeof body.refreshToken !== 'string' || body.refreshToken.length === 0) {
194
+ if (typeof body.refreshToken !== 'string' ||
195
+ body.refreshToken.length === 0) {
152
196
  throw new Error('Cursor login returned no refresh token');
153
197
  }
154
198
  const value = {
155
- type: 'oauth', access: body.accessToken,
199
+ type: 'oauth',
200
+ access: body.accessToken,
156
201
  refresh: body.refreshToken,
157
202
  expires: tokenExpiry(body.accessToken, this.now()),
158
203
  };
@@ -9,7 +9,8 @@ export function validateCursorModelSettings(input) {
9
9
  const models = input.visibleModels;
10
10
  if (models === undefined)
11
11
  return {};
12
- if (!Array.isArray(models) || models.some(id => typeof id !== 'string' || !id.trim())) {
12
+ if (!Array.isArray(models) ||
13
+ models.some((id) => typeof id !== 'string' || !id.trim())) {
13
14
  throw new Error('visibleModels must be an array of model ids');
14
15
  }
15
16
  return { visibleModels: [...new Set(models)] };
@@ -26,18 +27,26 @@ export class CursorModelSettingsStore {
26
27
  }
27
28
  catch (error) {
28
29
  if (error.code !== 'ENOENT')
29
- throw new Error(`Cannot read Cursor model settings at ${path}`, { cause: error });
30
+ throw new Error(`Cannot read Cursor model settings at ${path}`, {
31
+ cause: error,
32
+ });
30
33
  }
31
34
  }
32
- get() { return structuredClone(this.current); }
33
- visibleModels() { return this.current.visibleModels; }
35
+ get() {
36
+ return structuredClone(this.current);
37
+ }
38
+ visibleModels() {
39
+ return this.current.visibleModels;
40
+ }
34
41
  set(input) {
35
42
  const validated = validateCursorModelSettings(input);
36
43
  const run = this.writes.then(async () => {
37
44
  await mkdir(dirname(this.path), { recursive: true });
38
45
  const temporary = `${this.path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
39
46
  try {
40
- await writeFile(temporary, JSON.stringify(validated, null, 2), { mode: 0o600 });
47
+ await writeFile(temporary, JSON.stringify(validated, null, 2), {
48
+ mode: 0o600,
49
+ });
41
50
  await rename(temporary, this.path);
42
51
  }
43
52
  finally {
@@ -52,17 +52,21 @@ function legacyRequestWindow(body, resetsAt) {
52
52
  if (usage === undefined)
53
53
  return undefined;
54
54
  const gpt4 = record(usage['gpt-4']);
55
- const candidate = gpt4 !== undefined && number(gpt4.numRequests) !== undefined ? gpt4 : Object.values(usage)
56
- .map(record)
57
- .filter((value) => value !== undefined)
58
- .sort((a, b) => (number(b.maxRequestUsage) ?? 0) - (number(a.maxRequestUsage) ?? 0))[0];
55
+ const candidate = gpt4 !== undefined && number(gpt4.numRequests) !== undefined
56
+ ? gpt4
57
+ : Object.values(usage)
58
+ .map(record)
59
+ .filter((value) => value !== undefined)
60
+ .sort((a, b) => (number(b.maxRequestUsage) ?? 0) -
61
+ (number(a.maxRequestUsage) ?? 0))[0];
59
62
  const used = number(candidate?.numRequests);
60
63
  const limit = number(candidate?.maxRequestUsage);
61
64
  if (used === undefined || limit === undefined || limit <= 0)
62
65
  return undefined;
63
66
  return {
64
- kind: 'other', scope: 'Included requests',
65
- usedPercent: Math.max(0, Math.min(100, used / limit * 100)),
67
+ kind: 'other',
68
+ scope: 'Included requests',
69
+ usedPercent: Math.max(0, Math.min(100, (used / limit) * 100)),
66
70
  ...(resetsAt === undefined ? {} : { resetsAt }),
67
71
  };
68
72
  }
@@ -79,18 +83,26 @@ export async function fetchCursorUsage(accessToken, http = fetch, signal) {
79
83
  dashboardJson(`${ORIGIN}/api/usage-summary`, cookie, http, signal),
80
84
  dashboardJson(`${ORIGIN}/api/usage?user=${encodeURIComponent(userId)}`, cookie, http, signal),
81
85
  ]);
82
- if (summaryResult.status === 'rejected' && legacyResult.status === 'rejected') {
83
- throw summaryResult.reason instanceof Error ? summaryResult.reason : new Error('Cursor usage is unavailable');
86
+ if (summaryResult.status === 'rejected' &&
87
+ legacyResult.status === 'rejected') {
88
+ throw summaryResult.reason instanceof Error
89
+ ? summaryResult.reason
90
+ : new Error('Cursor usage is unavailable');
84
91
  }
85
- const summary = summaryResult.status === 'fulfilled' ? record(summaryResult.value) : undefined;
86
- const plan = record(record(summary?.individualUsage)?.plan) ?? record(record(summary?.teamUsage)?.plan);
92
+ const summary = summaryResult.status === 'fulfilled'
93
+ ? record(summaryResult.value)
94
+ : undefined;
95
+ const plan = record(record(summary?.individualUsage)?.plan) ??
96
+ record(record(summary?.teamUsage)?.plan);
87
97
  const resetValue = summary?.billingCycleEnd;
88
98
  const parsedReset = typeof resetValue === 'string' ? Date.parse(resetValue) : NaN;
89
99
  const resetsAt = Number.isFinite(parsedReset) ? parsedReset : undefined;
90
100
  const startValue = summary?.billingCycleStart;
91
101
  const parsedStart = typeof startValue === 'string' ? Date.parse(startValue) : NaN;
92
- const timing = { ...(resetsAt === undefined ? {} : { resetsAt }),
93
- ...(Number.isFinite(parsedStart) ? { startsAt: parsedStart } : {}) };
102
+ const timing = {
103
+ ...(resetsAt === undefined ? {} : { resetsAt }),
104
+ ...(Number.isFinite(parsedStart) ? { startsAt: parsedStart } : {}),
105
+ };
94
106
  const windows = [];
95
107
  for (const [field, scope] of [
96
108
  ['totalPercentUsed', 'Included'],
@@ -112,6 +124,8 @@ export async function fetchCursorUsage(accessToken, http = fetch, signal) {
112
124
  return {
113
125
  supported: true,
114
126
  windows,
115
- ...(typeof membershipType === 'string' && membershipType.length > 0 ? { plan: membershipType } : {}),
127
+ ...(typeof membershipType === 'string' && membershipType.length > 0
128
+ ? { plan: membershipType }
129
+ : {}),
116
130
  };
117
131
  }
@@ -1,6 +1,6 @@
1
1
  import type { ProviderUsage } from './common.js';
2
2
  /** Usage-only sources whose model routes are supplied by the DSH base install. */
3
- export type ExternalUsageSource = 'opencode-go' | 'kimi-code' | 'minimax' | 'minimax-cn';
3
+ export type ExternalUsageSource = 'opencode-go' | 'kimi-code' | 'minimax' | 'minimax-cn' | 'ollama-cloud';
4
4
  export declare const EXTERNAL_USAGE_SOURCES: readonly ExternalUsageSource[];
5
5
  export interface ExternalUsageStatus {
6
6
  configured: boolean;
@@ -8,11 +8,18 @@ export interface ExternalUsageStatus {
8
8
  type ResolveCredential = (name: string) => Promise<{
9
9
  value: string;
10
10
  } | undefined>;
11
+ /**
12
+ * A setting the controller reads per call. The function form keeps the Ollama
13
+ * reference and base URL live: the Settings card can change them after the
14
+ * controller was constructed.
15
+ */
16
+ type LiveValue = string | (() => string);
11
17
  export declare class ExternalUsageController {
12
18
  private readonly resolveCredential;
13
19
  private readonly http;
14
20
  private readonly refs;
15
- constructor(resolveCredential: ResolveCredential, http?: typeof fetch, refs?: Partial<Record<ExternalUsageSource, string>>);
21
+ private readonly ollamaBaseURL;
22
+ constructor(resolveCredential: ResolveCredential, http?: typeof fetch, refs?: Partial<Record<ExternalUsageSource, LiveValue>>, ollamaBaseURL?: LiveValue);
16
23
  status(): Promise<Record<ExternalUsageSource, ExternalUsageStatus>>;
17
24
  usage(source: ExternalUsageSource, signal?: AbortSignal): Promise<ProviderUsage>;
18
25
  }
@@ -1,35 +1,58 @@
1
1
  import { fetchMiniMaxUsage } from './minimax-usage.js';
2
- import { fetchKimiCodeUsage, fetchOpenCodeGoUsage } from './external-usage.js';
3
- export const EXTERNAL_USAGE_SOURCES = ['opencode-go', 'kimi-code', 'minimax', 'minimax-cn'];
2
+ import { fetchKimiCodeUsage, fetchOllamaUsage, fetchOpenCodeGoUsage, } from './external-usage.js';
3
+ import { DEFAULT_OLLAMA_API_KEY_REF, DEFAULT_OLLAMA_BASE_URL, } from './ollama.js';
4
+ export const EXTERNAL_USAGE_SOURCES = [
5
+ 'opencode-go',
6
+ 'kimi-code',
7
+ 'minimax',
8
+ 'minimax-cn',
9
+ 'ollama-cloud',
10
+ ];
11
+ function valueOf(value) {
12
+ return typeof value === 'function' ? value() : value;
13
+ }
4
14
  const DEFAULT_REFS = {
5
15
  'opencode-go': 'OPENCODE_GO_API_KEY',
6
16
  'kimi-code': 'KIMI_CODING_API_KEY',
7
- 'minimax': 'MINIMAX_API_KEY',
17
+ minimax: 'MINIMAX_API_KEY',
8
18
  'minimax-cn': 'MINIMAX_CN_API_KEY',
19
+ 'ollama-cloud': DEFAULT_OLLAMA_API_KEY_REF,
9
20
  };
10
21
  export class ExternalUsageController {
11
22
  resolveCredential;
12
23
  http;
13
24
  refs;
14
- constructor(resolveCredential, http = fetch, refs = {}) {
25
+ ollamaBaseURL;
26
+ constructor(resolveCredential, http = fetch, refs = {}, ollamaBaseURL = DEFAULT_OLLAMA_BASE_URL) {
15
27
  this.resolveCredential = resolveCredential;
16
28
  this.http = http;
17
29
  this.refs = { ...DEFAULT_REFS, ...refs };
30
+ this.ollamaBaseURL = ollamaBaseURL;
18
31
  }
19
32
  async status() {
20
33
  const values = await Promise.all(EXTERNAL_USAGE_SOURCES.map(async (source) => {
21
- const value = await this.resolveCredential(this.refs[source]);
34
+ const value = await this.resolveCredential(valueOf(this.refs[source]));
22
35
  return typeof value?.value === 'string' && value.value.length > 0;
23
36
  }));
24
- return Object.fromEntries(EXTERNAL_USAGE_SOURCES.map((source, i) => [source, { configured: values[i] ?? false }]));
37
+ return Object.fromEntries(EXTERNAL_USAGE_SOURCES.map((source, i) => [
38
+ source,
39
+ { configured: values[i] ?? false },
40
+ ]));
25
41
  }
26
42
  async usage(source, signal) {
27
- const credential = await this.resolveCredential(this.refs[source]);
43
+ // Snapshot the endpoint facts before the first await: a save that
44
+ // switches the ref or host mid-flight must not send the old
45
+ // reference's key to the new host.
46
+ const ref = valueOf(this.refs[source]);
47
+ const ollamaBase = valueOf(this.ollamaBaseURL);
48
+ const credential = await this.resolveCredential(ref);
28
49
  if (credential?.value === undefined || credential.value.length === 0) {
29
50
  throw new Error(`${source} API key is not configured`);
30
51
  }
31
52
  if (source === 'minimax' || source === 'minimax-cn')
32
53
  return fetchMiniMaxUsage(credential.value, source === 'minimax-cn' ? 'cn' : 'global', this.http, signal);
54
+ if (source === 'ollama-cloud')
55
+ return fetchOllamaUsage(credential.value, ollamaBase, this.http, signal);
33
56
  return source === 'opencode-go'
34
57
  ? fetchOpenCodeGoUsage(credential.value, this.http, signal)
35
58
  : fetchKimiCodeUsage(credential.value, this.http, signal);
@@ -5,4 +5,12 @@ type HttpFetch = typeof fetch;
5
5
  export declare function fetchOpenCodeGoUsage(apiKey: string, http?: HttpFetch, signal?: AbortSignal): Promise<ProviderUsage>;
6
6
  /** Read official CLI count windows and retain compatibility with older ratio pools. */
7
7
  export declare function fetchKimiCodeUsage(apiKey: string, http?: HttpFetch, signal?: AbortSignal): Promise<ProviderUsage>;
8
+ /**
9
+ * Read Ollama Cloud quota from `GET {base}/balance`. The Cloud has no
10
+ * `limits` object: quota is the included allowance against its remaining
11
+ * balance (`included.allowance_usd`, `included.balance_usd`) over the
12
+ * reported period. A 404 means a self-hosted endpoint without the
13
+ * surface: not an error, the card renders an unsupported note instead.
14
+ */
15
+ export declare function fetchOllamaUsage(apiKey: string, baseURL: string, http?: HttpFetch, signal?: AbortSignal): Promise<ProviderUsage>;
8
16
  export {};
@@ -1,5 +1,6 @@
1
1
  /** Usage readers for subscriptions whose model adapters are built into DSH. */
2
- import { parseProviderJson } from './common.js';
2
+ import { attributionHeaders } from '@deepseek-ai/dsh-llm';
3
+ import { parseProviderJson, readBoundedJson } from './common.js';
3
4
  function numeric(value) {
4
5
  const n = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
5
6
  return typeof n === 'number' && Number.isFinite(n) ? n : undefined;
@@ -23,7 +24,10 @@ function record(value) {
23
24
  : undefined;
24
25
  }
25
26
  function percent(value) {
26
- return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100
27
+ return typeof value === 'number' &&
28
+ Number.isFinite(value) &&
29
+ value >= 0 &&
30
+ value <= 100
27
31
  ? value
28
32
  : undefined;
29
33
  }
@@ -59,14 +63,24 @@ export async function fetchOpenCodeGoUsage(apiKey, http = fetch, signal) {
59
63
  if (usedPercent === undefined)
60
64
  continue;
61
65
  const resetsAt = resetTime(value?.resetsAt);
66
+ // Monthly has no interval fields. Both key providers' monthly pools
67
+ // read the same display assumption: a 30-day span back from the
68
+ // reset, clamped to now. Only the marker may ride it — pace never.
69
+ const monthlyDisplay = field === 'monthly' && resetsAt !== undefined
70
+ ? { displayStartsAt: Math.min(resetsAt - 30 * 86_400_000, Date.now()) }
71
+ : {};
62
72
  windows.push({
63
73
  kind,
64
74
  ...(scope === undefined ? {} : { scope }),
65
75
  usedPercent,
66
76
  // Go's rolling counter is first-use anchored, not per-request sliding.
67
- ...(field === 'rolling' ? { fixedWindow: true, windowDurationMs: 18_000_000 }
68
- : field === 'weekly' ? { fixedWindow: true, windowDurationMs: 604_800_000 } : {}),
77
+ ...(field === 'rolling'
78
+ ? { fixedWindow: true, windowDurationMs: 18_000_000 }
79
+ : field === 'weekly'
80
+ ? { fixedWindow: true, windowDurationMs: 604_800_000 }
81
+ : {}),
69
82
  ...(resetsAt === undefined ? {} : { resetsAt }),
83
+ ...monthlyDisplay,
70
84
  });
71
85
  }
72
86
  if (windows.length === 0)
@@ -79,40 +93,80 @@ export async function fetchKimiCodeUsage(apiKey, http = fetch, signal) {
79
93
  if (body === undefined)
80
94
  throw new Error('Kimi Code usage response is not an object');
81
95
  const modern = [];
96
+ // Only the top-level `usage` object is the summary the official CLI labels
97
+ // "Weekly limit"; an anonymous `limits[]` entry is "Limit #n" there.
82
98
  const rows = [
83
- ...(record(body.usage) ? [{ detail: body.usage }] : []),
84
- ...(Array.isArray(body.limits) ? body.limits : []),
99
+ ...(record(body.usage)
100
+ ? [{ raw: { detail: body.usage }, summary: true }]
101
+ : []),
102
+ ...(Array.isArray(body.limits)
103
+ ? body.limits.map((raw) => ({ raw, summary: false }))
104
+ : []),
85
105
  ];
86
- for (const raw of rows) {
106
+ for (const { raw, summary } of rows) {
87
107
  const item = record(raw);
88
108
  if (!item)
89
109
  continue;
90
110
  const detail = record(item.detail) ?? item;
91
111
  const limit = numeric(detail.limit);
92
- const used = numeric(detail.used) ?? (limit !== undefined && numeric(detail.remaining) !== undefined
93
- ? limit - numeric(detail.remaining) : undefined);
112
+ const used = numeric(detail.used) ??
113
+ (limit !== undefined && numeric(detail.remaining) !== undefined
114
+ ? limit - numeric(detail.remaining)
115
+ : undefined);
94
116
  if (limit === undefined || limit <= 0 || used === undefined)
95
117
  continue;
96
- const usedPercent = percent(used / limit * 100);
118
+ const usedPercent = percent((used / limit) * 100);
97
119
  if (usedPercent === undefined)
98
120
  continue;
99
121
  const window = record(item.window) ?? item;
100
122
  const duration = numeric(window.duration ?? item.duration ?? detail.duration);
101
123
  const unit = String(window.timeUnit ?? item.timeUnit ?? detail.timeUnit ?? '').toUpperCase();
102
- const multiplier = unit.includes('MINUTE') ? 60_000 : unit.includes('HOUR') ? 3_600_000
103
- : unit.includes('DAY') ? 86_400_000 : unit.includes('SECOND') ? 1000 : undefined;
124
+ const multiplier = unit.includes('MINUTE')
125
+ ? 60_000
126
+ : unit.includes('HOUR')
127
+ ? 3_600_000
128
+ : unit.includes('DAY')
129
+ ? 86_400_000
130
+ : unit.includes('SECOND')
131
+ ? 1000
132
+ : undefined;
104
133
  const durationMs = duration === undefined || duration <= 0 || multiplier === undefined
105
- ? undefined : duration * multiplier;
106
- const kind = durationMs === 18_000_000 ? 'session' : durationMs === 604_800_000 ? 'weekly' : 'other';
134
+ ? undefined
135
+ : duration * multiplier;
136
+ const kind = durationMs === 18_000_000
137
+ ? 'session'
138
+ : durationMs === 604_800_000
139
+ ? 'weekly'
140
+ : // No period fields at all: the official CLI labels the top-level
141
+ // summary the weekly limit, so use that kind rather than 'other'.
142
+ durationMs === undefined && unit === ''
143
+ ? 'weekly'
144
+ : 'other';
107
145
  const resetsAt = kimiReset(detail);
108
146
  const scope = detail.name ?? detail.title ?? item.name ?? item.title;
109
- modern.push({ kind, usedPercent,
147
+ // A vendor-declared span anchors the display marker at
148
+ // min(reset − span, now); pace stays unverified (rolling semantics
149
+ // are not established), so the assumed instant rides displayStartsAt.
150
+ // The top-level summary without a usable period (absent, zero, or no
151
+ // unit) is read as the weekly limit (above), so its marker assumes seven
152
+ // days. The official CLI
153
+ // gives only the label, not a span; the span rests on a live account
154
+ // whose summary reset matched `usages.limit_7d` to within a second.
155
+ // An anonymous `limits[]` entry gets no such assumption. Display only.
156
+ const markerSpan = durationMs ?? (summary && kind === 'weekly' ? 604_800_000 : undefined);
157
+ const displayStart = resetsAt !== undefined && markerSpan !== undefined
158
+ ? { displayStartsAt: Math.min(resetsAt - markerSpan, Date.now()) }
159
+ : {};
160
+ modern.push({
161
+ kind,
162
+ usedPercent,
110
163
  ...(typeof scope === 'string' ? { scope } : {}),
111
164
  ...(resetsAt === undefined ? {} : { resetsAt }),
112
165
  ...(durationMs === undefined ? {} : { windowDurationMs: durationMs }),
113
- // Legacy weekly plans reset as whole seven-day buckets; five-hour semantics
114
- // are documented as rolling, so no fixed-window assertion is made there.
115
- ...(kind === 'weekly' ? { fixedWindow: true } : {}),
166
+ // A whole seven-day bucket reset, not a rolling span, only where the
167
+ // endpoint actually said 7 DAY.
168
+ ...(durationMs === 604_800_000 ? { fixedWindow: true } : {}),
169
+ ...displayStart,
116
170
  });
117
171
  }
118
172
  if (modern.length)
@@ -128,16 +182,82 @@ export async function fetchKimiCodeUsage(apiKey, http = fetch, signal) {
128
182
  ]) {
129
183
  const value = record(usages[field]);
130
184
  const ratio = value?.used_ratio ?? value?.usedRatio;
131
- if (typeof ratio !== 'number' || !Number.isFinite(ratio) || ratio < 0 || ratio > 1)
185
+ if (typeof ratio !== 'number' ||
186
+ !Number.isFinite(ratio) ||
187
+ ratio < 0 ||
188
+ ratio > 1)
132
189
  continue;
133
190
  const resetsAt = resetTime(value?.reset_time ?? value?.resetAt);
191
+ // The monthly total resets on the subscription's cycle, not the
192
+ // calendar month; the display marker assumes ~30 days back. It only
193
+ // moves the marker — pace never rides it.
194
+ const monthlyDisplay = field === 'limit_month_total' && resetsAt !== undefined
195
+ ? { displayStartsAt: Math.min(resetsAt - 30 * 86_400_000, Date.now()) }
196
+ : {};
134
197
  windows.push({
135
198
  kind,
136
199
  ...(scope === undefined ? {} : { scope }),
137
200
  usedPercent: ratio * 100,
138
- ...(field === 'limit_7d' ? { fixedWindow: true, windowDurationMs: 604_800_000 } : {}),
201
+ ...(field === 'limit_7d'
202
+ ? { fixedWindow: true, windowDurationMs: 604_800_000 }
203
+ : {}),
139
204
  ...(resetsAt === undefined ? {} : { resetsAt }),
205
+ ...monthlyDisplay,
140
206
  });
141
207
  }
142
208
  return { supported: true, windows, plan: 'Kimi Code' };
143
209
  }
210
+ /**
211
+ * Read Ollama Cloud quota from `GET {base}/balance`. The Cloud has no
212
+ * `limits` object: quota is the included allowance against its remaining
213
+ * balance (`included.allowance_usd`, `included.balance_usd`) over the
214
+ * reported period. A 404 means a self-hosted endpoint without the
215
+ * surface: not an error, the card renders an unsupported note instead.
216
+ */
217
+ export async function fetchOllamaUsage(apiKey, baseURL, http = fetch, signal) {
218
+ const combined = signal === undefined
219
+ ? AbortSignal.timeout(15_000)
220
+ : AbortSignal.any([AbortSignal.timeout(15_000), signal]);
221
+ const response = await http(`${baseURL.replace(/\/+$/, '')}/balance`, {
222
+ headers: {
223
+ accept: 'application/json',
224
+ authorization: `Bearer ${apiKey}`,
225
+ ...attributionHeaders(),
226
+ },
227
+ redirect: 'error',
228
+ signal: combined,
229
+ });
230
+ if (signal?.aborted)
231
+ throw signal.reason;
232
+ if (response.status === 404)
233
+ return { supported: false };
234
+ if (!response.ok)
235
+ throw new Error(`Usage endpoint returned HTTP ${response.status}`);
236
+ const body = record(await readBoundedJson(response, 'usage', 1 * 1024 * 1024));
237
+ const included = record(body?.included);
238
+ const allowance = numeric(included?.allowance_usd);
239
+ const balance = numeric(included?.balance_usd);
240
+ if (included === undefined ||
241
+ allowance === undefined ||
242
+ allowance <= 0 ||
243
+ balance === undefined) {
244
+ throw new Error('Ollama balance response has no usable included allowance');
245
+ }
246
+ const period = record(included.period);
247
+ const resetsAt = period === undefined ? undefined : resetTime(period.until);
248
+ const startsAt = period === undefined ? undefined : resetTime(period.from);
249
+ return {
250
+ supported: true,
251
+ windows: [
252
+ {
253
+ kind: 'other',
254
+ scope: 'Credits',
255
+ // An overdrawn balance stays visible instead of dropping the window.
256
+ usedPercent: ((allowance - balance) / allowance) * 100,
257
+ ...(resetsAt === undefined ? {} : { resetsAt }),
258
+ ...(startsAt === undefined ? {} : { startsAt }),
259
+ },
260
+ ],
261
+ plan: 'Ollama Cloud',
262
+ };
263
+ }