tinker-agent 1.11.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,11 +4,62 @@ export const IMAGE_INPUT_POLICY = Object.freeze({
4
4
  maxBytesPerImage: 20 * 1024 * 1024,
5
5
  maxImagesPerMessage: 8,
6
6
  maxImagesPerRequest: 8,
7
+ maxProviderLongEdge: 2048,
7
8
  maxLongEdge: 4096,
8
9
  maxPixels: 8_847_360,
9
10
  maxRequestBodyBytes: 90_000_000,
10
- planningTokensPerImage: 2048,
11
+ allowUpscale: false,
12
+ resizePolicy: "sharp-auto-orient-lanczos3-round-v1",
13
+ outputEncodingPolicy: "preserve-input-format-fixed-encoding-v1",
14
+ imageTokenBuckets: Object.freeze([
15
+ Object.freeze({ maxLongEdge: 512, planningTokens: 384 }),
16
+ Object.freeze({ maxLongEdge: 1024, planningTokens: 1408 }),
17
+ Object.freeze({ maxLongEdge: 1536, planningTokens: 3072 }),
18
+ Object.freeze({ maxLongEdge: 2048, planningTokens: 5504 }),
19
+ ] as const),
20
+ imageTokensUseTextCorrectionFactor: false,
11
21
  retryPolicy: "none",
12
22
  } as const);
13
23
 
14
- export const IMAGE_INPUT_POLICY_VERSION = "image-input-policy-v1" as const;
24
+ export const IMAGE_INPUT_POLICY_VERSION = "image-input-policy-v2" as const;
25
+
26
+ export function providerImageDimensions(
27
+ width: number,
28
+ height: number,
29
+ ): { readonly width: number; readonly height: number } {
30
+ requireDimension(width, "width");
31
+ requireDimension(height, "height");
32
+ const longEdge = Math.max(width, height);
33
+ if (longEdge <= IMAGE_INPUT_POLICY.maxProviderLongEdge) {
34
+ return Object.freeze({ width, height });
35
+ }
36
+ const scale = IMAGE_INPUT_POLICY.maxProviderLongEdge / longEdge;
37
+ let targetWidth = Math.max(1, Math.round(width * scale));
38
+ let targetHeight = Math.max(1, Math.round(height * scale));
39
+ if (targetWidth > IMAGE_INPUT_POLICY.maxProviderLongEdge) {
40
+ targetWidth = IMAGE_INPUT_POLICY.maxProviderLongEdge;
41
+ }
42
+ if (targetHeight > IMAGE_INPUT_POLICY.maxProviderLongEdge) {
43
+ targetHeight = IMAGE_INPUT_POLICY.maxProviderLongEdge;
44
+ }
45
+ return Object.freeze({ width: targetWidth, height: targetHeight });
46
+ }
47
+
48
+ export function imagePlanningTokens(width: number, height: number): number {
49
+ requireDimension(width, "width");
50
+ requireDimension(height, "height");
51
+ const longEdge = Math.max(width, height);
52
+ const bucket = IMAGE_INPUT_POLICY.imageTokenBuckets.find(
53
+ (candidate) => longEdge <= candidate.maxLongEdge,
54
+ );
55
+ if (bucket === undefined) {
56
+ throw new Error("Materialized image exceeds the provider image size policy.");
57
+ }
58
+ return bucket.planningTokens;
59
+ }
60
+
61
+ function requireDimension(value: number, name: string): void {
62
+ if (!Number.isSafeInteger(value) || value < 1) {
63
+ throw new Error(`Image ${name} must be a positive safe integer.`);
64
+ }
65
+ }
@@ -1,5 +1,6 @@
1
1
  import sharp from "sharp";
2
2
  import { IMAGE_INPUT_POLICY } from "./image-input-policy";
3
+ import { orientedDimensions } from "./provider-image";
3
4
  import {
4
5
  imageAssetIdForBytes,
5
6
  type ImageAssetRef,
@@ -59,8 +60,13 @@ export async function probeImageBytes(
59
60
  `Image container and decoder disagree on format (${container.mimeType} vs ${mimeType}).`,
60
61
  );
61
62
  }
