dsh-subscriptions 0.1.3 → 0.1.5

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 (75) hide show
  1. package/README.md +64 -0
  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 +3 -1
  5. package/lib/auth/rpc.js +10 -1
  6. package/lib/client/ProviderAccountManager.js +34 -41
  7. package/lib/client/ProviderModelEditor.js +5 -9
  8. package/lib/client/SpeedSelect.js +8 -7
  9. package/lib/client/SubscriptionUsageBadge.js +6 -5
  10. package/lib/client/SubscriptionsSection.d.ts +21 -73
  11. package/lib/client/SubscriptionsSection.js +130 -104
  12. package/lib/client/UsageBadgeDisplaySetting.js +4 -8
  13. package/lib/client/host-contract.d.ts +1 -1
  14. package/lib/client/host-contract.js +22 -0
  15. package/lib/client/locales.d.ts +58 -0
  16. package/lib/client/locales.js +58 -0
  17. package/lib/client/provider-settings-styles.d.ts +3 -0
  18. package/lib/client/provider-settings-styles.js +206 -0
  19. package/lib/client/reset-action.d.ts +14 -0
  20. package/lib/client/reset-action.js +15 -0
  21. package/lib/client/reset-credits-view.d.ts +53 -0
  22. package/lib/client/reset-credits-view.js +103 -0
  23. package/lib/client.js +937 -310
  24. package/lib/client.js.map +1 -1
  25. package/lib/index.d.ts +9 -0
  26. package/lib/index.js +6058 -5298
  27. package/lib/model-defaults.d.ts +4 -5
  28. package/lib/model-defaults.js +18 -14
  29. package/lib/providers/account-preferences.js +3 -2
  30. package/lib/providers/antigravity.d.ts +0 -2
  31. package/lib/providers/antigravity.js +5 -9
  32. package/lib/providers/catalog-store.d.ts +3 -2
  33. package/lib/providers/catalog-store.js +57 -26
  34. package/lib/providers/claude.d.ts +22 -3
  35. package/lib/providers/claude.js +118 -20
  36. package/lib/providers/codex-search.js +5 -2
  37. package/lib/providers/codex.d.ts +15 -2
  38. package/lib/providers/codex.js +77 -9
  39. package/lib/providers/common.d.ts +38 -0
  40. package/lib/providers/common.js +44 -18
  41. package/lib/providers/copilot.d.ts +2 -1
  42. package/lib/providers/copilot.js +13 -6
  43. package/lib/providers/cursor-adapter.d.ts +35 -0
  44. package/lib/providers/cursor-adapter.js +245 -4
  45. package/lib/providers/cursor-auth.js +11 -4
  46. package/lib/providers/cursor-usage.js +2 -1
  47. package/lib/providers/external-usage.js +2 -1
  48. package/lib/providers/grok.d.ts +2 -2
  49. package/lib/providers/grok.js +16 -11
  50. package/lib/providers/minimax-usage.js +2 -1
  51. package/lib/providers/pool-health.d.ts +2 -0
  52. package/lib/providers/pool-health.js +9 -0
  53. package/lib/providers/pool-usage.d.ts +13 -1
  54. package/lib/providers/pool-usage.js +39 -7
  55. package/lib/providers/pool.d.ts +7 -5
  56. package/lib/providers/pool.js +89 -12
  57. package/lib/providers/rate-limit.d.ts +0 -14
  58. package/lib/providers/rate-limit.js +0 -26
  59. package/lib/providers/replay.d.ts +4 -0
  60. package/lib/providers/replay.js +50 -0
  61. package/lib/providers/reset-redemption.d.ts +51 -0
  62. package/lib/providers/reset-redemption.js +102 -0
  63. package/lib/tools/image-generate.js +2 -1
  64. package/lib/tools/video-generate.d.ts +0 -5
  65. package/lib/tools/video-generate.js +25 -14
  66. package/lib/tools/x-search.js +2 -2
  67. package/lib/translate/anthropic.d.ts +21 -2
  68. package/lib/translate/anthropic.js +35 -11
  69. package/lib/translate/antigravity.js +13 -6
  70. package/lib/translate/chat-completions.js +1 -1
  71. package/lib/translate/resolved.d.ts +24 -0
  72. package/lib/translate/resolved.js +66 -3
  73. package/lib/translate/responses.js +8 -6
  74. package/lib/translate/sse.js +8 -0
  75. package/package.json +1 -1
