dsh-subscriptions 0.1.5 → 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 (41) hide show
  1. package/README.md +31 -5
  2. package/lib/auth/rpc.d.ts +73 -2
  3. package/lib/auth/rpc.js +53 -3
  4. package/lib/client/ExternalUsageCards.js +1 -1
  5. package/lib/client/OllamaCard.d.ts +13 -0
  6. package/lib/client/OllamaCard.js +76 -0
  7. package/lib/client/OllamaSettingsManager.d.ts +24 -0
  8. package/lib/client/OllamaSettingsManager.js +246 -0
  9. package/lib/client/ProviderModelEditor.js +4 -2
  10. package/lib/client/SubscriptionUsageBadge.d.ts +1 -1
  11. package/lib/client/SubscriptionUsageBadge.js +3 -2
  12. package/lib/client/SubscriptionsSection.d.ts +2 -0
  13. package/lib/client/SubscriptionsSection.js +2 -1
  14. package/lib/client/UsageMeter.js +11 -7
  15. package/lib/client/locales.d.ts +68 -0
  16. package/lib/client/locales.js +77 -0
  17. package/lib/client/ollama-draft.d.ts +229 -0
  18. package/lib/client/ollama-draft.js +246 -0
  19. package/lib/client/usage-pace.d.ts +7 -0
  20. package/lib/client/usage-pace.js +15 -0
  21. package/lib/client.js +1385 -244
  22. package/lib/client.js.map +1 -1
  23. package/lib/index.d.ts +25 -0
  24. package/lib/index.js +3191 -2133
  25. package/lib/provider-settings.d.ts +2 -1
  26. package/lib/provider-settings.js +6 -4
  27. package/lib/providers/claude.d.ts +14 -1
  28. package/lib/providers/claude.js +19 -7
  29. package/lib/providers/common.d.ts +14 -0
  30. package/lib/providers/common.js +48 -0
  31. package/lib/providers/external-usage-controller.d.ts +9 -2
  32. package/lib/providers/external-usage-controller.js +19 -5
  33. package/lib/providers/external-usage.d.ts +8 -0
  34. package/lib/providers/external-usage.js +92 -8
  35. package/lib/providers/ollama-settings.d.ts +90 -0
  36. package/lib/providers/ollama-settings.js +197 -0
  37. package/lib/providers/ollama-web.d.ts +46 -0
  38. package/lib/providers/ollama-web.js +213 -0
  39. package/lib/providers/ollama.d.ts +107 -0
  40. package/lib/providers/ollama.js +545 -0
  41. package/package.json +1 -1
