@deepseek-ai/dsh-llm 0.1.1-rc.2 → 0.1.2-alpha.2

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.
@@ -0,0 +1,102 @@
1
+ /* Generated by @deepseek-ai/dsh-typert-generator from the Host FaceModel — do not edit. */
2
+ import { z } from 'zod'
3
+
4
+ const _deepseek_ai_dsh_llm_llm_discoverModels_parameter_0$schema = z.string()
5
+ const _deepseek_ai_dsh_llm_llm_discoverModels_parameter_1$schema = z.object({
6
+ 'provider': z.string().optional(),
7
+ 'baseURL': z.string().optional(),
8
+ 'api': z.string().optional(),
9
+ 'apiKey': z.string().optional(),
10
+ })
11
+ const _deepseek_ai_dsh_llm_llm_discoverModels_result$schema = z.array(z.object({
12
+ 'id': z.string(),
13
+ 'name': z.string().optional(),
14
+ 'contextWindow': z.number().optional(),
15
+ 'maxTokens': z.number().optional(),
16
+ }))
17
+ const _deepseek_ai_dsh_llm_llm_listConfigurableProviders_result$schema = z.array(z.object({
18
+ 'provider': z.string(),
19
+ 'displayName': z.string(),
20
+ 'settingsNs': z.string(),
21
+ 'settingsPath': z.array(z.string()),
22
+ 'declared': z.boolean().optional(),
23
+ }))
24
+ const _deepseek_ai_dsh_llm_llm_listProviders_result$schema = z.array(z.object({
25
+ 'id': z.string(),
26
+ 'name': z.string(),
27
+ }))
28
+
29
+ export const TYPERT_REMOTE = {
30
+ package: '@deepseek-ai/dsh-llm',
31
+ descriptors: [
32
+ {
33
+ id: '@deepseek-ai/dsh-llm#llm/discoverModels',
34
+ service: 'llm',
35
+ namespace: 'llm',
36
+ method: 'discoverModels',
37
+ implementation: 'remoteDiscoverModels',
38
+ invocation: { kind: 'direct' },
39
+ parameters: [
40
+ {
41
+ name: 'settingsNs',
42
+ wire: 'settingsNs',
43
+ source: 'json',
44
+ codec: {
45
+ mode: 'strict',
46
+ typeSymbol: '@deepseek-ai/dsh-llm#llm/discoverModels:settingsNs',
47
+ schema: _deepseek_ai_dsh_llm_llm_discoverModels_parameter_0$schema,
48
+ },
49
+ },
50
+ {
51
+ name: 'request',
52
+ wire: 'request',
53
+ source: 'json',
54
+ codec: {
55
+ mode: 'strict',
56
+ typeSymbol: '@deepseek-ai/dsh-llm/types#LlmModelDiscoveryRequest',
57
+ schema: _deepseek_ai_dsh_llm_llm_discoverModels_parameter_1$schema,
58
+ },
59
+ },
60
+ ],
61
+ cancellation: { parameter: 'signal' },
62
+ result: {
63
+ mode: 'strict',
64
+ typeSymbol: '@deepseek-ai/dsh-llm#llm/discoverModels:result',
65
+ schema: _deepseek_ai_dsh_llm_llm_discoverModels_result$schema,
66
+ },
67
+ sourceLocation: {"file":"packages/llm/llm/src/index.ts","line":621,"column":9},
68
+ },
69
+ {
70
+ id: '@deepseek-ai/dsh-llm#llm/listConfigurableProviders',
71
+ service: 'llm',
72
+ namespace: 'llm',
73
+ method: 'listConfigurableProviders',
74
+ invocation: { kind: 'direct' },
75
+ parameters: [
76
+ ],
77
+ result: {
78
+ mode: 'strict',
79
+ typeSymbol: '@deepseek-ai/dsh-llm#llm/listConfigurableProviders:result',
80
+ schema: _deepseek_ai_dsh_llm_llm_listConfigurableProviders_result$schema,
81
+ },
82
+ sourceLocation: {"file":"packages/llm/llm/src/index.ts","line":534,"column":3},
83
+ },
84
+ {
85
+ id: '@deepseek-ai/dsh-llm#llm/listProviders',
86
+ service: 'llm',
87
+ namespace: 'llm',
88
+ method: 'listProviders',
89
+ invocation: { kind: 'direct' },
90
+ parameters: [
91
+ ],
92
+ result: {
93
+ mode: 'strict',
94
+ typeSymbol: '@deepseek-ai/dsh-llm#llm/listProviders:result',
95
+ schema: _deepseek_ai_dsh_llm_llm_listProviders_result$schema,
96
+ },
97
+ sourceLocation: {"file":"packages/llm/llm/src/index.ts","line":462,"column":3},
98
+ },
99
+ ],
100
+ }
101
+
102
+ export default TYPERT_REMOTE
@@ -5,8 +5,8 @@
5
5
  *
