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.
- package/CHANGELOG.md +17 -1
- package/README.md +7 -23
- package/package.json +1 -1
- package/src/agent/context-meter.ts +14 -85
- package/src/agent/loop.ts +1 -14
- package/src/agent/runtime-session.ts +0 -23
- package/src/cli/config.ts +0 -5
- package/src/cli/model-profiles.ts +0 -98
- package/src/cli/public-config-contract.ts +1 -88
- package/src/cli/runner-dependencies.ts +0 -7
- package/src/cli/tui-memory.ts +0 -3
- package/src/image/image-input-policy.ts +53 -2
- package/src/image/image-probe.ts +8 -2
- package/src/image/provider-image.ts +99 -0
- package/src/model/fake-model-client.ts +69 -69
- package/src/model/model-client.ts +2 -2
- package/src/model/model-request-preflight.ts +0 -1
- package/src/model/openai-chat-model-client.ts +4 -28
- package/src/model/openai-model-utils.ts +69 -31
- package/src/model/openai-responses-model-client.ts +0 -29
- package/src/model/token-estimator.ts +16 -3
- package/src/session/session-store.ts +13 -23
- package/src/model/input-token-estimator.ts +0 -25
- package/src/model/moonshot-input-token-estimator.ts +0 -111
- package/src/model/openai-responses-token-estimator.ts +0 -155
|
@@ -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
|
-
|
|
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-
|
|
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
|
+
}
|
package/src/image/image-probe.ts
CHANGED
|
@@ -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
|
|
63
|
-
const
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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)
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
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.
|
|
1499
|
-
height: media.
|
|
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
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
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;
|
|
@@ -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 {
|
|
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 {
|
|
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:${
|
|
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
|
-
|
|
60
|
+
assertOpenAIRequestBodyLimit(bodyBytes, prepared.mediaOccurrenceCount);
|
|
60
61
|
const payload = deepFreeze(materializePayload(prepared.payload, dataUrls));
|
|
61
|
-
return Object.freeze({
|
|
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)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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.
|
|
208
|
-
height: media.
|
|
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
|
|
269
|
-
|
|
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
|
}
|