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
@@ -6,12 +6,11 @@ export type ModelDefaults = Readonly<Partial<Record<ProviderId, ModelDefaultMap>
6
6
  /** Absolute path of the defaults file. */
7
7
  export declare function modelDefaultsFilePath(): string;
8
8
  /**
9
- * The last load failure, or a warning about entries that were skipped while
10
- * loading; consumers only use it for diagnostics.
9
+ * Ready the defaults store.
10
+ * index.ts calls it at apply time so every later synchronous read sees the
11
+ * persisted state; store tests also call it after resetting the singleton.
11
12
  */
12
- export declare function modelDefaultsLoadError(): unknown;
13
- /** A detached snapshot for the RPC surface (render + diffing). */
14
- export declare function modelDefaultsSnapshot(): ModelDefaults;
13
+ export declare function loadModelDefaults(): Promise<void>;
15
14
  /**
16
15
  * Set or clear one model's configured default effort, then persist. The
17
16
  * memory snapshot updates only after the atomic write succeeds, so a failed
@@ -8,10 +8,10 @@
8
8
  * chip the model picker shows when the discovered catalog advertises no
9
9
  * default at all.
10
10
  *
11
- * Writes are single-process and atomic, but *not* as serialised as the rest
12
- * of the page: the Settings page disables only the row being saved, so two
13
- * rows saved back to back can overlap. The write chain below serialises them,
14
- * so no update is lost to a read-modify-write race. Every read comes from the
11
+ * Writes are single-process and atomic. The account manager submits effort
12
+ * edits sequentially and disables its editor while saving, but concurrent RPC
13
+ * callers can still overlap. The write chain below serialises them, so no
14
+ * update is lost to a read-modify-write race. Every read comes from the
15
15
  * in-memory snapshot, so the on-disk file only needs to survive a restart: a
16
16
  * malformed file reads as empty and is rewritten on the next save, never
17
17
  * taking the plugin down with it.
@@ -29,21 +29,21 @@ const EMPTY = Object.freeze({});
29
29
  let current = EMPTY;
30
30
  /** One lazy load of the on-disk file (read once per process). */
31
31
  let ready;
32
- /** Last load failure, surfaced to callers that care; defaults stay empty. */
32
+ /** Last load failure or skipped-entry warning, inspected only by tests. */
33
33
  let loadError;
34
34
  /**
35
35
  * Serialises every write: the read-modify-write sequence must not interleave,
36
36
  * or a fast second save would compute its snapshot from the stale `current`
37
- * and silently drop the first update (the UI disables only the row being
38
- * saved, so overlaps are reachable).
37
+ * and silently drop the first update. Concurrent RPC callers can overlap
38
+ * even though one account-manager dialog submits its edits sequentially.
39
39
  */
40
40
  let writeChain = Promise.resolve();
41
41
  /**
42
42
  * Validate one persisted provider section: a string→string map, or undefined.
43
43
  * Malformed *entries* are skipped, not the whole section: one bad value (a
44
44
  * hand edit losing its quotes) must not silently un-configure every model in
45
- * that provider. What was dropped is reported so the caller can surface it
46
- * instead of the loss disappearing.
45
+ * that provider. Dropped model ids are recorded in a test-only load warning;
46
+ * production callers do not surface this warning.
47
47
  */