6
6
  * @module @deepseek-ai/dsh-llm/assembler
7
7
  */
8
- import { CallId } from "./brand.js";
9
- import { assertNever } from "./never.js";
8
+ import { brandString } from '@deepseek-ai/dsh-brand';
9
+ import { assertNever } from '@deepseek-ai/dsh-util-values';
10
10
  import { createMessage } from "./message.js";
11
11
  /**
12
12
  * Incrementally assembles raw {@link StreamChunk}s into complete
@@ -99,7 +99,7 @@ export class BlockAssembler {
99
99
  case 'reasoning': return { type: 'reasoning', text: partial.text };
100
100
  case 'tool-call': return {
101
101
  type: 'tool-call',
102
- id: partial.toolCallId ?? CallId(`call-${index}`),
102
+ id: partial.toolCallId ?? brandString(`call-${index}`),
103
103
  name: partial.toolCallName ?? '',
104
104
  arguments: partial.toolCallArguments,
105
105
  };
@@ -2,33 +2,33 @@
2
2
  * dsh-llm's owned branded ids: tool-call correlation and provider request
3
3
  * diagnostics.
4
4
  *
5
- * The `Branded<B>` primitive itself lives in `@deepseek-ai/dsh-brand` (a
6
- * zero-dependency type-only package) so every owner of a cross-boundary id can
7
- * brand it without depending on dsh-llm; see that package's README for the
5
+ * The `Branded<B>` primitive and stateless constructor live in
6
+ * `@deepseek-ai/dsh-brand` so every owner of a cross-boundary id can brand it
7
+ * without depending on dsh-llm; see that package's README for the
8
8
  * nominal-typing policy.
9
9
  *
10
10
  * @module @deepseek-ai/dsh-llm/brand
11
11
  */
12
- import type { Branded } from '@deepseek-ai/dsh-brand';
12
+ import { type Branded } from '@deepseek-ai/dsh-brand';
13
13
  /** Stable identity carried by one message across inbox, log, and model-request boundaries. */
14
14
  export type MessageId = Branded<'MessageId'>;
15
15
  /**
16
16
  * Brand a message identifier.
17
17
  * @param id - the opaque message identifier.
18
- * @returns the same string, branded; no validation is performed.
18
+ * @returns the same string with the message-id brand.
19
19
  */
20
20
  export declare function MessageId(id: string): MessageId;
21
21
  /**
22
22
  * Correlates a model-issued tool call with its result. Provider-issued for
23
23
  * real adapters; synthesized by mocks/assembler fallbacks.
24
24
  */
25
- export type CallId = Branded<'CallId'>;
25
+ export type ToolCallId = Branded<'ToolCallId'>;
26
26
  /**
27
- * Brand a string as a {@link CallId}.
28
- * @param id - the provider-issued (or synthesized) call id.
29
- * @returns the same string, branded; no validation is performed.
27
+ * Brand a string as a {@link ToolCallId}.
28
+ * @param id - the provider-issued or synthesized call id.
29
+ * @returns the same string with the tool-call-id brand.
30
30
  */
31
- export declare function CallId(id: string): CallId;
31
+ export declare function ToolCallId(id: string): ToolCallId;
32
32
  /** Provider-issued request identifier retained for diagnostics across package boundaries. */
33
33
  export type ProviderRequestId = Branded<'ProviderRequestId'>;