@@ -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');
@@ -26,8 +26,8 @@ export declare const CODEX_PREEMPT_MS: number;
26
26
  * rollover snapshots the backend attaches to every response, one per window,
27
27
  * so they say nothing about which window refused: a burst 429 that would clear
28
28
  * in seconds still carries a primary rollover hours out, and reading it would
29
- * park the turn for those hours. They reach the operator through
30
- * `rateLimitDiagnostics` instead.
29
+ * park the turn for those hours. The parsed body reset supplies the retry hint;
30
+ * diagnostics omit raw provider headers and body text.
31
31
  */
32
32
  export declare const codexRateLimitReset: RateLimitResetReader;
33
33
  /**
@@ -77,6 +77,11 @@ export declare function refreshCodex(session: CodexSession): Promise<CodexSessio
77
77
  */
78
78
  export declare function isCodexPermanentRefreshError(error: unknown): boolean;
79
79
  export declare const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
80
+ export declare const CODEX_RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
81
+ /** Read banked resets without spending them; consumption uses a separate explicit POST. */
82
+ export declare function fetchCodexResetCredits(session: CodexSession, fetchFn?: FetchFn, signal?: AbortSignal): Promise<NonNullable<ProviderUsage['resetCredits']>>;
83
+ /** Submit one explicit reset; ambiguous or unconfirmed responses throw without retry. */
84
+ export declare function consumeCodexResetCredit(session: CodexSession, creditId: string, requestId: string, fetchFn?: FetchFn, signal?: AbortSignal): Promise<void>;
80
85
  /**
81
86
  * Fetch the codex subscription usage from the ChatGPT backend wham/usage
82
87
  * endpoint (the source of the codex CLI `/status` rate-limit lines). The
@@ -164,6 +169,14 @@ export declare const CODEX_UNKNOWN_TOOL_OUTCOME = "The tool call has no recorded
164
169
  * @returns the same array when already balanced, otherwise a repaired array.
165
170
  */
166
171
  export declare function reconcileResponsesToolCalls(input: ResponsesRequestInput['input']): ResponsesRequestInput['input'];
172
+ /**
173
+ * The `reasoning.effort` wire value for a selected effort. Codex's "ultra" is
174
+ * a client-side selection (max reasoning plus proactive sub-agent delegation)
175
+ * that the Codex CLI itself lowers to `max` at the Responses API boundary;
176
+ * the backend answers HTTP 400 to a literal `ultra`. Discovery no longer
177
+ * lists it, but an effort saved before that change may still arrive here.
178
+ */
179
+ export declare function codexWireEffort(effort: string): string;
167
180
  /**
168
181
  * The Responses request body for one generation. A fast-tier request (the
169
182
  * composer Speed toggle, the codex CLI's fast mode) carries
@@ -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';
@@ -35,8 +35,8 @@ const CODEX_RESET_FIELDS = ['resets_in_seconds', 'reset_after_seconds', 'resets_
35
35
  * rollover snapshots the backend attaches to every response, one per window,
36
36
  * so they say nothing about which window refused: a burst 429 that would clear
37
37
  * in seconds still carries a primary rollover hours out, and reading it would
38
- * park the turn for those hours. They reach the operator through
39
- * `rateLimitDiagnostics` instead.
38
+ * park the turn for those hours. The parsed body reset supplies the retry hint;
39
+ * diagnostics omit raw provider headers and body text.
40
40
  */
41
41
  export const codexRateLimitReset = (_response, body, now) => resetFromFields(jsonBody(body), CODEX_RESET_FIELDS, now);
42
42
  /** Default instruction when the request carries no system prompt. */
@@ -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.
@@ -217,6 +217,62 @@ export function isCodexPermanentRefreshError(error) {
217
217
  && PERMANENT_REFRESH_CODES.has(error.oauthCode);
218
218
  }
219
219
  export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
