dsh-subscriptions 0.1.3 → 0.1.4

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 (58) hide show
  1. package/README.md +64 -0
  2. package/lib/auth/rpc.d.ts +3 -1
  3. package/lib/auth/rpc.js +10 -1
  4. package/lib/client/ProviderAccountManager.js +34 -41
  5. package/lib/client/ProviderModelEditor.js +5 -9
  6. package/lib/client/SpeedSelect.js +8 -7
  7. package/lib/client/SubscriptionUsageBadge.js +6 -5
  8. package/lib/client/SubscriptionsSection.d.ts +22 -0
  9. package/lib/client/SubscriptionsSection.js +128 -38
  10. package/lib/client/UsageBadgeDisplaySetting.js +4 -8
  11. package/lib/client/host-contract.d.ts +1 -1
  12. package/lib/client/host-contract.js +22 -0
  13. package/lib/client/locales.d.ts +58 -0
  14. package/lib/client/locales.js +58 -0
  15. package/lib/client/provider-settings-styles.d.ts +3 -0
  16. package/lib/client/provider-settings-styles.js +206 -0
  17. package/lib/client/reset-action.d.ts +14 -0
  18. package/lib/client/reset-action.js +15 -0
  19. package/lib/client/reset-credits-view.d.ts +53 -0
  20. package/lib/client/reset-credits-view.js +103 -0
  21. package/lib/client.js +937 -310
  22. package/lib/client.js.map +1 -1
  23. package/lib/index.d.ts +9 -0
  24. package/lib/index.js +1099 -608
  25. package/lib/providers/account-preferences.js +3 -2
  26. package/lib/providers/catalog-store.d.ts +3 -2
  27. package/lib/providers/catalog-store.js +57 -26
  28. package/lib/providers/claude.d.ts +22 -3
  29. package/lib/providers/claude.js +113 -15
  30. package/lib/providers/codex.d.ts +15 -2
  31. package/lib/providers/codex.js +72 -4
  32. package/lib/providers/common.d.ts +25 -0
  33. package/lib/providers/common.js +22 -18
  34. package/lib/providers/copilot.d.ts +2 -1
  35. package/lib/providers/copilot.js +10 -3
  36. package/lib/providers/grok.d.ts +2 -2
  37. package/lib/providers/grok.js +2 -2
  38. package/lib/providers/pool-health.d.ts +2 -0
  39. package/lib/providers/pool-health.js +9 -0
  40. package/lib/providers/pool-usage.d.ts +13 -1
  41. package/lib/providers/pool-usage.js +39 -7
  42. package/lib/providers/pool.d.ts +7 -5
  43. package/lib/providers/pool.js +89 -12
  44. package/lib/providers/rate-limit.d.ts +0 -14
  45. package/lib/providers/rate-limit.js +0 -26
  46. package/lib/providers/replay.d.ts +4 -0
  47. package/lib/providers/replay.js +50 -0
  48. package/lib/providers/reset-redemption.d.ts +51 -0
  49. package/lib/providers/reset-redemption.js +102 -0
  50. package/lib/translate/anthropic.d.ts +21 -2
  51. package/lib/translate/anthropic.js +35 -11
  52. package/lib/translate/antigravity.js +13 -6
  53. package/lib/translate/chat-completions.js +1 -1
  54. package/lib/translate/resolved.d.ts +21 -0
  55. package/lib/translate/resolved.js +63 -3
  56. package/lib/translate/responses.js +8 -6
  57. package/lib/translate/sse.js +8 -0
  58. package/package.json +1 -1
