pi-openai-toolkit 0.20.8 → 0.21.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/LICENSE +21 -21
- package/NOTICE +83 -83
- package/README.md +8 -26
- package/README.zh.md +8 -26
- package/config.schema.json +278 -366
- package/docs/configuration.md +294 -310
- package/docs/internals.md +166 -175
- package/package.json +3 -13
- package/src/compact-client.ts +412 -389
- package/src/compaction-errors.ts +96 -0
- package/src/compaction-projection.ts +35 -35
- package/src/config/legacy.ts +519 -717
- package/src/config/migration.ts +103 -100
- package/src/config/notifications.ts +36 -28
- package/src/config/policy.ts +10 -18
- package/src/config/v2.ts +1167 -1380
- package/src/config.ts +310 -319
- package/src/context-management/codex-provider.ts +270 -270
- package/src/context-management/history-notes.ts +357 -357
- package/src/context-management/manual-compact.ts +0 -11
- package/src/context-management/tools.ts +371 -371
- package/src/context-management/types.ts +168 -168
- package/src/context-management/window-manager.ts +801 -801
- package/src/debug.ts +238 -237
- package/src/deferred-tool-carryover.ts +282 -282
- package/src/details-store.ts +151 -151
- package/src/extension-runtime.ts +1662 -1616
- package/src/image-generation/extension.ts +189 -189
- package/src/image-generation/protocol.ts +579 -579
- package/src/image-generation/types.ts +300 -300
- package/src/payload-rewrite.ts +515 -518
- package/src/pi-context-hook.ts +117 -117
- package/src/remote-v2-client.ts +811 -814
- package/src/request-context-cache.ts +155 -155
- package/src/responses-headers.ts +180 -180
- package/src/runtime.ts +865 -832
- package/src/serializer.ts +882 -882
- package/src/supported-environment.ts +27 -27
- package/src/types.ts +565 -648
- package/src/web-search/alpha-client.ts +876 -876
- package/src/web-search/extension.ts +530 -530
- package/src/web-search/payload.ts +299 -299
- package/src/web-search/prompt.ts +60 -60
- package/src/web-search/types.ts +166 -166
- package/tsconfig.check.json +50 -54
- package/src/auto-mode/classifier.ts +0 -161
- package/src/auto-mode/extension.ts +0 -691
- package/src/auto-mode/model-selection-guard.ts +0 -52
- package/src/auto-mode/policy.ts +0 -146
- package/src/auto-mode/prompt.ts +0 -279
- package/src/auto-mode/reviewer.ts +0 -325
- package/src/auto-mode/tool-review-tui.ts +0 -211
- package/src/auto-mode/transcript.ts +0 -135
- package/src/auto-mode/types.ts +0 -137
package/src/serializer.ts
CHANGED
|
@@ -1,882 +1,882 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
3
|
-
import { compact, convertToLlm } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import type {
|
|
5
|
-
Api,
|
|
6
|
-
AssistantMessage,
|
|
7
|
-
ImageContent,
|
|
8
|
-
Message,
|
|
9
|
-
Model,
|
|
10
|
-
TextContent,
|
|
11
|
-
ThinkingContent,
|
|
12
|
-
ToolCall,
|
|
13
|
-
ToolResultMessage,
|
|
14
|
-
UserMessage,
|
|
15
|
-
} from "@earendil-works/pi-ai";
|
|
16
|
-
import type { ResponsesCompatibleRequestPayload } from "./runtime";
|
|
17
|
-
import type { CompactionRequestExtras } from "./request-context-cache";
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* pi stopped exporting the CompactionPreparation type name in 0.80.x, but it is still
|
|
21
|
-
* structurally the first argument of the exported compact(). Derive it from there so we
|
|
22
|
-
* track pi's shape without depending on a private export.
|
|
23
|
-
*/
|
|
24
|
-
type CompactionPreparation = Parameters<typeof compact>[0];
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Decision for T4: keep a narrow local serializer instead of importing Pi internals.
|
|
28
|
-
*
|
|
29
|
-
* Why this is sufficient for v1:
|
|
30
|
-
* - we only target same-model OpenAI Responses-compatible requests
|
|
31
|
-
* - we only need Pi's current supported message semantics (assistant phase,
|
|
32
|
-
* reasoning signatures, tool call/result pairing, image blocks)
|
|
33
|
-
* - Pi's shared Responses converter is not publicly exported, so importing it
|
|
34
|
-
* would require a brittle install-path-specific wrapper
|
|
35
|
-
*
|
|
36
|
-
* The helpers below intentionally mirror Pi's same-model Responses serialization
|
|
37
|
-
* rules closely so later tasks can compare their output against captured
|
|
38
|
-
* before_provider_request payload artifacts.
|
|
39
|
-
*/
|
|
40
|
-
export const COMPACTION_SERIALIZER_STRATEGY = "local-same-model-responses-serializer" as const;
|
|
41
|
-
|
|
42
|
-
export type CompactionSerializerStrategy = typeof COMPACTION_SERIALIZER_STRATEGY;
|
|
43
|
-
export type AssistantPhase = "commentary" | "final_answer";
|
|
44
|
-
|
|
45
|
-
type ResponsesTextInputItem = {
|
|
46
|
-
type: "input_text";
|
|
47
|
-
text: string;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
type ResponsesImageInputItem = {
|
|
51
|
-
type: "input_image";
|
|
52
|
-
detail: "auto";
|
|
53
|
-
image_url: string;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
export type ResponsesInputContentItem = ResponsesTextInputItem | ResponsesImageInputItem;
|
|
57
|
-
|
|
58
|
-
export type ResponsesInputMessageItem = {
|
|
59
|
-
role: "user" | "developer" | "system";
|
|
60
|
-
content: ResponsesInputContentItem[] | string;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
export type ResponsesAssistantOutputItem = {
|
|
64
|
-
type: "message";
|
|
65
|
-
role: "assistant";
|
|
66
|
-
content: Array<{
|
|
67
|
-
type: "output_text";
|
|
68
|
-
text: string;
|
|
69
|
-
annotations: [];
|
|
70
|
-
}>;
|
|
71
|
-
status: "completed";
|
|
72
|
-
id: string;
|
|
73
|
-
phase?: AssistantPhase;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
export type ResponsesFunctionCallItem = {
|
|
77
|
-
type: "function_call";
|
|
78
|
-
id?: string;
|
|
79
|
-
call_id: string;
|
|
80
|
-
name: string;
|
|
81
|
-
arguments: string;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
export type ResponsesFunctionCallOutputItem = {
|
|
85
|
-
type: "function_call_output";
|
|
86
|
-
call_id: string;
|
|
87
|
-
output: ResponsesInputContentItem[] | string;
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
export type ResponsesReasoningItem = Record<string, unknown>;
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Pi 0.86 adds `SystemMessage` to the public `Message` union; 0.85.1 has no system
|
|
94
|
-
* variant and does not export that type name. Use the public shape when the installed
|
|
95
|
-
* Pi declares it and a structural fallback for the fields this serializer consumes
|
|
96
|
-
* otherwise, so one source file type-checks against both supported versions.
|
|
97
|
-
*/
|
|
98
|
-
type ResponsesSystemMessage = Extract<Message, { role: "system" }> extends never
|
|
99
|
-
? {
|
|
100
|
-
role: "system";
|
|
101
|
-
content: string | TextContent[];
|
|
102
|
-
sections?: Record<string, string | null>;
|
|
103
|
-
timestamp: number;
|
|
104
|
-
}
|
|
105
|
-
: Extract<Message, { role: "system" }>;
|
|
106
|
-
|
|
107
|
-
export type ResponsesInputItem =
|
|
108
|
-
| ResponsesInputMessageItem
|
|
109
|
-
| ResponsesAssistantOutputItem
|
|
110
|
-
| ResponsesFunctionCallItem
|
|
111
|
-
| ResponsesFunctionCallOutputItem
|
|
112
|
-
| ResponsesReasoningItem;
|
|
113
|
-
|
|
114
|
-
/** The synthetic request shares the live-request extras allowlist. */
|
|
115
|
-
export type NativeCompactionRequestBody = CompactionRequestExtras & {
|
|
116
|
-
model: string;
|
|
117
|
-
input: ResponsesInputItem[];
|
|
118
|
-
instructions: string;
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
export type SerializeResponsesMessagesOptions = {
|
|
122
|
-
instructions?: string;
|
|
123
|
-
includeInstructionsInInput?: boolean;
|
|
124
|
-
/** Treat a system message at the start of a sliced post-compaction tail as an update. */
|
|
125
|
-
firstSystemMessageIsUpdate?: boolean;
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
export type ResponsesParityReport = {
|
|
129
|
-
ok: boolean;
|
|
130
|
-
actual: string[];
|
|
131
|
-
expected: string[];
|
|
132
|
-
mismatches: string[];
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
type ParsedTextSignature = {
|
|
136
|
-
id: string;
|
|
137
|
-
phase?: AssistantPhase;
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
const SYNTHETIC_TOOL_RESULT_TEXT = "No result provided";
|
|
141
|
-
|
|
142
|
-
function sanitizeSurrogates(text: string): string {
|
|
143
|
-
return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function instructionRole<TApi extends Api>(model: Model<TApi>): "developer" | "system" {
|
|
147
|
-
// Pi's public Model compat union is broader than the OpenAI Responses compat
|
|
148
|
-
// shape, while the runtime field is shared by both supported Pi versions.
|
|
149
|
-
const compat = model.compat as { supportsDeveloperRole?: boolean } | undefined;
|
|
150
|
-
return model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export function collectCompactionWindowMessages(preparation: CompactionPreparation): AgentMessage[] {
|
|
154
|
-
return [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export function serializeCompactionPreparationToRequest<TApi extends Api>(args: {
|
|
158
|
-
model: Model<TApi>;
|
|
159
|
-
preparation: CompactionPreparation;
|
|
160
|
-
instructions: string;
|
|
161
|
-
}): NativeCompactionRequestBody {
|
|
162
|
-
return serializeMessagesToCompactRequest({
|
|
163
|
-
model: args.model,
|
|
164
|
-
messages: collectCompactionWindowMessages(args.preparation),
|
|
165
|
-
instructions: args.instructions,
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export function serializeMessagesToCompactRequest<TApi extends Api>(args: {
|
|
170
|
-
model: Model<TApi>;
|
|
171
|
-
messages: AgentMessage[];
|
|
172
|
-
instructions: string;
|
|
173
|
-
}): NativeCompactionRequestBody {
|
|
174
|
-
return {
|
|
175
|
-
model: args.model.id,
|
|
176
|
-
input: serializeMessagesToResponsesInput(args.model, args.messages),
|
|
177
|
-
instructions: sanitizeSurrogates(args.instructions),
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Async compaction serializer used by live synthetic requests. Pi exposes model
|
|
183
|
-
* image limits and newer versions expose a public resize helper; keep the
|
|
184
|
-
* historical sync API for callers/tests while applying those limits only at the
|
|
185
|
-
* actual request edge.
|
|
186
|
-
*/
|
|
187
|
-
export async function serializeMessagesToCompactRequestWithLimits<TApi extends Api>(args: {
|
|
188
|
-
model: Model<TApi>;
|
|
189
|
-
messages: AgentMessage[];
|
|
190
|
-
instructions: string;
|
|
191
|
-
}): Promise<NativeCompactionRequestBody> {
|
|
192
|
-
return {
|
|
193
|
-
model: args.model.id,
|
|
194
|
-
input: await serializeMessagesToResponsesInputWithLimits(args.model, args.messages),
|
|
195
|
-
instructions: sanitizeSurrogates(args.instructions),
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
export function serializeMessagesToResponsesInput<TApi extends Api>(
|
|
200
|
-
model: Model<TApi>,
|
|
201
|
-
messages: AgentMessage[],
|
|
202
|
-
options: SerializeResponsesMessagesOptions = {},
|
|
203
|
-
): ResponsesInputItem[] {
|
|
204
|
-
return serializeLlmMessagesToResponsesInput(model, convertToLlm(messages), options);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export async function serializeMessagesToResponsesInputWithLimits<TApi extends Api>(
|
|
208
|
-
model: Model<TApi>,
|
|
209
|
-
messages: AgentMessage[],
|
|
210
|
-
options: SerializeResponsesMessagesOptions = {},
|
|
211
|
-
): Promise<ResponsesInputItem[]> {
|
|
212
|
-
return applyModelImageInputLimits(
|
|
213
|
-
model,
|
|
214
|
-
serializeMessagesToResponsesInput(model, messages, options),
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export async function serializeLlmMessagesToResponsesInputWithLimits<TApi extends Api>(
|
|
219
|
-
model: Model<TApi>,
|
|
220
|
-
messages: Message[],
|
|
221
|
-
options: SerializeResponsesMessagesOptions = {},
|
|
222
|
-
): Promise<ResponsesInputItem[]> {
|
|
223
|
-
return applyModelImageInputLimits(
|
|
224
|
-
model,
|
|
225
|
-
serializeLlmMessagesToResponsesInput(model, messages, options),
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Serialize Pi's normalized LLM message union without running `convertToLlm` again.
|
|
231
|
-
* This keeps the system-message boundary directly testable while preserving the public
|
|
232
|
-
* AgentMessage-facing entry point above.
|
|
233
|
-
*/
|
|
234
|
-
export function serializeLlmMessagesToResponsesInput<TApi extends Api>(
|
|
235
|
-
model: Model<TApi>,
|
|
236
|
-
messages: Message[],
|
|
237
|
-
options: SerializeResponsesMessagesOptions = {},
|
|
238
|
-
): ResponsesInputItem[] {
|
|
239
|
-
const transformedMessages = transformMessagesForResponses(messages);
|
|
240
|
-
const input: ResponsesInputItem[] = [];
|
|
241
|
-
|
|
242
|
-
if (options.includeInstructionsInInput && options.instructions) {
|
|
243
|
-
input.push({
|
|
244
|
-
role: instructionRole(model),
|
|
245
|
-
content: sanitizeSurrogates(options.instructions),
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
let messageIndex = 0;
|
|
250
|
-
let sourceIndex = 0;
|
|
251
|
-
for (const message of transformedMessages) {
|
|
252
|
-
// Pi 0.85's Message union has no system member, so compare through the
|
|
253
|
-
// public role string while keeping the 0.86 discriminant runtime-compatible.
|
|
254
|
-
const isFirstMessage = sourceIndex++ === 0;
|
|
255
|
-
const isLeadingSystemMessage =
|
|
256
|
-
!options.firstSystemMessageIsUpdate && isFirstMessage && (message.role as string) === "system";
|
|
257
|
-
|
|
258
|
-
if (message.role === "user") {
|
|
259
|
-
const item = serializeUserMessage(message, model);
|
|
260
|
-
if (item) {
|
|
261
|
-
input.push(item);
|
|
262
|
-
}
|
|
263
|
-
messageIndex++;
|
|
264
|
-
continue;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (message.role === "assistant") {
|
|
268
|
-
const items = serializeAssistantMessage(message, messageIndex);
|
|
269
|
-
if (items.length > 0) {
|
|
270
|
-
input.push(...items);
|
|
271
|
-
}
|
|
272
|
-
messageIndex++;
|
|
273
|
-
continue;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
if (message.role === "toolResult") {
|
|
277
|
-
input.push(serializeToolResultMessage(message, model));
|
|
278
|
-
messageIndex++;
|
|
279
|
-
continue;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// In Pi 0.85.1 this branch narrows to `never`; in Pi 0.86 it is a SystemMessage.
|
|
283
|
-
// Handle it explicitly instead of letting a new message variant become tool output.
|
|
284
|
-
const systemItem = serializeSystemMessage(message, model, isLeadingSystemMessage);
|
|
285
|
-
if (systemItem) {
|
|
286
|
-
input.push(systemItem);
|
|
287
|
-
}
|
|
288
|
-
if (!isLeadingSystemMessage) {
|
|
289
|
-
messageIndex++;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
return input;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
type ImageResizeOptions = {
|
|
297
|
-
maxWidth?: number;
|
|
298
|
-
maxHeight?: number;
|
|
299
|
-
maxBytes?: number;
|
|
300
|
-
jpegQuality?: number;
|
|
301
|
-
};
|
|
302
|
-
|
|
303
|
-
type ModelImageInputLimits = {
|
|
304
|
-
resize?: ImageResizeOptions;
|
|
305
|
-
maxPerMessage?: number;
|
|
306
|
-
maxPerRequest?: number;
|
|
307
|
-
};
|
|
308
|
-
|
|
309
|
-
type ResizeImageResult = {
|
|
310
|
-
data: string;
|
|
311
|
-
mimeType: string;
|
|
312
|
-
};
|
|
313
|
-
|
|
314
|
-
type ResizeImageFunction = (
|
|
315
|
-
inputBytes: Uint8Array,
|
|
316
|
-
mimeType: string,
|
|
317
|
-
options?: ImageResizeOptions,
|
|
318
|
-
) => Promise<ResizeImageResult | null>;
|
|
319
|
-
|
|
320
|
-
let resizeImageLoader: Promise<ResizeImageFunction | undefined> | undefined;
|
|
321
|
-
|
|
322
|
-
function getModelImageInputLimits(model: Model<Api>): ModelImageInputLimits | undefined {
|
|
323
|
-
return (model as Model<Api> & { inputLimits?: { images?: ModelImageInputLimits } }).inputLimits?.images;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function readNonNegativeLimit(value: unknown, name: string): number | undefined {
|
|
327
|
-
if (value === undefined) return undefined;
|
|
328
|
-
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
329
|
-
throw new Error(`Model image input limit ${name} is invalid.`);
|
|
330
|
-
}
|
|
331
|
-
return value;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function normalizeResizeOptions(value: unknown): ImageResizeOptions | undefined {
|
|
335
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
336
|
-
const candidate = value as Record<string, unknown>;
|
|
337
|
-
const options: ImageResizeOptions = {};
|
|
338
|
-
for (const key of ["maxWidth", "maxHeight", "maxBytes"] as const) {
|
|
339
|
-
const number = candidate[key];
|
|
340
|
-
if (number === undefined) continue;
|
|
341
|
-
if (typeof number !== "number" || !Number.isFinite(number) || number <= 0) {
|
|
342
|
-
throw new Error(`Model image resize option ${key} is invalid.`);
|
|
343
|
-
}
|
|
344
|
-
options[key] = Math.floor(number);
|
|
345
|
-
}
|
|
346
|
-
if (candidate.jpegQuality !== undefined) {
|
|
347
|
-
if (
|
|
348
|
-
typeof candidate.jpegQuality !== "number" ||
|
|
349
|
-
!Number.isFinite(candidate.jpegQuality) ||
|
|
350
|
-
candidate.jpegQuality <= 0 ||
|
|
351
|
-
candidate.jpegQuality > 100
|
|
352
|
-
) {
|
|
353
|
-
throw new Error("Model image resize option jpegQuality is invalid.");
|
|
354
|
-
}
|
|
355
|
-
options.jpegQuality = candidate.jpegQuality;
|
|
356
|
-
}
|
|
357
|
-
return Object.keys(options).length > 0 ? options : undefined;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
async function loadResizeImage(): Promise<ResizeImageFunction | undefined> {
|
|
361
|
-
resizeImageLoader ??= (async () => {
|
|
362
|
-
try {
|
|
363
|
-
const module = await import("@earendil-works/pi-coding-agent") as {
|
|
364
|
-
resizeImage?: ResizeImageFunction;
|
|
365
|
-
};
|
|
366
|
-
return typeof module.resizeImage === "function" ? module.resizeImage : undefined;
|
|
367
|
-
} catch {
|
|
368
|
-
// Pi 0.87 does not export the helper. The request must fail closed when
|
|
369
|
-
// a newer model explicitly requires a resize rather than sending a
|
|
370
|
-
// potentially oversized image unchanged.
|
|
371
|
-
return undefined;
|
|
372
|
-
}
|
|
373
|
-
})();
|
|
374
|
-
return resizeImageLoader;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function inputContentArray(item: ResponsesInputItem): ResponsesInputContentItem[] | undefined {
|
|
378
|
-
if (!item || typeof item !== "object" || Array.isArray(item)) return undefined;
|
|
379
|
-
const candidate = item as Record<string, unknown>;
|
|
380
|
-
if (candidate.type === "function_call_output") {
|
|
381
|
-
return Array.isArray(candidate.output) ? candidate.output as ResponsesInputContentItem[] : undefined;
|
|
382
|
-
}
|
|
383
|
-
return Array.isArray(candidate.content) ? candidate.content as ResponsesInputContentItem[] : undefined;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function isInputImage(item: ResponsesInputContentItem): item is ResponsesImageInputItem {
|
|
387
|
-
return item.type === "input_image";
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function decodeDataImage(imageUrl: string): { mimeType: string; bytes: Uint8Array } {
|
|
391
|
-
const separator = imageUrl.indexOf(",");
|
|
392
|
-
const prefix = separator >= 0 ? imageUrl.slice(0, separator) : "";
|
|
393
|
-
if (!prefix.startsWith("data:") || !prefix.endsWith(";base64") || separator < 0) {
|
|
394
|
-
throw new Error("Model image input has an invalid data URL.");
|
|
395
|
-
}
|
|
396
|
-
const mimeType = prefix.slice("data:".length, -";base64".length);
|
|
397
|
-
if (!mimeType) throw new Error("Model image input has no MIME type.");
|
|
398
|
-
return { mimeType, bytes: Buffer.from(imageUrl.slice(separator + 1), "base64") };
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
async function resizeInputImage(
|
|
402
|
-
image: ResponsesImageInputItem,
|
|
403
|
-
options: ImageResizeOptions,
|
|
404
|
-
): Promise<ResponsesImageInputItem> {
|
|
405
|
-
const resizeImage = await loadResizeImage();
|
|
406
|
-
if (!resizeImage) {
|
|
407
|
-
throw new Error("The active Pi version cannot resize an image required by model input limits.");
|
|
408
|
-
}
|
|
409
|
-
const source = decodeDataImage(image.image_url);
|
|
410
|
-
const resized = await resizeImage(source.bytes, source.mimeType, options);
|
|
411
|
-
if (!resized || typeof resized.data !== "string" || typeof resized.mimeType !== "string") {
|
|
412
|
-
throw new Error("Model image input could not be resized within the provider limits.");
|
|
413
|
-
}
|
|
414
|
-
return {
|
|
415
|
-
...image,
|
|
416
|
-
image_url: `data:${resized.mimeType};base64,${resized.data}`,
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/** Enforce Pi's per-message/request image limits without mutating serialized input. */
|
|
421
|
-
export async function applyModelImageInputLimits<TApi extends Api>(
|
|
422
|
-
model: Model<TApi>,
|
|
423
|
-
input: ResponsesInputItem[],
|
|
424
|
-
): Promise<ResponsesInputItem[]> {
|
|
425
|
-
const limits = getModelImageInputLimits(model);
|
|
426
|
-
if (!limits) return input;
|
|
427
|
-
|
|
428
|
-
const maxPerMessage = readNonNegativeLimit(limits.maxPerMessage, "maxPerMessage");
|
|
429
|
-
const maxPerRequest = readNonNegativeLimit(limits.maxPerRequest, "maxPerRequest");
|
|
430
|
-
const resizeOptions = normalizeResizeOptions(limits.resize);
|
|
431
|
-
let requestImageCount = 0;
|
|
432
|
-
const output: ResponsesInputItem[] = [];
|
|
433
|
-
|
|
434
|
-
for (const item of input) {
|
|
435
|
-
const content = inputContentArray(item);
|
|
436
|
-
if (!content) {
|
|
437
|
-
output.push(item);
|
|
438
|
-
continue;
|
|
439
|
-
}
|
|
440
|
-
const imageCount = content.filter(isInputImage).length;
|
|
441
|
-
if (maxPerMessage !== undefined && imageCount > maxPerMessage) {
|
|
442
|
-
throw new Error(`Model accepts at most ${maxPerMessage} image(s) per message.`);
|
|
443
|
-
}
|
|
444
|
-
if (maxPerRequest !== undefined && requestImageCount + imageCount > maxPerRequest) {
|
|
445
|
-
throw new Error(`Model accepts at most ${maxPerRequest} image(s) per request.`);
|
|
446
|
-
}
|
|
447
|
-
requestImageCount += imageCount;
|
|
448
|
-
|
|
449
|
-
if (!resizeOptions || imageCount === 0) {
|
|
450
|
-
output.push(item);
|
|
451
|
-
continue;
|
|
452
|
-
}
|
|
453
|
-
const resizedContent: ResponsesInputContentItem[] = [];
|
|
454
|
-
for (const contentItem of content) {
|
|
455
|
-
resizedContent.push(isInputImage(contentItem)
|
|
456
|
-
? await resizeInputImage(contentItem, resizeOptions)
|
|
457
|
-
: contentItem);
|
|
458
|
-
}
|
|
459
|
-
const itemRecord = item as Record<string, unknown>;
|
|
460
|
-
if (itemRecord.type === "function_call_output") {
|
|
461
|
-
output.push({ ...item, output: resizedContent } as ResponsesInputItem);
|
|
462
|
-
} else {
|
|
463
|
-
output.push({ ...item, content: resizedContent } as ResponsesInputItem);
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
return output;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
export function createResponsesInputParitySignature(input: readonly unknown[]): string[] {
|
|
471
|
-
return input.map(describeResponsesInputItem);
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
export function compareResponsesInputParity(actual: readonly unknown[], expected: readonly unknown[]): ResponsesParityReport {
|
|
475
|
-
const actualSignature = createResponsesInputParitySignature(actual);
|
|
476
|
-
const expectedSignature = createResponsesInputParitySignature(expected);
|
|
477
|
-
const maxLength = Math.max(actualSignature.length, expectedSignature.length);
|
|
478
|
-
const mismatches: string[] = [];
|
|
479
|
-
|
|
480
|
-
for (let index = 0; index < maxLength; index++) {
|
|
481
|
-
const actualValue = actualSignature[index];
|
|
482
|
-
const expectedValue = expectedSignature[index];
|
|
483
|
-
if (actualValue !== expectedValue) {
|
|
484
|
-
mismatches.push(`index ${index}: expected ${expectedValue ?? "<missing>"}, got ${actualValue ?? "<missing>"}`);
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
return {
|
|
489
|
-
ok: mismatches.length === 0,
|
|
490
|
-
actual: actualSignature,
|
|
491
|
-
expected: expectedSignature,
|
|
492
|
-
mismatches,
|
|
493
|
-
};
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
export function compareCompactRequestToPayload(
|
|
497
|
-
request: NativeCompactionRequestBody,
|
|
498
|
-
payload: Pick<ResponsesCompatibleRequestPayload, "model" | "input" | "instructions">,
|
|
499
|
-
): ResponsesParityReport {
|
|
500
|
-
const parity = compareResponsesInputParity(request.input, payload.input);
|
|
501
|
-
const mismatches = [...parity.mismatches];
|
|
502
|
-
|
|
503
|
-
if (payload.model !== request.model) {
|
|
504
|
-
mismatches.unshift(`model: expected ${payload.model}, got ${request.model}`);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
if ((payload.instructions ?? "") !== request.instructions) {
|
|
508
|
-
mismatches.unshift("instructions: expected serialized instructions to match payload instructions");
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
return {
|
|
512
|
-
ok: mismatches.length === 0,
|
|
513
|
-
actual: parity.actual,
|
|
514
|
-
expected: parity.expected,
|
|
515
|
-
mismatches,
|
|
516
|
-
};
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
function transformMessagesForResponses(messages: Message[]): Message[] {
|
|
520
|
-
const transformed: Message[] = [];
|
|
521
|
-
let pendingToolCalls: ToolCall[] = [];
|
|
522
|
-
let existingToolResultIds = new Set<string>();
|
|
523
|
-
|
|
524
|
-
for (const message of messages) {
|
|
525
|
-
if (message.role === "assistant") {
|
|
526
|
-
if (pendingToolCalls.length > 0) {
|
|
527
|
-
transformed.push(...createSyntheticToolResults(pendingToolCalls, existingToolResultIds));
|
|
528
|
-
pendingToolCalls = [];
|
|
529
|
-
existingToolResultIds = new Set<string>();
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
|
533
|
-
continue;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
const normalizedContent: AssistantMessage["content"] = [];
|
|
537
|
-
for (const block of message.content) {
|
|
538
|
-
if (block.type !== "thinking" || block.thinkingSignature) {
|
|
539
|
-
normalizedContent.push(block);
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
const normalizedAssistantMessage: AssistantMessage = {
|
|
544
|
-
...message,
|
|
545
|
-
content: normalizedContent,
|
|
546
|
-
};
|
|
547
|
-
transformed.push(normalizedAssistantMessage);
|
|
548
|
-
|
|
549
|
-
const toolCalls = normalizedContent.filter(isToolCallBlock);
|
|
550
|
-
if (toolCalls.length > 0) {
|
|
551
|
-
pendingToolCalls = toolCalls;
|
|
552
|
-
existingToolResultIds = new Set<string>();
|
|
553
|
-
}
|
|
554
|
-
continue;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
if (message.role === "toolResult") {
|
|
558
|
-
existingToolResultIds.add(message.toolCallId);
|
|
559
|
-
transformed.push(message);
|
|
560
|
-
continue;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
if (pendingToolCalls.length > 0) {
|
|
564
|
-
transformed.push(...createSyntheticToolResults(pendingToolCalls, existingToolResultIds));
|
|
565
|
-
pendingToolCalls = [];
|
|
566
|
-
existingToolResultIds = new Set<string>();
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
transformed.push(message);
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
transformed.push(...createSyntheticToolResults(pendingToolCalls, existingToolResultIds));
|
|
573
|
-
return transformed;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
function createSyntheticToolResults(
|
|
577
|
-
pendingToolCalls: readonly ToolCall[],
|
|
578
|
-
existingToolResultIds: ReadonlySet<string>,
|
|
579
|
-
): ToolResultMessage[] {
|
|
580
|
-
const syntheticResults: ToolResultMessage[] = [];
|
|
581
|
-
|
|
582
|
-
for (const toolCall of pendingToolCalls) {
|
|
583
|
-
if (existingToolResultIds.has(toolCall.id)) {
|
|
584
|
-
continue;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
syntheticResults.push({
|
|
588
|
-
role: "toolResult",
|
|
589
|
-
toolCallId: toolCall.id,
|
|
590
|
-
toolName: toolCall.name,
|
|
591
|
-
content: [{ type: "text", text: SYNTHETIC_TOOL_RESULT_TEXT }],
|
|
592
|
-
isError: true,
|
|
593
|
-
timestamp: Date.now(),
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
return syntheticResults;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
function serializeUserMessage<TApi extends Api>(
|
|
601
|
-
message: UserMessage,
|
|
602
|
-
model: Model<TApi>,
|
|
603
|
-
): ResponsesInputMessageItem | undefined {
|
|
604
|
-
const contentItems = normalizeUserContent(message.content).flatMap((item) => serializeUserContentItem(item, model));
|
|
605
|
-
if (contentItems.length === 0) {
|
|
606
|
-
return undefined;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
return {
|
|
610
|
-
role: "user",
|
|
611
|
-
content: contentItems,
|
|
612
|
-
};
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
function serializeUserContentItem<TApi extends Api>(
|
|
616
|
-
item: TextContent | ImageContent,
|
|
617
|
-
model: Model<TApi>,
|
|
618
|
-
): ResponsesInputContentItem[] {
|
|
619
|
-
if (item.type === "text") {
|
|
620
|
-
return [{ type: "input_text", text: sanitizeSurrogates(item.text) }];
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
if (!model.input.includes("image")) {
|
|
624
|
-
return [];
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
return [
|
|
628
|
-
{
|
|
629
|
-
type: "input_image",
|
|
630
|
-
detail: "auto",
|
|
631
|
-
image_url: `data:${item.mimeType};base64,${item.data}`,
|
|
632
|
-
},
|
|
633
|
-
];
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
function serializeAssistantMessage(message: AssistantMessage, messageIndex: number): ResponsesInputItem[] {
|
|
637
|
-
const items: ResponsesInputItem[] = [];
|
|
638
|
-
|
|
639
|
-
for (const block of message.content) {
|
|
640
|
-
if (block.type === "thinking") {
|
|
641
|
-
const reasoningItem = parseReasoningItem(block);
|
|
642
|
-
if (reasoningItem) {
|
|
643
|
-
items.push(reasoningItem);
|
|
644
|
-
}
|
|
645
|
-
continue;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
if (block.type === "text") {
|
|
649
|
-
const signature = parseTextSignature(block.textSignature);
|
|
650
|
-
items.push({
|
|
651
|
-
type: "message",
|
|
652
|
-
role: "assistant",
|
|
653
|
-
content: [{ type: "output_text", text: sanitizeSurrogates(block.text), annotations: [] }],
|
|
654
|
-
status: "completed",
|
|
655
|
-
id: normalizeAssistantMessageId(signature?.id, messageIndex),
|
|
656
|
-
phase: signature?.phase,
|
|
657
|
-
});
|
|
658
|
-
continue;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
const [callId, rawItemId] = block.id.split("|");
|
|
662
|
-
items.push({
|
|
663
|
-
type: "function_call",
|
|
664
|
-
id: rawItemId,
|
|
665
|
-
call_id: callId,
|
|
666
|
-
name: block.name,
|
|
667
|
-
arguments: JSON.stringify(block.arguments),
|
|
668
|
-
});
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
return items;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
function serializeToolResultMessage<TApi extends Api>(
|
|
675
|
-
message: ToolResultMessage,
|
|
676
|
-
model: Model<TApi>,
|
|
677
|
-
): ResponsesFunctionCallOutputItem {
|
|
678
|
-
const [callId] = message.toolCallId.split("|");
|
|
679
|
-
const textOutput = message.content
|
|
680
|
-
.filter((item): item is TextContent => item.type === "text")
|
|
681
|
-
.map((item) => sanitizeSurrogates(item.text))
|
|
682
|
-
.join("\n");
|
|
683
|
-
const hasImages = message.content.some((item) => item.type === "image");
|
|
684
|
-
const hasText = textOutput.length > 0;
|
|
685
|
-
|
|
686
|
-
if (hasImages && model.input.includes("image")) {
|
|
687
|
-
const output: ResponsesInputContentItem[] = [];
|
|
688
|
-
if (hasText) {
|
|
689
|
-
output.push({ type: "input_text", text: textOutput });
|
|
690
|
-
}
|
|
691
|
-
for (const item of message.content) {
|
|
692
|
-
if (item.type !== "image") {
|
|
693
|
-
continue;
|
|
694
|
-
}
|
|
695
|
-
output.push({
|
|
696
|
-
type: "input_image",
|
|
697
|
-
detail: "auto",
|
|
698
|
-
image_url: `data:${item.mimeType};base64,${item.data}`,
|
|
699
|
-
});
|
|
700
|
-
}
|
|
701
|
-
return {
|
|
702
|
-
type: "function_call_output",
|
|
703
|
-
call_id: callId,
|
|
704
|
-
output,
|
|
705
|
-
};
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
return {
|
|
709
|
-
type: "function_call_output",
|
|
710
|
-
call_id: callId,
|
|
711
|
-
output: hasText ? textOutput : "(see attached image)",
|
|
712
|
-
};
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
function normalizeUserContent(content: UserMessage["content"]): Array<TextContent | ImageContent> {
|
|
716
|
-
return typeof content === "string" ? [{ type: "text", text: content }] : content;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
/**
|
|
720
|
-
* Serialize one normalized Pi system message as a Responses input message.
|
|
721
|
-
*
|
|
722
|
-
* The role follows the same reasoning-model convention as `instructions`: reasoning
|
|
723
|
-
* models use `developer`, everything else keeps `system`. Leading messages include
|
|
724
|
-
* their complete section state; later messages use Pi's update framing so section
|
|
725
|
-
* additions/removals are not mistaken for a new complete prompt. An empty system
|
|
726
|
-
* message produces no item, matching Pi.
|
|
727
|
-
*/
|
|
728
|
-
function serializeSystemMessage<TApi extends Api>(
|
|
729
|
-
message: ResponsesSystemMessage,
|
|
730
|
-
model: Model<TApi>,
|
|
731
|
-
isLeadingSystemMessage: boolean,
|
|
732
|
-
): ResponsesInputMessageItem | undefined {
|
|
733
|
-
const text = sanitizeSurrogates(
|
|
734
|
-
isLeadingSystemMessage ? getSystemMessageText(message) : renderSystemMessageUpdate(message),
|
|
735
|
-
);
|
|
736
|
-
if (text.length === 0) {
|
|
737
|
-
return undefined;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
return {
|
|
741
|
-
role: instructionRole(model),
|
|
742
|
-
content: text,
|
|
743
|
-
};
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
/** Mirrors Pi's public getSystemMessageText() without importing a 0.86-only helper. */
|
|
747
|
-
function getSystemMessageText(message: ResponsesSystemMessage): string {
|
|
748
|
-
const parts = [contentText(message.content)];
|
|
749
|
-
for (const text of Object.values(message.sections ?? {})) {
|
|
750
|
-
if (text !== null) {
|
|
751
|
-
parts.push(text);
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
return parts.filter((part) => part.length > 0).join("\n\n");
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
/** Mirrors Pi's public renderSystemMessageUpdate() for later transcript updates. */
|
|
758
|
-
function renderSystemMessageUpdate(message: ResponsesSystemMessage): string {
|
|
759
|
-
const parts: string[] = [];
|
|
760
|
-
const text = contentText(message.content);
|
|
761
|
-
if (text.length > 0) {
|
|
762
|
-
parts.push(text);
|
|
763
|
-
}
|
|
764
|
-
for (const [name, value] of Object.entries(message.sections ?? {})) {
|
|
765
|
-
parts.push(
|
|
766
|
-
value === null
|
|
767
|
-
? `Removed system prompt section "${name}".`
|
|
768
|
-
: `Updated system prompt section "${name}":\n\n${value}`,
|
|
769
|
-
);
|
|
770
|
-
}
|
|
771
|
-
return parts.join("\n\n");
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
function contentText(content: ResponsesSystemMessage["content"]): string {
|
|
775
|
-
if (typeof content === "string") {
|
|
776
|
-
return content;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
return content
|
|
780
|
-
.filter((item): item is TextContent => item.type === "text")
|
|
781
|
-
.map((item) => item.text)
|
|
782
|
-
.join("\n");
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
function parseReasoningItem(block: ThinkingContent): ResponsesReasoningItem | undefined {
|
|
786
|
-
if (!block.thinkingSignature) {
|
|
787
|
-
return undefined;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
try {
|
|
791
|
-
const parsed = JSON.parse(block.thinkingSignature);
|
|
792
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
793
|
-
return undefined;
|
|
794
|
-
}
|
|
795
|
-
return parsed as ResponsesReasoningItem;
|
|
796
|
-
} catch {
|
|
797
|
-
return undefined;
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
function parseTextSignature(signature: string | undefined): ParsedTextSignature | undefined {
|
|
802
|
-
if (!signature) {
|
|
803
|
-
return undefined;
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
if (!signature.startsWith("{")) {
|
|
807
|
-
return { id: signature };
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
try {
|
|
811
|
-
const parsed = JSON.parse(signature);
|
|
812
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
813
|
-
return undefined;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
const record = parsed as Record<string, unknown>;
|
|
817
|
-
if (record.v !== 1 || typeof record.id !== "string") {
|
|
818
|
-
return undefined;
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
return {
|
|
822
|
-
id: record.id,
|
|
823
|
-
phase:
|
|
824
|
-
record.phase === "commentary" || record.phase === "final_answer"
|
|
825
|
-
? record.phase
|
|
826
|
-
: undefined,
|
|
827
|
-
};
|
|
828
|
-
} catch {
|
|
829
|
-
return undefined;
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
function normalizeAssistantMessageId(id: string | undefined, messageIndex: number): string {
|
|
834
|
-
if (!id) {
|
|
835
|
-
return `msg_${messageIndex}`;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
if (id.length <= 64) {
|
|
839
|
-
return id;
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
return `msg_${createHash("sha1").update(id).digest("hex").slice(0, 12)}`;
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
function isToolCallBlock(block: AssistantMessage["content"][number]): block is ToolCall {
|
|
846
|
-
return block.type === "toolCall";
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
function describeResponsesInputItem(item: unknown): string {
|
|
850
|
-
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
851
|
-
return typeof item;
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
const record = item as Record<string, unknown>;
|
|
855
|
-
const type = typeof record.type === "string" ? record.type : undefined;
|
|
856
|
-
if (type === "message") {
|
|
857
|
-
const phase =
|
|
858
|
-
record.phase === "commentary" || record.phase === "final_answer"
|
|
859
|
-
? `:${record.phase}`
|
|
860
|
-
: "";
|
|
861
|
-
return `message:${typeof record.role === "string" ? record.role : "unknown"}${phase}`;
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
if (type === "function_call") {
|
|
865
|
-
return `function_call:${typeof record.name === "string" ? record.name : "unknown"}`;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
if (type === "function_call_output") {
|
|
869
|
-
return "function_call_output";
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
if (type === "reasoning") {
|
|
873
|
-
return "reasoning";
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
if (typeof record.role === "string") {
|
|
877
|
-
const content = Array.isArray(record.content) ? `[${record.content.length}]` : "";
|
|
878
|
-
return `input:${record.role}${content}`;
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
return type ? `item:${type}` : "object";
|
|
882
|
-
}
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import { compact, convertToLlm } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type {
|
|
5
|
+
Api,
|
|
6
|
+
AssistantMessage,
|
|
7
|
+
ImageContent,
|
|
8
|
+
Message,
|
|
9
|
+
Model,
|
|
10
|
+
TextContent,
|
|
11
|
+
ThinkingContent,
|
|
12
|
+
ToolCall,
|
|
13
|
+
ToolResultMessage,
|
|
14
|
+
UserMessage,
|
|
15
|
+
} from "@earendil-works/pi-ai";
|
|
16
|
+
import type { ResponsesCompatibleRequestPayload } from "./runtime";
|
|
17
|
+
import type { CompactionRequestExtras } from "./request-context-cache";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* pi stopped exporting the CompactionPreparation type name in 0.80.x, but it is still
|
|
21
|
+
* structurally the first argument of the exported compact(). Derive it from there so we
|
|
22
|
+
* track pi's shape without depending on a private export.
|
|
23
|
+
*/
|
|
24
|
+
type CompactionPreparation = Parameters<typeof compact>[0];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Decision for T4: keep a narrow local serializer instead of importing Pi internals.
|
|
28
|
+
*
|
|
29
|
+
* Why this is sufficient for v1:
|
|
30
|
+
* - we only target same-model OpenAI Responses-compatible requests
|
|
31
|
+
* - we only need Pi's current supported message semantics (assistant phase,
|
|
32
|
+
* reasoning signatures, tool call/result pairing, image blocks)
|
|
33
|
+
* - Pi's shared Responses converter is not publicly exported, so importing it
|
|
34
|
+
* would require a brittle install-path-specific wrapper
|
|
35
|
+
*
|
|
36
|
+
* The helpers below intentionally mirror Pi's same-model Responses serialization
|
|
37
|
+
* rules closely so later tasks can compare their output against captured
|
|
38
|
+
* before_provider_request payload artifacts.
|
|
39
|
+
*/
|
|
40
|
+
export const COMPACTION_SERIALIZER_STRATEGY = "local-same-model-responses-serializer" as const;
|
|
41
|
+
|
|
42
|
+
export type CompactionSerializerStrategy = typeof COMPACTION_SERIALIZER_STRATEGY;
|
|
43
|
+
export type AssistantPhase = "commentary" | "final_answer";
|
|
44
|
+
|
|
45
|
+
type ResponsesTextInputItem = {
|
|
46
|
+
type: "input_text";
|
|
47
|
+
text: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type ResponsesImageInputItem = {
|
|
51
|
+
type: "input_image";
|
|
52
|
+
detail: "auto";
|
|
53
|
+
image_url: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type ResponsesInputContentItem = ResponsesTextInputItem | ResponsesImageInputItem;
|
|
57
|
+
|
|
58
|
+
export type ResponsesInputMessageItem = {
|
|
59
|
+
role: "user" | "developer" | "system";
|
|
60
|
+
content: ResponsesInputContentItem[] | string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type ResponsesAssistantOutputItem = {
|
|
64
|
+
type: "message";
|
|
65
|
+
role: "assistant";
|
|
66
|
+
content: Array<{
|
|
67
|
+
type: "output_text";
|
|
68
|
+
text: string;
|
|
69
|
+
annotations: [];
|
|
70
|
+
}>;
|
|
71
|
+
status: "completed";
|
|
72
|
+
id: string;
|
|
73
|
+
phase?: AssistantPhase;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type ResponsesFunctionCallItem = {
|
|
77
|
+
type: "function_call";
|
|
78
|
+
id?: string;
|
|
79
|
+
call_id: string;
|
|
80
|
+
name: string;
|
|
81
|
+
arguments: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type ResponsesFunctionCallOutputItem = {
|
|
85
|
+
type: "function_call_output";
|
|
86
|
+
call_id: string;
|
|
87
|
+
output: ResponsesInputContentItem[] | string;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export type ResponsesReasoningItem = Record<string, unknown>;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Pi 0.86 adds `SystemMessage` to the public `Message` union; 0.85.1 has no system
|
|
94
|
+
* variant and does not export that type name. Use the public shape when the installed
|
|
95
|
+
* Pi declares it and a structural fallback for the fields this serializer consumes
|
|
96
|
+
* otherwise, so one source file type-checks against both supported versions.
|
|
97
|
+
*/
|
|
98
|
+
type ResponsesSystemMessage = Extract<Message, { role: "system" }> extends never
|
|
99
|
+
? {
|
|
100
|
+
role: "system";
|
|
101
|
+
content: string | TextContent[];
|
|
102
|
+
sections?: Record<string, string | null>;
|
|
103
|
+
timestamp: number;
|
|
104
|
+
}
|
|
105
|
+
: Extract<Message, { role: "system" }>;
|
|
106
|
+
|
|
107
|
+
export type ResponsesInputItem =
|
|
108
|
+
| ResponsesInputMessageItem
|
|
109
|
+
| ResponsesAssistantOutputItem
|
|
110
|
+
| ResponsesFunctionCallItem
|
|
111
|
+
| ResponsesFunctionCallOutputItem
|
|
112
|
+
| ResponsesReasoningItem;
|
|
113
|
+
|
|
114
|
+
/** The synthetic request shares the live-request extras allowlist. */
|
|
115
|
+
export type NativeCompactionRequestBody = CompactionRequestExtras & {
|
|
116
|
+
model: string;
|
|
117
|
+
input: ResponsesInputItem[];
|
|
118
|
+
instructions: string;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export type SerializeResponsesMessagesOptions = {
|
|
122
|
+
instructions?: string;
|
|
123
|
+
includeInstructionsInInput?: boolean;
|
|
124
|
+
/** Treat a system message at the start of a sliced post-compaction tail as an update. */
|
|
125
|
+
firstSystemMessageIsUpdate?: boolean;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export type ResponsesParityReport = {
|
|
129
|
+
ok: boolean;
|
|
130
|
+
actual: string[];
|
|
131
|
+
expected: string[];
|
|
132
|
+
mismatches: string[];
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
type ParsedTextSignature = {
|
|
136
|
+
id: string;
|
|
137
|
+
phase?: AssistantPhase;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const SYNTHETIC_TOOL_RESULT_TEXT = "No result provided";
|
|
141
|
+
|
|
142
|
+
function sanitizeSurrogates(text: string): string {
|
|
143
|
+
return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function instructionRole<TApi extends Api>(model: Model<TApi>): "developer" | "system" {
|
|
147
|
+
// Pi's public Model compat union is broader than the OpenAI Responses compat
|
|
148
|
+
// shape, while the runtime field is shared by both supported Pi versions.
|
|
149
|
+
const compat = model.compat as { supportsDeveloperRole?: boolean } | undefined;
|
|
150
|
+
return model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function collectCompactionWindowMessages(preparation: CompactionPreparation): AgentMessage[] {
|
|
154
|
+
return [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function serializeCompactionPreparationToRequest<TApi extends Api>(args: {
|
|
158
|
+
model: Model<TApi>;
|
|
159
|
+
preparation: CompactionPreparation;
|
|
160
|
+
instructions: string;
|
|
161
|
+
}): NativeCompactionRequestBody {
|
|
162
|
+
return serializeMessagesToCompactRequest({
|
|
163
|
+
model: args.model,
|
|
164
|
+
messages: collectCompactionWindowMessages(args.preparation),
|
|
165
|
+
instructions: args.instructions,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function serializeMessagesToCompactRequest<TApi extends Api>(args: {
|
|
170
|
+
model: Model<TApi>;
|
|
171
|
+
messages: AgentMessage[];
|
|
172
|
+
instructions: string;
|
|
173
|
+
}): NativeCompactionRequestBody {
|
|
174
|
+
return {
|
|
175
|
+
model: args.model.id,
|
|
176
|
+
input: serializeMessagesToResponsesInput(args.model, args.messages),
|
|
177
|
+
instructions: sanitizeSurrogates(args.instructions),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Async compaction serializer used by live synthetic requests. Pi exposes model
|
|
183
|
+
* image limits and newer versions expose a public resize helper; keep the
|
|
184
|
+
* historical sync API for callers/tests while applying those limits only at the
|
|
185
|
+
* actual request edge.
|
|
186
|
+
*/
|
|
187
|
+
export async function serializeMessagesToCompactRequestWithLimits<TApi extends Api>(args: {
|
|
188
|
+
model: Model<TApi>;
|
|
189
|
+
messages: AgentMessage[];
|
|
190
|
+
instructions: string;
|
|
191
|
+
}): Promise<NativeCompactionRequestBody> {
|
|
192
|
+
return {
|
|
193
|
+
model: args.model.id,
|
|
194
|
+
input: await serializeMessagesToResponsesInputWithLimits(args.model, args.messages),
|
|
195
|
+
instructions: sanitizeSurrogates(args.instructions),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function serializeMessagesToResponsesInput<TApi extends Api>(
|
|
200
|
+
model: Model<TApi>,
|
|
201
|
+
messages: AgentMessage[],
|
|
202
|
+
options: SerializeResponsesMessagesOptions = {},
|
|
203
|
+
): ResponsesInputItem[] {
|
|
204
|
+
return serializeLlmMessagesToResponsesInput(model, convertToLlm(messages), options);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function serializeMessagesToResponsesInputWithLimits<TApi extends Api>(
|
|
208
|
+
model: Model<TApi>,
|
|
209
|
+
messages: AgentMessage[],
|
|
210
|
+
options: SerializeResponsesMessagesOptions = {},
|
|
211
|
+
): Promise<ResponsesInputItem[]> {
|
|
212
|
+
return applyModelImageInputLimits(
|
|
213
|
+
model,
|
|
214
|
+
serializeMessagesToResponsesInput(model, messages, options),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function serializeLlmMessagesToResponsesInputWithLimits<TApi extends Api>(
|
|
219
|
+
model: Model<TApi>,
|
|
220
|
+
messages: Message[],
|
|
221
|
+
options: SerializeResponsesMessagesOptions = {},
|
|
222
|
+
): Promise<ResponsesInputItem[]> {
|
|
223
|
+
return applyModelImageInputLimits(
|
|
224
|
+
model,
|
|
225
|
+
serializeLlmMessagesToResponsesInput(model, messages, options),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Serialize Pi's normalized LLM message union without running `convertToLlm` again.
|
|
231
|
+
* This keeps the system-message boundary directly testable while preserving the public
|
|
232
|
+
* AgentMessage-facing entry point above.
|
|
233
|
+
*/
|
|
234
|
+
export function serializeLlmMessagesToResponsesInput<TApi extends Api>(
|
|
235
|
+
model: Model<TApi>,
|
|
236
|
+
messages: Message[],
|
|
237
|
+
options: SerializeResponsesMessagesOptions = {},
|
|
238
|
+
): ResponsesInputItem[] {
|
|
239
|
+
const transformedMessages = transformMessagesForResponses(messages);
|
|
240
|
+
const input: ResponsesInputItem[] = [];
|
|
241
|
+
|
|
242
|
+
if (options.includeInstructionsInInput && options.instructions) {
|
|
243
|
+
input.push({
|
|
244
|
+
role: instructionRole(model),
|
|
245
|
+
content: sanitizeSurrogates(options.instructions),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let messageIndex = 0;
|
|
250
|
+
let sourceIndex = 0;
|
|
251
|
+
for (const message of transformedMessages) {
|
|
252
|
+
// Pi 0.85's Message union has no system member, so compare through the
|
|
253
|
+
// public role string while keeping the 0.86 discriminant runtime-compatible.
|
|
254
|
+
const isFirstMessage = sourceIndex++ === 0;
|
|
255
|
+
const isLeadingSystemMessage =
|
|
256
|
+
!options.firstSystemMessageIsUpdate && isFirstMessage && (message.role as string) === "system";
|
|
257
|
+
|
|
258
|
+
if (message.role === "user") {
|
|
259
|
+
const item = serializeUserMessage(message, model);
|
|
260
|
+
if (item) {
|
|
261
|
+
input.push(item);
|
|
262
|
+
}
|
|
263
|
+
messageIndex++;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (message.role === "assistant") {
|
|
268
|
+
const items = serializeAssistantMessage(message, messageIndex);
|
|
269
|
+
if (items.length > 0) {
|
|
270
|
+
input.push(...items);
|
|
271
|
+
}
|
|
272
|
+
messageIndex++;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (message.role === "toolResult") {
|
|
277
|
+
input.push(serializeToolResultMessage(message, model));
|
|
278
|
+
messageIndex++;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// In Pi 0.85.1 this branch narrows to `never`; in Pi 0.86 it is a SystemMessage.
|
|
283
|
+
// Handle it explicitly instead of letting a new message variant become tool output.
|
|
284
|
+
const systemItem = serializeSystemMessage(message, model, isLeadingSystemMessage);
|
|
285
|
+
if (systemItem) {
|
|
286
|
+
input.push(systemItem);
|
|
287
|
+
}
|
|
288
|
+
if (!isLeadingSystemMessage) {
|
|
289
|
+
messageIndex++;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return input;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
type ImageResizeOptions = {
|
|
297
|
+
maxWidth?: number;
|
|
298
|
+
maxHeight?: number;
|
|
299
|
+
maxBytes?: number;
|
|
300
|
+
jpegQuality?: number;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
type ModelImageInputLimits = {
|
|
304
|
+
resize?: ImageResizeOptions;
|
|
305
|
+
maxPerMessage?: number;
|
|
306
|
+
maxPerRequest?: number;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
type ResizeImageResult = {
|
|
310
|
+
data: string;
|
|
311
|
+
mimeType: string;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
type ResizeImageFunction = (
|
|
315
|
+
inputBytes: Uint8Array,
|
|
316
|
+
mimeType: string,
|
|
317
|
+
options?: ImageResizeOptions,
|
|
318
|
+
) => Promise<ResizeImageResult | null>;
|
|
319
|
+
|
|
320
|
+
let resizeImageLoader: Promise<ResizeImageFunction | undefined> | undefined;
|
|
321
|
+
|
|
322
|
+
function getModelImageInputLimits(model: Model<Api>): ModelImageInputLimits | undefined {
|
|
323
|
+
return (model as Model<Api> & { inputLimits?: { images?: ModelImageInputLimits } }).inputLimits?.images;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function readNonNegativeLimit(value: unknown, name: string): number | undefined {
|
|
327
|
+
if (value === undefined) return undefined;
|
|
328
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
329
|
+
throw new Error(`Model image input limit ${name} is invalid.`);
|
|
330
|
+
}
|
|
331
|
+
return value;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function normalizeResizeOptions(value: unknown): ImageResizeOptions | undefined {
|
|
335
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
336
|
+
const candidate = value as Record<string, unknown>;
|
|
337
|
+
const options: ImageResizeOptions = {};
|
|
338
|
+
for (const key of ["maxWidth", "maxHeight", "maxBytes"] as const) {
|
|
339
|
+
const number = candidate[key];
|
|
340
|
+
if (number === undefined) continue;
|
|
341
|
+
if (typeof number !== "number" || !Number.isFinite(number) || number <= 0) {
|
|
342
|
+
throw new Error(`Model image resize option ${key} is invalid.`);
|
|
343
|
+
}
|
|
344
|
+
options[key] = Math.floor(number);
|
|
345
|
+
}
|
|
346
|
+
if (candidate.jpegQuality !== undefined) {
|
|
347
|
+
if (
|
|
348
|
+
typeof candidate.jpegQuality !== "number" ||
|
|
349
|
+
!Number.isFinite(candidate.jpegQuality) ||
|
|
350
|
+
candidate.jpegQuality <= 0 ||
|
|
351
|
+
candidate.jpegQuality > 100
|
|
352
|
+
) {
|
|
353
|
+
throw new Error("Model image resize option jpegQuality is invalid.");
|
|
354
|
+
}
|
|
355
|
+
options.jpegQuality = candidate.jpegQuality;
|
|
356
|
+
}
|
|
357
|
+
return Object.keys(options).length > 0 ? options : undefined;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function loadResizeImage(): Promise<ResizeImageFunction | undefined> {
|
|
361
|
+
resizeImageLoader ??= (async () => {
|
|
362
|
+
try {
|
|
363
|
+
const module = await import("@earendil-works/pi-coding-agent") as {
|
|
364
|
+
resizeImage?: ResizeImageFunction;
|
|
365
|
+
};
|
|
366
|
+
return typeof module.resizeImage === "function" ? module.resizeImage : undefined;
|
|
367
|
+
} catch {
|
|
368
|
+
// Pi 0.87 does not export the helper. The request must fail closed when
|
|
369
|
+
// a newer model explicitly requires a resize rather than sending a
|
|
370
|
+
// potentially oversized image unchanged.
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
})();
|
|
374
|
+
return resizeImageLoader;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function inputContentArray(item: ResponsesInputItem): ResponsesInputContentItem[] | undefined {
|
|
378
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) return undefined;
|
|
379
|
+
const candidate = item as Record<string, unknown>;
|
|
380
|
+
if (candidate.type === "function_call_output") {
|
|
381
|
+
return Array.isArray(candidate.output) ? candidate.output as ResponsesInputContentItem[] : undefined;
|
|
382
|
+
}
|
|
383
|
+
return Array.isArray(candidate.content) ? candidate.content as ResponsesInputContentItem[] : undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function isInputImage(item: ResponsesInputContentItem): item is ResponsesImageInputItem {
|
|
387
|
+
return item.type === "input_image";
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function decodeDataImage(imageUrl: string): { mimeType: string; bytes: Uint8Array } {
|
|
391
|
+
const separator = imageUrl.indexOf(",");
|
|
392
|
+
const prefix = separator >= 0 ? imageUrl.slice(0, separator) : "";
|
|
393
|
+
if (!prefix.startsWith("data:") || !prefix.endsWith(";base64") || separator < 0) {
|
|
394
|
+
throw new Error("Model image input has an invalid data URL.");
|
|
395
|
+
}
|
|
396
|
+
const mimeType = prefix.slice("data:".length, -";base64".length);
|
|
397
|
+
if (!mimeType) throw new Error("Model image input has no MIME type.");
|
|
398
|
+
return { mimeType, bytes: Buffer.from(imageUrl.slice(separator + 1), "base64") };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function resizeInputImage(
|
|
402
|
+
image: ResponsesImageInputItem,
|
|
403
|
+
options: ImageResizeOptions,
|
|
404
|
+
): Promise<ResponsesImageInputItem> {
|
|
405
|
+
const resizeImage = await loadResizeImage();
|
|
406
|
+
if (!resizeImage) {
|
|
407
|
+
throw new Error("The active Pi version cannot resize an image required by model input limits.");
|
|
408
|
+
}
|
|
409
|
+
const source = decodeDataImage(image.image_url);
|
|
410
|
+
const resized = await resizeImage(source.bytes, source.mimeType, options);
|
|
411
|
+
if (!resized || typeof resized.data !== "string" || typeof resized.mimeType !== "string") {
|
|
412
|
+
throw new Error("Model image input could not be resized within the provider limits.");
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
...image,
|
|
416
|
+
image_url: `data:${resized.mimeType};base64,${resized.data}`,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Enforce Pi's per-message/request image limits without mutating serialized input. */
|
|
421
|
+
export async function applyModelImageInputLimits<TApi extends Api>(
|
|
422
|
+
model: Model<TApi>,
|
|
423
|
+
input: ResponsesInputItem[],
|
|
424
|
+
): Promise<ResponsesInputItem[]> {
|
|
425
|
+
const limits = getModelImageInputLimits(model);
|
|
426
|
+
if (!limits) return input;
|
|
427
|
+
|
|
428
|
+
const maxPerMessage = readNonNegativeLimit(limits.maxPerMessage, "maxPerMessage");
|
|
429
|
+
const maxPerRequest = readNonNegativeLimit(limits.maxPerRequest, "maxPerRequest");
|
|
430
|
+
const resizeOptions = normalizeResizeOptions(limits.resize);
|
|
431
|
+
let requestImageCount = 0;
|
|
432
|
+
const output: ResponsesInputItem[] = [];
|
|
433
|
+
|
|
434
|
+
for (const item of input) {
|
|
435
|
+
const content = inputContentArray(item);
|
|
436
|
+
if (!content) {
|
|
437
|
+
output.push(item);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const imageCount = content.filter(isInputImage).length;
|
|
441
|
+
if (maxPerMessage !== undefined && imageCount > maxPerMessage) {
|
|
442
|
+
throw new Error(`Model accepts at most ${maxPerMessage} image(s) per message.`);
|
|
443
|
+
}
|
|
444
|
+
if (maxPerRequest !== undefined && requestImageCount + imageCount > maxPerRequest) {
|
|
445
|
+
throw new Error(`Model accepts at most ${maxPerRequest} image(s) per request.`);
|
|
446
|
+
}
|
|
447
|
+
requestImageCount += imageCount;
|
|
448
|
+
|
|
449
|
+
if (!resizeOptions || imageCount === 0) {
|
|
450
|
+
output.push(item);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const resizedContent: ResponsesInputContentItem[] = [];
|
|
454
|
+
for (const contentItem of content) {
|
|
455
|
+
resizedContent.push(isInputImage(contentItem)
|
|
456
|
+
? await resizeInputImage(contentItem, resizeOptions)
|
|
457
|
+
: contentItem);
|
|
458
|
+
}
|
|
459
|
+
const itemRecord = item as Record<string, unknown>;
|
|
460
|
+
if (itemRecord.type === "function_call_output") {
|
|
461
|
+
output.push({ ...item, output: resizedContent } as ResponsesInputItem);
|
|
462
|
+
} else {
|
|
463
|
+
output.push({ ...item, content: resizedContent } as ResponsesInputItem);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return output;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function createResponsesInputParitySignature(input: readonly unknown[]): string[] {
|
|
471
|
+
return input.map(describeResponsesInputItem);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function compareResponsesInputParity(actual: readonly unknown[], expected: readonly unknown[]): ResponsesParityReport {
|
|
475
|
+
const actualSignature = createResponsesInputParitySignature(actual);
|
|
476
|
+
const expectedSignature = createResponsesInputParitySignature(expected);
|
|
477
|
+
const maxLength = Math.max(actualSignature.length, expectedSignature.length);
|
|
478
|
+
const mismatches: string[] = [];
|
|
479
|
+
|
|
480
|
+
for (let index = 0; index < maxLength; index++) {
|
|
481
|
+
const actualValue = actualSignature[index];
|
|
482
|
+
const expectedValue = expectedSignature[index];
|
|
483
|
+
if (actualValue !== expectedValue) {
|
|
484
|
+
mismatches.push(`index ${index}: expected ${expectedValue ?? "<missing>"}, got ${actualValue ?? "<missing>"}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
ok: mismatches.length === 0,
|
|
490
|
+
actual: actualSignature,
|
|
491
|
+
expected: expectedSignature,
|
|
492
|
+
mismatches,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function compareCompactRequestToPayload(
|
|
497
|
+
request: NativeCompactionRequestBody,
|
|
498
|
+
payload: Pick<ResponsesCompatibleRequestPayload, "model" | "input" | "instructions">,
|
|
499
|
+
): ResponsesParityReport {
|
|
500
|
+
const parity = compareResponsesInputParity(request.input, payload.input);
|
|
501
|
+
const mismatches = [...parity.mismatches];
|
|
502
|
+
|
|
503
|
+
if (payload.model !== request.model) {
|
|
504
|
+
mismatches.unshift(`model: expected ${payload.model}, got ${request.model}`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if ((payload.instructions ?? "") !== request.instructions) {
|
|
508
|
+
mismatches.unshift("instructions: expected serialized instructions to match payload instructions");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
ok: mismatches.length === 0,
|
|
513
|
+
actual: parity.actual,
|
|
514
|
+
expected: parity.expected,
|
|
515
|
+
mismatches,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function transformMessagesForResponses(messages: Message[]): Message[] {
|
|
520
|
+
const transformed: Message[] = [];
|
|
521
|
+
let pendingToolCalls: ToolCall[] = [];
|
|
522
|
+
let existingToolResultIds = new Set<string>();
|
|
523
|
+
|
|
524
|
+
for (const message of messages) {
|
|
525
|
+
if (message.role === "assistant") {
|
|
526
|
+
if (pendingToolCalls.length > 0) {
|
|
527
|
+
transformed.push(...createSyntheticToolResults(pendingToolCalls, existingToolResultIds));
|
|
528
|
+
pendingToolCalls = [];
|
|
529
|
+
existingToolResultIds = new Set<string>();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const normalizedContent: AssistantMessage["content"] = [];
|
|
537
|
+
for (const block of message.content) {
|
|
538
|
+
if (block.type !== "thinking" || block.thinkingSignature) {
|
|
539
|
+
normalizedContent.push(block);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const normalizedAssistantMessage: AssistantMessage = {
|
|
544
|
+
...message,
|
|
545
|
+
content: normalizedContent,
|
|
546
|
+
};
|
|
547
|
+
transformed.push(normalizedAssistantMessage);
|
|
548
|
+
|
|
549
|
+
const toolCalls = normalizedContent.filter(isToolCallBlock);
|
|
550
|
+
if (toolCalls.length > 0) {
|
|
551
|
+
pendingToolCalls = toolCalls;
|
|
552
|
+
existingToolResultIds = new Set<string>();
|
|
553
|
+
}
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (message.role === "toolResult") {
|
|
558
|
+
existingToolResultIds.add(message.toolCallId);
|
|
559
|
+
transformed.push(message);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (pendingToolCalls.length > 0) {
|
|
564
|
+
transformed.push(...createSyntheticToolResults(pendingToolCalls, existingToolResultIds));
|
|
565
|
+
pendingToolCalls = [];
|
|
566
|
+
existingToolResultIds = new Set<string>();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
transformed.push(message);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
transformed.push(...createSyntheticToolResults(pendingToolCalls, existingToolResultIds));
|
|
573
|
+
return transformed;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function createSyntheticToolResults(
|
|
577
|
+
pendingToolCalls: readonly ToolCall[],
|
|
578
|
+
existingToolResultIds: ReadonlySet<string>,
|
|
579
|
+
): ToolResultMessage[] {
|
|
580
|
+
const syntheticResults: ToolResultMessage[] = [];
|
|
581
|
+
|
|
582
|
+
for (const toolCall of pendingToolCalls) {
|
|
583
|
+
if (existingToolResultIds.has(toolCall.id)) {
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
syntheticResults.push({
|
|
588
|
+
role: "toolResult",
|
|
589
|
+
toolCallId: toolCall.id,
|
|
590
|
+
toolName: toolCall.name,
|
|
591
|
+
content: [{ type: "text", text: SYNTHETIC_TOOL_RESULT_TEXT }],
|
|
592
|
+
isError: true,
|
|
593
|
+
timestamp: Date.now(),
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return syntheticResults;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function serializeUserMessage<TApi extends Api>(
|
|
601
|
+
message: UserMessage,
|
|
602
|
+
model: Model<TApi>,
|
|
603
|
+
): ResponsesInputMessageItem | undefined {
|
|
604
|
+
const contentItems = normalizeUserContent(message.content).flatMap((item) => serializeUserContentItem(item, model));
|
|
605
|
+
if (contentItems.length === 0) {
|
|
606
|
+
return undefined;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
role: "user",
|
|
611
|
+
content: contentItems,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function serializeUserContentItem<TApi extends Api>(
|
|
616
|
+
item: TextContent | ImageContent,
|
|
617
|
+
model: Model<TApi>,
|
|
618
|
+
): ResponsesInputContentItem[] {
|
|
619
|
+
if (item.type === "text") {
|
|
620
|
+
return [{ type: "input_text", text: sanitizeSurrogates(item.text) }];
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (!model.input.includes("image")) {
|
|
624
|
+
return [];
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return [
|
|
628
|
+
{
|
|
629
|
+
type: "input_image",
|
|
630
|
+
detail: "auto",
|
|
631
|
+
image_url: `data:${item.mimeType};base64,${item.data}`,
|
|
632
|
+
},
|
|
633
|
+
];
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function serializeAssistantMessage(message: AssistantMessage, messageIndex: number): ResponsesInputItem[] {
|
|
637
|
+
const items: ResponsesInputItem[] = [];
|
|
638
|
+
|
|
639
|
+
for (const block of message.content) {
|
|
640
|
+
if (block.type === "thinking") {
|
|
641
|
+
const reasoningItem = parseReasoningItem(block);
|
|
642
|
+
if (reasoningItem) {
|
|
643
|
+
items.push(reasoningItem);
|
|
644
|
+
}
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (block.type === "text") {
|
|
649
|
+
const signature = parseTextSignature(block.textSignature);
|
|
650
|
+
items.push({
|
|
651
|
+
type: "message",
|
|
652
|
+
role: "assistant",
|
|
653
|
+
content: [{ type: "output_text", text: sanitizeSurrogates(block.text), annotations: [] }],
|
|
654
|
+
status: "completed",
|
|
655
|
+
id: normalizeAssistantMessageId(signature?.id, messageIndex),
|
|
656
|
+
phase: signature?.phase,
|
|
657
|
+
});
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const [callId, rawItemId] = block.id.split("|");
|
|
662
|
+
items.push({
|
|
663
|
+
type: "function_call",
|
|
664
|
+
id: rawItemId,
|
|
665
|
+
call_id: callId,
|
|
666
|
+
name: block.name,
|
|
667
|
+
arguments: JSON.stringify(block.arguments),
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return items;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function serializeToolResultMessage<TApi extends Api>(
|
|
675
|
+
message: ToolResultMessage,
|
|
676
|
+
model: Model<TApi>,
|
|
677
|
+
): ResponsesFunctionCallOutputItem {
|
|
678
|
+
const [callId] = message.toolCallId.split("|");
|
|
679
|
+
const textOutput = message.content
|
|
680
|
+
.filter((item): item is TextContent => item.type === "text")
|
|
681
|
+
.map((item) => sanitizeSurrogates(item.text))
|
|
682
|
+
.join("\n");
|
|
683
|
+
const hasImages = message.content.some((item) => item.type === "image");
|
|
684
|
+
const hasText = textOutput.length > 0;
|
|
685
|
+
|
|
686
|
+
if (hasImages && model.input.includes("image")) {
|
|
687
|
+
const output: ResponsesInputContentItem[] = [];
|
|
688
|
+
if (hasText) {
|
|
689
|
+
output.push({ type: "input_text", text: textOutput });
|
|
690
|
+
}
|
|
691
|
+
for (const item of message.content) {
|
|
692
|
+
if (item.type !== "image") {
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
output.push({
|
|
696
|
+
type: "input_image",
|
|
697
|
+
detail: "auto",
|
|
698
|
+
image_url: `data:${item.mimeType};base64,${item.data}`,
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
return {
|
|
702
|
+
type: "function_call_output",
|
|
703
|
+
call_id: callId,
|
|
704
|
+
output,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return {
|
|
709
|
+
type: "function_call_output",
|
|
710
|
+
call_id: callId,
|
|
711
|
+
output: hasText ? textOutput : "(see attached image)",
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function normalizeUserContent(content: UserMessage["content"]): Array<TextContent | ImageContent> {
|
|
716
|
+
return typeof content === "string" ? [{ type: "text", text: content }] : content;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Serialize one normalized Pi system message as a Responses input message.
|
|
721
|
+
*
|
|
722
|
+
* The role follows the same reasoning-model convention as `instructions`: reasoning
|
|
723
|
+
* models use `developer`, everything else keeps `system`. Leading messages include
|
|
724
|
+
* their complete section state; later messages use Pi's update framing so section
|
|
725
|
+
* additions/removals are not mistaken for a new complete prompt. An empty system
|
|
726
|
+
* message produces no item, matching Pi.
|
|
727
|
+
*/
|
|
728
|
+
function serializeSystemMessage<TApi extends Api>(
|
|
729
|
+
message: ResponsesSystemMessage,
|
|
730
|
+
model: Model<TApi>,
|
|
731
|
+
isLeadingSystemMessage: boolean,
|
|
732
|
+
): ResponsesInputMessageItem | undefined {
|
|
733
|
+
const text = sanitizeSurrogates(
|
|
734
|
+
isLeadingSystemMessage ? getSystemMessageText(message) : renderSystemMessageUpdate(message),
|
|
735
|
+
);
|
|
736
|
+
if (text.length === 0) {
|
|
737
|
+
return undefined;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
role: instructionRole(model),
|
|
742
|
+
content: text,
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Mirrors Pi's public getSystemMessageText() without importing a 0.86-only helper. */
|
|
747
|
+
function getSystemMessageText(message: ResponsesSystemMessage): string {
|
|
748
|
+
const parts = [contentText(message.content)];
|
|
749
|
+
for (const text of Object.values(message.sections ?? {})) {
|
|
750
|
+
if (text !== null) {
|
|
751
|
+
parts.push(text);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return parts.filter((part) => part.length > 0).join("\n\n");
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** Mirrors Pi's public renderSystemMessageUpdate() for later transcript updates. */
|
|
758
|
+
function renderSystemMessageUpdate(message: ResponsesSystemMessage): string {
|
|
759
|
+
const parts: string[] = [];
|
|
760
|
+
const text = contentText(message.content);
|
|
761
|
+
if (text.length > 0) {
|
|
762
|
+
parts.push(text);
|
|
763
|
+
}
|
|
764
|
+
for (const [name, value] of Object.entries(message.sections ?? {})) {
|
|
765
|
+
parts.push(
|
|
766
|
+
value === null
|
|
767
|
+
? `Removed system prompt section "${name}".`
|
|
768
|
+
: `Updated system prompt section "${name}":\n\n${value}`,
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
return parts.join("\n\n");
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function contentText(content: ResponsesSystemMessage["content"]): string {
|
|
775
|
+
if (typeof content === "string") {
|
|
776
|
+
return content;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
return content
|
|
780
|
+
.filter((item): item is TextContent => item.type === "text")
|
|
781
|
+
.map((item) => item.text)
|
|
782
|
+
.join("\n");
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function parseReasoningItem(block: ThinkingContent): ResponsesReasoningItem | undefined {
|
|
786
|
+
if (!block.thinkingSignature) {
|
|
787
|
+
return undefined;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
try {
|
|
791
|
+
const parsed = JSON.parse(block.thinkingSignature);
|
|
792
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
793
|
+
return undefined;
|
|
794
|
+
}
|
|
795
|
+
return parsed as ResponsesReasoningItem;
|
|
796
|
+
} catch {
|
|
797
|
+
return undefined;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function parseTextSignature(signature: string | undefined): ParsedTextSignature | undefined {
|
|
802
|
+
if (!signature) {
|
|
803
|
+
return undefined;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (!signature.startsWith("{")) {
|
|
807
|
+
return { id: signature };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
try {
|
|
811
|
+
const parsed = JSON.parse(signature);
|
|
812
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
813
|
+
return undefined;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const record = parsed as Record<string, unknown>;
|
|
817
|
+
if (record.v !== 1 || typeof record.id !== "string") {
|
|
818
|
+
return undefined;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return {
|
|
822
|
+
id: record.id,
|
|
823
|
+
phase:
|
|
824
|
+
record.phase === "commentary" || record.phase === "final_answer"
|
|
825
|
+
? record.phase
|
|
826
|
+
: undefined,
|
|
827
|
+
};
|
|
828
|
+
} catch {
|
|
829
|
+
return undefined;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function normalizeAssistantMessageId(id: string | undefined, messageIndex: number): string {
|
|
834
|
+
if (!id) {
|
|
835
|
+
return `msg_${messageIndex}`;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (id.length <= 64) {
|
|
839
|
+
return id;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
return `msg_${createHash("sha1").update(id).digest("hex").slice(0, 12)}`;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function isToolCallBlock(block: AssistantMessage["content"][number]): block is ToolCall {
|
|
846
|
+
return block.type === "toolCall";
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function describeResponsesInputItem(item: unknown): string {
|
|
850
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
851
|
+
return typeof item;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const record = item as Record<string, unknown>;
|
|
855
|
+
const type = typeof record.type === "string" ? record.type : undefined;
|
|
856
|
+
if (type === "message") {
|
|
857
|
+
const phase =
|
|
858
|
+
record.phase === "commentary" || record.phase === "final_answer"
|
|
859
|
+
? `:${record.phase}`
|
|
860
|
+
: "";
|
|
861
|
+
return `message:${typeof record.role === "string" ? record.role : "unknown"}${phase}`;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (type === "function_call") {
|
|
865
|
+
return `function_call:${typeof record.name === "string" ? record.name : "unknown"}`;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (type === "function_call_output") {
|
|
869
|
+
return "function_call_output";
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (type === "reasoning") {
|
|
873
|
+
return "reasoning";
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (typeof record.role === "string") {
|
|
877
|
+
const content = Array.isArray(record.content) ? `[${record.content.length}]` : "";
|
|
878
|
+
return `input:${record.role}${content}`;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
return type ? `item:${type}` : "object";
|
|
882
|
+
}
|