pi-openai-toolkit 0.18.0 → 0.19.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/src/runtime.ts CHANGED
@@ -1,5 +1,6 @@
1
- import type { Api, Model, ProviderHeaders } from "@earendil-works/pi-ai";
2
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { convertToLlm, type AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { Api, Message, Model, ModelThinkingLevel, ProviderHeaders } from "@earendil-works/pi-ai";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
4
  import { isExactModelAllowed } from "./model-scope";
4
5
  import {
5
6
  CODEX_AFFINITY_SCOPE,
@@ -9,9 +10,13 @@ import { RESPONSES_COMPACT_CAPABLE_APIS } from "./types";
9
10
 
10
11
  const OPENAI_RESPONSES_PATH = "responses";
11
12
  const CODEX_RESPONSES_PATH = "codex/responses";
13
+ const AZURE_RESPONSES_PATH = "responses";
12
14
  const OPENAI_COMPACT_PATH = "responses/compact";
13
15
  const CODEX_COMPACT_PATH = "codex/responses/compact";
16
+ const AZURE_COMPACT_PATH = "responses/compact";
17
+ const AZURE_DEFAULT_API_VERSION = "v1";
14
18
  const ALPHA_SEARCH_PATH = "alpha/search";
19
+ export const VIRTUAL_MODEL_API = "pi-virtual" as const;
15
20
 
16
21
  export type ResponsesApi = (typeof RESPONSES_COMPACT_CAPABLE_APIS)[number];
17
22
 
@@ -26,10 +31,12 @@ export type ParsedModelSpec = {
26
31
  export type NativeCompactionFailureReason =
27
32
  | "disabled"
28
33
  | "missing-model"
34
+ | "virtual-model-unresolved"
29
35
  | "invalid-model-spec"
30
36
  | "model-not-found"
31
37
  | "unsupported-api"
32
38
  | "missing-base-url"
39
+ | "invalid-base-url"
33
40
  | "missing-session-id"
34
41
  | "missing-api-key"
35
42
  | "auth-resolution-failed"
@@ -57,9 +64,13 @@ export type ResponsesCompatibleRequestPayload = {
57
64
  export type ResponsesRuntime = {
58
65
  provider: string;
59
66
  api: ResponsesApi;
67
+ /** Logical Pi model id. */
60
68
  model: string;
69
+ /** Wire model/deployment id; usually the same as `model`. */
70
+ requestModel?: string;
61
71
  baseUrl: string;
62
72
  apiKey: string;
73
+ env?: Record<string, string>;
63
74
  headers?: ProviderHeaders;
64
75
  responsesPath: string;
65
76
  responsesUrl: string;
@@ -131,9 +142,157 @@ type ResolvedRequestAuth =
131
142
  apiKey?: string;
132
143
  headers?: ProviderHeaders;
133
144
  baseUrl?: string;
145
+ env?: Record<string, string>;
134
146
  }
135
147
  | { ok: false; error: string };
136
148
 
149
+ type VirtualModelRuntime = {
150
+ resolveModel?: (
151
+ model: RuntimeModel,
152
+ messages: readonly Message[],
153
+ options: {
154
+ reason: "direct";
155
+ thinkingLevel: ModelThinkingLevel;
156
+ signal?: AbortSignal;
157
+ state?: unknown;
158
+ },
159
+ ) => Promise<{ model: RuntimeModel; thinkingLevel: ModelThinkingLevel; state?: unknown }>;
160
+ getPhysicalModel?: (provider: string, modelId: string) => RuntimeModel | undefined;
161
+ };
162
+
163
+ type ModelRegistryRuntimeBridge = {
164
+ runtime?: VirtualModelRuntime;
165
+ };
166
+
167
+ export type EffectiveModelResolution =
168
+ | { ok: true; model: RuntimeModel; virtual: boolean }
169
+ | { ok: false; reason: "missing-model" | "virtual-model-unresolved"; errorMessage?: string };
170
+
171
+ const physicalModelCache = new WeakMap<object, Map<string, RuntimeModel>>();
172
+ const physicalModelObserverApis = new WeakSet<object>();
173
+
174
+ function modelKey(model: RuntimeModel): string {
175
+ return `${model.provider}/${model.id}`;
176
+ }
177
+
178
+ function getPhysicalModelCache(ctx: ExtensionContext): Map<string, RuntimeModel> {
179
+ const registry = ctx.modelRegistry as object;
180
+ let cache = physicalModelCache.get(registry);
181
+ if (!cache) {
182
+ cache = new Map<string, RuntimeModel>();
183
+ physicalModelCache.set(registry, cache);
184
+ }
185
+ return cache;
186
+ }
187
+
188
+ function sessionModelKey(ctx: ExtensionContext, model: RuntimeModel): string | undefined {
189
+ try {
190
+ const sessionId = ctx.sessionManager.getSessionId();
191
+ return sessionId ? `${sessionId}\u0000${modelKey(model)}` : undefined;
192
+ } catch {
193
+ return undefined;
194
+ }
195
+ }
196
+
197
+ function isAssistantModelMessage(message: AgentMessage): message is AgentMessage & {
198
+ provider: string;
199
+ api: string;
200
+ model: string;
201
+ } {
202
+ if (message.role !== "assistant") return false;
203
+ const candidate = message as unknown as Record<string, unknown>;
204
+ return typeof candidate.provider === "string" &&
205
+ typeof candidate.api === "string" &&
206
+ typeof candidate.model === "string";
207
+ }
208
+
209
+ /** Remember the physical model Pi actually used as a fallback for a virtual selection. */
210
+ export function rememberPhysicalModel(ctx: ExtensionContext, message: AgentMessage): void {
211
+ const selected = ctx.model as RuntimeModel | undefined;
212
+ if (!selected || selected.api !== VIRTUAL_MODEL_API || !isAssistantModelMessage(message)) return;
213
+ const physical = ctx.modelRegistry.find(message.provider, message.model);
214
+ const key = sessionModelKey(ctx, selected);
215
+ if (physical && physical.api !== VIRTUAL_MODEL_API && key) {
216
+ getPhysicalModelCache(ctx).set(key, physical);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Register the fallback cache observer used by feature extensions. Pi 0.99
222
+ * exposes a resolver for fresh routes; the assistant message cache only helps
223
+ * when a host cannot expose that resolver after a physical response exists.
224
+ */
225
+ export function registerPhysicalModelObserver(pi: ExtensionAPI): void {
226
+ if (physicalModelObserverApis.has(pi)) return;
227
+ physicalModelObserverApis.add(pi);
228
+ pi.on("message_end", (event, ctx) => {
229
+ rememberPhysicalModel(ctx, event.message);
230
+ });
231
+ }
232
+
233
+ async function resolveVirtualModel(ctx: ExtensionContext, model: RuntimeModel): Promise<EffectiveModelResolution> {
234
+ const cache = getPhysicalModelCache(ctx);
235
+ const key = sessionModelKey(ctx, model);
236
+ const bridge = ctx.modelRegistry as unknown as ModelRegistryRuntimeBridge;
237
+ const runtime = bridge.runtime;
238
+ if (typeof runtime?.resolveModel !== "function") {
239
+ const cached = key ? cache.get(key) : undefined;
240
+ if (cached) return { ok: true, model: cached, virtual: true };
241
+ return {
242
+ ok: false,
243
+ reason: "virtual-model-unresolved",
244
+ errorMessage: "Pi does not expose virtual-model routing to extensions.",
245
+ };
246
+ }
247
+
248
+ let messages: Message[];
249
+ try {
250
+ messages = convertToLlm(ctx.sessionManager.buildSessionProjection().messages);
251
+ } catch (error) {
252
+ return {
253
+ ok: false,
254
+ reason: "virtual-model-unresolved",
255
+ errorMessage: error instanceof Error ? error.message : String(error),
256
+ };
257
+ }
258
+
259
+ try {
260
+ const route = await runtime.resolveModel(model, messages, {
261
+ reason: "direct",
262
+ thinkingLevel: (ctx.thinkingLevel ?? "off") as ModelThinkingLevel,
263
+ signal: ctx.signal,
264
+ });
265
+ const physical = runtime.getPhysicalModel?.(route.model.provider, route.model.id)
266
+ ?? ctx.modelRegistry.find(route.model.provider, route.model.id)
267
+ ?? route.model;
268
+ if (!physical || physical.api === VIRTUAL_MODEL_API) {
269
+ return {
270
+ ok: false,
271
+ reason: "virtual-model-unresolved",
272
+ errorMessage: "Pi virtual-model routing did not return a physical model.",
273
+ };
274
+ }
275
+ if (key) cache.set(key, physical);
276
+ return { ok: true, model: physical, virtual: true };
277
+ } catch (error) {
278
+ return {
279
+ ok: false,
280
+ reason: "virtual-model-unresolved",
281
+ errorMessage: error instanceof Error ? error.message : String(error),
282
+ };
283
+ }
284
+ }
285
+
286
+ export async function resolveEffectiveModel(
287
+ ctx: ExtensionContext,
288
+ model: ExtensionContext["model"] = ctx.model,
289
+ ): Promise<EffectiveModelResolution> {
290
+ if (!model) return { ok: false, reason: "missing-model" };
291
+ const runtimeModel = model as RuntimeModel;
292
+ if (runtimeModel.api !== VIRTUAL_MODEL_API) return { ok: true, model: runtimeModel, virtual: false };
293
+ return resolveVirtualModel(ctx, runtimeModel);
294
+ }
295
+
137
296
  function normalizeConfiguredApis(values: readonly string[] | undefined): Set<string> {
138
297
  if (values === undefined) {
139
298
  return new Set(RESPONSES_COMPACT_CAPABLE_APIS);
@@ -144,8 +303,12 @@ function normalizeConfiguredApis(values: readonly string[] | undefined): Set<str
144
303
  function resolveCodexGatewayAffinity(
145
304
  model: RuntimeModel,
146
305
  gatewayModels: readonly string[] | undefined,
306
+ logicalModel?: RuntimeModel,
147
307
  ): CodexAffinity | undefined {
148
- if (model.api !== "openai-responses" || !isExactModelAllowed(model, gatewayModels ?? [])) {
308
+ const allowlist = gatewayModels ?? [];
309
+ const allowed = isExactModelAllowed(model, allowlist) ||
310
+ (logicalModel?.api === VIRTUAL_MODEL_API && isExactModelAllowed(logicalModel, allowlist));
311
+ if (model.api !== "openai-responses" || !allowed) {
149
312
  return undefined;
150
313
  }
151
314
  return {
@@ -200,20 +363,81 @@ function buildCodexCompactUrl(baseUrl: string): string {
200
363
  return `${buildCodexResponsesUrl(baseUrl)}/compact`;
201
364
  }
202
365
 
203
- export function buildResponsesUrl(baseUrl: string, api: ResponsesCompactApi): string {
204
- return api === "openai-codex-responses" ? buildCodexResponsesUrl(baseUrl) : buildOpenAIResponsesUrl(baseUrl);
366
+ function envValue(env: Record<string, string> | undefined, name: string): string | undefined {
367
+ const value = env?.[name]?.trim();
368
+ return value || undefined;
369
+ }
370
+
371
+ function buildAzureBaseUrl(baseUrl: string | undefined, env: Record<string, string> | undefined): string | undefined {
372
+ const configured = envValue(env, "AZURE_OPENAI_BASE_URL") ?? normalizeBaseUrl(baseUrl);
373
+ if (configured) return configured;
374
+ const resource = envValue(env, "AZURE_OPENAI_RESOURCE_NAME");
375
+ return resource ? `https://${resource}.openai.azure.com/openai/v1` : undefined;
376
+ }
377
+
378
+ function normalizeAzureBaseUrl(baseUrl: string): string {
379
+ const url = new URL(baseUrl);
380
+ const pathname = url.pathname.replace(/\/+$/, "");
381
+ if (pathname === "" || pathname === "/" || pathname === "/openai") url.pathname = "/openai/v1";
382
+ else if (pathname.endsWith("/responses")) url.pathname = pathname.slice(0, -"/responses".length);
383
+ url.search = "";
384
+ url.hash = "";
385
+ return url.toString().replace(/\/$/, "");
386
+ }
387
+
388
+ function appendAzureApiVersion(urlValue: string, env: Record<string, string> | undefined): string {
389
+ const url = new URL(urlValue);
390
+ if (!url.searchParams.has("api-version")) {
391
+ url.searchParams.set("api-version", envValue(env, "AZURE_OPENAI_API_VERSION") ?? AZURE_DEFAULT_API_VERSION);
392
+ }
393
+ return url.toString();
394
+ }
395
+
396
+ export function resolveAzureDeploymentName(modelId: string, env: Record<string, string> | undefined): string {
397
+ const mapping = envValue(env, "AZURE_OPENAI_DEPLOYMENT_NAME_MAP");
398
+ if (mapping) {
399
+ for (const entry of mapping.split(",")) {
400
+ const separator = entry.indexOf("=");
401
+ if (separator <= 0) continue;
402
+ if (entry.slice(0, separator).trim() === modelId) {
403
+ const deployment = entry.slice(separator + 1).trim();
404
+ if (deployment) return deployment;
405
+ }
406
+ }
407
+ }
408
+ return modelId;
409
+ }
410
+
411
+ export function buildResponsesUrl(
412
+ baseUrl: string,
413
+ api: ResponsesCompactApi,
414
+ env?: Record<string, string>,
415
+ ): string {
416
+ if (api === "openai-codex-responses") return buildCodexResponsesUrl(baseUrl);
417
+ if (api === "azure-openai-responses") {
418
+ return appendAzureApiVersion(`${normalizeAzureBaseUrl(baseUrl)}/${AZURE_RESPONSES_PATH}`, env);
419
+ }
420
+ return buildOpenAIResponsesUrl(baseUrl);
205
421
  }
206
422
 
207
423
  export function buildResponsesPath(api: ResponsesCompactApi): string {
208
- return api === "openai-codex-responses" ? CODEX_RESPONSES_PATH : OPENAI_RESPONSES_PATH;
424
+ return api === "openai-codex-responses" ? CODEX_RESPONSES_PATH : AZURE_RESPONSES_PATH;
209
425
  }
210
426
 
211
- export function buildCompactUrl(baseUrl: string, api: ResponsesCompactApi): string {
212
- return api === "openai-codex-responses" ? buildCodexCompactUrl(baseUrl) : buildOpenAICompactUrl(baseUrl);
427
+ export function buildCompactUrl(
428
+ baseUrl: string,
429
+ api: ResponsesCompactApi,
430
+ env?: Record<string, string>,
431
+ ): string {
432
+ if (api === "openai-codex-responses") return buildCodexCompactUrl(baseUrl);
433
+ if (api === "azure-openai-responses") {
434
+ return appendAzureApiVersion(`${normalizeAzureBaseUrl(baseUrl)}/${AZURE_COMPACT_PATH}`, env);
435
+ }
436
+ return buildOpenAICompactUrl(baseUrl);
213
437
  }
214
438
 
215
439
  export function buildCompactPath(api: ResponsesCompactApi): string {
216
- return api === "openai-codex-responses" ? CODEX_COMPACT_PATH : OPENAI_COMPACT_PATH;
440
+ return api === "openai-codex-responses" ? CODEX_COMPACT_PATH : AZURE_COMPACT_PATH;
217
441
  }
218
442
 
219
443
  /**
@@ -332,8 +556,27 @@ async function resolveNativeCompactionEnvironmentForModel(
332
556
  };
333
557
  }
334
558
 
335
- const descriptor = getRuntimeModelDescriptor(currentModel);
336
- if (!currentModel || !descriptor.provider || !descriptor.api || !descriptor.model) {
559
+ const originalDescriptor = getRuntimeModelDescriptor(currentModel);
560
+ if (!currentModel || !originalDescriptor.provider || !originalDescriptor.api || !originalDescriptor.model) {
561
+ return {
562
+ ok: false,
563
+ reason: "missing-model",
564
+ ...originalDescriptor,
565
+ };
566
+ }
567
+
568
+ const effective = await resolveEffectiveModel(ctx, currentModel);
569
+ if (!effective.ok) {
570
+ return {
571
+ ok: false,
572
+ reason: effective.reason,
573
+ errorMessage: effective.errorMessage,
574
+ ...originalDescriptor,
575
+ };
576
+ }
577
+ const effectiveModel = effective.model;
578
+ const descriptor = getRuntimeModelDescriptor(effectiveModel);
579
+ if (!descriptor.provider || !descriptor.api || !descriptor.model) {
337
580
  return {
338
581
  ok: false,
339
582
  reason: "missing-model",
@@ -342,8 +585,8 @@ async function resolveNativeCompactionEnvironmentForModel(
342
585
  }
343
586
 
344
587
  // The compact endpoint is selected purely by API family: any provider speaking
345
- // openai-responses/openai-codex-responses gets a native compact attempt and fails
346
- // open (to the fallback model or pi's default) when the endpoint is missing.
588
+ // a validated Responses API gets a native compact attempt and fails open (to
589
+ // the fallback model or pi's default) when the endpoint is missing.
347
590
  const configuredApis = normalizeConfiguredApis(options.responsesApis);
348
591
  if (!configuredApis.has(descriptor.api) || !isSupportedApi(descriptor.api)) {
349
592
  return {
@@ -362,21 +605,12 @@ async function resolveNativeCompactionEnvironmentForModel(
362
605
  ...descriptor,
363
606
  };
364
607
  }
365
-
366
- if (payload.model !== descriptor.model) {
367
- return {
368
- ok: false,
369
- reason: "payload-model-mismatch",
370
- ...descriptor,
371
- };
372
- }
373
-
374
608
  requestPayload = payload;
375
609
  }
376
610
 
377
611
  let auth: ResolvedRequestAuth;
378
612
  try {
379
- auth = await resolveRequestAuth(ctx, currentModel);
613
+ auth = await resolveRequestAuth(ctx, effectiveModel);
380
614
  } catch (error) {
381
615
  return {
382
616
  ok: false,
@@ -395,7 +629,11 @@ async function resolveNativeCompactionEnvironmentForModel(
395
629
  };
396
630
  }
397
631
 
398
- const baseUrl = normalizeBaseUrl(auth.baseUrl) ?? descriptor.baseUrl;
632
+ const rawBaseUrl = normalizeBaseUrl(auth.baseUrl) ?? descriptor.baseUrl;
633
+ let baseUrl = rawBaseUrl;
634
+ if (descriptor.api === "azure-openai-responses") {
635
+ baseUrl = buildAzureBaseUrl(rawBaseUrl, auth.env);
636
+ }
399
637
  if (!baseUrl) {
400
638
  return {
401
639
  ok: false,
@@ -403,8 +641,33 @@ async function resolveNativeCompactionEnvironmentForModel(
403
641
  ...descriptor,
404
642
  };
405
643
  }
644
+ if (descriptor.api === "azure-openai-responses") {
645
+ try {
646
+ baseUrl = normalizeAzureBaseUrl(baseUrl);
647
+ } catch (error) {
648
+ return {
649
+ ok: false,
650
+ reason: "invalid-base-url",
651
+ errorMessage: error instanceof Error ? error.message : String(error),
652
+ ...descriptor,
653
+ baseUrl,
654
+ };
655
+ }
656
+ }
657
+
658
+ const requestModel = descriptor.api === "azure-openai-responses"
659
+ ? resolveAzureDeploymentName(descriptor.model, auth.env)
660
+ : descriptor.model;
661
+ if (requestPayload && requestPayload.model !== descriptor.model && requestPayload.model !== requestModel) {
662
+ return {
663
+ ok: false,
664
+ reason: "payload-model-mismatch",
665
+ ...descriptor,
666
+ baseUrl,
667
+ };
668
+ }
406
669
 
407
- const codexAffinity = resolveCodexGatewayAffinity(currentModel, options.codexGatewayModels);
670
+ const codexAffinity = resolveCodexGatewayAffinity(effectiveModel, options.codexGatewayModels, currentModel);
408
671
  const sessionId = resolveRuntimeSessionId(ctx);
409
672
  if (codexAffinity && !sessionId) {
410
673
  return {
@@ -430,17 +693,19 @@ async function resolveNativeCompactionEnvironmentForModel(
430
693
  provider: descriptor.provider,
431
694
  api: descriptor.api,
432
695
  model: descriptor.model,
696
+ requestModel,
433
697
  baseUrl,
434
698
  apiKey: auth.apiKey,
699
+ env: auth.env,
435
700
  headers: auth.headers,
436
701
  responsesPath: buildResponsesPath(descriptor.api),
437
- responsesUrl: buildResponsesUrl(baseUrl, descriptor.api),
702
+ responsesUrl: buildResponsesUrl(baseUrl, descriptor.api, auth.env),
438
703
  compactPath: buildCompactPath(descriptor.api),
439
- compactUrl: buildCompactUrl(baseUrl, descriptor.api),
704
+ compactUrl: buildCompactUrl(baseUrl, descriptor.api, auth.env),
440
705
  sessionId,
441
706
  codexAffinity,
442
707
  payload: requestPayload,
443
- currentModel,
708
+ currentModel: effectiveModel,
444
709
  },
445
710
  };
446
711
  }
@@ -459,8 +724,10 @@ export async function resolveResponsesEnvironment(
459
724
  provider: runtime.provider,
460
725
  api: runtime.api,
461
726
  model: runtime.model,
727
+ requestModel: runtime.requestModel,
462
728
  baseUrl: runtime.baseUrl,
463
729
  apiKey: runtime.apiKey,
730
+ env: runtime.env,
464
731
  headers: runtime.headers,
465
732
  responsesPath: runtime.responsesPath,
466
733
  responsesUrl: runtime.responsesUrl,
package/src/serializer.ts CHANGED
@@ -178,6 +178,24 @@ export function serializeMessagesToCompactRequest<TApi extends Api>(args: {
178
178
  };
179
179
  }
180
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
+
181
199
  export function serializeMessagesToResponsesInput<TApi extends Api>(
182
200
  model: Model<TApi>,
183
201
  messages: AgentMessage[],
@@ -186,6 +204,28 @@ export function serializeMessagesToResponsesInput<TApi extends Api>(
186
204
  return serializeLlmMessagesToResponsesInput(model, convertToLlm(messages), options);
187
205
  }
188
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
+
189
229
  /**
190
230
  * Serialize Pi's normalized LLM message union without running `convertToLlm` again.
191
231
  * This keeps the system-message boundary directly testable while preserving the public
@@ -253,6 +293,180 @@ export function serializeLlmMessagesToResponsesInput<TApi extends Api>(
253
293
  return input;
254
294
  }
255
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
+
256
470
  export function createResponsesInputParitySignature(input: readonly unknown[]): string[] {
257
471
  return input.map(describeResponsesInputItem);
258
472
  }