@@ -0,0 +1,51 @@
1
+ import type { ProviderUsage } from './common.js';
2
+ export interface ResetConfirmation {
3
+ ticket: string;
4
+ expiresAt: number;
5
+ creditExpiresAt?: number;
6
+ weeklyUsedPercent: number;
7
+ }
8
+ /**
9
+ * Manual-only redemption with single-use tickets. An ambiguous submission parks
10
+ * the account to prevent an immediate blind retry. This block is process-local,
11
+ * not a durable rate limit: restarting the host clears it. Re-check credits and
12
+ * limits in Codex before retrying; a lost success response may have spent a
13
+ * credit already, and another submission can spend another credit.
14
+ * Rationale: [manual reset note](../../.agents/notes/implemented/provider/2026-10-04-codex-manual-reset.md).
15
+ */
16
+ export declare class ResetRedemption {
17
+ private readonly read;
18
+ private readonly consume;
19
+ private readonly invalidate;
20
+ /**
21
+ * Stable identity for one real account: the same key the usage cache uses,
22
+ * so the guards and the cache invalidate together. The RPC carries whatever
23
+ * reference the browser holds — a canonical id, a legacy key, an email, a
24
+ * workspace id — and all of them resolve to one stored account.
25
+ *
26
+ * It must not fall back to the raw reference on failure. A fallback would
27
+ * re-key the state by whatever happened to arrive, so a transient lookup
28
+ * error could let a later alias past an uncertain park. Resolution here
29
+ * reads the local credential store only, so failing is safe and honest.
30
+ */
31
+ private readonly canonical;
32
+ private tickets;
33
+ private busy;
34
+ private uncertain;
35
+ constructor(read: (account: string, signal: AbortSignal) => Promise<ProviderUsage>, consume: (account: string, creditId: string, requestId: string, signal: AbortSignal) => Promise<void>, invalidate: (account: string) => void,
36
+ /**
37
+ * Stable identity for one real account: the same key the usage cache uses,
38
+ * so the guards and the cache invalidate together. The RPC carries whatever
39
+ * reference the browser holds — a canonical id, a legacy key, an email, a
40
+ * workspace id — and all of them resolve to one stored account.
41
+ *
42
+ * It must not fall back to the raw reference on failure. A fallback would
43
+ * re-key the state by whatever happened to arrive, so a transient lookup
44
+ * error could let a later alias past an uncertain park. Resolution here
45
+ * reads the local credential store only, so failing is safe and honest.
46
+ */
47
+ canonical: (account: string, signal: AbortSignal) => Promise<string>);
48
+ private eligible;
49
+ prepare(account: string, signal: AbortSignal): Promise<ResetConfirmation>;
50
+ redeem(account: string, ticket: string, signal: AbortSignal): Promise<void>;
51
+ }
@@ -0,0 +1,102 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /**
3
+ * Manual-only redemption with single-use tickets. An ambiguous submission parks
4
+ * the account to prevent an immediate blind retry. This block is process-local,
5
+ * not a durable rate limit: restarting the host clears it. Re-check credits and
6
+ * limits in Codex before retrying; a lost success response may have spent a
7
+ * credit already, and another submission can spend another credit.
8
+ * Rationale: [manual reset note](../../.agents/notes/implemented/provider/2026-10-04-codex-manual-reset.md).
9
+ */
10
+ export class ResetRedemption {
11
+ read;
12
+ consume;
13
+ invalidate;
14
+ canonical;
15
+ tickets = new Map();
16
+ busy = new Set();
17
+ uncertain = new Set();
18
+ constructor(read, consume, invalidate,
19
+ /**
20
+ * Stable identity for one real account: the same key the usage cache uses,
21
+ * so the guards and the cache invalidate together. The RPC carries whatever
22
+ * reference the browser holds — a canonical id, a legacy key, an email, a
23
+ * workspace id — and all of them resolve to one stored account.
24
+ *
25
+ * It must not fall back to the raw reference on failure. A fallback would
26
+ * re-key the state by whatever happened to arrive, so a transient lookup
27
+ * error could let a later alias past an uncertain park. Resolution here
28
+ * reads the local credential store only, so failing is safe and honest.
29
+ */
30
+ canonical) {
31
+ this.read = read;
32
+ this.consume = consume;
33
+ this.invalidate = invalidate;
34
+ this.canonical = canonical;
35
+ }
36
+ eligible(usage) {
37
+ const weekly = usage.windows?.find(w => w.kind === 'weekly' && w.scope === undefined);
38
+ if (!weekly || !Number.isFinite(weekly.usedPercent) || weekly.usedPercent < 80
39
+ || weekly.resetsAt === undefined || weekly.resetsAt <= Date.now()) {
40
+ throw new Error('Reset blocked: fresh weekly usage must be at least 80%.');
41
+ }
42
+ if (usage.resetCreditsError)
43
+ throw new Error('Reset credits could not be verified.');
44
+ return weekly.usedPercent;
45
+ }
46
+ async prepare(account, signal) {
47
+ const who = await this.canonical(account, signal);
48
+ if (this.busy.has(who) || this.uncertain.has(who))
49
+ throw new Error('Reset blocked: an operation is pending or its outcome is uncertain. Check Codex before retrying.');
50
+ const usage = await this.read(account, signal);
51
+ // Re-check after the read. A confirmation prepared while another client was
52
+ // redeeming would otherwise be minted against a park that has since been
53
+ // cleared, letting the next click spend a second credit.
54
+ if (this.busy.has(who) || this.uncertain.has(who))
55
+ throw new Error('Reset blocked: an operation is pending or its outcome is uncertain. Check Codex before retrying.');
56
+ const weeklyUsedPercent = this.eligible(usage);
57
+ const credit = usage.resetCredits?.filter(c => c.id && (c.expiresAt === undefined || c.expiresAt > Date.now()))
58
+ .sort((a, b) => (a.expiresAt ?? Infinity) - (b.expiresAt ?? Infinity))[0];
59
+ if (!credit?.id)
60
+ throw new Error('No available reset credit.');
61
+ for (const [key, value] of this.tickets)
62
+ if (value.account === who || value.expiresAt <= Date.now())
63
+ this.tickets.delete(key);
64
+ const ticket = randomUUID();
65
+ const expiresAt = Date.now() + 60_000;
66
+ this.tickets.set(ticket, { account: who, creditId: credit.id, expiresAt });
67
+ return { ticket, expiresAt, weeklyUsedPercent, ...(credit.expiresAt === undefined ? {} : { creditExpiresAt: credit.expiresAt }) };
68
+ }
69
+ async redeem(account, ticket, signal) {
70
+ const who = await this.canonical(account, signal);
71
+ const pending = this.tickets.get(ticket);
72
+ this.tickets.delete(ticket);
73
+ if (!pending || pending.account !== who || pending.expiresAt <= Date.now())
74
+ throw new Error('Confirmation expired. Start again.');
75
+ if (this.busy.has(who) || this.uncertain.has(who))
76
+ throw new Error('Reset blocked: operation pending or uncertain.');
77
+ this.busy.add(who);
78
+ try {
79
+ const usage = await this.read(account, signal);
80
+ // The ticket bounds how long the confirmation counts. A read that
81
+ // outlives it must not go on to submit, so this comes first.
82
+ if (pending.expiresAt <= Date.now())
83
+ throw new Error('Confirmation expired. Start again.');
84
+ this.eligible(usage);
85
+ if (!usage.resetCredits?.some(c => c.id === pending.creditId && (c.expiresAt === undefined || c.expiresAt > Date.now())))
86
+ throw new Error('The selected reset is no longer available.');
87
+ // Mark before submission: no blind retry after transport or response ambiguity.
88
+ this.uncertain.add(who);
89
+ try {
90
+ await this.consume(account, pending.creditId, randomUUID(), signal);
91
+ }
92
+ catch {
93
+ throw new Error('Reset outcome could not be confirmed. Do not retry or restart to bypass this block; verify credits and limits in Codex first.');
94
+ }
95
+ this.uncertain.delete(who);
96
+ }
97
+ finally {
98
+ this.invalidate(who);
99
+ this.busy.delete(who);
100
+ }
101
+ }
102
+ }
@@ -32,6 +32,23 @@ export declare const CACHE_BLOCK_STRIDE = 15;
32
32
  * blocks before a read is lost.