62
- const width = requireDimension(metadata.width, "width");
63
- const height = requireDimension(metadata.height, "height");
63
+ const decodedWidth = requireDimension(metadata.width, "width");
64
+ const decodedHeight = requireDimension(metadata.height, "height");
65
+ const { width, height } = orientedDimensions(
66
+ decodedWidth,
67
+ decodedHeight,
68
+ metadata.orientation,
69
+ );
64
70
  const decoderAnimated =
65
71
  (metadata.pages ?? 1) > 1 || metadata.pageHeight !== undefined;
66
72
  if (container.animated || decoderAnimated) {
@@ -0,0 +1,99 @@
1
+ import sharp from "sharp";
2
+ import {
3
+ IMAGE_INPUT_POLICY,
4
+ imagePlanningTokens,
5
+ providerImageDimensions,
6
+ } from "./image-input-policy";
7
+ import type { ImageMimeType } from "./image-types";
8
+
9
+ export type ProviderImage = {
10
+ readonly bytes: Buffer;
11
+ readonly mimeType: ImageMimeType;
12
+ readonly width: number;
13
+ readonly height: number;
14
+ readonly planningTokens: number;
15
+ };
16
+
17
+ export async function materializeProviderImage(
18
+ bytes: Buffer,
19
+ mimeType: ImageMimeType,
20
+ ): Promise<ProviderImage> {
21
+ const input = sharp(bytes, {
22
+ failOn: "warning",
23
+ limitInputPixels: IMAGE_INPUT_POLICY.maxPixels,
24
+ unlimited: false,
25
+ sequentialRead: true,
26
+ });
27
+ const metadata = await input.metadata();
28
+ const sourceWidth = requireDimension(metadata.width, "width");
29
+ const sourceHeight = requireDimension(metadata.height, "height");
30
+ const oriented = orientedDimensions(sourceWidth, sourceHeight, metadata.orientation);
31
+ const target = providerImageDimensions(oriented.width, oriented.height);
32
+ const requiresOrientation =
33
+ metadata.orientation !== undefined && metadata.orientation !== 1;
34
+ const requiresResize =
35
+ target.width !== oriented.width || target.height !== oriented.height;
36
+
37
+ let outputBytes = bytes;
38
+ if (requiresOrientation || requiresResize) {
39
+ let pipeline = sharp(bytes, {
40
+ failOn: "warning",
41
+ limitInputPixels: IMAGE_INPUT_POLICY.maxPixels,
42
+ unlimited: false,
43
+ sequentialRead: true,
44
+ }).rotate();
45
+ if (requiresResize) {
46
+ pipeline = pipeline.resize(target.width, target.height, {
47
+ fit: "fill",
48
+ kernel: sharp.kernel.lanczos3,
49
+ withoutEnlargement: true,
50
+ });
51
+ }
52
+ outputBytes = await encodeInOriginalFormat(pipeline, mimeType);
53
+ }
54
+
55
+ const outputMetadata = await sharp(outputBytes).metadata();
56
+ const width = requireDimension(outputMetadata.width, "width");
57
+ const height = requireDimension(outputMetadata.height, "height");
58
+ if (Math.max(width, height) > IMAGE_INPUT_POLICY.maxProviderLongEdge) {
59
+ throw new Error("Materialized image exceeds the provider image size policy.");
60
+ }
61
+ return Object.freeze({
62
+ bytes: outputBytes,
63
+ mimeType,
64
+ width,
65
+ height,
66
+ planningTokens: imagePlanningTokens(width, height),
67
+ });
68
+ }
69
+
70
+ export function orientedDimensions(
71
+ width: number,
72
+ height: number,
73
+ orientation: number | undefined,
74
+ ): { readonly width: number; readonly height: number } {
75
+ return orientation !== undefined && orientation >= 5 && orientation <= 8
76
+ ? Object.freeze({ width: height, height: width })
77
+ : Object.freeze({ width, height });
78
+ }
79
+
80
+ async function encodeInOriginalFormat(
81
+ pipeline: ReturnType<typeof sharp>,
82
+ mimeType: ImageMimeType,
83
+ ): Promise<Buffer> {
84
+ switch (mimeType) {
85
+ case "image/png":
86
+ return pipeline.png({ compressionLevel: 6, adaptiveFiltering: false }).toBuffer();
87
+ case "image/jpeg":
88
+ return pipeline.jpeg({ quality: 80, chromaSubsampling: "4:2:0" }).toBuffer();
89
+ case "image/webp":
90
+ return pipeline.webp({ quality: 80, effort: 4 }).toBuffer();
91
+ }
92
+ }
93
+
94
+ function requireDimension(value: number | undefined, name: string): number {
95
+ if (!Number.isSafeInteger(value) || value === undefined || value < 1) {
96
+ throw new Error(`Decoded image ${name} is invalid.`);
97
+ }
98
+ return value;
99
+ }
@@ -2,9 +2,13 @@ import { createHash } from "node:crypto";
2
2
  import { appendFile } from "node:fs/promises";
3
3
  import type { AgentMessage, AssistantMessage } from "../agent/types";
4
4
  import { cancellationError } from "../agent/turn-cancellation";
5
- import { IMAGE_INPUT_POLICY } from "../image/image-input-policy";
5
+ import {
6
+ IMAGE_INPUT_POLICY,
7
+ imagePlanningTokens,
8
+ providerImageDimensions,
9
+ } from "../image/image-input-policy";
6
10
  import type { ImageAssetId, ImageAssetRef } from "../image/image-types";
7
- import type { InputTokenEstimator } from "./input-token-estimator";
11
+ import { materializeProviderImage } from "../image/provider-image";
8
12
  import type { ModelContextBudget } from "./model-context-profile";
9
13
  import type { ReasoningEffortController } from "./reasoning-effort";
10
14
  import type {
@@ -24,7 +28,6 @@ import { estimatePromptSegments } from "./token-estimator";
24
28
 
25
29
  export class FakeModelClient implements ModelClient {
26
30
  readonly inputModalities: readonly ("text" | "image")[];
27
- readonly inputTokenEstimator?: InputTokenEstimator;
28
31
  readonly reasoningEffort?: ReasoningEffortController;
29
32
  readonly messageProtocol: ModelMessageProtocol = Object.freeze({
30
33
  adapter: "fake",
@@ -42,13 +45,6 @@ export class FakeModelClient implements ModelClient {
42
45
  inputModalities?: readonly ("text" | "image")[];
43
46
  reasoningEffort?: ReasoningEffortController;
44
47
  requestLogPath?: string;
45
- tokenEstimator?: {
46
- kind: "moonshot-estimate-token-count-v1";
47
- model: string;
48
- apiBase: string;
49
- timeoutMs: number;
50
- maxRetries: 0;
51
- };
52
48
  },
53
49
  ) {
54
50
  this.reasoningEffort = options.reasoningEffort;
@@ -58,38 +54,6 @@ export class FakeModelClient implements ModelClient {
58
54
  if (!this.inputModalities.includes("text")) {
59
55
  throw new Error('Fake model input modalities must include "text".');
60
56
  }
61
- if (
62
- this.inputModalities.includes("image") &&
63
- options.tokenEstimator === undefined
64
- ) {
65
- throw new Error("Image-capable fake model requires a token estimator.");
66
- }
67
- if (options.tokenEstimator !== undefined) {
68
- const estimator = options.tokenEstimator;
69
- const endpoint = tokenEstimatorEndpoint(estimator.apiBase);
70
- this.inputTokenEstimator = Object.freeze({
71
- kind: estimator.kind,
72
- compatibility: Object.freeze({
73
- kind: estimator.kind,
74
- coverageVersion: "full-request-v1",
75
- model: estimator.model,
76
- endpoint,
77
- timeoutMs: estimator.timeoutMs,
78
- maxRetries: estimator.maxRetries,
79
- }),
80
- async estimate(
81
- request: MaterializedModelRequest,
82
- estimateOptions: { signal: AbortSignal },
83
- ) {
84
- estimateOptions.signal.throwIfAborted();
85
- return Object.freeze({
86
- inputTokens: estimatePromptSegments(request.promptSegments).totalTokens,
87
- source: "provider_estimated" as const,
88
- coverage: "full_request" as const,
89
- });
90
- },
91
- });
92
- }
93
57
  }
94
58
 
95
59
  prepare(input: ModelRequestInput): PreparedModelRequest {
@@ -113,9 +77,6 @@ export class FakeModelClient implements ModelClient {
113
77
  model: this.options.model,
114
78
  requestMaxOutputTokens: this.options.contextBudget.requestMaxOutputTokens,
115
79
  inputModalities: this.inputModalities,
116
- ...(this.inputTokenEstimator === undefined
117
- ? {}
118
- : { tokenEstimator: this.inputTokenEstimator.compatibility }),
119
80
  }),
120
81
  );
121
82
  const prepared: PreparedModelRequest = Object.freeze({
@@ -167,6 +128,9 @@ export class FakeModelClient implements ModelClient {
167
128
  const materializedAssets: Array<{
168
129
  readonly assetId: ImageAssetId;
169
130
  readonly byteLength: number;
131
+ readonly width: number;
132
+ readonly height: number;
133
+ readonly planningTokens: number;
170
134
  readonly bytesSha256: string;
171
135
  }> = [];
172
136
  for (const asset of assets.values()) {
@@ -174,11 +138,15 @@ export class FakeModelClient implements ModelClient {
174
138
  const bytes = await options.assetStore.readVerified(asset, {
175
139
  signal: options.signal,
176
140
  });
141
+ const image = await materializeProviderImage(bytes, asset.mimeType);
177
142
  materializedAssets.push(
178
143
  Object.freeze({
179
144
  assetId: asset.assetId,
180
- byteLength: bytes.byteLength,
181
- bytesSha256: createHash("sha256").update(bytes).digest("hex"),
145
+ byteLength: image.bytes.byteLength,
146
+ width: image.width,
147
+ height: image.height,
148
+ planningTokens: image.planningTokens,
149
+ bytesSha256: createHash("sha256").update(image.bytes).digest("hex"),
182
150
  }),
183
151
  );
184
152
  }
@@ -191,6 +159,10 @@ export class FakeModelClient implements ModelClient {
191
159
  const materialized = Object.freeze({
192
160
  ...prepared,
193
161
  payload,
162
+ promptSegments: materializedFakePromptSegments(
163
+ prepared.promptSegments,
164
+ materializedAssets,
165
+ ),
194
166
  bodyBytes: Buffer.byteLength(stableJsonStringify(payload), "utf8"),
195
167
  });
196
168
  this.preparedInputs.set(materialized, input);
@@ -1455,19 +1427,21 @@ function lastMessageIndex(
1455
1427
 
1456
1428
  function toPromptSegment(message: AgentMessage): PreparedPromptSegment {
1457
1429
  if (message.role === "user" && message.attachments !== undefined) {
1458
- const media = message.attachments.map(
1459
- (attachment): PreparedMediaDescriptor =>
1460
- Object.freeze({
1461
- assetId: attachment.assetId,
1462
- label: attachment.label,
1463
- range: Object.freeze({ ...attachment.range }),
1464
- mimeType: attachment.mimeType,
1465
- byteLength: attachment.byteLength,
1466
- width: attachment.width,
1467
- height: attachment.height,
1468
- planningTokens: IMAGE_INPUT_POLICY.planningTokensPerImage,
1469
- }),
1470
- );
1430
+ const media = message.attachments.map((attachment): PreparedMediaDescriptor => {
1431
+ const dimensions = providerImageDimensions(attachment.width, attachment.height);
1432
+ return Object.freeze({
1433
+ assetId: attachment.assetId,
1434
+ label: attachment.label,
1435
+ range: Object.freeze({ ...attachment.range }),
1436
+ mimeType: attachment.mimeType,
1437
+ byteLength: attachment.byteLength,
1438
+ sourceWidth: attachment.width,
1439
+ sourceHeight: attachment.height,
1440
+ width: dimensions.width,
1441
+ height: dimensions.height,
1442
+ planningTokens: imagePlanningTokens(dimensions.width, dimensions.height),
1443
+ });
1444
+ });
1471
1445
  return Object.freeze({
1472
1446
  kind: "user",
1473
1447
  normalizedText: message.content,
@@ -1495,8 +1469,8 @@ function distinctPreparedAssets(
1495
1469
  assetId: media.assetId,
1496
1470
  mimeType: media.mimeType,
1497
1471
  byteLength: media.byteLength,
1498
- width: media.width,
1499
- height: media.height,
1472
+ width: media.sourceWidth,
1473
+ height: media.sourceHeight,
1500
1474
  });
1501
1475
  const existing = assets.get(media.assetId);
1502
1476
  if (
@@ -1511,13 +1485,39 @@ function distinctPreparedAssets(
1511
1485
  return assets;
1512
1486
  }
1513
1487
 
1514
- function tokenEstimatorEndpoint(apiBase: string): string {
1515
- const base = new URL(apiBase.endsWith("/") ? apiBase : `${apiBase}/`);
1516
- base.username = "";
1517
- base.password = "";
1518
- base.search = "";
1519
- base.hash = "";
1520
- return new URL("tokenizers/estimate-token-count", base).toString();
1488
+ function materializedFakePromptSegments(
1489
+ segments: readonly PreparedPromptSegment[],
1490
+ images: readonly {
1491
+ assetId: ImageAssetId;
1492
+ width: number;
1493
+ height: number;
1494
+ planningTokens: number;
1495
+ }[],
1496
+ ): readonly PreparedPromptSegment[] {
1497
+ const byId = new Map(images.map((image) => [image.assetId, image] as const));
1498
+ return Object.freeze(
1499
+ segments.map((segment) =>
1500
+ segment.media === undefined
1501
+ ? segment
1502
+ : Object.freeze({
1503
+ ...segment,
1504
+ media: Object.freeze(
1505
+ segment.media.map((media) => {
1506
+ const image = byId.get(media.assetId);
1507
+ if (image === undefined) {
1508
+ throw new Error(`Fake image ${media.assetId} was not materialized.`);
1509
+ }
1510
+ return Object.freeze({
1511
+ ...media,
1512
+ width: image.width,
1513
+ height: image.height,
1514
+ planningTokens: image.planningTokens,
1515
+ });
1516
+ }),
1517
+ ),
1518
+ }),
1519
+ ),
1520
+ );
1521
1521
  }
1522
1522
 
1523
1523
  function recallMarker(
@@ -3,12 +3,10 @@ import type { RuntimeSessionContext } from "../agent/runtime-session";
3
3
  import type { ToolDefinition } from "../tools/types";
4
4
  import type { ImageAssetStore } from "../image/image-asset-store";
5
5
  import type { CodePointRange, ImageAssetId, ImageMimeType } from "../image/image-types";
6
- import type { InputTokenEstimator } from "./input-token-estimator";
7
6
  import type { ReasoningEffortController } from "./reasoning-effort";
8
7
 
9
8
  export interface ModelClient {
10
9
  readonly messageProtocol: ModelMessageProtocol;
11
- readonly inputTokenEstimator?: InputTokenEstimator;
12
10
  readonly inputModalities?: readonly ("text" | "image")[];
13
11
  readonly reasoningEffort?: ReasoningEffortController;
14
12
  prepare(input: ModelRequestInput): PreparedModelRequest;
@@ -76,6 +74,8 @@ export type PreparedMediaDescriptor = {
76
74
  range: CodePointRange;
77
75
  mimeType: ImageMimeType;
78
76
  byteLength: number;
77
+ sourceWidth: number;
78
+ sourceHeight: number;
79
79
  width: number;
80
80
  height: number;
81
81
  planningTokens: number;
@@ -3,7 +3,6 @@ import type { ModelContextBudget } from "./model-context-profile";
3
3
 
4
4
  export type ContextUsageSource =
5
5
  | "estimated_full"
6
- | "provider_estimated"
7
6
  | "provider_measured"
8
7
  | "measured_plus_estimated_delta";
9
8
 
@@ -9,7 +9,6 @@ import {
9
9
  IMAGE_INPUT_POLICY_VERSION,
10
10
  } from "../image/image-input-policy";
11
11
  import type { ModelContextBudget } from "./model-context-profile";
12
- import type { InputTokenEstimator } from "./input-token-estimator";
13
12
  import { ProviderResponseError } from "./model-client";
14
13
  import type {
15
14
  MaterializedModelRequest,
@@ -36,7 +35,6 @@ import {
36
35
  sanitizedProviderError,
37
36
  segmentKind,
38
37
  } from "./openai-model-utils";
39
- import { MoonshotInputTokenEstimator } from "./moonshot-input-token-estimator";
40
38
  import type { ReasoningEffortController } from "./reasoning-effort";
41
39
  import { sha256, stableJsonStringify } from "./model-request-preflight";
42
40
 
@@ -48,7 +46,6 @@ export class OpenAIChatModelClient implements ModelClient {
48
46
  adapter: "openai-chat",
49
47
  serializationVersion: OPENAI_CHAT_SERIALIZATION_VERSION,
50
48
  });
51
- readonly inputTokenEstimator?: InputTokenEstimator;
52
49
  readonly reasoningEffort?: ReasoningEffortController;
53
50
  private readonly client: OpenAI;
54
51
  private readonly preparedRequests = new WeakSet<object>();
@@ -64,14 +61,6 @@ export class OpenAIChatModelClient implements ModelClient {
64
61
  baseURL?: string;
65
62
  includeReasoningContent?: boolean;
66
63
  inputModalities?: readonly ("text" | "image")[];
67
- tokenEstimator?: {
68
- kind: "moonshot-estimate-token-count-v1";
69
- model: string;
70
- apiBase: string;
71
- apiKey: string;
72
- timeoutMs: number;
73
- maxRetries: 0;
74
- };
75
64
  model: string;
76
65
  providerName?: string;
77
66
  reasoningEffort?: ReasoningEffortController;
@@ -84,13 +73,9 @@ export class OpenAIChatModelClient implements ModelClient {
84
73
  this.stream = options.stream ?? true;
85
74
  this.reasoningEffort = options.reasoningEffort;
86
75
  this.inputModalities = Object.freeze([...(options.inputModalities ?? ["text"])]);
87
- const supportsImages = this.inputModalities.includes("image");
88
76
  if (!this.inputModalities.includes("text")) {
89
77
  throw new Error('OpenAI chat input modalities must include "text".');
90
78
  }
91
- if (supportsImages && options.tokenEstimator === undefined) {
92
- throw new Error("Image-capable OpenAI chat requires a token estimator.");
93
- }
94
79
  this.client = new OpenAI({
95
80
  apiKey: options.apiKey,
96
81
  baseURL: options.baseURL,
@@ -100,15 +85,6 @@ export class OpenAIChatModelClient implements ModelClient {
100
85
  maxRetries: 0,
101
86
  fetch: options.fetch,
102
87
  });
103
- if (options.tokenEstimator !== undefined) {
104
- this.inputTokenEstimator = new MoonshotInputTokenEstimator({
105
- apiKey: options.tokenEstimator.apiKey,
106
- baseURL: options.tokenEstimator.apiBase,
107
- model: options.tokenEstimator.model,
108
- timeoutMs: options.tokenEstimator.timeoutMs,
109
- fetch: options.fetch,
110
- });
111
- }
112
88
  }
113
89
 
114
90
  prepare(input: ModelRequestInput): PreparedModelRequest {
@@ -164,9 +140,6 @@ export class OpenAIChatModelClient implements ModelClient {
164
140
  version: IMAGE_INPUT_POLICY_VERSION,
165
141
  ...IMAGE_INPUT_POLICY,
166
142
  },
167
- ...(this.inputTokenEstimator === undefined
168
- ? {}
169
- : { tokenEstimator: this.inputTokenEstimator.compatibility }),
170
143
  }),
171
144
  );
172
145
  const prepared: PreparedModelRequest = {
@@ -299,4 +272,7 @@ export class OpenAIChatModelClient implements ModelClient {
299
272
  }
300
273
  }
301
274
 
302
- export { exactJsonBodyBytes } from "./openai-model-utils";
275
+ export {
276
+ assertOpenAIRequestBodyLimit,
277
+ exactJsonBodyBytes,
278
+ } from "./openai-model-utils";
@@ -1,7 +1,12 @@
1
1
  import OpenAI from "openai";
2
2
  import type { UserMessage } from "../agent/types";
3
- import { IMAGE_INPUT_POLICY } from "../image/image-input-policy";
3
+ import {
4
+ IMAGE_INPUT_POLICY,
5
+ imagePlanningTokens,
6
+ providerImageDimensions,
7
+ } from "../image/image-input-policy";
4
8
  import type { ImageAssetId, ImageAssetRef } from "../image/image-types";
9
+ import { materializeProviderImage, type ProviderImage } from "../image/provider-image";
5
10
  import {
6
11
  ModelRequestMediaAggregateError,
7
12
  ProviderResponseError,
@@ -28,26 +33,22 @@ export async function materializeOpenAIRequest(
28
33
  }
29
34
 
30
35
  const assets = distinctPreparedAssets(prepared.promptSegments);
31
- const lowerLengths = new Map<ImageAssetId, number>();
32
- for (const asset of assets.values()) {
33
- lowerLengths.set(asset.assetId, dataUrlLength(asset));
34
- }
35
36
  const markerCount = countImageMarkers(prepared.payload);
36
37
  if (markerCount !== prepared.mediaOccurrenceCount) {
37
38
  throw new Error("Prepared image marker count does not match media descriptors.");
38
39
  }
39
- const lowerBodyBytes = exactJsonBodyBytes(prepared.payload, lowerLengths);
40
- assertBodyLimit(lowerBodyBytes, prepared.mediaOccurrenceCount);
41
-
42
40
  const dataUrls = new Map<ImageAssetId, string>();
41
+ const providerImages = new Map<ImageAssetId, ProviderImage>();
43
42
  for (const asset of assets.values()) {
44
43
  options.signal.throwIfAborted();
45
44
  const bytes = await options.assetStore.readVerified(asset, {
46
45
  signal: options.signal,
47
46
  });
47
+ const image = await materializeProviderImage(bytes, asset.mimeType);
48
+ providerImages.set(asset.assetId, image);
48
49
  dataUrls.set(
49
50
  asset.assetId,
50
- `data:${asset.mimeType};base64,${bytes.toString("base64")}`,
51
+ `data:${image.mimeType};base64,${image.bytes.toString("base64")}`,
51
52
  );
52
53
  await yieldToEventLoop();
53
54
  }
@@ -56,9 +57,14 @@ export async function materializeOpenAIRequest(
56
57
  [...dataUrls].map(([assetId, value]) => [assetId, value.length] as const),
57
58
  );
58
59
  const bodyBytes = exactJsonBodyBytes(prepared.payload, exactLengths);
59
- assertBodyLimit(bodyBytes, prepared.mediaOccurrenceCount);
60
+ assertOpenAIRequestBodyLimit(bodyBytes, prepared.mediaOccurrenceCount);
60
61
  const payload = deepFreeze(materializePayload(prepared.payload, dataUrls));
61
- return Object.freeze({ ...prepared, payload, bodyBytes });
62
+ return Object.freeze({
63
+ ...prepared,
64
+ payload,
65
+ promptSegments: materializedPromptSegments(prepared.promptSegments, providerImages),
66
+ bodyBytes,
67
+ });
62
68
  }
63
69
 
64
70
  export function exactJsonBodyBytes(
@@ -119,19 +125,21 @@ export function exactJsonBodyBytes(
119
125
  }
120
126
 
121
127
  export function imageUserSegment(message: UserMessage): PreparedPromptSegment {
122
- const media = message.attachments!.map(
123
- (attachment): PreparedMediaDescriptor =>
124
- Object.freeze({
125
- assetId: attachment.assetId,
126
- label: attachment.label,
127
- range: Object.freeze({ ...attachment.range }),
128
- mimeType: attachment.mimeType,
129
- byteLength: attachment.byteLength,
130
- width: attachment.width,
131
- height: attachment.height,
132
- planningTokens: IMAGE_INPUT_POLICY.planningTokensPerImage,
133
- }),
134
- );
128
+ const media = message.attachments!.map((attachment): PreparedMediaDescriptor => {
129
+ const dimensions = providerImageDimensions(attachment.width, attachment.height);
130
+ return Object.freeze({
131
+ assetId: attachment.assetId,
132
+ label: attachment.label,
133
+ range: Object.freeze({ ...attachment.range }),
134
+ mimeType: attachment.mimeType,
135
+ byteLength: attachment.byteLength,
136
+ sourceWidth: attachment.width,
137
+ sourceHeight: attachment.height,
138
+ width: dimensions.width,
139
+ height: dimensions.height,
140
+ planningTokens: imagePlanningTokens(dimensions.width, dimensions.height),
141
+ });
142
+ });
135
143
  return Object.freeze({
136
144
  kind: "user",
137
145
  normalizedText: message.content,
@@ -204,8 +212,8 @@ function distinctPreparedAssets(
204
212
  assetId: media.assetId,
205
213
  mimeType: media.mimeType,
206
214
  byteLength: media.byteLength,
207
- width: media.width,
208
- height: media.height,
215
+ width: media.sourceWidth,
216
+ height: media.sourceHeight,
209
217
  });
210
218
  const existing = assets.get(media.assetId);
211
219
  if (
@@ -265,11 +273,10 @@ function countImageMarkers(value: unknown): number {
265
273
  return 0;
266
274
  }
267
275
 
268
- function dataUrlLength(asset: ImageAssetRef): number {
269
- return `data:${asset.mimeType};base64,`.length + 4 * Math.ceil(asset.byteLength / 3);
270
- }
271
-
272
- function assertBodyLimit(bodyBytes: number, imageCount: number): void {
276
+ export function assertOpenAIRequestBodyLimit(
277
+ bodyBytes: number,
278
+ imageCount: number,
279
+ ): void {
273
280
  if (bodyBytes > IMAGE_INPUT_POLICY.maxRequestBodyBytes) {
274
281
  throw new ModelRequestMediaAggregateError(
275
282
  `Model request is ${bodyBytes} UTF-8 bytes with ${imageCount} images; maximum is ${IMAGE_INPUT_POLICY.maxRequestBodyBytes}.`,
@@ -277,6 +284,37 @@ function assertBodyLimit(bodyBytes: number, imageCount: number): void {
277
284
  }
278
285
  }
279
286
 
287
+ function materializedPromptSegments(
288
+ segments: readonly PreparedPromptSegment[],
289
+ images: ReadonlyMap<ImageAssetId, ProviderImage>,
290
+ ): readonly PreparedPromptSegment[] {
291
+ return Object.freeze(
292
+ segments.map((segment) =>
293
+ segment.media === undefined
294
+ ? segment
295
+ : Object.freeze({
296
+ ...segment,
297
+ media: Object.freeze(
298
+ segment.media.map((media) => {
299
+ const image = images.get(media.assetId);
300
+ if (image === undefined) {
301
+ throw new Error(
302
+ `Image ${media.assetId.slice(0, 12)}… was not materialized.`,
303
+ );
304
+ }
305
+ return Object.freeze({
306
+ ...media,
307
+ width: image.width,
308
+ height: image.height,
309
+ planningTokens: image.planningTokens,
310
+ });
311
+ }),
312
+ ),
313
+ }),
314
+ ),
315
+ );
316
+ }
317
+
280
318
  function yieldToEventLoop(): Promise<void> {
281
319
  return new Promise((resolve) => setTimeout(resolve, 0));
282
320
  }