34
34
  /**
@@ -2,28 +2,29 @@
2
2
  * dsh-llm's owned branded ids: tool-call correlation and provider request
3
3
  * diagnostics.
4
4
  *
5
- * The `Branded<B>` primitive itself lives in `@deepseek-ai/dsh-brand` (a
6
- * zero-dependency type-only package) so every owner of a cross-boundary id can
7
- * brand it without depending on dsh-llm; see that package's README for the
5
+ * The `Branded<B>` primitive and stateless constructor live in
6
+ * `@deepseek-ai/dsh-brand` so every owner of a cross-boundary id can brand it
7
+ * without depending on dsh-llm; see that package's README for the
8
8
  * nominal-typing policy.
9
9
  *
10
10
  * @module @deepseek-ai/dsh-llm/brand
11
11
  */
12
+ import { brandString } from '@deepseek-ai/dsh-brand';
12
13
  /**
13
14
  * Brand a message identifier.
14
15
  * @param id - the opaque message identifier.
15
- * @returns the same string, branded; no validation is performed.
16
+ * @returns the same string with the message-id brand.
16
17
  */
17
18
  export function MessageId(id) {
18
- return id;
19
+ return brandString(id);
19
20
  }
20
21
  /**
21
- * Brand a string as a {@link CallId}.
22
- * @param id - the provider-issued (or synthesized) call id.
23
- * @returns the same string, branded; no validation is performed.
22
+ * Brand a string as a {@link ToolCallId}.
23
+ * @param id - the provider-issued or synthesized call id.
24
+ * @returns the same string with the tool-call-id brand.
24
25
  */