48
48
  function sanitizeProvider(value, dropped) {
49
49
  if (typeof value !== 'object' || value === null || Array.isArray(value))
@@ -136,15 +136,16 @@ function sectionOf(defaults, provider) {
136
136
  }
137
137
  /**
138
138
  * Ready the defaults store.
139
- * @internal Exported for tests; index.ts calls it at apply time so every
140
- * later synchronous read sees the persisted state.
139
+ * index.ts calls it at apply time so every later synchronous read sees the
140
+ * persisted state; store tests also call it after resetting the singleton.
141
141
  */
142
142
  export async function loadModelDefaults() {
143
143
  await ensureReady();
144
144
  }
145
145
  /**
146
- * The last load failure, or a warning about entries that were skipped while
147
- * loading; consumers only use it for diagnostics.
146
+ * Test-only observation of the last load failure or skipped-entry warning.
147
+ * No production diagnostic consumer currently reads it.
148
+ * @internal Exported for tests only.
148
149
  */
149
150
  export function modelDefaultsLoadError() {
150
151
  return loadError;
@@ -164,7 +165,10 @@ export function defaultEffortOf(provider, model) {
164
165
  // breaks that model's resolution).
165
166
  return Object.prototype.hasOwnProperty.call(section, model) ? section[model] : undefined;
166
167
  }
167
- /** A detached snapshot for the RPC surface (render + diffing). */
168
+ /**
169
+ * Detached snapshot for store assertions in tests; no production RPC uses it.
170
+ * @internal Exported for tests only.
171
+ */
168
172
  export function modelDefaultsSnapshot() {
169
173
  const result = {};
170
174
  for (const provider of PROVIDER_IDS) {
@@ -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. */
@@ -60,8 +60,6 @@ export declare function isAntigravityPermanentRefreshError(error: unknown): bool
60
60
  export declare function fetchAntigravityModels(session: AntigravitySession, runtime?: AntigravityRuntimeConfig, fetchFn?: FetchFn, signal?: AbortSignal): Promise<DiscoveredModel[]>;
61
61
  /** Fetch plan and per-model quota windows when the upstream exposes them. */
62
62
  export declare function fetchAntigravityUsage(session: AntigravitySession, runtime?: AntigravityRuntimeConfig, fetchFn?: FetchFn, signal?: AbortSignal): Promise<ProviderUsage>;
63
- /** URL for either v1internal generation transport. */
64
- export declare function antigravityGenerateURL(baseURL: string | undefined, stream: boolean): string;
65
63
  /** Forward one already-built payload to generateContent or streamGenerateContent. */
66
64
  export declare function requestAntigravityContent(session: AntigravitySession, payload: AntigravityRequest, stream: boolean, runtime?: AntigravityRuntimeConfig, fetchFn?: FetchFn, signal?: AbortSignal): Promise<Response>;
67
65
  export interface AntigravityAdapterOptions {
@@ -7,7 +7,7 @@ import { errorChain, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError } from '@deepseek
7
7
  import { resolveImages } from '../translate/resolved.js';
8
8
  import { antigravityReasoning } from '../translate/antigravity-thinking.js';
9
9
  import { parseAntigravityResponse, streamAntigravity, toAntigravityRequest, } from '../translate/antigravity.js';
10
- import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverOrRetryAuth, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, discoverAcrossAccounts, isDiscoveryAborted, } from './common.js';
10
+ import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverOrRetryAuth, isMissingOrInvalidCredential, parseProviderJson, oauthEndpointError, OAuthEndpointError, discoverAcrossAccounts, isDiscoveryAborted, } from './common.js';
11
11
  import { hostFetch } from '../http.js';
12
12
  import { ANTIGRAVITY_DEFAULT_CLIENT_ID, ANTIGRAVITY_DEFAULT_CLIENT_SECRET } from './antigravity-oauth-client.js';
13
13
  import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
@@ -126,7 +126,7 @@ async function callInternal(method, body, accessToken, runtime, fetchFn, signal)
126
126
  }, runtime, fetchFn);
127
127
  if (!response.ok)
128
128
  throw await httpLlmError(response, `Antigravity ${method}`);
129
- return response.json();
129
+ return parseProviderJson(response, `Antigravity ${method}`);
130
130
  }
131
131
  function projectIdOf(value) {
132
132
  if (typeof value === 'string' && value.length > 0)
@@ -217,7 +217,7 @@ export async function exchangeAntigravityCode(code, verifier, redirectUri, oauth
217
217
  });
218
218
  if (!response.ok)
219
219
  throw await oauthEndpointError(response, 'Antigravity');
220
- const tokens = await response.json();
220
+ const tokens = await parseProviderJson(response, 'Antigravity token exchange');
221
221
  if (typeof tokens.access_token !== 'string')
222
222
  throw new Error('Antigravity token endpoint returned no access token');
223
223
  const account = await discoverAntigravityAccount(tokens.access_token, runtime, fetchFn);
@@ -238,7 +238,7 @@ export async function refreshAntigravity(session, oauth, fetchFn = hostFetch) {
238
238
  });
239
239
  if (!response.ok)
240
240
  throw await oauthEndpointError(response, 'Antigravity');
