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.
@@ -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);
@@ -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): PreparedMediaDescriptor =>
1431
- Object.freeze({
1432
- assetId: attachment.assetId,
1433
- label: attachment.label,
1434
- range: Object.freeze({ ...attachment.range }),
1435
- mimeType: attachment.mimeType,
1436
- byteLength: attachment.byteLength,
1437
- width: attachment.width,
1438
- height: attachment.height,
1439
- planningTokens: IMAGE_INPUT_POLICY.planningTokensPerImage,
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.width,
1470
- height: media.height,
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 tokenEstimatorEndpoint(apiBase: string): string {
1486
- const base = new URL(apiBase.endsWith("/") ? apiBase : `${apiBase}/`);
1487
- base.username = "";
1488
- base.password = "";
1489
- base.search = "";
1490
- base.hash = "";
1491
- 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
+ );
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;
@@ -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
  }
@@ -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<keyof RawContextBreakdown, "totalTokens">;
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
- exact[breakdownKey(segment.kind)] += (segment.media ?? []).reduce(
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 { ...rounded, totalTokens };
80
+ return {
81
+ ...rounded,
82
+ textAndProtocolTokens: totalTokens - imageTokens,
83
+ imageTokens,
84
+ totalTokens,
85
+ };
73
86
  }
74
87
 
75
88
  export class RollingTokenCalibration {