tinker-agent 1.9.0 → 1.10.1
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 +26 -1
- package/README.md +64 -6
- package/package.json +1 -1
- package/src/agent/loop.ts +13 -0
- package/src/agent/runtime-session.ts +165 -0
- package/src/agent/session-ledger.ts +20 -3
- package/src/cli/config.ts +11 -2
- package/src/cli/model-profiles.ts +58 -0
- package/src/cli/public-config-contract.ts +73 -7
- package/src/cli/run-runner.ts +4 -1
- package/src/cli/runner-dependencies.ts +28 -4
- package/src/cli/tui-memory.ts +4 -0
- package/src/cli/tui-runner.tsx +8 -1
- package/src/context/context-automation-policy.ts +22 -21
- package/src/context/context-manager.ts +91 -15
- package/src/context/context-policy.ts +0 -2
- package/src/context/context-swap-renderer.ts +1 -1
- package/src/context/prefix-retirement-planner.ts +58 -8
- package/src/context/recall-retirement-contract.ts +5 -4
- package/src/context/swap-planner.ts +33 -27
- package/src/model/fake-model-client.ts +26 -16
- package/src/model/model-api.ts +12 -0
- package/src/model/model-client.ts +9 -1
- package/src/model/moonshot-input-token-estimator.ts +5 -1
- package/src/model/openai-chat-mapping.ts +2 -24
- package/src/model/openai-chat-model-client.ts +18 -294
- package/src/model/openai-image-mapping.ts +20 -0
- package/src/model/openai-model-utils.ts +304 -0
- package/src/model/openai-responses-mapping.ts +532 -0
- package/src/model/openai-responses-model-client.ts +295 -0
- package/src/model/openai-responses-stream.ts +96 -0
- package/src/model/openai-responses-token-estimator.ts +155 -0
- package/src/model/reasoning-effort.ts +60 -0
- package/src/session/session-catalog.ts +2 -2
- package/src/session/session-history-reader.ts +6 -1
- package/src/session/session-schema.ts +268 -4
- package/src/session/session-store.ts +105 -26
- package/src/skills/skill-context.ts +2 -2
- package/src/tools/bounded-output-preview.ts +276 -0
- package/src/tools/recall.ts +67 -36
- package/src/tools/registry.ts +7 -2
- package/src/tools/task-output-snapshot.ts +6 -22
- package/src/tools/task-output.ts +23 -27
- package/src/tui/app.tsx +82 -5
- package/src/tui/components/prompt-input.tsx +9 -1
- package/src/tui/slash-commands.ts +20 -0
- package/src/tui/tui-session-controller.ts +7 -0
|
@@ -3,19 +3,14 @@ import type {
|
|
|
3
3
|
ChatCompletionCreateParamsNonStreaming,
|
|
4
4
|
ChatCompletionCreateParamsStreaming,
|
|
5
5
|
} from "openai/resources/chat/completions";
|
|
6
|
-
import type { AssistantMessage
|
|
6
|
+
import type { AssistantMessage } from "../agent/types";
|
|
7
7
|
import {
|
|
8
8
|
IMAGE_INPUT_POLICY,
|
|
9
9
|
IMAGE_INPUT_POLICY_VERSION,
|
|
10
10
|
} from "../image/image-input-policy";
|
|
11
|
-
import type { ImageAssetId, ImageAssetRef } from "../image/image-types";
|
|
12
11
|
import type { ModelContextBudget } from "./model-context-profile";
|
|
13
12
|
import type { InputTokenEstimator } from "./input-token-estimator";
|
|
14
|
-
import {
|
|
15
|
-
ModelRequestMediaAggregateError,
|
|
16
|
-
ProviderResponseError,
|
|
17
|
-
type ProviderResponseErrorCode,
|
|
18
|
-
} from "./model-client";
|
|
13
|
+
import { ProviderResponseError } from "./model-client";
|
|
19
14
|
import type {
|
|
20
15
|
MaterializedModelRequest,
|
|
21
16
|
ModelClient,
|
|
@@ -24,18 +19,25 @@ import type {
|
|
|
24
19
|
ModelRequestInput,
|
|
25
20
|
ModelRequestOptions,
|
|
26
21
|
ModelRequestOutput,
|
|
27
|
-
PreparedMediaDescriptor,
|
|
28
22
|
PreparedModelRequest,
|
|
29
23
|
PreparedPromptSegment,
|
|
30
24
|
} from "./model-client";
|
|
31
25
|
import {
|
|
32
26
|
fromOpenAIChatCompletion,
|
|
33
|
-
parseImageAssetUrlMarker,
|
|
34
27
|
toOpenAIChatMessages,
|
|
35
28
|
toOpenAIChatTools,
|
|
36
29
|
} from "./openai-chat-mapping";
|
|
37
30
|
import { OpenAIChatCompletionStreamAccumulator } from "./openai-chat-stream";
|
|
31
|
+
import {
|
|
32
|
+
deepFreeze,
|
|
33
|
+
imageUserSegment,
|
|
34
|
+
materializeOpenAIRequest,
|
|
35
|
+
normalizedEndpointPolicy,
|
|
36
|
+
sanitizedProviderError,
|
|
37
|
+
segmentKind,
|
|
38
|
+
} from "./openai-model-utils";
|
|
38
39
|
import { MoonshotInputTokenEstimator } from "./moonshot-input-token-estimator";
|
|
40
|
+
import type { ReasoningEffortController } from "./reasoning-effort";
|
|
39
41
|
import { sha256, stableJsonStringify } from "./model-request-preflight";
|
|
40
42
|
|
|
41
43
|
const OPENAI_CHAT_SERIALIZATION_VERSION = "openai-chat-v2";
|
|
@@ -47,6 +49,7 @@ export class OpenAIChatModelClient implements ModelClient {
|
|
|
47
49
|
serializationVersion: OPENAI_CHAT_SERIALIZATION_VERSION,
|
|
48
50
|
});
|
|
49
51
|
readonly inputTokenEstimator?: InputTokenEstimator;
|
|
52
|
+
readonly reasoningEffort?: ReasoningEffortController;
|
|
50
53
|
private readonly client: OpenAI;
|
|
51
54
|
private readonly preparedRequests = new WeakSet<object>();
|
|
52
55
|
private readonly materializedRequests = new WeakSet<object>();
|
|
@@ -71,6 +74,7 @@ export class OpenAIChatModelClient implements ModelClient {
|
|
|
71
74
|
};
|
|
72
75
|
model: string;
|
|
73
76
|
providerName?: string;
|
|
77
|
+
reasoningEffort?: ReasoningEffortController;
|
|
74
78
|
stream?: boolean;
|
|
75
79
|
timeoutMs?: number;
|
|
76
80
|
fetch?: typeof fetch;
|
|
@@ -78,6 +82,7 @@ export class OpenAIChatModelClient implements ModelClient {
|
|
|
78
82
|
) {
|
|
79
83
|
this.provider = options.providerName ?? "openai-compatible";
|
|
80
84
|
this.stream = options.stream ?? true;
|
|
85
|
+
this.reasoningEffort = options.reasoningEffort;
|
|
81
86
|
this.inputModalities = Object.freeze([...(options.inputModalities ?? ["text"])]);
|
|
82
87
|
const supportsImages = this.inputModalities.includes("image");
|
|
83
88
|
if (!this.inputModalities.includes("text")) {
|
|
@@ -111,10 +116,12 @@ export class OpenAIChatModelClient implements ModelClient {
|
|
|
111
116
|
includeReasoningContent: this.options.includeReasoningContent,
|
|
112
117
|
});
|
|
113
118
|
const tools = input.tools.length > 0 ? toOpenAIChatTools(input.tools) : undefined;
|
|
119
|
+
const reasoningEffort = this.reasoningEffort?.snapshot().effort;
|
|
114
120
|
const payload = deepFreeze({
|
|
115
121
|
model: this.options.model,
|
|
116
122
|
messages,
|
|
117
123
|
...(tools === undefined ? {} : { tools, tool_choice: "auto" as const }),
|
|
124
|
+
...(reasoningEffort === undefined ? {} : { reasoning_effort: reasoningEffort }),
|
|
118
125
|
max_completion_tokens: this.options.contextBudget.requestMaxOutputTokens,
|
|
119
126
|
...(this.stream
|
|
120
127
|
? {
|
|
@@ -185,48 +192,10 @@ export class OpenAIChatModelClient implements ModelClient {
|
|
|
185
192
|
options: ModelMaterializeOptions,
|
|
186
193
|
): Promise<MaterializedModelRequest> {
|
|
187
194
|
this.assertPrepared(prepared);
|
|
188
|
-
options.signal.throwIfAborted();
|
|
189
|
-
if (prepared.mediaOccurrenceCount > IMAGE_INPUT_POLICY.maxImagesPerRequest) {
|
|
190
|
-
throw new ModelRequestMediaAggregateError(
|
|
191
|
-
`Model request has ${prepared.mediaOccurrenceCount} images; maximum is ${IMAGE_INPUT_POLICY.maxImagesPerRequest}.`,
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
195
|
if (prepared.mediaOccurrenceCount > 0 && !this.inputModalities.includes("image")) {
|
|
195
196
|
throw new Error("Current model profile does not support image input.");
|
|
196
197
|
}
|
|
197
|
-
|
|
198
|
-
const assets = distinctPreparedAssets(prepared.promptSegments);
|
|
199
|
-
const lowerLengths = new Map<ImageAssetId, number>();
|
|
200
|
-
for (const asset of assets.values()) {
|
|
201
|
-
lowerLengths.set(asset.assetId, dataUrlLength(asset));
|
|
202
|
-
}
|
|
203
|
-
const markerCount = countImageMarkers(prepared.payload);
|
|
204
|
-
if (markerCount !== prepared.mediaOccurrenceCount) {
|
|
205
|
-
throw new Error("Prepared image marker count does not match media descriptors.");
|
|
206
|
-
}
|
|
207
|
-
const lowerBodyBytes = exactJsonBodyBytes(prepared.payload, lowerLengths);
|
|
208
|
-
assertBodyLimit(lowerBodyBytes, prepared.mediaOccurrenceCount);
|
|
209
|
-
|
|
210
|
-
const dataUrls = new Map<ImageAssetId, string>();
|
|
211
|
-
for (const asset of assets.values()) {
|
|
212
|
-
options.signal.throwIfAborted();
|
|
213
|
-
const bytes = await options.assetStore.readVerified(asset, {
|
|
214
|
-
signal: options.signal,
|
|
215
|
-
});
|
|
216
|
-
dataUrls.set(
|
|
217
|
-
asset.assetId,
|
|
218
|
-
`data:${asset.mimeType};base64,${bytes.toString("base64")}`,
|
|
219
|
-
);
|
|
220
|
-
await yieldToEventLoop();
|
|
221
|
-
}
|
|
222
|
-
options.signal.throwIfAborted();
|
|
223
|
-
const exactLengths = new Map(
|
|
224
|
-
[...dataUrls].map(([assetId, value]) => [assetId, value.length] as const),
|
|
225
|
-
);
|
|
226
|
-
const bodyBytes = exactJsonBodyBytes(prepared.payload, exactLengths);
|
|
227
|
-
assertBodyLimit(bodyBytes, prepared.mediaOccurrenceCount);
|
|
228
|
-
const payload = deepFreeze(materializePayload(prepared.payload, dataUrls));
|
|
229
|
-
const materialized = Object.freeze({ ...prepared, payload, bodyBytes });
|
|
198
|
+
const materialized = await materializeOpenAIRequest(prepared, options);
|
|
230
199
|
this.materializedRequests.add(materialized);
|
|
231
200
|
return materialized;
|
|
232
201
|
}
|
|
@@ -330,249 +299,4 @@ export class OpenAIChatModelClient implements ModelClient {
|
|
|
330
299
|
}
|
|
331
300
|
}
|
|
332
301
|
|
|
333
|
-
export
|
|
334
|
-
value: unknown,
|
|
335
|
-
imageDataUrlLengths: ReadonlyMap<ImageAssetId, number>,
|
|
336
|
-
): number {
|
|
337
|
-
const assetId = parseImageAssetUrlMarker(value);
|
|
338
|
-
if (assetId !== undefined) {
|
|
339
|
-
const length = imageDataUrlLengths.get(assetId);
|
|
340
|
-
if (!Number.isSafeInteger(length) || length === undefined || length < 1) {
|
|
341
|
-
throw new Error(
|
|
342
|
-
`Missing materialized length for image ${assetId.slice(0, 12)}….`,
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
return length + 2;
|
|
346
|
-
}
|
|
347
|
-
if (
|
|
348
|
-
value === null ||
|
|
349
|
-
typeof value === "string" ||
|
|
350
|
-
typeof value === "number" ||
|
|
351
|
-
typeof value === "boolean"
|
|
352
|
-
) {
|
|
353
|
-
const serialized: unknown = JSON.stringify(value);
|
|
354
|
-
if (typeof serialized !== "string") {
|
|
355
|
-
throw new Error("JSON primitive did not serialize to a string.");
|
|
356
|
-
}
|
|
357
|
-
return Buffer.byteLength(serialized, "utf8");
|
|
358
|
-
}
|
|
359
|
-
if (Array.isArray(value)) {
|
|
360
|
-
const entries: readonly unknown[] = value;
|
|
361
|
-
return (
|
|
362
|
-
2 +
|
|
363
|
-
Math.max(0, entries.length - 1) +
|
|
364
|
-
entries.reduce<number>(
|
|
365
|
-
(total, entry) => total + exactJsonBodyBytes(entry, imageDataUrlLengths),
|
|
366
|
-
0,
|
|
367
|
-
)
|
|
368
|
-
);
|
|
369
|
-
}
|
|
370
|
-
if (typeof value !== "object" || value === undefined) {
|
|
371
|
-
throw new Error(`Cannot size non-JSON value of type ${typeof value}.`);
|
|
372
|
-
}
|
|
373
|
-
const entries = Object.entries(value as Record<string, unknown>).filter(
|
|
374
|
-
([, entry]) => entry !== undefined,
|
|
375
|
-
);
|
|
376
|
-
return (
|
|
377
|
-
2 +
|
|
378
|
-
Math.max(0, entries.length - 1) +
|
|
379
|
-
entries.reduce(
|
|
380
|
-
(total, [key, entry]) =>
|
|
381
|
-
total +
|
|
382
|
-
Buffer.byteLength(JSON.stringify(key), "utf8") +
|
|
383
|
-
1 +
|
|
384
|
-
exactJsonBodyBytes(entry, imageDataUrlLengths),
|
|
385
|
-
0,
|
|
386
|
-
)
|
|
387
|
-
);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function imageUserSegment(message: UserMessage): PreparedPromptSegment {
|
|
391
|
-
const media = message.attachments!.map(
|
|
392
|
-
(attachment): PreparedMediaDescriptor =>
|
|
393
|
-
Object.freeze({
|
|
394
|
-
assetId: attachment.assetId,
|
|
395
|
-
label: attachment.label,
|
|
396
|
-
range: Object.freeze({ ...attachment.range }),
|
|
397
|
-
mimeType: attachment.mimeType,
|
|
398
|
-
byteLength: attachment.byteLength,
|
|
399
|
-
width: attachment.width,
|
|
400
|
-
height: attachment.height,
|
|
401
|
-
planningTokens: IMAGE_INPUT_POLICY.planningTokensPerImage,
|
|
402
|
-
}),
|
|
403
|
-
);
|
|
404
|
-
return Object.freeze({
|
|
405
|
-
kind: "user",
|
|
406
|
-
normalizedText: message.content,
|
|
407
|
-
media: Object.freeze(media),
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
function distinctPreparedAssets(
|
|
412
|
-
segments: readonly PreparedPromptSegment[],
|
|
413
|
-
): Map<ImageAssetId, ImageAssetRef> {
|
|
414
|
-
const assets = new Map<ImageAssetId, ImageAssetRef>();
|
|
415
|
-
for (const segment of segments) {
|
|
416
|
-
for (const media of segment.media ?? []) {
|
|
417
|
-
const asset = Object.freeze({
|
|
418
|
-
assetId: media.assetId,
|
|
419
|
-
mimeType: media.mimeType,
|
|
420
|
-
byteLength: media.byteLength,
|
|
421
|
-
width: media.width,
|
|
422
|
-
height: media.height,
|
|
423
|
-
});
|
|
424
|
-
const existing = assets.get(media.assetId);
|
|
425
|
-
if (
|
|
426
|
-
existing !== undefined &&
|
|
427
|
-
stableJsonStringify(existing) !== stableJsonStringify(asset)
|
|
428
|
-
) {
|
|
429
|
-
throw new Error(`Conflicting descriptors for image ${media.assetId}.`);
|
|
430
|
-
}
|
|
431
|
-
assets.set(media.assetId, asset);
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
return assets;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
function materializePayload(
|
|
438
|
-
value: unknown,
|
|
439
|
-
dataUrls: ReadonlyMap<ImageAssetId, string>,
|
|
440
|
-
): unknown {
|
|
441
|
-
const assetId = parseImageAssetUrlMarker(value);
|
|
442
|
-
if (assetId !== undefined) {
|
|
443
|
-
const dataUrl = dataUrls.get(assetId);
|
|
444
|
-
if (dataUrl === undefined) {
|
|
445
|
-
throw new Error(`Image ${assetId.slice(0, 12)}… was not materialized.`);
|
|
446
|
-
}
|
|
447
|
-
return dataUrl;
|
|
448
|
-
}
|
|
449
|
-
if (Array.isArray(value)) {
|
|
450
|
-
return value.map((entry) => materializePayload(entry, dataUrls));
|
|
451
|
-
}
|
|
452
|
-
if (typeof value === "object" && value !== null) {
|
|
453
|
-
return Object.fromEntries(
|
|
454
|
-
Object.entries(value)
|
|
455
|
-
.filter(([, entry]) => entry !== undefined)
|
|
456
|
-
.map(([key, entry]) => [key, materializePayload(entry, dataUrls)]),
|
|
457
|
-
);
|
|
458
|
-
}
|
|
459
|
-
return value;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
function countImageMarkers(value: unknown): number {
|
|
463
|
-
if (parseImageAssetUrlMarker(value) !== undefined) {
|
|
464
|
-
return 1;
|
|
465
|
-
}
|
|
466
|
-
if (Array.isArray(value)) {
|
|
467
|
-
const entries: readonly unknown[] = value;
|
|
468
|
-
return entries.reduce<number>(
|
|
469
|
-
(total, entry) => total + countImageMarkers(entry),
|
|
470
|
-
0,
|
|
471
|
-
);
|
|
472
|
-
}
|
|
473
|
-
if (typeof value === "object" && value !== null) {
|
|
474
|
-
return Object.values(value as Record<string, unknown>).reduce<number>(
|
|
475
|
-
(total, entry) => total + countImageMarkers(entry),
|
|
476
|
-
0,
|
|
477
|
-
);
|
|
478
|
-
}
|
|
479
|
-
return 0;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
function dataUrlLength(asset: ImageAssetRef): number {
|
|
483
|
-
return `data:${asset.mimeType};base64,`.length + 4 * Math.ceil(asset.byteLength / 3);
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
function assertBodyLimit(bodyBytes: number, imageCount: number): void {
|
|
487
|
-
if (bodyBytes > IMAGE_INPUT_POLICY.maxRequestBodyBytes) {
|
|
488
|
-
throw new ModelRequestMediaAggregateError(
|
|
489
|
-
`Model request is ${bodyBytes} UTF-8 bytes with ${imageCount} images; maximum is ${IMAGE_INPUT_POLICY.maxRequestBodyBytes}.`,
|
|
490
|
-
);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
function normalizedEndpointPolicy(baseURL: string | undefined): string {
|
|
495
|
-
const url = new URL(baseURL ?? "https://api.openai.com/v1");
|
|
496
|
-
url.username = "";
|
|
497
|
-
url.password = "";
|
|
498
|
-
url.search = "";
|
|
499
|
-
url.hash = "";
|
|
500
|
-
const pathname = url.pathname.replace(/\/+$/, "") || "/";
|
|
501
|
-
return `${url.protocol}//${url.host}${pathname}`;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function segmentKind(
|
|
505
|
-
role: ModelRequestInput["messages"][number]["role"],
|
|
506
|
-
): PreparedPromptSegment["kind"] {
|
|
507
|
-
switch (role) {
|
|
508
|
-
case "system":
|
|
509
|
-
return "kernel";
|
|
510
|
-
case "user":
|
|
511
|
-
return "user";
|
|
512
|
-
case "assistant":
|
|
513
|
-
return "assistant";
|
|
514
|
-
case "tool":
|
|
515
|
-
return "tool";
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
function yieldToEventLoop(): Promise<void> {
|
|
520
|
-
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
function deepFreeze<T>(value: T): T {
|
|
524
|
-
if (typeof value !== "object" || value === null || Object.isFrozen(value)) {
|
|
525
|
-
return value;
|
|
526
|
-
}
|
|
527
|
-
for (const child of Object.values(value)) {
|
|
528
|
-
deepFreeze(child);
|
|
529
|
-
}
|
|
530
|
-
return Object.freeze(value);
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
function sanitizedProviderError(
|
|
534
|
-
error: unknown,
|
|
535
|
-
provider: string,
|
|
536
|
-
model: string,
|
|
537
|
-
): ProviderResponseError {
|
|
538
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
539
|
-
const sanitized = message
|
|
540
|
-
.replace(
|
|
541
|
-
/data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/=]+/gu,
|
|
542
|
-
"[redacted image data]",
|
|
543
|
-
)
|
|
544
|
-
.replace(/Bearer\s+[A-Za-z0-9._~+/-]+/giu, "Bearer [redacted]");
|
|
545
|
-
return new ProviderResponseError(
|
|
546
|
-
providerErrorCode(error),
|
|
547
|
-
sanitized,
|
|
548
|
-
{ provider, model },
|
|
549
|
-
{ cause: error },
|
|
550
|
-
);
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
function providerErrorCode(error: unknown): ProviderResponseErrorCode {
|
|
554
|
-
const status = providerErrorStatus(error);
|
|
555
|
-
if (status === 429) {
|
|
556
|
-
return "provider_rate_limited";
|
|
557
|
-
}
|
|
558
|
-
if (status === 500 || status === 502 || status === 503 || status === 504) {
|
|
559
|
-
return "provider_unavailable";
|
|
560
|
-
}
|
|
561
|
-
if (status === undefined && isProviderConnectionError(error)) {
|
|
562
|
-
return "provider_unavailable";
|
|
563
|
-
}
|
|
564
|
-
return "provider_request_error";
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
function providerErrorStatus(error: unknown): number | undefined {
|
|
568
|
-
if (typeof error !== "object" || error === null || !("status" in error)) {
|
|
569
|
-
return undefined;
|
|
570
|
-
}
|
|
571
|
-
const status = (error as { status?: unknown }).status;
|
|
572
|
-
return typeof status === "number" ? status : undefined;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
function isProviderConnectionError(error: unknown): boolean {
|
|
576
|
-
// APIConnectionTimeoutError extends APIConnectionError in the SDK.
|
|
577
|
-
return error instanceof OpenAI.APIConnectionError;
|
|
578
|
-
}
|
|
302
|
+
export { exactJsonBodyBytes } from "./openai-model-utils";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { parseImageAssetId, type ImageAssetId } from "../image/image-types";
|
|
2
|
+
|
|
3
|
+
const IMAGE_ASSET_URL_MARKER = Symbol("tinker.image-asset-url-marker");
|
|
4
|
+
|
|
5
|
+
export type ImageAssetUrlMarker = {
|
|
6
|
+
readonly [IMAGE_ASSET_URL_MARKER]: ImageAssetId;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function imageAssetUrlMarker(assetId: ImageAssetId): ImageAssetUrlMarker {
|
|
10
|
+
parseImageAssetId(assetId);
|
|
11
|
+
return Object.freeze({ [IMAGE_ASSET_URL_MARKER]: assetId });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseImageAssetUrlMarker(value: unknown): ImageAssetId | undefined {
|
|
15
|
+
if (typeof value !== "object" || value === null) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const assetId = (value as Partial<ImageAssetUrlMarker>)[IMAGE_ASSET_URL_MARKER];
|
|
19
|
+
return typeof assetId === "string" ? parseImageAssetId(assetId) : undefined;
|
|
20
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import type { UserMessage } from "../agent/types";
|
|
3
|
+
import { IMAGE_INPUT_POLICY } from "../image/image-input-policy";
|
|
4
|
+
import type { ImageAssetId, ImageAssetRef } from "../image/image-types";
|
|
5
|
+
import {
|
|
6
|
+
ModelRequestMediaAggregateError,
|
|
7
|
+
ProviderResponseError,
|
|
8
|
+
type MaterializedModelRequest,
|
|
9
|
+
type ModelMaterializeOptions,
|
|
10
|
+
type ModelRequestInput,
|
|
11
|
+
type PreparedMediaDescriptor,
|
|
12
|
+
type PreparedModelRequest,
|
|
13
|
+
type PreparedPromptSegment,
|
|
14
|
+
type ProviderResponseErrorCode,
|
|
15
|
+
} from "./model-client";
|
|
16
|
+
import { parseImageAssetUrlMarker } from "./openai-image-mapping";
|
|
17
|
+
import { stableJsonStringify } from "./model-request-preflight";
|
|
18
|
+
|
|
19
|
+
export async function materializeOpenAIRequest(
|
|
20
|
+
prepared: PreparedModelRequest,
|
|
21
|
+
options: ModelMaterializeOptions,
|
|
22
|
+
): Promise<MaterializedModelRequest> {
|
|
23
|
+
options.signal.throwIfAborted();
|
|
24
|
+
if (prepared.mediaOccurrenceCount > IMAGE_INPUT_POLICY.maxImagesPerRequest) {
|
|
25
|
+
throw new ModelRequestMediaAggregateError(
|
|
26
|
+
`Model request has ${prepared.mediaOccurrenceCount} images; maximum is ${IMAGE_INPUT_POLICY.maxImagesPerRequest}.`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
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
|
+
const markerCount = countImageMarkers(prepared.payload);
|
|
36
|
+
if (markerCount !== prepared.mediaOccurrenceCount) {
|
|
37
|
+
throw new Error("Prepared image marker count does not match media descriptors.");
|
|
38
|
+
}
|
|
39
|
+
const lowerBodyBytes = exactJsonBodyBytes(prepared.payload, lowerLengths);
|
|
40
|
+
assertBodyLimit(lowerBodyBytes, prepared.mediaOccurrenceCount);
|
|
41
|
+
|
|
42
|
+
const dataUrls = new Map<ImageAssetId, string>();
|
|
43
|
+
for (const asset of assets.values()) {
|
|
44
|
+
options.signal.throwIfAborted();
|
|
45
|
+
const bytes = await options.assetStore.readVerified(asset, {
|
|
46
|
+
signal: options.signal,
|
|
47
|
+
});
|
|
48
|
+
dataUrls.set(
|
|
49
|
+
asset.assetId,
|
|
50
|
+
`data:${asset.mimeType};base64,${bytes.toString("base64")}`,
|
|
51
|
+
);
|
|
52
|
+
await yieldToEventLoop();
|
|
53
|
+
}
|
|
54
|
+
options.signal.throwIfAborted();
|
|
55
|
+
const exactLengths = new Map(
|
|
56
|
+
[...dataUrls].map(([assetId, value]) => [assetId, value.length] as const),
|
|
57
|
+
);
|
|
58
|
+
const bodyBytes = exactJsonBodyBytes(prepared.payload, exactLengths);
|
|
59
|
+
assertBodyLimit(bodyBytes, prepared.mediaOccurrenceCount);
|
|
60
|
+
const payload = deepFreeze(materializePayload(prepared.payload, dataUrls));
|
|
61
|
+
return Object.freeze({ ...prepared, payload, bodyBytes });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function exactJsonBodyBytes(
|
|
65
|
+
value: unknown,
|
|
66
|
+
imageDataUrlLengths: ReadonlyMap<ImageAssetId, number>,
|
|
67
|
+
): number {
|
|
68
|
+
const assetId = parseImageAssetUrlMarker(value);
|
|
69
|
+
if (assetId !== undefined) {
|
|
70
|
+
const length = imageDataUrlLengths.get(assetId);
|
|
71
|
+
if (!Number.isSafeInteger(length) || length === undefined || length < 1) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Missing materialized length for image ${assetId.slice(0, 12)}….`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return length + 2;
|
|
77
|
+
}
|
|
78
|
+
if (
|
|
79
|
+
value === null ||
|
|
80
|
+
typeof value === "string" ||
|
|
81
|
+
typeof value === "number" ||
|
|
82
|
+
typeof value === "boolean"
|
|
83
|
+
) {
|
|
84
|
+
const serialized: unknown = JSON.stringify(value);
|
|
85
|
+
if (typeof serialized !== "string") {
|
|
86
|
+
throw new Error("JSON primitive did not serialize to a string.");
|
|
87
|
+
}
|
|
88
|
+
return Buffer.byteLength(serialized, "utf8");
|
|
89
|
+
}
|
|
90
|
+
if (Array.isArray(value)) {
|
|
91
|
+
const entries: readonly unknown[] = value;
|
|
92
|
+
return (
|
|
93
|
+
2 +
|
|
94
|
+
Math.max(0, entries.length - 1) +
|
|
95
|
+
entries.reduce<number>(
|
|
96
|
+
(total, entry) => total + exactJsonBodyBytes(entry, imageDataUrlLengths),
|
|
97
|
+
0,
|
|
98
|
+
)
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (typeof value !== "object" || value === undefined) {
|
|
102
|
+
throw new Error(`Cannot size non-JSON value of type ${typeof value}.`);
|
|
103
|
+
}
|
|
104
|
+
const entries = Object.entries(value as Record<string, unknown>).filter(
|
|
105
|
+
([, entry]) => entry !== undefined,
|
|
106
|
+
);
|
|
107
|
+
return (
|
|
108
|
+
2 +
|
|
109
|
+
Math.max(0, entries.length - 1) +
|
|
110
|
+
entries.reduce(
|
|
111
|
+
(total, [key, entry]) =>
|
|
112
|
+
total +
|
|
113
|
+
Buffer.byteLength(JSON.stringify(key), "utf8") +
|
|
114
|
+
1 +
|
|
115
|
+
exactJsonBodyBytes(entry, imageDataUrlLengths),
|
|
116
|
+
0,
|
|
117
|
+
)
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
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
|
+
);
|
|
135
|
+
return Object.freeze({
|
|
136
|
+
kind: "user",
|
|
137
|
+
normalizedText: message.content,
|
|
138
|
+
media: Object.freeze(media),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function normalizedEndpointPolicy(baseURL: string | undefined): string {
|
|
143
|
+
const url = new URL(baseURL ?? "https://api.openai.com/v1");
|
|
144
|
+
url.username = "";
|
|
145
|
+
url.password = "";
|
|
146
|
+
url.search = "";
|
|
147
|
+
url.hash = "";
|
|
148
|
+
const pathname = url.pathname.replace(/\/+$/, "") || "/";
|
|
149
|
+
return `${url.protocol}//${url.host}${pathname}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function segmentKind(
|
|
153
|
+
role: ModelRequestInput["messages"][number]["role"],
|
|
154
|
+
): PreparedPromptSegment["kind"] {
|
|
155
|
+
switch (role) {
|
|
156
|
+
case "system":
|
|
157
|
+
return "kernel";
|
|
158
|
+
case "user":
|
|
159
|
+
return "user";
|
|
160
|
+
case "assistant":
|
|
161
|
+
return "assistant";
|
|
162
|
+
case "tool":
|
|
163
|
+
return "tool";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function deepFreeze<T>(value: T): T {
|
|
168
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value)) {
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
for (const child of Object.values(value)) {
|
|
172
|
+
deepFreeze(child);
|
|
173
|
+
}
|
|
174
|
+
return Object.freeze(value);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function sanitizedProviderError(
|
|
178
|
+
error: unknown,
|
|
179
|
+
provider: string,
|
|
180
|
+
model: string,
|
|
181
|
+
): ProviderResponseError {
|
|
182
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
183
|
+
const sanitized = message
|
|
184
|
+
.replace(
|
|
185
|
+
/data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/=]+/gu,
|
|
186
|
+
"[redacted image data]",
|
|
187
|
+
)
|
|
188
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/-]+/giu, "Bearer [redacted]");
|
|
189
|
+
return new ProviderResponseError(
|
|
190
|
+
providerErrorCode(error),
|
|
191
|
+
sanitized,
|
|
192
|
+
{ provider, model },
|
|
193
|
+
{ cause: error },
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function distinctPreparedAssets(
|
|
198
|
+
segments: readonly PreparedPromptSegment[],
|
|
199
|
+
): Map<ImageAssetId, ImageAssetRef> {
|
|
200
|
+
const assets = new Map<ImageAssetId, ImageAssetRef>();
|
|
201
|
+
for (const segment of segments) {
|
|
202
|
+
for (const media of segment.media ?? []) {
|
|
203
|
+
const asset = Object.freeze({
|
|
204
|
+
assetId: media.assetId,
|
|
205
|
+
mimeType: media.mimeType,
|
|
206
|
+
byteLength: media.byteLength,
|
|
207
|
+
width: media.width,
|
|
208
|
+
height: media.height,
|
|
209
|
+
});
|
|
210
|
+
const existing = assets.get(media.assetId);
|
|
211
|
+
if (
|
|
212
|
+
existing !== undefined &&
|
|
213
|
+
stableJsonStringify(existing) !== stableJsonStringify(asset)
|
|
214
|
+
) {
|
|
215
|
+
throw new Error(`Conflicting descriptors for image ${media.assetId}.`);
|
|
216
|
+
}
|
|
217
|
+
assets.set(media.assetId, asset);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return assets;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function materializePayload(
|
|
224
|
+
value: unknown,
|
|
225
|
+
dataUrls: ReadonlyMap<ImageAssetId, string>,
|
|
226
|
+
): unknown {
|
|
227
|
+
const assetId = parseImageAssetUrlMarker(value);
|
|
228
|
+
if (assetId !== undefined) {
|
|
229
|
+
const dataUrl = dataUrls.get(assetId);
|
|
230
|
+
if (dataUrl === undefined) {
|
|
231
|
+
throw new Error(`Image ${assetId.slice(0, 12)}… was not materialized.`);
|
|
232
|
+
}
|
|
233
|
+
return dataUrl;
|
|
234
|
+
}
|
|
235
|
+
if (Array.isArray(value)) {
|
|
236
|
+
return value.map((entry) => materializePayload(entry, dataUrls));
|
|
237
|
+
}
|
|
238
|
+
if (typeof value === "object" && value !== null) {
|
|
239
|
+
return Object.fromEntries(
|
|
240
|
+
Object.entries(value)
|
|
241
|
+
.filter(([, entry]) => entry !== undefined)
|
|
242
|
+
.map(([key, entry]) => [key, materializePayload(entry, dataUrls)]),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return value;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function countImageMarkers(value: unknown): number {
|
|
249
|
+
if (parseImageAssetUrlMarker(value) !== undefined) {
|
|
250
|
+
return 1;
|
|
251
|
+
}
|
|
252
|
+
if (Array.isArray(value)) {
|
|
253
|
+
const entries: readonly unknown[] = value;
|
|
254
|
+
return entries.reduce<number>(
|
|
255
|
+
(total, entry) => total + countImageMarkers(entry),
|
|
256
|
+
0,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
if (typeof value === "object" && value !== null) {
|
|
260
|
+
return Object.values(value as Record<string, unknown>).reduce<number>(
|
|
261
|
+
(total, entry) => total + countImageMarkers(entry),
|
|
262
|
+
0,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
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 {
|
|
273
|
+
if (bodyBytes > IMAGE_INPUT_POLICY.maxRequestBodyBytes) {
|
|
274
|
+
throw new ModelRequestMediaAggregateError(
|
|
275
|
+
`Model request is ${bodyBytes} UTF-8 bytes with ${imageCount} images; maximum is ${IMAGE_INPUT_POLICY.maxRequestBodyBytes}.`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function yieldToEventLoop(): Promise<void> {
|
|
281
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function providerErrorCode(error: unknown): ProviderResponseErrorCode {
|
|
285
|
+
const status = providerErrorStatus(error);
|
|
286
|
+
if (status === 429) {
|
|
287
|
+
return "provider_rate_limited";
|
|
288
|
+
}
|
|
289
|
+
if (status === 500 || status === 502 || status === 503 || status === 504) {
|
|
290
|
+
return "provider_unavailable";
|
|
291
|
+
}
|
|
292
|
+
if (status === undefined && error instanceof OpenAI.APIConnectionError) {
|
|
293
|
+
return "provider_unavailable";
|
|
294
|
+
}
|
|
295
|
+
return "provider_request_error";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function providerErrorStatus(error: unknown): number | undefined {
|
|
299
|
+
if (typeof error !== "object" || error === null || !("status" in error)) {
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
const status = (error as { status?: unknown }).status;
|
|
303
|
+
return typeof status === "number" ? status : undefined;
|
|
304
|
+
}
|