241
- return sessionFromTokens(await response.json(), {
241
+ return sessionFromTokens(await parseProviderJson(response, 'Antigravity token refresh'), {
242
242
  projectId: session.projectId,
243
243
  ...session.account === undefined ? {} : { account: session.account },
244
244
  ...session.plan === undefined ? {} : { plan: session.plan },
@@ -313,10 +313,6 @@ export async function fetchAntigravityUsage(session, runtime = {}, fetchFn = hos
313
313
  ...displayPlan === undefined ? {} : { plan: displayPlan },
314
314
  };
315
315
  }
316
- /** URL for either v1internal generation transport. */
317
- export function antigravityGenerateURL(baseURL, stream) {
318
- return `${antigravityBaseURL(baseURL)}/v1internal:${stream ? 'streamGenerateContent?alt=sse' : 'generateContent'}`;
319
- }
320
316
  /** Forward one already-built payload to generateContent or streamGenerateContent. */
321
317
  export async function requestAntigravityContent(session, payload, stream, runtime = {}, fetchFn = hostFetch, signal) {
322
318
  return fetchAntigravity(stream ? 'streamGenerateContent?alt=sse' : 'generateContent', {
@@ -511,6 +507,6 @@ export class AntigravityAdapter extends LlmAdapter {
511
507
  const response = await requestAntigravityContent(session, toAntigravityRequest(options, messages, session.projectId), false, this.options.runtime, this.options.fetchFn, options.signal);
512
508
  if (!response.ok)
513
509
  throw await httpLlmError(response, 'Antigravity API');
514
- return parseAntigravityResponse(await response.json());
510
+ return parseAntigravityResponse(await parseProviderJson(response, 'Antigravity generateContent'));
515
511
  }
516
512
  }
@@ -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>;
@@ -7,7 +7,7 @@ import { execFileSync } from 'node:child_process';
7
7
  import { EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
8
8
  import { hostSupportsImageOffload, IMAGE_OFFLOAD_REQUIRED, requiredImageOffloadCount, resolveImages } from '../translate/resolved.js';
9
9
  import { markMessageCache, streamAnthropic, toAnthropicMessages, toAnthropicSystem, toAnthropicTools, } from '../translate/anthropic.js';
10
- import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
10
+ import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, parseProviderJson, oauthEndpointError, OAuthEndpointError, } from './common.js';
11
11
  import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
12
12
  import { hostFetch } from '../http.js';
13
13
  import { compareVersions } from './npm-cli-version.js';
@@ -212,7 +212,7 @@ export async function exchangeClaudeCode(code, verifier, redirectUri, state) {
212
212
  });
213
213
  if (!response.ok)
214
214
  throw await oauthEndpointError(response, 'claude');
215
- return claudeSession(await response.json(), undefined, true);
215
+ return claudeSession(await parseProviderJson(response, 'claude token exchange'), undefined, true);
216
216
  }
217
217
  /**
218
218
  * Refresh a claude session (JSON grant echoing the issued scope).
@@ -232,7 +232,7 @@ export async function refreshClaude(session) {
232
232
  });
233
233
  if (!response.ok)
234
234
  throw await oauthEndpointError(response, 'claude');
235
- const next = await claudeSession(await response.json(), session.refreshToken, false);
235
+ const next = await claudeSession(await parseProviderJson(response, 'claude token refresh'), session.refreshToken, false);
236
236
  return {
237
237
  ...next,
238
238
  ...session.emailAddress === undefined ? {} : { emailAddress: session.emailAddress },
@@ -398,7 +398,7 @@ export async function fetchClaudeUsage(session, fetchFn = hostFetch, signal, cli
398
398
  }
399
399
  if (!response.ok)
400
400
  throw await oauthEndpointError(response, 'claude usage');
401
- const payload = await response.json();
401
+ const payload = await parseProviderJson(response, 'claude usage');
402
402
  const resetCredits = resetCreditsError === undefined ? claudeResetCredits(payload.cedar_ember, Date.now()) : undefined;
403
403
  const resets = {
404
404
  ...resetCredits === undefined ? {} : { resetCredits },
@@ -462,7 +462,7 @@ export async function fetchClaudeModels(session, fetchFn = hostFetch, signal, cl
462
462
  });
463
463
  if (!response.ok)
464
464
  throw await httpLlmError(response, 'claude models API');
465
- const payload = await response.json();
465
+ const payload = await parseProviderJson(response, 'claude models API');
466
466
  if (!Array.isArray(payload.data)) {
467
467
  throw new Error('claude models API returned an invalid catalog');
468
468
  }
@@ -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)) {
@@ -752,7 +764,7 @@ export class ClaudeAdapter extends LlmAdapter {
752
764
  if (hostSupportsImageOffload()) {
753
765
  const offloadImages = requiredImageOffloadCount(messages, CLAUDE_REQUEST_IMAGE_BUDGET);
754
766
  if (offloadImages > 0) {
755
- // Older dsh-llm option types lack `offloadImages`; 0.2+ hosts read it.
767
+ // The supported 0.1.7-rc.2 floor reads `offloadImages`; older option types lack it.
756
768
  throw new LlmError(`claude request images exceed the ${CLAUDE_REQUEST_IMAGE_BUDGET}-byte base64 budget; `
757
769
  + `${offloadImages} more oldest image(s) must be offloaded`, IMAGE_OFFLOAD_REQUIRED, { offloadImages });
758
770
  }
@@ -86,8 +86,11 @@ export class CodexWebSearchProvider {
86
86
  try {
87
87
  return JSON.parse(text);
88
88
  }
89
- catch (cause) {
90
- throw new WebError('Codex Web Search returned invalid JSON', 'CODEX_SEARCH_RESPONSE', { cause });
89
+ catch {
90
+ // No cause: the host expands causes recursively, and Node's parse error
91
+ // quotes a bounded excerpt of the body, so attaching it would relocate
92
+ // the leak rather than remove it.
93
+ throw new WebError('Codex Web Search returned invalid JSON', 'CODEX_SEARCH_RESPONSE');
91
94
  }
92
95
  }
93
96
  throw new WebError('Codex Web Search exhausted its retry policy', 'CODEX_SEARCH_UPSTREAM');
@@ -8,7 +8,7 @@ import { attributionHeaders, EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmErr
8
8
  import { decodeJwtPayload } from '../auth/jwt.js';
9
9
  import { resolveImages } from '../translate/resolved.js';
10
10
  import { streamResponses, toResponsesInput, toResponsesTools } from '../translate/responses.js';
11
- import { deterministicSessionId, effortDisplayName, httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isDiscoveryAuthFailure, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
11
+ import { deterministicSessionId, effortDisplayName, httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isDiscoveryAuthFailure, isMissingOrInvalidCredential, parseProviderJson, oauthEndpointError, OAuthEndpointError, } from './common.js';
12
12
  import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
13
13
  import { hostFetch } from '../http.js';
14
14
  import { DEFAULT_RATE_LIMIT_WAIT, DEFAULT_RETRY, jsonBody, resetFromFields, subscriptionRetryPolicy, } from './rate-limit.js';
@@ -185,7 +185,7 @@ export async function exchangeCodexCode(code, verifier, redirectUri) {
185
185
  });
186
186
  if (!response.ok)
187
187
  throw await oauthEndpointError(response, 'codex');
188
- return codexSession(await response.json());
188
+ return codexSession(await parseProviderJson(response, 'codex token exchange'));
189
189
  }
190
190
  /**
191
191
  * Refresh a codex session (JSON grant — unlike the code exchange).
@@ -204,7 +204,7 @@ export async function refreshCodex(session) {
204
204
  });
205
205
  if (!response.ok)
206
206
  throw await oauthEndpointError(response, 'codex');
207
- return codexSession(await response.json(), session);
207
+ return codexSession(await parseProviderJson(response, 'codex token refresh'), session);
208
208
  }
209
209
  /**
210
210
  * Whether a codex refresh failure means the login is permanently gone.
@@ -239,7 +239,7 @@ export async function fetchCodexResetCredits(session, fetchFn = hostFetch, signa
239
239
  });
240
240
  if (!response.ok)
241
241
  throw await oauthEndpointError(response, 'codex reset credits');
242
- const payload = await response.json();
242
+ const payload = await parseProviderJson(response, 'codex reset credits');
243
243
  if (!Array.isArray(payload?.credits))
244
244
  throw new Error('codex reset credits: unexpected response (missing credits array)');
245
245
  return payload.credits.flatMap((value) => {
@@ -269,7 +269,7 @@ export async function consumeCodexResetCredit(session, creditId, requestId, fetc
269
269
  });
270
270
  if (!response.ok)
271
271
  throw await oauthEndpointError(response, 'codex consume reset');
272
- const payload = await response.json();
272
+ const payload = await parseProviderJson(response, 'codex consume reset');
273
273
  if (payload?.code !== 'reset')
274
274
  throw new Error('Reset was not confirmed. Verify the account in Codex before retrying.');
275
275
  }
@@ -347,7 +347,7 @@ export async function fetchCodexUsage(session, fetchFn = hostFetch, signal) {
347
347
  });
348
348
  if (!response.ok)
349
349
  throw await oauthEndpointError(response, 'codex usage');
350
- const payload = await response.json();
350
+ const payload = await parseProviderJson(response, 'codex usage');
351
351
  const windows = [];
352
352
  const primary = codexUsageWindow(payload.rate_limit?.primary_window, 'session');
353
353
  const secondary = codexUsageWindow(payload.rate_limit?.secondary_window, 'weekly');
@@ -410,7 +410,7 @@ export async function fetchCodexModels(session, fetchFn = hostFetch, signal, cli
410
410
  });
411
411
  if (!response.ok)
412
412
  throw await oauthEndpointError(response, 'codex models');
413
- const payload = await response.json();
413
+ const payload = await parseProviderJson(response, 'codex models');
414
414
  if (!Array.isArray(payload.models))
415
415
  throw new Error('codex models endpoint returned no models array');
416
416
  const discovered = [];
@@ -33,6 +33,26 @@ export interface ModelEntry {
33
33
  * @returns the validated entries.
34
34
  */
35
35
  export declare function validateModels(models: readonly ModelEntry[], label: string): ModelEntry[];
36
+ /**
37
+ * Parse a provider response or stored JSON string without exposing input excerpts.
38
+ * Success returns the same JSON value; only SyntaxError is replaced, preserving
39
+ * parse-failure type checks and leaving body-read/abort failures unchanged.
40
+ * Node's Unexpected token errors include a bounded input excerpt. Do not retain
41
+ * the original error as cause: the host displays causes recursively.
42
+ * @param input - response to consume once, or JSON text to parse.
43
+ * @param origin - local endpoint/operation label; never response.url, a discovered
44
+ * URL, a credential, or any other provider-controlled text.
45
+ * @returns the parsed value, without validation or credential filtering.
46
+ * @throws SyntaxError with the local origin and a fixed omission marker.
47
+ */
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>;
36
56
  /** Optional per-call hooks {@link httpLlmError} uses to read a rate-limit window. */
37
57
  export interface HttpLlmErrorOptions {
38
58
  /**
@@ -199,6 +219,13 @@ export interface UsageWindow {
199
219
  windowDurationMs?: number;
200
220
  /** Provider-verified fixed interval; permits deriving start from end and duration. */
201
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;
202
229
  }
203
230
  /** One banked reset credit (Codex), or one grant containing resets (Claude). */
204
231
  export interface ResetCredit {
@@ -61,6 +61,76 @@ export function validateModels(models, label) {
61
61
  function providerDiagnostic(summary, hasBody) {
62
62
  return hasBody ? `${summary}: [provider response body omitted]` : summary;
63
63
  }
64
+ /**
65
+ * Parse a provider response or stored JSON string without exposing input excerpts.
66
+ * Success returns the same JSON value; only SyntaxError is replaced, preserving
67
+ * parse-failure type checks and leaving body-read/abort failures unchanged.
68
+ * Node's Unexpected token errors include a bounded input excerpt. Do not retain
69
+ * the original error as cause: the host displays causes recursively.
70
+ * @param input - response to consume once, or JSON text to parse.
71
+ * @param origin - local endpoint/operation label; never response.url, a discovered
72
+ * URL, a credential, or any other provider-controlled text.
73
+ * @returns the parsed value, without validation or credential filtering.
74
+ * @throws SyntaxError with the local origin and a fixed omission marker.
75
+ */
76
+ export async function parseProviderJson(input, origin) {
77
+ try {
78
+ return (typeof input === 'string' ? JSON.parse(input) : await input.json());
79
+ }
80
+ catch (error) {
81
+ if (!(error instanceof SyntaxError))
82
+ throw error;
83
+ throw new SyntaxError(providerDiagnostic(`${origin}: invalid JSON`, true));
84
+ }
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
+ }
64
134
  /**
65
135
  * Build an LlmError from a non-2xx provider response, mapping the status to a
66
136
  * stable code and, for a rate-limited request, the disclosed reset instant to
@@ -17,7 +17,7 @@ import { EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, ReasoningEffortI
17
17
  import { resolveImages } from '../translate/resolved.js';
18
18
  import { streamChatCompletions, toChatMessages, toChatTools, } from '../translate/chat-completions.js';
19
19
  import { streamResponses, toResponsesInput, toResponsesTools } from '../translate/responses.js';
20
- import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
20
+ import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, parseProviderJson, oauthEndpointError, OAuthEndpointError, } from './common.js';
21
21
  import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
22
22
  import { hostFetch } from '../http.js';
23
23
  import { DEFAULT_RATE_LIMIT_WAIT, DEFAULT_RETRY, subscriptionRetryPolicy, } from './rate-limit.js';
@@ -144,7 +144,7 @@ export async function exchangeCopilotToken(githubToken, fetchFn = hostFetch) {
144
144
  });
145
145
  if (!response.ok)
146
146
  throw await oauthEndpointError(response, 'copilot');
147
- const wire = await response.json();
147
+ const wire = await parseProviderJson(response, 'copilot token exchange');
148
148
  if (typeof wire.token !== 'string' || wire.token.length === 0) {
149
149
  throw new Error('copilot token endpoint returned no token');
150
150
  }
@@ -268,7 +268,7 @@ export async function fetchCopilotModels(session, fetchFn = hostFetch, signal) {
268
268
  });
269
269
  if (!response.ok)
270
270
  throw await oauthEndpointError(response, 'copilot models');
271
- const payload = await response.json();
271
+ const payload = await parseProviderJson(response, 'copilot models');
272
272
  if (!Array.isArray(payload.data))
273
273
  throw new Error('copilot models endpoint returned no data array');
274
274
  const seen = new Set();
@@ -5,8 +5,43 @@ import { CursorAdapter } from 'dsh-subscriptions/cursor-transport';
5
5
  * blocks. DSH 0.1.7 carries them as first-class role=tool messages instead.
6
6
  */
7
7
  export declare function projectCursorMessages(messages: GenerateOptions['messages']): unknown[];
8
+ /**
9
+ * Cursor adapter for the DSH host.
10
+ *
11
+ * Failure text contract: `finish/error` messages that originate from the
12
+ * vendor stream and can quote provider-controlled text (the response content
13
+ * type, trailers, end-stream errors, a frame-reader or other error whose message
14
+ * is not one of the literals below, and a transport-coded failure that is not a
15
+ * socket-layer error) are replaced by `<code> [provider response text omitted]`. Failures that are
16
+ * wholly local keep their own message and code:
17
+ *
18
+ * - anything the auth service threw (not signed in, sign-in expired, token
19
+ * refresh status), and a host attachment read failure;
20
+ * - the vendor's own request validation, by code (`UNSUPPORTED_OPTION`,
21
+ * `UNSUPPORTED_CONTENT`, `TOOL_LIMIT`);
22
+ * - the vendor's fixed-literal plain errors, by exact message: the HTTP status
23
+ * (`Cursor agent returned HTTP 429`), the response, idle and progress
24
+ * timeouts, a closed bridge or stream, an unreadable compressed frame, and
25
+ * an invalid Cursor setting;
26
+ * - a network error raised by the operating system (`ECONNREFUSED`,
27
+ * `ENOTFOUND`, ...), recognised from the thrown error's own code, not from
28
+ * the vendor's classification.
29
+ *
30
+ * Not a provider-origin class: a network error such as `ECONNREFUSED` is a
31
+ * local failure (no network, daemon down, DNS), and is kept. What is still
32
+ * replaced although it is probably local is a transport failure with no
33
+ * socket-layer errno: a TLS or certificate failure and an HTTP/2 protocol
34
+ * error. The vendor's `TRANSPORT` code cannot decide those, because
35
+ * `classifyCursorError` derives it from message text (`network`, `connection`,
36
+ * `socket`, `fetch`, `ECONN`, `http2`) and a provider's end-stream message or
37
+ * `grpc-message` can contain any of them; so the code is never trusted alone.
38
+ * Telling these apart would need a check on the thrown error object, as the
39
+ * errno check above does, extended to the TLS and HTTP/2 error codes. That is
40
+ * not done here and is not verified.
41
+ */
8
42
  export declare class CursorCompatAdapter extends CursorAdapter {
9
43
  private readonly visibleModels;
44
+ private readonly localFailures;
10
45
  constructor(options: ConstructorParameters<typeof CursorAdapter>[0] & {
11
46
  visibleModels?: () => readonly string[] | undefined;
12
47
  });