@@ -32,7 +32,8 @@ export declare class ProviderSettingsStore {
32
32
  constructor(path?: string);
33
33
  get(provider: ProviderId): ProviderPreferences;
34
34
  visible(provider: ProviderId, model: string): boolean;
35
- contextWindow(model: string): number | undefined;
35
+ /** The user's local context-window override for one model; absent follows the provider. */
36
+ contextWindow(model: string, provider?: ProviderId): number | undefined;
36
37
  /** Creation-time policy survives restarts and never changes an existing session. */
37
38
  toolEnabled(provider: ProviderId, tool: SubscriptionTool, createdAt?: number): boolean;
38
39
  set(provider: ProviderId, input: unknown): Promise<void>;
@@ -51,8 +51,9 @@ export function validatePreferences(provider, input) {
51
51
  result.visibleModels = [...new Set(raw.visibleModels)];
52
52
  }
53
53
  if (raw.contextWindows !== undefined) {
54
- if (provider !== 'codex')
55
- throw new Error('context window overrides are currently supported only for Codex');
54
+ if (provider !== 'codex' && provider !== 'claude') {
55
+ throw new Error('context window overrides are supported only for Codex and Claude');
56
+ }
56
57
  if (!raw.contextWindows || typeof raw.contextWindows !== 'object' || Array.isArray(raw.contextWindows)) {
57
58
  throw new Error('contextWindows must be a model-to-token map');
58
59
  }
@@ -110,8 +111,9 @@ export class ProviderSettingsStore {
110
111
  visible(provider, model) {
111
112
  return this.current.providers[provider]?.visibleModels?.includes(model) ?? true;
112
113
  }
113
- contextWindow(model) {
114
- const windows = this.current.providers.codex?.contextWindows;
114
+ /** The user's local context-window override for one model; absent follows the provider. */
115
+ contextWindow(model, provider = 'codex') {
116
+ const windows = this.current.providers[provider]?.contextWindows;
115
117
  return windows && Object.hasOwn(windows, model) ? windows[model] : undefined;
116
118
  }
117
119
  /** Creation-time policy survives restarts and never changes an existing session. */
@@ -127,6 +127,12 @@ export interface ClaudeAdapterOptions {
127
127
  * the provider's own default.
128
128
  */
129
129
  defaultEffortOf?: (model: string) => string | undefined;
130
+ /**
131
+ * The user's local context-window override for one model (the Settings
132
+ * editor). Returns undefined to follow the advertised window. The result
133
+ * is capped at that window: Anthropic advertises no separate maximum.
134
+ */
135
+ contextWindowOf?: (model: string) => number | undefined;
130
136
  /** Resolves the Claude Code version to present (npm-backed); absent means the local floor. */
131
137
  resolveCliVersion?: () => Promise<string>;
132
138
  /** Cache breakpoint lifetime; absent means the API's five-minute default. */
@@ -183,7 +189,14 @@ export declare class ClaudeAdapter extends LlmAdapter {
183
189
  listOwnModels(provider: string, account?: string, signal?: AbortSignal): Promise<readonly LlmModelInfo[]>;
184
190
  resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
185
191
  /** Capability resolution of the provider's own models (the pool resolves members here). */
186
- resolveOwnModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
192
+ resolveOwnModel(provider: string, model: string, account?: string): Promise<LlmResolvedModelInfo>;
193
+ /** The advertised window is both the default and the ceiling for a local override. */
194
+ private contextLimitsFor;
195
+ /** Account-specific bounds the Settings editor shows next to a model's override input. */
196
+ contextLimits(model: string, account?: string): Promise<{
197
+ default: number;
198
+ max: number;
199
+ }>;
187
200
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
188
201
  /** Pool seam: stream through one specific account instead of the default. */
189
202
  streamAccount(options: GenerateOptions, account: string): AsyncIterable<StreamChunk>;
@@ -600,10 +600,14 @@ export class ClaudeAdapter extends LlmAdapter {
600
600
  }
601
601
  return cache;
602
602
  }
603
- async discovered(model) {
603
+ async discovered(model, account) {
604
604
  if (!this.options.discovery)
605
605
  return undefined;
606
- const accounts = (await this.options.tokens.list()).map(entry => entry.key);
606
+ // A named account is looked up alone: its window must not be borrowed
607
+ // from another account's catalog.
608
+ const accounts = account === undefined
609
+ ? (await this.options.tokens.list()).map(entry => entry.key)
610
+ : [account];
607
611
  return discoverAcrossAccounts(accounts, async (account) => {
608
612
  const catalog = await this.catalogFor(account);
609
613
  const models = await catalog.resolve(() => this.fetchCatalog(account));
@@ -674,22 +678,30 @@ export class ClaudeAdapter extends LlmAdapter {
674
678
  return this.resolveOwnModel(provider, model);
675
679
  }
676
680
  /** Capability resolution of the provider's own models (the pool resolves members here). */
677
- async resolveOwnModel(provider, model) {
678
- const disc = await this.discovered(model);
681
+ async resolveOwnModel(provider, model, account) {
682
+ const disc = await this.discovered(model, account);
679
683
  const configured = this.options.models.find(entry => entry.id === model);
684
+ const limits = this.contextLimitsFor(disc, configured);
680
685
  const reasoning = mergeReasoning(this.options.defaultEffortOf?.(model), disc?.reasoning);
681
686
  return {
682
687
  provider,
683
688
  id: model,
684
689
  name: disc?.name ?? configured?.name ?? model,
685
690
  inputModalities: configured?.inputModalities ?? CLAUDE_MODALITIES,
686
- context: {
687
- contextWindow: disc?.contextWindow ?? configured?.contextWindow ?? CLAUDE_CONTEXT_WINDOW,
688
- },
691
+ context: { contextWindow: Math.min(this.options.contextWindowOf?.(model) ?? limits.default, limits.max) },
689
692
  defaultMaxTokens: claudeMaxTokens(configured, disc),
690
693
  ...(reasoning === undefined ? {} : { reasoning }),
691
694
  };
692
695
  }
696
+ /** The advertised window is both the default and the ceiling for a local override. */
697
+ contextLimitsFor(disc, configured) {
698
+ const advertised = disc?.contextWindow ?? configured?.contextWindow ?? CLAUDE_CONTEXT_WINDOW;
699
+ return { default: advertised, max: advertised };
700
+ }
701
+ /** Account-specific bounds the Settings editor shows next to a model's override input. */
702
+ async contextLimits(model, account) {
703
+ return this.contextLimitsFor(await this.discovered(model, account), this.options.models.find(entry => entry.id === model));
704
+ }
693
705
  async *stream(options) {
694
706
  const pool = this.options.pool?.();
695
707
  if (pool !== undefined && await pool.owns(options.provider, options.model)) {
@@ -46,6 +46,13 @@ export declare function validateModels(models: readonly ModelEntry[], label: str
46
46
  * @throws SyntaxError with the local origin and a fixed omission marker.
47
47
  */
48
48
  export declare function parseProviderJson<T = unknown>(input: Response | string, origin: string): Promise<T>;
49
+ /**
50
+ * Read a bounded JSON body; oversized or malformed payloads fail loudly.
51
+ * Bytes accumulate per chunk and the stream is cancelled past the cap, so
52
+ * the bound limits memory even when the body has no end; a declared
53
+ * over-cap `content-length` fails before the first read.
54
+ */
55
+ export declare function readBoundedJson(response: Response, label: string, maxBytes: number): Promise<unknown>;
49
56
  /** Optional per-call hooks {@link httpLlmError} uses to read a rate-limit window. */
50
57
  export interface HttpLlmErrorOptions {
51
58
  /**
@@ -212,6 +219,13 @@ export interface UsageWindow {
212
219
  windowDurationMs?: number;
213
220
  /** Provider-verified fixed interval; permits deriving start from end and duration. */
214
221
  fixedWindow?: boolean;
222
+ /**
223
+ * Display-only start the reader assumed (kind or label based, clamped
224
+ * to now). The time marker may ride it; pace and color must never —
225
+ * it is not evidence of when the window opened. Mutually exclusive
226
+ * with `startsAt`.
227
+ */
228
+ displayStartsAt?: number;
215
229
  }
216
230
  /** One banked reset credit (Codex), or one grant containing resets (Claude). */
217
231
  export interface ResetCredit {
@@ -83,6 +83,54 @@ export async function parseProviderJson(input, origin) {
83
83
  throw new SyntaxError(providerDiagnostic(`${origin}: invalid JSON`, true));
84
84
  }
85
85
  }
86
+ /**
87
+ * Read a bounded JSON body; oversized or malformed payloads fail loudly.
88
+ * Bytes accumulate per chunk and the stream is cancelled past the cap, so
89
+ * the bound limits memory even when the body has no end; a declared
90
+ * over-cap `content-length` fails before the first read.
91
+ */
92
+ export async function readBoundedJson(response, label, maxBytes) {
93
+ const declared = Number(response.headers.get('content-length'));
94
+ if (Number.isFinite(declared) && declared > maxBytes) {
95
+ // Stop the socket too: the headers arrived, but the body must never flow.
96
+ await response.body?.cancel().catch(() => undefined);
97
+ throw new Error(`${label} response exceeds ${String(maxBytes)} bytes`);
98
+ }
99
+ if (response.body === null)
100
+ throw new Error(`${label} response has no body`);
101
+ const reader = response.body.getReader();
102
+ const decoder = new TextDecoder();
103
+ let bytes = 0;
104
+ let text = '';
105
+ try {
106
+ for (;;) {
107
+ const chunk = await reader.read();
108
+ if (chunk.done)
109
+ break;
110
+ bytes += chunk.value.byteLength;
111
+ if (bytes > maxBytes) {
112
+ // Cancel before throwing so the live stream does not linger behind
113
+ // a rejection the caller already stopped listening to.
114
+ await reader.cancel().catch(() => undefined);
115
+ throw new Error(`${label} response exceeds ${String(maxBytes)} bytes`);
116
+ }
117
+ text += decoder.decode(chunk.value, { stream: true });
118
+ }
119
+ text += decoder.decode();
120
+ }
121
+ finally {
122
+ try {
123
+ reader.releaseLock();
124
+ }
125
+ catch { /* best effort */ }
126
+ }
127
+ try {
128
+ return JSON.parse(text);
129
+ }
130
+ catch {
131
+ throw new Error(`${label} response is not JSON`);
132
+ }
133
+ }
86
134
  /**
87
135
  * Build an LlmError from a non-2xx provider response, mapping the status to a
88
136
  * stable code and, for a rate-limited request, the disclosed reset instant to
@@ -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,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;
@@ -59,6 +60,12 @@ export async function fetchOpenCodeGoUsage(apiKey, http = fetch, signal) {
59
60
  if (usedPercent === undefined)
60
61
  continue;
61
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
+ : {};
62
69
  windows.push({
63
70
  kind,
64
71
  ...(scope === undefined ? {} : { scope }),
@@ -67,6 +74,7 @@ export async function fetchOpenCodeGoUsage(apiKey, http = fetch, signal) {
67
74
  ...(field === 'rolling' ? { fixedWindow: true, windowDurationMs: 18_000_000 }
68
75
  : field === 'weekly' ? { fixedWindow: true, windowDurationMs: 604_800_000 } : {}),
69
76
  ...(resetsAt === undefined ? {} : { resetsAt }),
77
+ ...monthlyDisplay,
70
78
  });
71
79
  }
72
80
  if (windows.length === 0)
@@ -79,11 +87,13 @@ export async function fetchKimiCodeUsage(apiKey, http = fetch, signal) {
79
87
  if (body === undefined)
80
88
  throw new Error('Kimi Code usage response is not an object');
81
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.
82
92
  const rows = [
83
- ...(record(body.usage) ? [{ detail: body.usage }] : []),
84
- ...(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 })) : []),
85
95
  ];
86
- for (const raw of rows) {
96
+ for (const { raw, summary } of rows) {
87
97
  const item = record(raw);
88
98
  if (!item)
89
99
  continue;
@@ -103,16 +113,34 @@ export async function fetchKimiCodeUsage(apiKey, http = fetch, signal) {
103
113
  : unit.includes('DAY') ? 86_400_000 : unit.includes('SECOND') ? 1000 : undefined;
104
114
  const durationMs = duration === undefined || duration <= 0 || multiplier === undefined
105
115
  ? undefined : duration * multiplier;
106
- 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';
107
121
  const resetsAt = kimiReset(detail);
108
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
+ : {};
109
136
  modern.push({ kind, usedPercent,
110
137
  ...(typeof scope === 'string' ? { scope } : {}),
111
138
  ...(resetsAt === undefined ? {} : { resetsAt }),
112
139
  ...(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 } : {}),
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,
116
144
  });
117
145
  }
118
146
  if (modern.length)
@@ -131,13 +159,69 @@ export async function fetchKimiCodeUsage(apiKey, http = fetch, signal) {
131
159
  if (typeof ratio !== 'number' || !Number.isFinite(ratio) || ratio < 0 || ratio > 1)
132
160
  continue;
133
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
+ : {};
134
168
  windows.push({
135
169
  kind,
136
170
  ...(scope === undefined ? {} : { scope }),
137
171
  usedPercent: ratio * 100,
138
172
  ...(field === 'limit_7d' ? { fixedWindow: true, windowDurationMs: 604_800_000 } : {}),
139
173
  ...(resetsAt === undefined ? {} : { resetsAt }),
174
+ ...monthlyDisplay,
140
175
  });
141
176
  }
142
177
  return { supported: true, windows, plan: 'Kimi Code' };
143
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
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * One model the Ollama Cloud route serves. The route ships no catalog of its
3
+ * own: this list is the catalog, in the order the picker shows it. Adoption
4
+ * fills the capability fields from the endpoint's `/api/tags` + `/api/show`
5
+ * reply; every field except `id` is optional so a hand-written row still
6
+ * works.
7
+ */
8
+ export interface OllamaModelSetting {
9
+ /** Wire model id; must be non-empty and unique within the list. */
10
+ id: string;
11
+ /** Selector label; defaults to the id. */
12
+ name?: string;
13
+ /** Known combined request/response context capacity in tokens. */
14
+ contextWindow?: number;
15
+ /**
16
+ * The user's local context-window choice. Unlike `contextWindow`, which
17
+ * snapshots what the endpoint advertised, this records only an explicit
18
+ * edit: absent follows the endpoint. The runtime caps it at the advertised
19
+ * window and it never widens the API's real capacity.
20
+ */
21
+ contextOverride?: number;
22
+ /** Per-request output cap; defaults to the route's fallback. */
23
+ maxTokens?: number;
24
+ /** The endpoint advertised the `vision` capability. */
25
+ vision?: boolean;
26
+ /** The endpoint advertised the `thinking` capability. */
27
+ thinking?: boolean;
28
+ /** Advertised reasoning efforts, in picker order; empty means non-reasoning. */
29
+ efforts?: string[];
30
+ /** Effort preselected when the session picks none; must be one of `efforts`. */
31
+ defaultEffort?: string;
32
+ }
33
+ /**
34
+ * Durable, non-secret Ollama Cloud configuration. Every field is optional and
35
+ * its absence means "no override": the effective value then falls back to the
36
+ * plugin config and, last, to the built-in default. The API key itself never
37
+ * appears here; `apiKeyRef` only names a DSH credential.
38
+ */
39
+ export interface OllamaSettings {
40
+ /** The route's catalog, in picker order. `[]` hides every model. */
41
+ models?: OllamaModelSetting[];
42
+ /** Register the web search/fetch providers; the config value is the default. */
43
+ web?: boolean;
44
+ /** Native base URL; the config value is the default. */
45
+ baseURL?: string;
46
+ /** Credential reference naming the API key; the config value is the default. */
47
+ apiKeyRef?: string;
48
+ }
49
+ /**
50
+ * Accept only an absolute http(s) base URL with no credentials, query, or
51
+ * fragment, and return its trimmed form. The route appends `/tags`, `/show`,
52
+ * and `/v1/chat/completions` to this value, so anything else would build a URL
53
+ * that cannot work — and a non-official host receives the referenced
54
+ * credential, which the dialog tells the user.
55
+ * @param value - the candidate URL.
56
+ * @returns the trimmed URL to store.
57
+ * @throws when the value is not a usable absolute http(s) URL.
58
+ */
59
+ export declare function validateOllamaBaseURL(value: unknown): string;
60
+ /**
61
+ * Validate one settings document, dropping unknown keys and duplicate model
62
+ * ids (first wins, matching the Cursor store's visible-model handling).
63
+ * @param input - the parsed store document or an RPC payload.
64
+ * @throws when a known field has the wrong type or an unusable value.
65
+ */
66
+ export declare function validateOllamaSettings(input: unknown): OllamaSettings;
67
+ /**
68
+ * Durable Ollama Cloud configuration under the DSH home directory. Writes are
69
+ * serialized and atomic (temp file with mode 0600, then rename), so a crash
70
+ * never leaves a half-written document and a concurrent writer cannot lose an
71
+ * earlier save.
72
+ */
73
+ export declare class OllamaSettingsStore {
74
+ readonly path: string;
75
+ private readonly onCorrupt?;
76
+ private current;
77
+ private writes;
78
+ /** Set when the stored file existed but was unreadable or invalid. */
79
+ private corrupt;
80
+ constructor(path?: string, onCorrupt?: ((message: string) => void) | undefined);
81
+ /** Why the stored file is ignored, when it is. */
82
+ corruption(): string | undefined;
83
+ get(): OllamaSettings;
84
+ /** The stored catalog, or `undefined` when the store carries no override. */
85
+ models(): readonly OllamaModelSetting[] | undefined;
86
+ web(): boolean | undefined;
87
+ baseURL(): string | undefined;
88
+ apiKeyRef(): string | undefined;
89
+ set(input: unknown): Promise<void>;
90
+ }