dsh-subscriptions 0.1.4 → 0.2.0

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 (62) hide show
  1. package/README.md +31 -5
  2. package/lib/auth/device-flow.js +6 -3
  3. package/lib/auth/oauth-flow.js +41 -8
  4. package/lib/auth/rpc.d.ts +73 -2
  5. package/lib/auth/rpc.js +53 -3
  6. package/lib/client/ExternalUsageCards.js +1 -1
  7. package/lib/client/OllamaCard.d.ts +13 -0
  8. package/lib/client/OllamaCard.js +76 -0
  9. package/lib/client/OllamaSettingsManager.d.ts +24 -0
  10. package/lib/client/OllamaSettingsManager.js +246 -0
  11. package/lib/client/ProviderModelEditor.js +4 -2
  12. package/lib/client/SubscriptionUsageBadge.d.ts +1 -1
  13. package/lib/client/SubscriptionUsageBadge.js +3 -2
  14. package/lib/client/SubscriptionsSection.d.ts +2 -74
  15. package/lib/client/SubscriptionsSection.js +4 -67
  16. package/lib/client/UsageMeter.js +11 -7
  17. package/lib/client/locales.d.ts +68 -0
  18. package/lib/client/locales.js +77 -0
  19. package/lib/client/ollama-draft.d.ts +229 -0
  20. package/lib/client/ollama-draft.js +246 -0
  21. package/lib/client/usage-pace.d.ts +7 -0
  22. package/lib/client/usage-pace.js +15 -0
  23. package/lib/client.js +1385 -244
  24. package/lib/client.js.map +1 -1
  25. package/lib/index.d.ts +25 -0
  26. package/lib/index.js +11060 -9733
  27. package/lib/model-defaults.d.ts +4 -5
  28. package/lib/model-defaults.js +18 -14
  29. package/lib/provider-settings.d.ts +2 -1
  30. package/lib/provider-settings.js +6 -4
  31. package/lib/providers/antigravity.d.ts +0 -2
  32. package/lib/providers/antigravity.js +5 -9
  33. package/lib/providers/claude.d.ts +14 -1
  34. package/lib/providers/claude.js +25 -13
  35. package/lib/providers/codex-search.js +5 -2
  36. package/lib/providers/codex.js +7 -7
  37. package/lib/providers/common.d.ts +27 -0
  38. package/lib/providers/common.js +70 -0
  39. package/lib/providers/copilot.js +3 -3
  40. package/lib/providers/cursor-adapter.d.ts +35 -0
  41. package/lib/providers/cursor-adapter.js +245 -4
  42. package/lib/providers/cursor-auth.js +11 -4
  43. package/lib/providers/cursor-usage.js +2 -1
  44. package/lib/providers/external-usage-controller.d.ts +9 -2
  45. package/lib/providers/external-usage-controller.js +19 -5
  46. package/lib/providers/external-usage.d.ts +8 -0
  47. package/lib/providers/external-usage.js +93 -8
  48. package/lib/providers/grok.js +14 -9
  49. package/lib/providers/minimax-usage.js +2 -1
  50. package/lib/providers/ollama-settings.d.ts +90 -0
  51. package/lib/providers/ollama-settings.js +197 -0
  52. package/lib/providers/ollama-web.d.ts +46 -0
  53. package/lib/providers/ollama-web.js +213 -0
  54. package/lib/providers/ollama.d.ts +107 -0
  55. package/lib/providers/ollama.js +545 -0
  56. package/lib/tools/image-generate.js +2 -1
  57. package/lib/tools/video-generate.d.ts +0 -5
  58. package/lib/tools/video-generate.js +25 -14
  59. package/lib/tools/x-search.js +2 -2
  60. package/lib/translate/resolved.d.ts +5 -2
  61. package/lib/translate/resolved.js +5 -2
  62. package/package.json +1 -1
@@ -17,23 +17,264 @@ export function projectCursorMessages(messages) {
17
17
  }
18
18
  : message);
19
19
  }