33
33
  */
34
34
  export declare const MESSAGE_CACHE_BREAKPOINTS = 3;
35
+ /**
36
+ * How long Anthropic keeps a cache entry after its last use: five minutes
37
+ * (the API default) or one hour. A one-hour entry is written at twice the base
38
+ * input price instead of 1.25×, and reads cost the same either way.
39
+ */
40
+ export type PromptCacheTtl = '5m' | '1h';
41
+ /** The API default; a marker at this TTL carries no `ttl` field at all. */
42
+ export declare const DEFAULT_PROMPT_CACHE_TTL: PromptCacheTtl;
43
+ /**
44
+ * Build one `cache_control` marker.
45
+ *
46
+ * Five minutes is spelled by omitting `ttl`, so a request that does not opt in
47
+ * stays byte-identical to what earlier releases sent.
48
+ * @param ttl - cache lifetime; defaults to five minutes.
49
+ * @returns a fresh marker object (callers attach it to one block each).
50
+ */
51
+ export declare function cacheControl(ttl?: PromptCacheTtl): Record<string, unknown>;
35
52
  /** One Anthropic request message. */
36
53
  export interface AnthropicMessage {
37
54
  role: 'user' | 'assistant';
@@ -62,17 +79,19 @@ export declare function toAnthropicMessages(messages: readonly TranslatableMessa
62
79
  * Marks are counted across the flattened block sequence, not per message,
63
80
  * because the lookback window Anthropic walks counts blocks the same way.
64
81
  * @param messages - assembled Anthropic messages, marked in place.
82
+ * @param ttl - cache lifetime for every mark; defaults to five minutes.
65
83
  */
66
- export declare function markMessageCache(messages: readonly AnthropicMessage[]): void;
84
+ export declare function markMessageCache(messages: readonly AnthropicMessage[], ttl?: PromptCacheTtl): void;
67
85
  /**
68
86
  * Build the Anthropic `system` array: the mandatory Claude Code identity
69
87
  * block, then the explicit system prompt, then any system-role messages.
70
88
  * @param system - explicit system prompt, when set.
71
89
  * @param messages - conversation messages; the system-role text preceding the
72
90
  * conversation is appended, and a later one is left to {@link toAnthropicMessages}.
91
+ * @param ttl - cache lifetime for the tools+system mark; defaults to five minutes.
73
92
  * @returns the system content blocks.
74
93
  */
75
- export declare function toAnthropicSystem(system?: string, messages?: readonly TranslatableMessage[]): Record<string, unknown>[];
94
+ export declare function toAnthropicSystem(system?: string, messages?: readonly TranslatableMessage[], ttl?: PromptCacheTtl): Record<string, unknown>[];
76
95
  /**
77
96
  * Map harness tool schemas to Anthropic tools, in name order.
78
97
  *
@@ -32,6 +32,19 @@ export const CACHE_BLOCK_STRIDE = 15;
32
32
  * blocks before a read is lost.
33
33
  */
34
34
  export const MESSAGE_CACHE_BREAKPOINTS = 3;
35
+ /** The API default; a marker at this TTL carries no `ttl` field at all. */
36
+ export const DEFAULT_PROMPT_CACHE_TTL = '5m';
37
+ /**
38
+ * Build one `cache_control` marker.
39
+ *
40
+ * Five minutes is spelled by omitting `ttl`, so a request that does not opt in
41
+ * stays byte-identical to what earlier releases sent.
42
+ * @param ttl - cache lifetime; defaults to five minutes.
43
+ * @returns a fresh marker object (callers attach it to one block each).
44
+ */
45
+ export function cacheControl(ttl = DEFAULT_PROMPT_CACHE_TTL) {
46
+ return ttl === '1h' ? { type: 'ephemeral', ttl: '1h' } : { type: 'ephemeral' };
47
+ }
35
48
  /** Preserve native image blocks, retaining the existing text-only wire shape. */
36
49
  function toolResultContent(block) {
37
50
  if (!block.content.some(part => part.type === 'image' && 'dataBase64' in part)) {
@@ -217,14 +230,15 @@ export function toAnthropicMessages(messages) {
217
230
  * Marks are counted across the flattened block sequence, not per message,
218
231
  * because the lookback window Anthropic walks counts blocks the same way.
219
232
  * @param messages - assembled Anthropic messages, marked in place.
233
+ * @param ttl - cache lifetime for every mark; defaults to five minutes.
220
234
  */
221
- export function markMessageCache(messages) {
235
+ export function markMessageCache(messages, ttl) {
222
236
  const blocks = messages.flatMap(message => message.content);
223
237
  for (let mark = 0; mark < MESSAGE_CACHE_BREAKPOINTS; mark++) {
224
238
  const at = blocks.length - 1 - mark * CACHE_BLOCK_STRIDE;
225
239
  if (at < 0)
226
240
  return;
227
- blocks[at].cache_control = { type: 'ephemeral' };
241
+ blocks[at].cache_control = cacheControl(ttl);
228
242
  }
229
243
  }
230
244
  /**
@@ -233,9 +247,10 @@ export function markMessageCache(messages) {
233
247
  * @param system - explicit system prompt, when set.
234
248
  * @param messages - conversation messages; the system-role text preceding the
235
249
  * conversation is appended, and a later one is left to {@link toAnthropicMessages}.
250
+ * @param ttl - cache lifetime for the tools+system mark; defaults to five minutes.
236
251
  * @returns the system content blocks.
237
252
  */
238
- export function toAnthropicSystem(system, messages) {
253
+ export function toAnthropicSystem(system, messages, ttl) {
239
254
  const blocks = [{ type: 'text', text: CLAUDE_CODE_IDENTITY }];
240
255
  if (system !== undefined && system.length > 0)
241
256
  blocks.push({ type: 'text', text: system });
@@ -249,7 +264,11 @@ export function toAnthropicSystem(system, messages) {
249
264
  // `tools` renders ahead of `system`, so this one marker caches both. It is
250
265
  // deliberately separate from the message marks: a tool_choice or thinking
251
266
  // change invalidates the messages tier only, and this entry survives it.
252
- blocks[blocks.length - 1].cache_control = { type: 'ephemeral' };
267
+ //
268
+ // Anthropic processes `tools`, `system`, then `messages`, and rejects a
269
+ // one-hour mark that follows a five-minute one (HTTP 400). This mark and the
270
+ // message marks therefore share one TTL, which can never hit that ordering.
271
+ blocks[blocks.length - 1].cache_control = cacheControl(ttl);
253
272
  return blocks;
254
273
  }
255
274
  /**
@@ -294,16 +313,21 @@ function closeBlock(block) {
294
313
  * @returns the mapped error.
295
314
  */
296
315
  export function anthropicFailure(error) {
316
+ // Safe here means this diagnostic path carries no provider-controlled free text,
317
+ // not that no secret can exist anywhere. Raw values remain local classification inputs.
297
318
  const type = error?.type ?? 'unknown_error';
298
319
  const message = error?.message ?? `Anthropic reported ${type}`;
320
+ let classification = 'SERVER';
299
321
  if (type === 'invalid_request_error' && /prompt is too long/i.test(message)) {
300
- return new LlmError(message, CONTEXT_WINDOW_EXCEEDED_CODE);
322
+ classification = CONTEXT_WINDOW_EXCEEDED_CODE;
323
+ }
324
+ else if (type === 'rate_limit_error') {
325
+ classification = 'RATE_LIMIT';
326
+ }
327
+ else if (type === 'authentication_error') {
328
+ classification = 'AUTH';
301
329
  }
302
- if (type === 'rate_limit_error')
303
- return new LlmError(message, 'RATE_LIMIT');
304
- if (type === 'authentication_error')
305
- return new LlmError(message, 'AUTH');
306
- return new LlmError(message, 'SERVER');
330
+ return new LlmError(`Anthropic failure (${classification}): [provider response body omitted]`, classification);
307
331
  }
308
332
  /**
309
333
  * Push-model Anthropic SSE translator: feed each parsed event object to
@@ -505,7 +529,7 @@ export async function* streamAnthropic(stream, onActivity) {
505
529
  event = JSON.parse(sseEvent.data);
506
530
  }
507
531
  catch {
508
- throw new LlmError(`malformed SSE payload: ${sseEvent.data.slice(0, 120)}`, 'MALFORMED_RESPONSE');
532
+ throw new LlmError('malformed SSE payload (MALFORMED_RESPONSE): [provider response body omitted]', 'MALFORMED_RESPONSE');
509
533
  }
510
534
  yield* translator.push(event);
511
535
  if (translator.terminated)
@@ -15,9 +15,11 @@ import { antigravityToolParameters } from './antigravity-schema.js';
15
15
  * `FunctionResponse.response` field requires. That field maps to a singular
16
16
  * protobuf `Struct`, so a bare array, string, number or boolean is rejected
17
17
  * by the endpoint with "Proto field is not repeating, cannot start list".
18
- * Only a non-array JSON object survives verbatim; every other JSON value is
18
+ * Non-array JSON objects use their own fields; every other JSON value is
19
19
  * wrapped in the same "output" envelope the non-JSON path already uses --
20
20
  * the shape other Cloud Code Assist clients send for every tool result.
21
+ * Failed results carry top-level `isError: true`, overriding a payload field;
22
+ * successful results retain their existing serialized shape.
21
23
  */
22
24
  function toolResultValue(block) {
23
25
  const text = block.content.map(part => part.type === 'text' ? part.text : '').join('');
@@ -28,9 +30,11 @@ function toolResultValue(block) {
28
30
  catch {
29
31
  return { output: text, ...block.isError === true ? { isError: true } : {} };
30
32
  }
31
- if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed))
32
- return parsed;
33
- return { output: parsed };
33
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
34
+ const value = parsed;
35
+ return block.isError === true ? { ...value, isError: true } : value;
36
+ }
37
+ return { output: parsed, ...block.isError === true ? { isError: true } : {} };
34
38
  }
35
39
  /** Safely read per-block replay metadata emitted by this adapter. */
36
40
  function replayBlocks(message, model) {
@@ -88,7 +92,10 @@ export function toAntigravityContents(messages, model) {
88
92
  const part = { functionResponse: {
89
93
  id,
90
94
  name: callNames.get(id) ?? '',
91
- response: toolResultValue({ type: 'tool-result', toolCallId: ToolCallId(id), content: message.content }),
95
+ response: toolResultValue({
96
+ type: 'tool-result', toolCallId: ToolCallId(id), content: message.content,
97
+ ...message.isError === undefined ? {} : { isError: message.isError },
98
+ }),
92
99
  } };
93
100
  const previous = out.at(-1);
94
101
  if (previous?.role === 'user')
@@ -350,7 +357,7 @@ export async function* streamAntigravity(stream, onActivity) {
350
357
  parsed = JSON.parse(event.data);
351
358
  }
352
359
  catch {
353
- throw new LlmError(`malformed Antigravity SSE payload: ${event.data.slice(0, 120)}`, 'MALFORMED_RESPONSE');
360
+ throw new LlmError('malformed Antigravity SSE payload (MALFORMED_RESPONSE): [provider response body omitted]', 'MALFORMED_RESPONSE');
354
361
  }
355
362
  yield* translator.push(parsed);
356
363
  if (translator.terminated)
@@ -369,7 +369,7 @@ export async function* streamChatCompletions(stream, onActivity) {
369
369
  event = JSON.parse(sseEvent.data);
370
370
  }
371
371
  catch {
372
- throw new LlmError(`malformed SSE payload: ${sseEvent.data.slice(0, 120)}`, 'MALFORMED_RESPONSE');
372
+ throw new LlmError('malformed SSE payload (MALFORMED_RESPONSE): [provider response body omitted]', 'MALFORMED_RESPONSE');
373
373
  }
374
374
  yield* translator.push(event);
375
375
  if (translator.terminated)
@@ -50,6 +50,27 @@ export interface ImageRequestLimit {
50
50
  /** Encoded-byte target before base64 expansion. */
51
51
  maxBytes: number;
52
52
  }
53
+ /**
54
+ * Failure code a host's image-offload executor answers by replacing the oldest
55
+ * retained images with text and retrying (`IMAGE_OFFLOAD_REQUIRED_CODE` in
56
+ * dsh-llm 0.2+). Spelled out so the build links against older hosts too.
57
+ */
58
+ export declare const IMAGE_OFFLOAD_REQUIRED = "IMAGE_OFFLOAD_REQUIRED";
59
+ /**
60
+ * Whether the installed host records image offloads. Hosts that predate the
61
+ * executor never mark images `offloaded`, so asking them to would fail every
62
+ * later request instead of shrinking it.
63
+ */
64
+ export declare function hostSupportsImageOffload(): boolean;
65
+ /**
66
+ * How many of the oldest images still sent inline must be offloaded before
67
+ * the resolved request fits `maxBase64Bytes` of image data; zero when it fits.
68
+ * Images are counted depth-first in request order, skipping assistant
69
+ * messages, the same order the host's executor selects occurrences in.
70
+ * @param messages - the resolved request, after {@link resolveImages}.
71
+ * @param maxBase64Bytes - the route's budget for all inline image data.
72
+ */
73
+ export declare function requiredImageOffloadCount(messages: readonly TranslatableMessage[], maxBase64Bytes: number): number;
53
74
  /**
54
75
  * The request projection for one oversized image, or undefined when it fits.
55
76
  * Hosts before DSH 0.1.7 read a pixel budget (`maxPixels`), later ones the
@@ -6,6 +6,7 @@
6
6
  * base64 data.
7
7
  */
8
8
  import { LlmError } from '@deepseek-ai/dsh-llm';
9
+ import * as llm from '@deepseek-ai/dsh-llm';
9
10
  /**
10
11
  * Wires with text-only tool outputs receive images in a following user turn.
11
12
  * Defer that turn until all consecutive user messages have been processed:
@@ -42,6 +43,52 @@ export function withToolResultImages(messages) {
42
43
  flush();
43
44
  return out;
44
45
  }
46
+ /**
47
+ * Failure code a host's image-offload executor answers by replacing the oldest
48
+ * retained images with text and retrying (`IMAGE_OFFLOAD_REQUIRED_CODE` in
49
+ * dsh-llm 0.2+). Spelled out so the build links against older hosts too.
50
+ */
51
+ export const IMAGE_OFFLOAD_REQUIRED = 'IMAGE_OFFLOAD_REQUIRED';
52
+ /**
53
+ * Whether the installed host records image offloads. Hosts that predate the
54
+ * executor never mark images `offloaded`, so asking them to would fail every
55
+ * later request instead of shrinking it.
56
+ */
57
+ export function hostSupportsImageOffload() {
58
+ const exports = llm;
59
+ return exports['IMAGE_OFFLOAD_REQUIRED_CODE'] === IMAGE_OFFLOAD_REQUIRED
60
+ && typeof exports['offloadedImageText'] === 'function';
61
+ }
62
+ /**
63
+ * How many of the oldest images still sent inline must be offloaded before
64
+ * the resolved request fits `maxBase64Bytes` of image data; zero when it fits.
65
+ * Images are counted depth-first in request order, skipping assistant
66
+ * messages, the same order the host's executor selects occurrences in.
67
+ * @param messages - the resolved request, after {@link resolveImages}.
68
+ * @param maxBase64Bytes - the route's budget for all inline image data.
69
+ */
70
+ export function requiredImageOffloadCount(messages, maxBase64Bytes) {
71
+ const lengths = [];
72
+ const visit = (blocks) => {
73
+ for (const block of blocks) {
74
+ if (block.type === 'image' && 'dataBase64' in block)
75
+ lengths.push(block.dataBase64.length);
76
+ else if (block.type === 'tool-result')
77
+ visit(block.content);
78
+ }
79
+ };
80
+ for (const message of messages) {
81
+ if (message.role !== 'assistant')
82
+ visit(message.content);
83
+ }
84
+ let excess = lengths.reduce((sum, bytes) => sum + bytes, 0) - maxBase64Bytes;
85
+ let count = 0;
86
+ while (excess > 0 && count < lengths.length) {
87
+ excess -= lengths[count];
88
+ count += 1;
89
+ }
90
+ return count;
91
+ }
45
92
  /**
46
93
  * The request projection for one oversized image, or undefined when it fits.
47
94
  * Hosts before DSH 0.1.7 read a pixel budget (`maxPixels`), later ones the
@@ -70,8 +117,11 @@ export function imageRequestTarget(ref, limit) {
70
117
  * @returns the same messages with image blocks resolved for the translators.
71
118
  */
72
119
  export async function resolveImages(messages, attachments, signal, limit) {
73
- const hasImage = messages.some(message => message.content.some(block => block.type === 'image'));
74
- if (hasImage && attachments === undefined) {
120
+ const hasRetainedImage = (block) => block.type === 'image'
121
+ ? !('offloaded' in block && block.offloaded === true)
122
+ : block.type === 'tool-result' && block.content.some(hasRetainedImage);
123
+ if (attachments === undefined
124
+ && messages.some(message => message.content.some(hasRetainedImage))) {
75
125
  throw new LlmError('dsh-subscriptions: the request carries an image but no attachments service is mounted; '
76
126
  + 'image input requires the harness attachment store', 'UNSUPPORTED');
77
127
  }
@@ -94,8 +144,18 @@ export async function resolveImages(messages, attachments, signal, limit) {
94
144
  return { data: stored.data, mediaType: stored.ref.mediaType, ref: stored.ref };
95
145
  };
96
146
  const resolveBlock = async (block) => {
97
- if (block.type !== 'image')
147
+ if (block.type === 'tool-result') {
148
+ return [{ ...block, content: (await Promise.all(block.content.map(resolveBlock))).flat() }];
149
+ }
150
+ if (block.type !== 'image' || 'dataBase64' in block)
98
151
  return [block];
152
+ if ('offloaded' in block && block.offloaded === true) {
153
+ // The host owns omission decisions. Never restore offloaded bytes, even
154
+ // when the original attachment is unreadable or no store is mounted.
155
+ const format = llm.offloadedImageText;
156
+ return [{ type: 'text', text: format?.(block.attachment)
157
+ ?? `[image omitted to fit request image limits; ${block.attachment.attachmentId}]` }];
158
+ }
99
159
  const { data, mediaType: sentType, ref } = await readForRequest(block.attachment);
100
160
  const { attachmentId, mediaType, bytes, width, height, name } = ref;
101
161
  return [{
@@ -168,15 +168,17 @@ export function mapResponsesUsage(usage) {
168
168
  * @returns the mapped error (context overflow, quota, otherwise SERVER).
169
169
  */
170
170
  export function responsesFailure(code, message) {
171
- const text = message ?? code ?? 'the provider reported a failed response';
171
+ // Safe here means this diagnostic path carries no provider-controlled free text,
172
+ // not that no secret can exist anywhere. Raw values remain local classification inputs.
172
173
  const detail = `${code ?? ''} ${message ?? ''}`;
174
+ let classification = 'SERVER';
173
175
  if (code === 'context_window_exceeded' || isContextWindowExceededError(detail)) {
174
- return new LlmError(text, CONTEXT_WINDOW_EXCEEDED_CODE);
176
+ classification = CONTEXT_WINDOW_EXCEEDED_CODE;
175
177
  }
176
- if ((code !== undefined && /insufficient|quota/i.test(code)) || isQuotaExceededError(detail)) {
177
- return new LlmError(text, QUOTA_EXCEEDED_CODE);
178
+ else if ((code !== undefined && /insufficient|quota/i.test(code)) || isQuotaExceededError(detail)) {
179
+ classification = QUOTA_EXCEEDED_CODE;
178
180
  }
179
- return new LlmError(text, 'SERVER');
181
+ return new LlmError(`Responses failure (${classification}): [provider response body omitted]`, classification);
180
182
  }
181
183
  /** Assemble the final ContentBlock for one open block. */
182
184
  function closeBlock(block) {
@@ -396,7 +398,7 @@ export async function* streamResponses(stream, onActivity, transform) {
396
398
  event = JSON.parse(sseEvent.data);
397
399
  }
398
400
  catch {
399
- throw new LlmError(`malformed SSE payload: ${sseEvent.data.slice(0, 120)}`, 'MALFORMED_RESPONSE');
401
+ throw new LlmError('malformed SSE payload (MALFORMED_RESPONSE): [provider response body omitted]', 'MALFORMED_RESPONSE');
400
402
  }
401
403
  if (transform !== undefined)
402
404
  event = transform(event);
@@ -51,6 +51,14 @@ export async function* parseSse(stream, onActivity) {
51
51
  }
52
52
  }
53
53
  finally {
54
+ // A terminal SSE frame can precede HTTP EOF; returning or decoding failure
55
+ // must close the remaining body rather than leave the connection reading.
56
+ try {
57
+ await reader.cancel();
58
+ }
59
+ catch {
60
+ // Cleanup failure must not mask the original stream outcome.
61
+ }
54
62
  reader.releaseLock();
55
63
  }
56
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-subscriptions",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Unified subscription providers and usage dashboard for DeepSeek Harness",
5
5
  "icon": "./icon.svg",
6
6
  "license": "MIT",