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/README.md +4 -4
- package/README.zh.md +4 -4
- package/config.schema.json +2 -1
- package/docs/configuration.md +7 -7
- package/package.json +5 -5
- package/src/context-management/codex-provider.ts +18 -8
- package/src/extension-runtime.ts +47 -26
- package/src/image-generation/artifacts.ts +28 -7
- package/src/image-generation/client.ts +5 -3
- package/src/image-generation/eligibility.ts +10 -5
- package/src/image-generation/extension.ts +6 -4
- package/src/image-generation/protocol.ts +175 -38
- package/src/image-generation/render.ts +13 -5
- package/src/image-generation/service.ts +35 -7
- package/src/image-generation/types.ts +23 -3
- package/src/payload-rewrite.ts +16 -1
- package/src/responses-headers.ts +6 -1
- package/src/runtime.ts +295 -28
- package/src/serializer.ts +214 -0
- package/src/supported-environment.ts +5 -0
- package/src/types.ts +6 -2
- package/src/web-search/extension.ts +4 -3
- package/src/web-search/types.ts +25 -3
package/src/runtime.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type {
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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 :
|
|
424
|
+
return api === "openai-codex-responses" ? CODEX_RESPONSES_PATH : AZURE_RESPONSES_PATH;
|
|
209
425
|
}
|
|
210
426
|
|
|
211
|
-
export function buildCompactUrl(
|
|
212
|
-
|
|
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 :
|
|
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
|
|
336
|
-
if (!currentModel || !
|
|
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
|
-
//
|
|
346
|
-
//
|
|
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,
|
|
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
|
|
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(
|
|
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
|
}
|