220
+ export const CODEX_RESET_CREDITS_URL = 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits';
221
+ function parseResetDate(value) {
222
+ if (typeof value !== 'string')
223
+ return undefined;
224
+ const timestamp = Date.parse(value);
225
+ return Number.isFinite(timestamp) ? timestamp : undefined;
226
+ }
227
+ /** Read banked resets without spending them; consumption uses a separate explicit POST. */
228
+ export async function fetchCodexResetCredits(session, fetchFn = hostFetch, signal) {
229
+ const response = await fetchFn(CODEX_RESET_CREDITS_URL, {
230
+ headers: {
231
+ authorization: `Bearer ${session.accessToken}`,
232
+ 'chatgpt-account-id': session.accountId,
233
+ originator: 'Codex Desktop',
234
+ 'openai-beta': 'codex-1',
235
+ accept: 'application/json',
236
+ ...attributionHeaders(),
237
+ },
238
+ ...signal === undefined ? {} : { signal },
239
+ });
240
+ if (!response.ok)
241
+ throw await oauthEndpointError(response, 'codex reset credits');
242
+ const payload = await parseProviderJson(response, 'codex reset credits');
243
+ if (!Array.isArray(payload?.credits))
244
+ throw new Error('codex reset credits: unexpected response (missing credits array)');
245
+ return payload.credits.flatMap((value) => {
246
+ if (typeof value !== 'object' || value === null)
247
+ return [];
248
+ const credit = value;
249
+ if (credit.status !== 'available' || credit.reset_type !== 'codex_rate_limits')
250
+ return [];
251
+ const grantedAt = parseResetDate(credit.granted_at);
252
+ const expiresAt = parseResetDate(credit.expires_at);
253
+ return [{ ...(typeof credit.id === 'string' ? { id: credit.id } : {}), ...(grantedAt === undefined ? {} : { grantedAt }), ...(expiresAt === undefined ? {} : { expiresAt }) }];
254
+ });
255
+ }
256
+ /** Submit one explicit reset; ambiguous or unconfirmed responses throw without retry. */
257
+ export async function consumeCodexResetCredit(session, creditId, requestId, fetchFn = hostFetch, signal) {
258
+ const response = await fetchFn(CODEX_RESET_CREDITS_URL + '/consume', {
259
+ method: 'POST',
260
+ headers: {
261
+ authorization: 'Bearer ' + session.accessToken,
262
+ 'chatgpt-account-id': session.accountId,
263
+ originator: 'Codex Desktop', 'openai-beta': 'codex-1',
264
+ 'content-type': 'application/json', accept: 'application/json',
265
+ ...attributionHeaders(),
266
+ },
267
+ body: JSON.stringify({ redeem_request_id: requestId, credit_id: creditId }),
268
+ ...signal === undefined ? {} : { signal },
269
+ });
270
+ if (!response.ok)
271
+ throw await oauthEndpointError(response, 'codex consume reset');
272
+ const payload = await parseProviderJson(response, 'codex consume reset');
273
+ if (payload?.code !== 'reset')
274
+ throw new Error('Reset was not confirmed. Verify the account in Codex before retrying.');
275
+ }
220
276
  /** Seconds of the canonical 5-hour session and 7-day weekly windows. */
221
277
  const SESSION_WINDOW_SECONDS = 5 * 60 * 60;
222
278
  const WEEKLY_WINDOW_SECONDS = 7 * 24 * 60 * 60;
@@ -291,7 +347,7 @@ export async function fetchCodexUsage(session, fetchFn = hostFetch, signal) {
291
347
  });
292
348
  if (!response.ok)
293
349
  throw await oauthEndpointError(response, 'codex usage');
294
- const payload = await response.json();
350
+ const payload = await parseProviderJson(response, 'codex usage');
295
351
  const windows = [];
296
352
  const primary = codexUsageWindow(payload.rate_limit?.primary_window, 'session');
297
353
  const secondary = codexUsageWindow(payload.rate_limit?.secondary_window, 'weekly');
@@ -354,7 +410,7 @@ export async function fetchCodexModels(session, fetchFn = hostFetch, signal, cli
354
410
  });
355
411
  if (!response.ok)
356
412
  throw await oauthEndpointError(response, 'codex models');
357
- const payload = await response.json();
413
+ const payload = await parseProviderJson(response, 'codex models');
358
414
  if (!Array.isArray(payload.models))