25
- export function CallId(id) {
26
- return id;
26
+ export function ToolCallId(id) {
27
+ return brandString(id);
27
28
  }
28
29
  /**
29
30
  * Brand a provider-issued request identifier.
@@ -31,7 +32,7 @@ export function CallId(id) {
31
32
  * @returns the same string, branded; no validation is performed.
32
33
  */
33
34
  export function ProviderRequestId(id) {
34
- return id;
35
+ return brandString(id);
35
36
  }
36
37
  /**
37
38
  * Brand an adapter-owned reasoning-effort identifier.
@@ -39,6 +40,6 @@ export function ProviderRequestId(id) {
39
40
  * @returns the same string, branded; no validation is performed.
40
41
  */
41
42
  export function ReasoningEffortId(id) {
42
- return id;
43
+ return brandString(id);
43
44
  }
44
45
  //# sourceMappingURL=brand.js.map
@@ -50,13 +50,4 @@ export declare function markAgentLoopRequest<T extends GenerateOptions>(request:
50
50
  * @returns whether {@link markAgentLoopRequest} recorded this object.
51
51
  */
52
52
  export declare function isAgentLoopRequest(request: GenerateOptions): boolean;
53
- /**
54
- * Deep-freeze a value in place with an iterative traversal, guarding cycles,
55
- * so later mutation throws without imposing a JavaScript call-stack depth cap.
56
- * {@link AbortSignal} objects are deliberately skipped because they are the
57
- * request's live cancellation channel and freezing them breaks abort.
58
- * @param value - the value to freeze in place.
59
- * @returns the same value, frozen.
60
- */
61
- export declare function deepFreeze<T>(value: T): T;
62
53
  //# sourceMappingURL=call-config.d.ts.map
@@ -43,44 +43,4 @@ export function markAgentLoopRequest(request) {
43
43
  export function isAgentLoopRequest(request) {
44
44
  return AGENT_LOOP_REQUESTS.has(request);
45
45
  }
46
- /**
47
- * Deep-freeze a value in place with an iterative traversal, guarding cycles,
48
- * so later mutation throws without imposing a JavaScript call-stack depth cap.
49
- * {@link AbortSignal} objects are deliberately skipped because they are the
50
- * request's live cancellation channel and freezing them breaks abort.
51
- * @param value - the value to freeze in place.
52
- * @returns the same value, frozen.
53
- */
54
- export function deepFreeze(value) {
55
- const seen = new WeakSet();
56
- const pending = [{ kind: 'visit', node: value }];
57
- while (pending.length > 0) {
58
- const task = pending.pop();
59
- /* v8 ignore next -- the loop condition guarantees one pending task. */
60
- if (task === undefined)
61
- continue;
62
- if (task.kind === 'property') {
63
- pending.push({ kind: 'visit', node: task.source[task.key] });
64
- continue;
65
- }
66
- const node = task.node;
67
- if (node === null || typeof node !== 'object')
68
- continue;
69
- if (node instanceof AbortSignal)
70
- continue;
71
- if (seen.has(node))
72
- continue;
73
- seen.add(node);
74
- Object.freeze(node);
75
- const keys = Object.keys(node);
76
- for (let index = keys.length - 1; index >= 0; index--) {
77
- const key = keys[index];
78
- /* v8 ignore next -- the loop is bounded by the captured key count. */
79
- if (key === undefined)
80
- continue;
81
- pending.push({ kind: 'property', source: node, key });
82
- }
83
- }
84
- return value;
85
- }
86
46
  //# sourceMappingURL=call-config.js.map
@@ -1,21 +1,53 @@
1
1
  /** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */
2
2
  import type { ContentBlock } from './types.ts';
3
3
  import type { Message } from './message.ts';
4
- import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment';
5
- /** Model-facing stand-in for an image removed to fit a provider request bound. */
6
- export declare const OFFLOADED_IMAGE_TEXT = "[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]";
4
+ import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment';
5
+ /** Execution-world path that model tools can use to read one normalized attachment. */
6
+ export interface ImageAttachmentAccess {
7
+ /** Absolute path to immutable normalized bytes; callers must treat it as read-only. */
8
+ readonlyPath: string;
9
+ }
10
+ /**
11
+ * Resolve current execution-world access for one durable image reference.
12
+ * @param ref - durable normalized attachment reference.
13
+ * @returns a read-only execution-world path, or undefined when unavailable.
14
+ */
15
+ export type ImageAttachmentAccessResolver = (ref: ImageAttachmentRef) => ImageAttachmentAccess | undefined;
16
+ /**
17
+ * Bridge one attachment provider's host object location into the mounted
18
+ * tool execution world. The consumer supplies the current filesystem
19
+ * provider's mapping without making attachment or LLM definitions depend on it.
20
+ * @param attachments - provider that owns the normalized attachment object.
21
+ * @param mapHostPath - map one absolute host path into the current tool execution world.
22
+ * @param ref - durable normalized attachment reference.
23
+ * @returns a read-only execution-world path, or undefined when either provider exposes no mapping.
24
+ * @throws an attachment error when the durable reference is invalid.
25
+ */
26
+ export declare function resolveImageAttachmentAccess(attachments: AttachmentStore, mapHostPath: (hostPath: string) => string | undefined, ref: ImageAttachmentRef): ImageAttachmentAccess | undefined;
7
27
  /**
8
28
  * Stable text shown to a model that cannot accept one durable image reference.
9
- * @param ref - durable master reference omitted from the request.
29
+ * @param ref - durable normalized attachment omitted from the request.
10
30
  * @returns deterministic text-only placeholder.
11
31
  */
12
32
  export declare function textOnlyImageText(ref: ImageAttachmentRef): string;
13
33
  /**
14
- * Stable model-facing handle for one exact request image.
15
- * @param version - exact request image shown beside the text.
34
+ * Stable model-facing handle for one exact request image. Identity comes from
35
+ * the occurrence's own durable reference: request versions are prepared per
36
+ * attachment id, so one shared version may serve occurrences whose display
37
+ * names differ.
38
+ * @param ref - the occurrence's durable normalized attachment.
39
+ * @param version - exact request-image dimensions shown beside the text.
40
+ * @param access - optional path resolved for the current tool execution world.
16
41
  * @returns attachment handle and request-image dimensions.
17
42
  */
18
- export declare function requestImageHandleText(version: RequestImageAttachment): string;
43
+ export declare function requestImageHandleText(ref: ImageAttachmentRef, version: Pick<RequestImageAttachment, 'width' | 'height'>, access?: ImageAttachmentAccess): string;
44
+ /**
45
+ * Stable per-image placeholder for a request-limit omission.
46
+ * @param ref - durable normalized attachment omitted from this request.
47
+ * @param access - optional provider-resolved path for model tools.
48
+ * @returns identity, normalized metadata, and the available recovery path.
49
+ */
50
+ export declare function offloadedImageText(ref: ImageAttachmentRef, access?: ImageAttachmentAccess): string;
19
51
  /**
20
52
  * True when typed model content contains an image block, walking nested
21
53
  * tool-result content. This is the one recursive image walk shared by every
@@ -37,8 +69,10 @@ export interface RequestImageOffloadPolicy {
37
69
  byteQuantum?: number;
38
70
  /** Whether byte accounting uses raw file bytes or inline base64 length. */
39
71
  representation: 'raw' | 'base64';
40
- /** Resolve the encoded request-version length; omission uses master attachment bytes. */
72
+ /** Resolve the encoded request-version length; omission uses normalized attachment bytes. */
41
73
  byteLength?: (ref: ImageAttachmentRef) => number;
74
+ /** Build the model-visible replacement for each omitted attachment. */
75
+ placeholder: (ref: ImageAttachmentRef) => string;
42
76
  }
43
77
  /**
44
78
  * Project durable image history into deterministic text for an exact text-only model.
@@ -47,15 +81,15 @@ export interface RequestImageOffloadPolicy {
47
81
  */
48
82
  export declare function projectImagesForTextModel(messages: readonly Message[]): readonly Message[];
49
83
  /**
50
- * Return transient request messages whose oldest images are replaced until
51
- * their accumulated base64 payload fits the configured bound. The selection
52
- * is deterministic from durable message order and attachment metadata; a
53
- * provider can serialize the returned messages without reading omitted bytes.
54
- * @param messages - complete request history, oldest first.
55
- * @param maxRequestImageBytes - positive bound on total base64 image payload; undefined preserves every image.
56
- * @returns the original messages when they already fit, otherwise shallow message copies with replaced content trees.
84
+ * Number of oldest image occurrences one request projection removes, in whole
85
+ * count and byte quanta, once a route budget is exceeded. The result depends
86
+ * only on the represented lengths, so provider request pricing reproduces the
87
+ * exact serialization decision without building the projected messages.
88
+ * @param lengths - represented byte length of every occurrence, in request order.
89
+ * @param policy - count/byte budgets and removal quanta; unbounded when absent.
90
+ * @returns how many leading occurrences the projection replaces with placeholders.
57
91
  */
58
- export declare function offloadRequestImages(messages: readonly Message[], maxRequestImageBytes: number | undefined): readonly Message[];
92
+ export declare function offloadedImagePrefixCount(lengths: readonly number[], policy: Pick<RequestImageOffloadPolicy, 'maxImages' | 'maxBytes' | 'countQuantum' | 'byteQuantum'>): number;
59
93
  /**
60
94
  * Return a deterministic transient projection whose oldest images are replaced
61
95
  * in whole count and byte quanta after a route budget is exceeded. The target
@@ -1,9 +1,47 @@
1
1
  /** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */
2
- /** Model-facing stand-in for an image removed to fit a provider request bound. */
3
- export const OFFLOADED_IMAGE_TEXT = '[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]';
2
+ import { assertNever } from '@deepseek-ai/dsh-util-values';
3
+ /**
4
+ * Bridge one attachment provider's host object location into the mounted
5
+ * tool execution world. The consumer supplies the current filesystem
6
+ * provider's mapping without making attachment or LLM definitions depend on it.
7
+ * @param attachments - provider that owns the normalized attachment object.
8
+ * @param mapHostPath - map one absolute host path into the current tool execution world.
9
+ * @param ref - durable normalized attachment reference.
10
+ * @returns a read-only execution-world path, or undefined when either provider exposes no mapping.
11
+ * @throws an attachment error when the durable reference is invalid.
12
+ */
13
+ export function resolveImageAttachmentAccess(attachments, mapHostPath, ref) {
14
+ const hostPath = attachments.imageHostPath(ref);
15
+ if (hostPath === undefined)
16
+ return undefined;
17
+ const readonlyPath = mapHostPath(hostPath);
18
+ return readonlyPath === undefined ? undefined : { readonlyPath };
19
+ }
20
+ function quoted(value) {
21
+ return JSON.stringify(value);
22
+ }
23
+ function imageIdentity(ref) {
24
+ return ref.name === undefined
25
+ ? String(ref.attachmentId)
26
+ : `${quoted(ref.name)} (${ref.attachmentId})`;
27
+ }
28
+ function extension(mediaType) {
29
+ switch (mediaType) {
30
+ case 'image/png': return '.png';
31
+ case 'image/jpeg': return '.jpg';
32
+ case 'image/webp': return '.webp';
33
+ case 'image/gif': return '.gif';
34
+ default: return assertNever(mediaType, 'image extension');
35
+ }
36
+ }
37
+ function normalizedAccessText(ref, access) {
38
+ return ` Normalized copy (read-only; may be resized or re-encoded): ${quoted(access.readonlyPath)} (${ref.width}x${ref.height}px, ${ref.mediaType}).`
39
+ + ' Source dimensions, format, and byte size may differ.'
40
+ + ` Copy to a writable path ending in ${extension(ref.mediaType)} before editing.`;
41
+ }
4
42
  /**
5
43
  * Stable text shown to a model that cannot accept one durable image reference.
6
- * @param ref - durable master reference omitted from the request.
44
+ * @param ref - durable normalized attachment omitted from the request.
7
45
  * @returns deterministic text-only placeholder.
8
46
  */
9
47
  export function textOnlyImageText(ref) {
@@ -11,12 +49,33 @@ export function textOnlyImageText(ref) {
11
49
  return `[image omitted because this model accepts text only; attachment sha256:${digest}]`;
12
50
  }
13
51
  /**
14
- * Stable model-facing handle for one exact request image.
15
- * @param version - exact request image shown beside the text.
52
+ * Stable model-facing handle for one exact request image. Identity comes from
53
+ * the occurrence's own durable reference: request versions are prepared per
54
+ * attachment id, so one shared version may serve occurrences whose display
55
+ * names differ.
56
+ * @param ref - the occurrence's durable normalized attachment.
57
+ * @param version - exact request-image dimensions shown beside the text.
58
+ * @param access - optional path resolved for the current tool execution world.
16
59
  * @returns attachment handle and request-image dimensions.
17
60
  */
18
- export function requestImageHandleText(version) {
19
- return `Image ${version.attachment.attachmentId}; request image ${version.width}x${version.height}px.`;
61
+ export function requestImageHandleText(ref, version, access) {
62
+ const preview = `Image ${imageIdentity(ref)}; request preview ${version.width}x${version.height}px.`;
63
+ return access === undefined
64
+ ? `${preview} It may be resized or re-encoded; source dimensions, format, and byte size may differ.`
65
+ : preview + normalizedAccessText(ref, access);
66
+ }
67
+ /**
68
+ * Stable per-image placeholder for a request-limit omission.
69
+ * @param ref - durable normalized attachment omitted from this request.
70
+ * @param access - optional provider-resolved path for model tools.
71
+ * @returns identity, normalized metadata, and the available recovery path.
72
+ */
73
+ export function offloadedImageText(ref, access) {
74
+ const identity = `image omitted to fit request image limits; ${imageIdentity(ref)}.`;
75
+ if (access === undefined) {
76
+ return `[${identity} No local normalized image path is available; ask the user to attach it again if needed.]`;
77
+ }
78
+ return `[${identity}${normalizedAccessText(ref, access)}]`;
20
79
  }
21
80
  /**
22
81
  * True when typed model content contains an image block, walking nested
@@ -49,17 +108,17 @@ function collectImageLengths(blocks, lengths, policy) {
49
108
  }
50
109
  }
51
110
  /** Replace the first `remaining.count` image occurrences without mutating durable messages. */
52
- function replaceOldestImages(blocks, remaining) {
111
+ function replaceOldestImages(blocks, remaining, placeholder) {
53
112
  let next;
54
113
  for (const [index, block] of blocks.entries()) {
55
114
  if (block.type === 'image' && remaining.count > 0) {
56
115
  remaining.count -= 1;
57
116
  next ??= blocks.slice(0, index);
58
- next.push({ type: 'text', text: OFFLOADED_IMAGE_TEXT });
117
+ next.push({ type: 'text', text: placeholder(block.attachment) });
59
118
  continue;
60
119
  }
61
120
  if (block.type === 'tool-result') {
62
- const content = replaceOldestImages(block.content, remaining);
121
+ const content = replaceOldestImages(block.content, remaining, placeholder);
63
122
  if (content !== block.content) {
64
123
  next ??= blocks.slice(0, index);
65
124
  next.push({ ...block, content });
@@ -105,41 +164,20 @@ export function projectImagesForTextModel(messages) {
105
164
  });
106
165
  }
107
166
  /**
108
- * Return transient request messages whose oldest images are replaced until
109
- * their accumulated base64 payload fits the configured bound. The selection
110
- * is deterministic from durable message order and attachment metadata; a
111
- * provider can serialize the returned messages without reading omitted bytes.
112
- * @param messages - complete request history, oldest first.
113
- * @param maxRequestImageBytes - positive bound on total base64 image payload; undefined preserves every image.
114
- * @returns the original messages when they already fit, otherwise shallow message copies with replaced content trees.
115
- */
116
- export function offloadRequestImages(messages, maxRequestImageBytes) {
117
- return offloadRequestImagesWithPolicy(messages, {
118
- representation: 'base64',
119
- ...maxRequestImageBytes === undefined ? {} : { maxBytes: maxRequestImageBytes },
120
- byteQuantum: 1,
121
- });
122
- }
123
- /**
124
- * Return a deterministic transient projection whose oldest images are replaced
125
- * in whole count and byte quanta after a route budget is exceeded. The target
126
- * depends only on complete durable history: at 129 one-megabyte images under
127
- * a 128 MiB bound with a 64 MiB quantum, the oldest 65 images are removed so
128
- * 64 MiB remain; that removed prefix stays fixed until total history exceeds
129
- * 192 MiB.
130
- * @param messages - complete request history, oldest first.
131
- * @param policy - route representation, budgets, and removal quanta.
132
- * @returns original messages below both bounds, otherwise shallow copies with deterministic placeholders.
167
+ * Number of oldest image occurrences one request projection removes, in whole
168
+ * count and byte quanta, once a route budget is exceeded. The result depends
169
+ * only on the represented lengths, so provider request pricing reproduces the
170
+ * exact serialization decision without building the projected messages.
171
+ * @param lengths - represented byte length of every occurrence, in request order.
172
+ * @param policy - count/byte budgets and removal quanta; unbounded when absent.
173
+ * @returns how many leading occurrences the projection replaces with placeholders.
133
174
  */
134
- export function offloadRequestImagesWithPolicy(messages, policy) {
135
- const lengths = [];
136
- for (const message of messages)
137
- collectImageLengths(message.content, lengths, policy);
175
+ export function offloadedImagePrefixCount(lengths, policy) {
138
176
  const total = lengths.reduce((sum, bytes) => sum + bytes, 0);
139
177
  const excessCount = policy.maxImages === undefined ? 0 : Math.max(0, lengths.length - policy.maxImages);
140
178
  const excessBytes = policy.maxBytes === undefined ? 0 : Math.max(0, total - policy.maxBytes);
141
179
  if (excessCount === 0 && excessBytes === 0)
142
- return messages;
180
+ return 0;
143
181
  const countQuantum = policy.countQuantum ?? 1;
144
182
  const byteQuantum = policy.byteQuantum ?? 1;
145
183
  const removeCount = excessCount === 0 ? 0 : Math.ceil(excessCount / countQuantum) * countQuantum;
@@ -154,9 +192,29 @@ export function offloadRequestImagesWithPolicy(messages, policy) {
154
192
  removedBytes += imageBytes;
155
193
  count += 1;
156
194
  }
195
+ return count;
196
+ }
197
+ /**
198
+ * Return a deterministic transient projection whose oldest images are replaced
199
+ * in whole count and byte quanta after a route budget is exceeded. The target
200
+ * depends only on complete durable history: at 129 one-megabyte images under
201
+ * a 128 MiB bound with a 64 MiB quantum, the oldest 65 images are removed so
202
+ * 64 MiB remain; that removed prefix stays fixed until total history exceeds
203
+ * 192 MiB.
204
+ * @param messages - complete request history, oldest first.
205
+ * @param policy - route representation, budgets, and removal quanta.
206
+ * @returns original messages below both bounds, otherwise shallow copies with deterministic placeholders.
207
+ */
208
+ export function offloadRequestImagesWithPolicy(messages, policy) {
209
+ const lengths = [];
210
+ for (const message of messages)
211
+ collectImageLengths(message.content, lengths, policy);
212
+ const count = offloadedImagePrefixCount(lengths, policy);
213
+ if (count === 0)
214
+ return messages;
157
215
  const remaining = { count };
158
216
  return messages.map((message) => {
159
- const content = replaceOldestImages(message.content, remaining);
217
+ const content = replaceOldestImages(message.content, remaining, policy.placeholder);
160
218
  return content === message.content ? message : { ...message, content };
161
219
  });
162
220
  }