tinker-agent 1.10.1 → 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 +27 -1
- package/README.md +7 -23
- package/package.json +1 -1
- package/src/agent/context-meter.ts +14 -85
- package/src/agent/loop.ts +5 -14
- package/src/agent/runtime-session.ts +176 -24
- package/src/agent/session-ledger.ts +80 -0
- 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/events/observation-text-log.ts +4 -0
- package/src/events/stdout-event-printer.ts +5 -0
- package/src/events/types.ts +5 -1
- 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 +98 -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 +42 -23
- package/src/tui/app.tsx +72 -7
- package/src/tui/components/footer.tsx +6 -1
- package/src/tui/event-store.ts +15 -0
- package/src/tui/tui-session-controller.ts +7 -0
- 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
|
@@ -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);
|
|
@@ -241,6 +213,9 @@ export class FakeModelClient implements ModelClient {
|
|
|
241
213
|
if (this.mode === "pty-incremental-output") {
|
|
242
214
|
return this.ptyIncrementalOutput(input, prepared, options);
|
|
243
215
|
}
|
|
216
|
+
if (this.mode === "pty-steering-notice") {
|
|
217
|
+
return this.ptySteeringNotice(input, prepared, options);
|
|
218
|
+
}
|
|
244
219
|
if (this.mode === "pty-resume-layout") {
|
|
245
220
|
return this.ptyResumeLayout(input, prepared, options);
|
|
246
221
|
}
|
|
@@ -412,6 +387,32 @@ export class FakeModelClient implements ModelClient {
|
|
|
412
387
|
return textOutput(prepared, chunks.join(""));
|
|
413
388
|
}
|
|
414
389
|
|
|
390
|
+
private async ptySteeringNotice(
|
|
391
|
+
input: ModelRequestInput,
|
|
392
|
+
prepared: PreparedModelRequest,
|
|
393
|
+
options: ModelRequestOptions,
|
|
394
|
+
): Promise<ModelRequestOutput> {
|
|
395
|
+
requireTools(input, ["Bash"]);
|
|
396
|
+
const prompt = lastUserMessage(input.messages);
|
|
397
|
+
if (prompt === "PTY_STEERING_START") {
|
|
398
|
+
await Bun.sleep(600);
|
|
399
|
+
options.signal.throwIfAborted();
|
|
400
|
+
return toolCallOutput(prepared, options, "Bash", {
|
|
401
|
+
command: "printf 'PTY_STEERING_TOOL_DONE\\n'",
|
|
402
|
+
description: "Create steering boundary",
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
if (prompt === "PTY_STEERING_FOLLOWUP") {
|
|
406
|
+
requireToolMessage(input.messages, "Bash", "PTY_STEERING_TOOL_DONE");
|
|
407
|
+
await Bun.sleep(1_500);
|
|
408
|
+
options.signal.throwIfAborted();
|
|
409
|
+
return textOutput(prepared, "PTY_STEERING_FINAL");
|
|
410
|
+
}
|
|
411
|
+
throw new Error(
|
|
412
|
+
`Unexpected pty-steering-notice prompt: ${JSON.stringify(prompt)}.`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
415
416
|
private ptyResumeLayout(
|
|
416
417
|
input: ModelRequestInput,
|
|
417
418
|
prepared: PreparedModelRequest,
|
|
@@ -1426,19 +1427,21 @@ function lastMessageIndex(
|
|
|
1426
1427
|
|
|
1427
1428
|
function toPromptSegment(message: AgentMessage): PreparedPromptSegment {
|
|
1428
1429
|
if (message.role === "user" && message.attachments !== undefined) {
|
|
1429
|
-
const media = message.attachments.map(
|
|
1430
|
-
(attachment)
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
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
|
+
});
|
|
1442
1445
|
return Object.freeze({
|
|
1443
1446
|
kind: "user",
|
|
1444
1447
|
normalizedText: message.content,
|
|
@@ -1466,8 +1469,8 @@ function distinctPreparedAssets(
|
|
|
1466
1469
|
assetId: media.assetId,
|
|
1467
1470
|
mimeType: media.mimeType,
|
|
1468
1471
|
byteLength: media.byteLength,
|
|
1469
|
-
width: media.
|
|
1470
|
-
height: media.
|
|
1472
|
+
width: media.sourceWidth,
|
|
1473
|
+
height: media.sourceHeight,
|
|
1471
1474
|
});
|
|
1472
1475
|
const existing = assets.get(media.assetId);
|
|
1473
1476
|
if (
|
|
@@ -1482,13 +1485,39 @@ function distinctPreparedAssets(
|
|
|
1482
1485
|
return assets;
|
|
1483
1486
|
}
|
|
1484
1487
|
|
|
1485
|
-
function
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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
|
+
);
|
|
1492
1521
|
}
|
|
1493
1522
|
|
|
1494
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
|
}
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
IMAGE_INPUT_POLICY,
|
|
9
9
|
IMAGE_INPUT_POLICY_VERSION,
|
|
10
10
|
} from "../image/image-input-policy";
|
|
11
|
-
import type { InputTokenEstimator } from "./input-token-estimator";
|
|
12
11
|
import type { ModelContextBudget } from "./model-context-profile";
|
|
13
12
|
import { ProviderResponseError } from "./model-client";
|
|
14
13
|
import type {
|
|
@@ -22,7 +21,6 @@ import type {
|
|
|
22
21
|
PreparedModelRequest,
|
|
23
22
|
PreparedPromptSegment,
|
|
24
23
|
} from "./model-client";
|
|
25
|
-
import { MoonshotInputTokenEstimator } from "./moonshot-input-token-estimator";
|
|
26
24
|
import {
|
|
27
25
|
deepFreeze,
|
|
28
26
|
imageUserSegment,
|
|
@@ -38,7 +36,6 @@ import {
|
|
|
38
36
|
toOpenAIResponsesTools,
|
|
39
37
|
} from "./openai-responses-mapping";
|
|
40
38
|
import { OpenAIResponsesStreamAccumulator } from "./openai-responses-stream";
|
|
41
|
-
import { responsesPayloadForChatTokenEstimator } from "./openai-responses-token-estimator";
|
|
42
39
|
import type { ReasoningEffortController } from "./reasoning-effort";
|
|
43
40
|
import { sha256, stableJsonStringify } from "./model-request-preflight";
|
|
44
41
|
|
|
@@ -50,7 +47,6 @@ export class OpenAIResponsesModelClient implements ModelClient {
|
|
|
50
47
|
adapter: "openai-responses",
|
|
51
48
|
serializationVersion: OPENAI_RESPONSES_SERIALIZATION_VERSION,
|
|
52
49
|
});
|
|
53
|
-
readonly inputTokenEstimator?: InputTokenEstimator;
|
|
54
50
|
readonly inputModalities: readonly ("text" | "image")[];
|
|
55
51
|
readonly reasoningEffort?: ReasoningEffortController;
|
|
56
52
|
private readonly client: OpenAI;
|
|
@@ -65,14 +61,6 @@ export class OpenAIResponsesModelClient implements ModelClient {
|
|
|
65
61
|
contextBudget: ModelContextBudget;
|
|
66
62
|
baseURL?: string;
|
|
67
63
|
inputModalities?: readonly ("text" | "image")[];
|
|
68
|
-
tokenEstimator?: {
|
|
69
|
-
kind: "moonshot-estimate-token-count-v1";
|
|
70
|
-
model: string;
|
|
71
|
-
apiBase: string;
|
|
72
|
-
apiKey: string;
|
|
73
|
-
timeoutMs: number;
|
|
74
|
-
maxRetries: 0;
|
|
75
|
-
};
|
|
76
64
|
model: string;
|
|
77
65
|
providerName?: string;
|
|
78
66
|
reasoningEffort?: ReasoningEffortController;
|
|
@@ -85,13 +73,9 @@ export class OpenAIResponsesModelClient implements ModelClient {
|
|
|
85
73
|
this.stream = options.stream ?? true;
|
|
86
74
|
this.reasoningEffort = options.reasoningEffort;
|
|
87
75
|
this.inputModalities = Object.freeze([...(options.inputModalities ?? ["text"])]);
|
|
88
|
-
const supportsImages = this.inputModalities.includes("image");
|
|
89
76
|
if (!this.inputModalities.includes("text")) {
|
|
90
77
|
throw new Error('OpenAI Responses input modalities must include "text".');
|
|
91
78
|
}
|
|
92
|
-
if (supportsImages && options.tokenEstimator === undefined) {
|
|
93
|
-
throw new Error("Image-capable OpenAI Responses requires a token estimator.");
|
|
94
|
-
}
|
|
95
79
|
this.client = new OpenAI({
|
|
96
80
|
apiKey: options.apiKey,
|
|
97
81
|
baseURL: options.baseURL,
|
|
@@ -99,16 +83,6 @@ export class OpenAIResponsesModelClient implements ModelClient {
|
|
|
99
83
|
maxRetries: 0,
|
|
100
84
|
fetch: options.fetch,
|
|
101
85
|
});
|
|
102
|
-
if (options.tokenEstimator !== undefined) {
|
|
103
|
-
this.inputTokenEstimator = new MoonshotInputTokenEstimator({
|
|
104
|
-
apiKey: options.tokenEstimator.apiKey,
|
|
105
|
-
baseURL: options.tokenEstimator.apiBase,
|
|
106
|
-
model: options.tokenEstimator.model,
|
|
107
|
-
timeoutMs: options.tokenEstimator.timeoutMs,
|
|
108
|
-
fetch: options.fetch,
|
|
109
|
-
payloadMapper: responsesPayloadForChatTokenEstimator,
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
86
|
}
|
|
113
87
|
|
|
114
88
|
prepare(input: ModelRequestInput): PreparedModelRequest {
|
|
@@ -163,9 +137,6 @@ export class OpenAIResponsesModelClient implements ModelClient {
|
|
|
163
137
|
version: IMAGE_INPUT_POLICY_VERSION,
|
|
164
138
|
...IMAGE_INPUT_POLICY,
|
|
165
139
|
},
|
|
166
|
-
...(this.inputTokenEstimator === undefined
|
|
167
|
-
? {}
|
|
168
|
-
: { tokenEstimator: this.inputTokenEstimator.compatibility }),
|
|
169
140
|
}),
|
|
170
141
|
);
|
|
171
142
|
const prepared: PreparedModelRequest = {
|
|
@@ -12,10 +12,15 @@ export type RawContextBreakdown = {
|
|
|
12
12
|
toolTokens: number;
|
|
13
13
|
toolSchemaTokens: number;
|
|
14
14
|
protocolTokens: number;
|
|
15
|
+
textAndProtocolTokens: number;
|
|
16
|
+
imageTokens: number;
|
|
15
17
|
totalTokens: number;
|
|
16
18
|
};
|
|
17
19
|
|
|
18
|
-
type BreakdownKey = Exclude<
|
|
20
|
+
type BreakdownKey = Exclude<
|
|
21
|
+
keyof RawContextBreakdown,
|
|
22
|
+
"totalTokens" | "textAndProtocolTokens" | "imageTokens"
|
|
23
|
+
>;
|
|
19
24
|
|
|
20
25
|
const breakdownKeys: BreakdownKey[] = [
|
|
21
26
|
"kernelTokens",
|
|
@@ -37,13 +42,16 @@ export function estimatePromptSegments(
|
|
|
37
42
|
toolSchemaTokens: 0,
|
|
38
43
|
protocolTokens: segments.length * 8,
|
|
39
44
|
};
|
|
45
|
+
let imageTokens = 0;
|
|
40
46
|
|
|
41
47
|
for (const segment of segments) {
|
|
42
48
|
exact[breakdownKey(segment.kind)] += estimateText(segment.normalizedText);
|
|
43
|
-
|
|
49
|
+
const segmentImageTokens = (segment.media ?? []).reduce(
|
|
44
50
|
(total, media) => total + media.planningTokens,
|
|
45
51
|
0,
|
|
46
52
|
);
|
|
53
|
+
exact[breakdownKey(segment.kind)] += segmentImageTokens;
|
|
54
|
+
imageTokens += segmentImageTokens;
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
const totalTokens = Math.ceil(
|
|
@@ -69,7 +77,12 @@ export function estimatePromptSegments(
|
|
|
69
77
|
remaining -= 1;
|
|
70
78
|
}
|
|
71
79
|
|
|
72
|
-
return {
|
|
80
|
+
return {
|
|
81
|
+
...rounded,
|
|
82
|
+
textAndProtocolTokens: totalTokens - imageTokens,
|
|
83
|
+
imageTokens,
|
|
84
|
+
totalTokens,
|
|
85
|
+
};
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
export class RollingTokenCalibration {
|