359
415
  throw new Error('codex models endpoint returned no models array');
360
416
  const discovered = [];
@@ -365,8 +421,10 @@ export async function fetchCodexModels(session, fetchFn = hostFetch, signal, cli
365
421
  // dropped, and an absent or unknown value is included (in doubt, include).
366
422
  if (entry.visibility === 'hide' || entry.visibility === 'none')
367
423
  continue;
424
+ // "ultra" is a Codex-client mode (max reasoning + automatic sub-agent
425
+ // delegation), not a wire value: the Responses API rejects it with HTTP 400.
368
426
  const efforts = (entry.supported_reasoning_levels ?? [])
369
- .filter(level => typeof level.effort === 'string' && level.effort.length > 0)
427
+ .filter(level => typeof level.effort === 'string' && level.effort.length > 0 && level.effort !== 'ultra')
370
428
  .map(level => ({
371
429
  id: ReasoningEffortId(level.effort),
372
430
  name: effortDisplayName(level.effort),
@@ -491,6 +549,16 @@ export function reconcileResponsesToolCalls(input) {
491
549
  }
492
550
  return balanced;
493
551
  }
552
+ /**
553
+ * The `reasoning.effort` wire value for a selected effort. Codex's "ultra" is
554
+ * a client-side selection (max reasoning plus proactive sub-agent delegation)
555
+ * that the Codex CLI itself lowers to `max` at the Responses API boundary;
556
+ * the backend answers HTTP 400 to a literal `ultra`. Discovery no longer
557
+ * lists it, but an effort saved before that change may still arrive here.
558
+ */
559
+ export function codexWireEffort(effort) {
560
+ return effort === 'ultra' ? 'max' : effort;
561
+ }
494
562
  /**
495
563
  * The Responses request body for one generation. A fast-tier request (the
496
564
  * composer Speed toggle, the codex CLI's fast mode) carries
@@ -509,7 +577,7 @@ export function codexRequestBody(options, resolved, fast) {
509
577
  ? { tools: toResponsesTools(options.tools, { strict: false }), tool_choice: 'auto', parallel_tool_calls: true }
510
578
  : {},
511
579
  ...options.reasoningEffort !== undefined
512
- ? { reasoning: { effort: String(options.reasoningEffort), summary: 'auto' } }
580
+ ? { reasoning: { effort: codexWireEffort(String(options.reasoningEffort)), summary: 'auto' } }
513
581
  : {},
514
582
  store: false,
515
583
  stream: true,
@@ -33,6 +33,19 @@ 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>;
36
49
  /** Optional per-call hooks {@link httpLlmError} uses to read a rate-limit window. */
37
50
  export interface HttpLlmErrorOptions {
38
51
  /**
@@ -200,6 +213,27 @@ export interface UsageWindow {
200
213
  /** Provider-verified fixed interval; permits deriving start from end and duration. */
201
214
  fixedWindow?: boolean;
202
215
  }
216
+ /** One banked reset credit (Codex), or one grant containing resets (Claude). */
217
+ export interface ResetCredit {
218
+ id?: string;
219
+ grantedAt?: number;
220
+ /** Expiration in epoch ms; never a recurring usage-window reset. */
221
+ expiresAt?: number;
222
+ /** Claude grant counts; one array entry per grant, not per remaining reset. */
223
+ resetsTotal?: number;
224
+ resetsLeft?: number;
225
+ /** Claude's disclosed grant state, absent when the server omits it. */
226
+ usableNow?: boolean;
227
+ paused?: boolean;
228
+ /**
229
+ * Claude server-selected grant is currently usable. Read-only metadata, not
230
+ * permission to redeem; no Claude redemption endpoint is implemented.
231
+ * Codex entries omit this field and retain their existing semantics.
232
+ */
233
+ claimable?: boolean;
234
+ /** Claude program cooldown in epoch ms, repeated on each grant when disclosed. */
235
+ cooldownUntil?: number;
236
+ }
203
237
  /** Subscription usage of one provider, as served by the `usage` RPC endpoint. */
204
238
  export interface ProviderUsage {
205
239
  /** Original successful observation time, preserved across cache reads. */
@@ -212,6 +246,10 @@ export interface ProviderUsage {
212
246
  windows?: UsageWindow[];
213
247
  /** Plan name the usage endpoint reported, when present. */
214
248
  plan?: string;
249
+ /** Banked Codex resets or Claude grants, when the provider discloses them. */
250
+ resetCredits?: ResetCredit[];
251
+ /** Optional reset lookup failure; ordinary usage remains usable. */
252
+ resetCreditsError?: string;
215
253
  }
216
254
  /** One model discovered from a provider's live model-list endpoint. */
217
255
  export interface DiscoveredModel {
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { createHash, randomUUID } from 'node:crypto';
9
9
  import { CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
10
- import { durationMs, rateLimitDiagnostics, retryAfterInstant, waitFromReset } from './rate-limit.js';
10
+ import { durationMs, retryAfterInstant, waitFromReset } from './rate-limit.js';
11
11
  /**
12
12
  * Validate a configured model catalog (mirrors llm-deepseek's resolveModels).
13
13
  * @param models - raw configured entries.
@@ -50,6 +50,39 @@ export function validateModels(models, label) {
50
50
  };
51
51
  });
52
52
  }
53
+ /**
54
+ * Provider failures can echo credentials or customer input in any format.
55
+ * Shape-based filtering cannot make arbitrary response text safe to display.
56
+ * Here, safe means this diagnostic carries no provider-controlled free text,
57
+ * not that secrets cannot exist elsewhere in the system. Keep raw responses
58
+ * internal for classification and reset parsing; display only local summaries.
59
+ * This deliberately loses model, parameter and plan details useful to support.
60
+ */
61
+ function providerDiagnostic(summary, hasBody) {
62
+ return hasBody ? `${summary}: [provider response body omitted]` : summary;
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
+ }
53
86
  /**
54
87
  * Build an LlmError from a non-2xx provider response, mapping the status to a
55
88
  * stable code and, for a rate-limited request, the disclosed reset instant to
@@ -73,11 +106,8 @@ export async function httpLlmError(response, label, options = {}) {
73
106
  catch {
74
107
  // Only swallow error-body reading: the HTTP status still identifies the failure.
75
108
  }
76
- // Truncated for display only; the readers below need the whole body to parse it.
109
+ // Preserve the existing classification window; reset readers need the full body.
77
110
  const shown = body.slice(0, 500);
78
- const message = shown.length > 0
79
- ? `${label} error (HTTP ${String(response.status)}): ${shown}`
80
- : `${label} error (HTTP ${String(response.status)})`;
81
111
  let code;
82
112
  if (response.status === 401 || response.status === 403)
83
113
  code = 'AUTH';
@@ -93,6 +123,7 @@ export async function httpLlmError(response, label, options = {}) {
93
123
  code = 'SERVER';
94
124
  else
95
125
  code = `HTTP_${String(response.status)}`;
126
+ const message = providerDiagnostic(`${label} error (HTTP ${String(response.status)}, ${code})`, body.length > 0);
96
127
  const now = Date.now();
97
128
  // The provider's reader runs on a 429 and nowhere else. Providers attach
98
129
  // their rate-limit headers to every response, so reading them on a transient
@@ -111,7 +142,8 @@ export async function httpLlmError(response, label, options = {}) {
111
142
  ? options.rateLimitReset?.(response, body, now) ?? googleQuotaReset(body, now) ?? retryAfterInstant(response, now)
112
143
  : retryAfterInstant(response, now);
113
144
  if (reset === undefined && rateLimited) {
114
- options.onWarn?.(`${label}: ${rateLimitDiagnostics(response, body)}`);
145
+ // Header names and values are provider-controlled too; none are displayed.
146
+ options.onWarn?.(providerDiagnostic(`${label}: 429 disclosed no reset time; [provider response headers omitted]`, body.length > 0));
115
147
  }
116
148
  return new LlmError(message, code, {
117
149
  status: response.status,
@@ -246,9 +278,10 @@ export class OAuthEndpointError extends Error {
246
278
  */
247
279
  export async function oauthEndpointError(response, label) {
248
280
  let oauthCode;
249
- let detail = '';
281
+ let body = '';
250
282
  try {
251
- const parsed = await response.json();
283
+ body = await response.text();
284
+ const parsed = JSON.parse(body);
252
285
  if (typeof parsed.error === 'string') {
253
286
  oauthCode = parsed.error;
254
287
  }
@@ -256,20 +289,13 @@ export async function oauthEndpointError(response, label) {
256
289
  const nested = parsed.error;
257
290
  if (typeof nested.code === 'string' && nested.code.length > 0)
258
291
  oauthCode = nested.code;
259
- if (typeof nested.message === 'string')
260
- detail = nested.message;
261
292
  }
262
- if (typeof parsed.error_description === 'string')
263
- detail = parsed.error_description;
264
- if (detail.length === 0)
265
- detail = oauthCode ?? '';
266
293
  }
267
294
  catch {
268
- // Only swallow error-body parsing: the HTTP status still identifies the failure.
295
+ // Only swallow error-body reading/parsing: HTTP status still identifies the failure.
269
296
  }
270
- const message = detail.length > 0
271
- ? `${label} token endpoint error (HTTP ${String(response.status)}): ${detail}`
272
- : `${label} token endpoint error (HTTP ${String(response.status)})`;
297
+ // oauthCode remains structural for auth classification, never display text.
298
+ const message = providerDiagnostic(`${label} token endpoint error (HTTP ${String(response.status)})`, body.length > 0);
273
299
  return new OAuthEndpointError(message, response.status, oauthCode, parseRetryAfterMs(response));
274
300
  }
275
301
  /**
@@ -297,7 +297,8 @@ export declare class CopilotAdapter extends LlmAdapter {
297
297
  * account identity is the session's long-lived GitHub token (stable across
298
298
  * Copilot-token refreshes, different per GitHub login); the conversation is
299
299
  * the loop-stamped `sessionId`, falling back to the first message's id
300
- * when a hand-built request carries no session stamp; the model separates
300
+ * when a hand-built request carries no session stamp. Identity-free
301
+ * requests have no replay scope; the model separates
301
302
  * wire families. A call id captured in one scope is invisible to every
302
303
  * other scope, so reused ids cannot leak reasoning across accounts,
303
304
  * conversations, or models.
@@ -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();
@@ -677,7 +677,8 @@ export class CopilotAdapter extends LlmAdapter {
677
677
  * account identity is the session's long-lived GitHub token (stable across
678
678
  * Copilot-token refreshes, different per GitHub login); the conversation is
679
679
  * the loop-stamped `sessionId`, falling back to the first message's id
680
- * when a hand-built request carries no session stamp; the model separates
680
+ * when a hand-built request carries no session stamp. Identity-free
681
+ * requests have no replay scope; the model separates
681
682
  * wire families. A call id captured in one scope is invisible to every
682
683
  * other scope, so reused ids cannot leak reasoning across accounts,
683
684
  * conversations, or models.
@@ -685,9 +686,11 @@ export class CopilotAdapter extends LlmAdapter {
685
686
  replayScope(tokenKey, options) {
686
687
  const conversation = options.sessionId !== undefined
687
688
  ? `session:${String(options.sessionId)}`
688
- : options.messages[0] !== undefined
689
+ : options.messages[0]?.id !== undefined
689
690
  ? `anchor:${String(options.messages[0].id)}`
690
- : 'conversation:none';
691
+ : undefined;
692
+ if (conversation === undefined)
693
+ return undefined;
691
694
  return `${tokenKey}\u0000${conversation}\u0000${options.model}`;
692
695
  }
693
696
  /**
@@ -701,6 +704,8 @@ export class CopilotAdapter extends LlmAdapter {
701
704
  * per call.
702
705
  */
703
706
  captureReasoning(scope, callIds, items) {
707
+ if (scope === undefined)
708
+ return;
704
709
  let entries = this.replayByScope.get(scope);
705
710
  if (entries === undefined) {
706
711
  entries = new Map();
@@ -745,6 +750,8 @@ export class CopilotAdapter extends LlmAdapter {
745
750
  * degradation, never an error.
746
751
  */
747
752
  replayFor(scope, callId) {
753
+ if (scope === undefined)
754
+ return undefined;
748
755
  const entries = this.replayByScope.get(scope);
749
756
  const entry = entries?.get(callId);
750
757
  if (entries === undefined || entry === undefined)
@@ -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
  });