20
+ /**
21
+ * Vendor `LlmError` codes whose whole message is a local literal (no response
22
+ * header, trailer, body or end-stream text). Provider-derived failures can only
23
+ * carry a `classifyCursorError` code or `CURSOR_PROTOCOL`, never these, so the
24
+ * code is a sound discriminator for them.
25
+ */
26
+ const LOCAL_VENDOR_CODES = new Set(['UNSUPPORTED_OPTION', 'UNSUPPORTED_CONTENT', 'TOOL_LIMIT']);
27
+ /**
28
+ * Plain-error messages the vendor throws as fixed literals (`vendor/cursor/
29
+ * index.js`: `waitForResponse`, the request write, the tool-continuation write,
30
+ * the stream `close` handler, the frame reader, the stall watchdog and the
31
+ * settings validation). None
32
+ * interpolates response text, so they are matched by exact message, the same way
33
+ * the auth messages are. A provider that echoed one of these sentences would
34
+ * only make the user read a local literal.
35
+ */
36
+ const LOCAL_VENDOR_MESSAGES = new Set([
37
+ 'Cursor HTTP response timeout',
38
+ 'Cursor HTTP stream closed before response',
39
+ 'Cursor agent bridge closed before accepting the request',
40
+ 'Cursor tool continuation bridge closed before accepting the result',
41
+ 'Cursor sent an unreadable compressed frame',
42
+ 'Cursor stream idle timeout',
43
+ // `resolveCursorSettings`, run at the top of every stream.
44
+ 'cursor-subscription: maxToolRounds must be an integer between 1 and 1000',
45
+ 'cursor-subscription: retryCount must be an integer between 0 and 10',
46
+ 'cursor-subscription: retryIntervalMs must be an integer between 0 and 300000',
47
+ 'cursor-subscription: retryHttpStatusCodes must contain only HTTP status integers from 400 to 599',
48
+ 'cursor-subscription: retryHttpStatusCodes must not contain duplicates',
49
+ 'cursor-subscription: replayHistoryEachStep must be a boolean',
50
+ ]);
51
+ /**
52
+ * The same, for the two literals that embed a number the vendor computes: the
53
+ * response status (`Number(headers[':status'])`, so digits or `NaN`) and the
54
+ * configured progress timeout.
55
+ */
56
+ const LOCAL_VENDOR_PATTERNS = [
57
+ /^Cursor agent returned HTTP \d{3}$/,
58
+ /^Cursor stream progress timeout: no content for \d+ms$/,
59
+ ];
60
+ /**
61
+ * Operating-system network error codes. An `Error` carrying one of these on
62
+ * itself, or on its `cause` (Node wraps a refused connection in
63
+ * `ERR_HTTP2_STREAM_CANCEL`), was produced by the local socket layer, so its
64
+ * message is local: `connect ECONNREFUSED 127.0.0.1:443`, `getaddrinfo ENOTFOUND
65
+ * host`. This is a property of the thrown object, which a provider cannot
66
+ * forge. It is deliberately not a test on the vendor's `TRANSPORT` code: the
67
+ * vendor derives that code from message text, so a provider's end-stream
68
+ * message or `grpc-message` containing "connection" or "socket" gets it too.
69
+ * Transport failures with no such code (TLS and certificate errors, HTTP/2
70
+ * protocol errors) are not recognised and stay replaced.
71
+ */
72
+ const LOCAL_SYSTEM_ERRNO = new Set([
73
+ 'ECONNREFUSED', 'ECONNRESET', 'ECONNABORTED', 'ENOTFOUND', 'EAI_AGAIN', 'ETIMEDOUT',
74
+ 'ENETUNREACH', 'ENETDOWN', 'EHOSTUNREACH', 'EHOSTDOWN', 'EPIPE',
75
+ ]);
76
+ function errorCode(value) {
77
+ return typeof value === 'object' && value !== null ? value.code : undefined;
78
+ }
79
+ function isLocalSystemError(error) {
80
+ if (!(error instanceof Error))
81
+ return false;
82
+ const own = errorCode(error);
83
+ const cause = errorCode(error.cause);
84
+ return (typeof own === 'string' && LOCAL_SYSTEM_ERRNO.has(own))
85
+ || (typeof cause === 'string' && LOCAL_SYSTEM_ERRNO.has(cause));
86
+ }
87
+ function messageOf(error) {
88
+ return error instanceof Error ? error.message : String(error);
89
+ }
90
+ /** Bound on remembered local failure messages; they are short and few. */
91
+ const MAX_LOCAL_MESSAGES = 64;
92
+ /**
93
+ * Remembers the messages of failures raised by this machine rather than read
94
+ * from the provider: whatever the auth service threw, whatever the host
95
+ * attachment store threw while reading an image, and a socket-layer network
96
+ * error seen on the run. The vendor stream turns each of these into
97
+ * `finish/error` carrying exactly `error.message`, so a failure whose message
98
+ * equals a remembered one originated locally and may keep its text. The match
99
+ * is by exact text: a provider that echoed a remembered message would only
100
+ * display a local literal.
101
+ */
102
+ class LocalFailureMessages {
103
+ messages = new Set();
104
+ /** Wrap `auth` so its failures are remembered and rethrown unchanged. */
105
+ wrap(auth) {
106
+ return {
107
+ accessToken: async (options) => {
108
+ try {
109
+ return await auth.accessToken(options);
110
+ }
111
+ catch (error) {
112
+ this.remember(messageOf(error));
113
+ throw error;
114
+ }
115
+ },
116
+ };
117
+ }
118
+ /**
119
+ * Wrap the host attachment store the vendor reads images through, so a read
120
+ * failure keeps its own message. The store is a local service; its errors
121
+ * carry no provider text. Anything that is not a store passes through.
122
+ */
123
+ wrapAttachments(resolve) {
124
+ return () => {
125
+ const store = resolve();
126
+ const readImage = store?.readImage;
127
+ if (typeof readImage !== 'function')
128
+ return store;
129
+ return Object.create(store, {
130
+ readImage: {
131
+ value: async (...args) => {
132
+ try {
133
+ return await readImage.apply(store, args);
134
+ }
135
+ catch (error) {
136
+ this.remember(messageOf(error));
137
+ throw error;
138
+ }
139
+ },
140
+ },
141
+ });
142
+ };
143
+ }
144
+ /**
145
+ * Observe a vendor run's two failure channels, the response promise and the
146
+ * frame reader, and remember the message of an error the socket layer raised.
147
+ * The run's behavior is unchanged: the same error is rethrown.
148
+ */
149
+ observeRun(run) {
150
+ if (typeof run !== 'object' || run === null)
151
+ return run;
152
+ const target = run;
153
+ const observe = (error) => {
154
+ if (isLocalSystemError(error))
155
+ this.remember(messageOf(error));
156
+ throw error;
157
+ };
158
+ const wait = target.waitForResponse;
159
+ if (typeof wait === 'function') {
160
+ target.waitForResponse = (...args) => Promise.resolve(wait.apply(target, args)).catch(observe);
161
+ }
162
+ const frames = target.frames;
163
+ const next = frames?.next;
164
+ if (frames !== undefined && typeof next === 'function') {
165
+ frames.next = (...args) => Promise.resolve(next.apply(frames, args)).catch(observe);
166
+ }
167
+ return run;
168
+ }
169
+ has(message) {
170
+ return this.messages.has(message);
171
+ }
172
+ remember(message) {
173
+ // An empty message displays nothing; remembering it would exempt every
174
+ // empty failure from the boundary for no benefit.
175
+ if (message === '')
176
+ return;
177
+ this.messages.delete(message);
178
+ this.messages.add(message);
179
+ if (this.messages.size > MAX_LOCAL_MESSAGES) {
180
+ const oldest = this.messages.values().next().value;
181
+ if (oldest !== undefined)
182
+ this.messages.delete(oldest);
183
+ }
184
+ }
185
+ }
186
+ function isVendorLocalMessage(message) {
187
+ return LOCAL_VENDOR_MESSAGES.has(message) || LOCAL_VENDOR_PATTERNS.some(pattern => pattern.test(message));
188
+ }
189
+ /**
190
+ * Cursor adapter for the DSH host.
191
+ *
192
+ * Failure text contract: `finish/error` messages that originate from the
193
+ * vendor stream and can quote provider-controlled text (the response content
194
+ * type, trailers, end-stream errors, a frame-reader or other error whose message
195
+ * is not one of the literals below, and a transport-coded failure that is not a
196
+ * socket-layer error) are replaced by `<code> [provider response text omitted]`. Failures that are
197
+ * wholly local keep their own message and code:
198
+ *
199
+ * - anything the auth service threw (not signed in, sign-in expired, token
200
+ * refresh status), and a host attachment read failure;
201
+ * - the vendor's own request validation, by code (`UNSUPPORTED_OPTION`,
202
+ * `UNSUPPORTED_CONTENT`, `TOOL_LIMIT`);
203
+ * - the vendor's fixed-literal plain errors, by exact message: the HTTP status
204
+ * (`Cursor agent returned HTTP 429`), the response, idle and progress
205
+ * timeouts, a closed bridge or stream, an unreadable compressed frame, and
206
+ * an invalid Cursor setting;
207
+ * - a network error raised by the operating system (`ECONNREFUSED`,
208
+ * `ENOTFOUND`, ...), recognised from the thrown error's own code, not from
209
+ * the vendor's classification.
210
+ *
211
+ * Not a provider-origin class: a network error such as `ECONNREFUSED` is a
212
+ * local failure (no network, daemon down, DNS), and is kept. What is still
213
+ * replaced although it is probably local is a transport failure with no
214
+ * socket-layer errno: a TLS or certificate failure and an HTTP/2 protocol
215
+ * error. The vendor's `TRANSPORT` code cannot decide those, because
216
+ * `classifyCursorError` derives it from message text (`network`, `connection`,
217
+ * `socket`, `fetch`, `ECONN`, `http2`) and a provider's end-stream message or
218
+ * `grpc-message` can contain any of them; so the code is never trusted alone.
219
+ * Telling these apart would need a check on the thrown error object, as the
220
+ * errno check above does, extended to the TLS and HTTP/2 error codes. That is
221
+ * not done here and is not verified.
222
+ */
20
223
  export class CursorCompatAdapter extends CursorAdapter {
21
224
  visibleModels;
225
+ localFailures;
22
226
  constructor(options) {
23
- super(options);
227
+ const localFailures = new LocalFailureMessages();
228
+ super({
229
+ ...options,
230
+ auth: localFailures.wrap(options.auth),
231
+ ...(options.resolveAttachments === undefined
232
+ ? {}
233
+ : { resolveAttachments: localFailures.wrapAttachments(options.resolveAttachments) }),
234
+ });
235
+ this.localFailures = localFailures;
24
236
  this.visibleModels = options.visibleModels;
237
+ // The vendor assigns `createAgentRun` (the injected one, or its own
238
+ // `AgentRun` factory) in its constructor but does not type it as a member.
239
+ const vendor = this;
240
+ const createRun = vendor.createAgentRun;
241
+ vendor.createAgentRun = access => localFailures.observeRun(createRun(access));
25
242
  }
26
243
  async listModels(provider) {
27
244
  const models = await super.listModels(provider);
28
245
  const visible = this.visibleModels?.();
29
246
  return visible === undefined ? models : models.filter(model => visible.includes(model.id));
30
247
  }
31
- stream(options) {
32
- return super.stream({
248
+ async *stream(options) {
249
+ for await (const chunk of super.stream({
33
250
  ...options,
34
251
  // Existing DSH sessions may still hold this retired selection.
35
252
  model: options.model === 'composer-2' ? 'composer-2.5' : options.model,
36
253
  messages: projectCursorMessages(options.messages),
37
- });
254
+ })) {
255
+ if (chunk.type === 'finish' && chunk.reason.kind === 'error'
256
+ && !LOCAL_VENDOR_CODES.has(chunk.reason.failure.code)
257
+ && !isVendorLocalMessage(chunk.reason.failure.message)
258
+ && !this.localFailures.has(chunk.reason.failure.message)) {
259
+ // The vendor uses raw headers, trailers and end-stream errors for protocol
260
+ // checks and classification before this boundary. Keep its code and retry
261
+ // decisions; do not reclassify the replacement text. "Safe" here means no
262
+ // provider-controlled free text is displayed in failures, not that no
263
+ // secret can exist anywhere internally.
264
+ yield {
265
+ ...chunk,
266
+ reason: {
267
+ ...chunk.reason,
268
+ failure: {
269
+ ...chunk.reason.failure,
270
+ message: `${chunk.reason.failure.code} [provider response text omitted]`,
271
+ },
272
+ },
273
+ };
274
+ }
275
+ else {
276
+ yield chunk;
277
+ }
278
+ }
38
279
  }
39
280
  }
@@ -1,13 +1,20 @@
1
1
  /** Cursor OAuth session and browser-login lifecycle. Credentials stay in DSH. */
2
2
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
3
3
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
4
+ import { parseProviderJson } from './common.js';
4
5
  export const CURSOR_CREDENTIAL_REF = credentialRef('CURSOR_SUBSCRIPTION_OAUTH');
5
6
  const API_ORIGIN = 'https://api2.cursor.sh';
6
7
  const REFRESH_AHEAD_MS = 5 * 60_000;
7
- function parseCredential(value) {
8
+ async function parseCredential(value) {
8
9
  if (value === undefined || value.length === 0)
9
10
  return undefined;
10
- const parsed = JSON.parse(value);
11
+ // A parse failure here is a local shape error, not a provider signal. That is a
12
+ // deliberate change: the vendor classifies a stream failure from the message
13
+ // text, and the raw JSON.parse message used to carry a body excerpt, so a
14
+ // corrupt credential whose body happened to contain a word like `quota` was
15
+ // classified as a rate limit. Sanitising the message removes that accident, so
16
+ // a malformed credential now fails as a general provider error instead.
17
+ const parsed = await parseProviderJson(value, 'Cursor stored credential');
11
18
  if (parsed === null || typeof parsed !== 'object')
12
19
  throw new Error('Cursor credential is malformed');
13
20
  const item = parsed;
@@ -89,7 +96,7 @@ export class CursorAuth {
89
96
  });
90
97
  if (!response.ok)
91
98
  throw new Error(`Cursor token refresh failed (HTTP ${response.status})`);
92
- const body = await response.json();
99
+ const body = await parseProviderJson(response, 'Cursor token refresh');
93
100
  if (typeof body.accessToken !== 'string' || body.accessToken.length === 0) {
94
101
  throw new Error('Cursor token refresh returned no access token');
95
102
  }
@@ -136,7 +143,7 @@ export class CursorAuth {
136
143
  }
137
144
  if (!response.ok)
138
145
  throw new Error(`Cursor login failed (HTTP ${response.status})`);
139
- const body = await response.json();
146
+ const body = await parseProviderJson(response, 'Cursor login poll');
140
147
  signal.throwIfAborted();
141
148
  if (typeof body.accessToken !== 'string' || body.accessToken.length === 0) {
142
149
  throw new Error('Cursor login returned no access token');
@@ -1,4 +1,5 @@
1
1
  /** Cursor individual-account quota projection from its dashboard endpoints. */
2
+ import { parseProviderJson } from './common.js';
2
3
  const ORIGIN = 'https://cursor.com';
3
4
  function record(value) {
4
5
  return value !== null && typeof value === 'object' && !Array.isArray(value)
@@ -44,7 +45,7 @@ async function dashboardJson(url, cookie, http, signal) {
44
45
  });
45
46
  if (!response.ok)
46
47
  throw new Error(`Cursor usage endpoint returned HTTP ${response.status}`);
47
- return response.json();
48
+ return parseProviderJson(response, 'Cursor usage');
48
49
  }
49
50
  function legacyRequestWindow(body, resetsAt) {
50
51
  const usage = record(body);
@@ -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,49 @@
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 = ['opencode-go', 'kimi-code', 'minimax', 'minimax-cn', 'ollama-cloud'];
5
+ function valueOf(value) {
6
+ return typeof value === 'function' ? value() : value;
7
+ }
4
8
  const DEFAULT_REFS = {
5
9
  'opencode-go': 'OPENCODE_GO_API_KEY',
6
10
  'kimi-code': 'KIMI_CODING_API_KEY',
7
11
  'minimax': 'MINIMAX_API_KEY',
8
12
  'minimax-cn': 'MINIMAX_CN_API_KEY',
13
+ 'ollama-cloud': DEFAULT_OLLAMA_API_KEY_REF,
9
14
  };
10
15
  export class ExternalUsageController {
11
16
  resolveCredential;
12
17
  http;
13
18
  refs;
14
- constructor(resolveCredential, http = fetch, refs = {}) {
19
+ ollamaBaseURL;
20
+ constructor(resolveCredential, http = fetch, refs = {}, ollamaBaseURL = DEFAULT_OLLAMA_BASE_URL) {
15
21
  this.resolveCredential = resolveCredential;
16
22
  this.http = http;
17
23
  this.refs = { ...DEFAULT_REFS, ...refs };
24
+ this.ollamaBaseURL = ollamaBaseURL;
18
25
  }
19
26
  async status() {
20
27
  const values = await Promise.all(EXTERNAL_USAGE_SOURCES.map(async (source) => {
21
- const value = await this.resolveCredential(this.refs[source]);
28
+ const value = await this.resolveCredential(valueOf(this.refs[source]));
22
29
  return typeof value?.value === 'string' && value.value.length > 0;
23
30
  }));
24
31
  return Object.fromEntries(EXTERNAL_USAGE_SOURCES.map((source, i) => [source, { configured: values[i] ?? false }]));
25
32
  }
26
33
  async usage(source, signal) {
27
- const credential = await this.resolveCredential(this.refs[source]);
34
+ // Snapshot the endpoint facts before the first await: a save that
35
+ // switches the ref or host mid-flight must not send the old
36
+ // reference's key to the new host.
37
+ const ref = valueOf(this.refs[source]);
38
+ const ollamaBase = valueOf(this.ollamaBaseURL);
39
+ const credential = await this.resolveCredential(ref);
28
40
  if (credential?.value === undefined || credential.value.length === 0) {
29
41
  throw new Error(`${source} API key is not configured`);
30
42
  }
31
43
  if (source === 'minimax' || source === 'minimax-cn')
32
44
  return fetchMiniMaxUsage(credential.value, source === 'minimax-cn' ? 'cn' : 'global', this.http, signal);
45
+ if (source === 'ollama-cloud')
46
+ return fetchOllamaUsage(credential.value, ollamaBase, this.http, signal);
33
47
  return source === 'opencode-go'
34
48
  ? fetchOpenCodeGoUsage(credential.value, this.http, signal)
35
49
  : 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,4 +1,6 @@
1
1
  /** Usage readers for subscriptions whose model adapters are built into DSH. */
2
+ import { attributionHeaders } from '@deepseek-ai/dsh-llm';
3
+ import { parseProviderJson, readBoundedJson } from './common.js';
2
4
  function numeric(value) {
3
5
  const n = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
4
6
  return typeof n === 'number' && Number.isFinite(n) ? n : undefined;
@@ -39,7 +41,7 @@ async function getUsage(url, apiKey, http, signal) {
39
41
  });
40
42
  if (!response.ok)
41
43
  throw new Error(`Usage endpoint returned HTTP ${response.status}`);
42
- return response.json();
44
+ return parseProviderJson(response, 'external usage');
43
45
  }
44
46
  /** Read the three account-wide OpenCode Go windows from its Go gateway. */
45
47
  export async function fetchOpenCodeGoUsage(apiKey, http = fetch, signal) {
@@ -58,6 +60,12 @@ export async function fetchOpenCodeGoUsage(apiKey, http = fetch, signal) {
58
60
  if (usedPercent === undefined)
59
61
  continue;
60
62
  const resetsAt = resetTime(value?.resetsAt);
63
+ // Monthly has no interval fields. Both key providers' monthly pools
64
+ // read the same display assumption: a 30-day span back from the
65
+ // reset, clamped to now. Only the marker may ride it — pace never.
66
+ const monthlyDisplay = field === 'monthly' && resetsAt !== undefined
67
+ ? { displayStartsAt: Math.min(resetsAt - 30 * 86_400_000, Date.now()) }
68
+ : {};
61
69
  windows.push({
62
70
  kind,
63
71
  ...(scope === undefined ? {} : { scope }),
@@ -66,6 +74,7 @@ export async function fetchOpenCodeGoUsage(apiKey, http = fetch, signal) {
66
74
  ...(field === 'rolling' ? { fixedWindow: true, windowDurationMs: 18_000_000 }
67
75
  : field === 'weekly' ? { fixedWindow: true, windowDurationMs: 604_800_000 } : {}),
68
76
  ...(resetsAt === undefined ? {} : { resetsAt }),
77
+ ...monthlyDisplay,
69
78
  });
70
79
  }
71
80
  if (windows.length === 0)
@@ -78,11 +87,13 @@ export async function fetchKimiCodeUsage(apiKey, http = fetch, signal) {
78
87
  if (body === undefined)
79
88
  throw new Error('Kimi Code usage response is not an object');
80
89
  const modern = [];
90
+ // Only the top-level `usage` object is the summary the official CLI labels
91
+ // "Weekly limit"; an anonymous `limits[]` entry is "Limit #n" there.
81
92
  const rows = [
82
- ...(record(body.usage) ? [{ detail: body.usage }] : []),
83
- ...(Array.isArray(body.limits) ? body.limits : []),
93
+ ...(record(body.usage) ? [{ raw: { detail: body.usage }, summary: true }] : []),
94
+ ...(Array.isArray(body.limits) ? body.limits.map(raw => ({ raw, summary: false })) : []),
84
95
  ];
85
- for (const raw of rows) {
96
+ for (const { raw, summary } of rows) {
86
97
  const item = record(raw);
87
98
  if (!item)
88
99
  continue;
@@ -102,16 +113,34 @@ export async function fetchKimiCodeUsage(apiKey, http = fetch, signal) {
102
113
  : unit.includes('DAY') ? 86_400_000 : unit.includes('SECOND') ? 1000 : undefined;
103
114
  const durationMs = duration === undefined || duration <= 0 || multiplier === undefined
104
115
  ? undefined : duration * multiplier;
105
- const kind = durationMs === 18_000_000 ? 'session' : durationMs === 604_800_000 ? 'weekly' : 'other';
116
+ const kind = durationMs === 18_000_000 ? 'session'
117
+ : durationMs === 604_800_000 ? 'weekly'
118
+ // No period fields at all: the official CLI labels the top-level
119
+ // summary the weekly limit, so use that kind rather than 'other'.
120
+ : durationMs === undefined && unit === '' ? 'weekly' : 'other';
106
121
  const resetsAt = kimiReset(detail);
107
122
  const scope = detail.name ?? detail.title ?? item.name ?? item.title;
123
+ // A vendor-declared span anchors the display marker at
124
+ // min(reset − span, now); pace stays unverified (rolling semantics
125
+ // are not established), so the assumed instant rides displayStartsAt.
126
+ // The top-level summary without a usable period (absent, zero, or no
127
+ // unit) is read as the weekly limit (above), so its marker assumes seven
128
+ // days. The official CLI
129
+ // gives only the label, not a span; the span rests on a live account
130
+ // whose summary reset matched `usages.limit_7d` to within a second.
131
+ // An anonymous `limits[]` entry gets no such assumption. Display only.
132
+ const markerSpan = durationMs ?? (summary && kind === 'weekly' ? 604_800_000 : undefined);
133
+ const displayStart = resetsAt !== undefined && markerSpan !== undefined
134
+ ? { displayStartsAt: Math.min(resetsAt - markerSpan, Date.now()) }
135
+ : {};
108
136
  modern.push({ kind, usedPercent,
109
137
  ...(typeof scope === 'string' ? { scope } : {}),
110
138
  ...(resetsAt === undefined ? {} : { resetsAt }),
111
139
  ...(durationMs === undefined ? {} : { windowDurationMs: durationMs }),
112
- // Legacy weekly plans reset as whole seven-day buckets; five-hour semantics
113
- // are documented as rolling, so no fixed-window assertion is made there.
114
- ...(kind === 'weekly' ? { fixedWindow: true } : {}),
140
+ // A whole seven-day bucket reset, not a rolling span, only where the
141
+ // endpoint actually said 7 DAY.
142
+ ...(durationMs === 604_800_000 ? { fixedWindow: true } : {}),
143
+ ...displayStart,
115
144
  });
116
145
  }
117
146
  if (modern.length)
@@ -130,13 +159,69 @@ export async function fetchKimiCodeUsage(apiKey, http = fetch, signal) {
130
159
  if (typeof ratio !== 'number' || !Number.isFinite(ratio) || ratio < 0 || ratio > 1)
131
160
  continue;
132
161
  const resetsAt = resetTime(value?.reset_time ?? value?.resetAt);
162
+ // The monthly total resets on the subscription's cycle, not the
163
+ // calendar month; the display marker assumes ~30 days back. It only
164
+ // moves the marker — pace never rides it.
165
+ const monthlyDisplay = field === 'limit_month_total' && resetsAt !== undefined
166
+ ? { displayStartsAt: Math.min(resetsAt - 30 * 86_400_000, Date.now()) }
167
+ : {};
133
168
  windows.push({
134
169
  kind,
135
170
  ...(scope === undefined ? {} : { scope }),
136
171
  usedPercent: ratio * 100,
137
172
  ...(field === 'limit_7d' ? { fixedWindow: true, windowDurationMs: 604_800_000 } : {}),
138
173
  ...(resetsAt === undefined ? {} : { resetsAt }),
174
+ ...monthlyDisplay,
139
175
  });
140
176
  }
141
177
  return { supported: true, windows, plan: 'Kimi Code' };
142
178
  }
179
+ /**
180
+ * Read Ollama Cloud quota from `GET {base}/balance`. The Cloud has no
181
+ * `limits` object: quota is the included allowance against its remaining
182
+ * balance (`included.allowance_usd`, `included.balance_usd`) over the
183
+ * reported period. A 404 means a self-hosted endpoint without the
184
+ * surface: not an error, the card renders an unsupported note instead.
185
+ */
186
+ export async function fetchOllamaUsage(apiKey, baseURL, http = fetch, signal) {
187
+ const combined = signal === undefined
188
+ ? AbortSignal.timeout(15_000)
189
+ : AbortSignal.any([AbortSignal.timeout(15_000), signal]);
190
+ const response = await http(`${baseURL.replace(/\/+$/, '')}/balance`, {
191
+ headers: {
192
+ accept: 'application/json',
193
+ authorization: `Bearer ${apiKey}`,
194
+ ...attributionHeaders(),
195
+ },
196
+ redirect: 'error',
197
+ signal: combined,
198
+ });
199
+ if (signal?.aborted)
200
+ throw signal.reason;
201
+ if (response.status === 404)
202
+ return { supported: false };
203
+ if (!response.ok)
204
+ throw new Error(`Usage endpoint returned HTTP ${response.status}`);
205
+ const body = record(await readBoundedJson(response, 'usage', 1 * 1024 * 1024));
206
+ const included = record(body?.included);
207
+ const allowance = numeric(included?.allowance_usd);
208
+ const balance = numeric(included?.balance_usd);
209
+ if (included === undefined || allowance === undefined || allowance <= 0 || balance === undefined) {
210
+ throw new Error('Ollama balance response has no usable included allowance');
211
+ }
212
+ const period = record(included.period);
213
+ const resetsAt = period === undefined ? undefined : resetTime(period.until);
214
+ const startsAt = period === undefined ? undefined : resetTime(period.from);
215
+ return {
216
+ supported: true,
217
+ windows: [{
218
+ kind: 'other',
219
+ scope: 'Credits',
220
+ // An overdrawn balance stays visible instead of dropping the window.
221
+ usedPercent: (allowance - balance) / allowance * 100,
222
+ ...(resetsAt === undefined ? {} : { resetsAt }),
223
+ ...(startsAt === undefined ? {} : { startsAt }),
224
+ }],
225
+ plan: 'Ollama Cloud',
226
+ };
227
+ }