pi-openai-toolkit 0.18.0 → 0.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-toolkit",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -38,7 +38,8 @@
38
38
  "./src/web-search/extension.ts",
39
39
  "./src/image-generation/extension.ts",
40
40
  "./src/auto-mode/extension.ts",
41
- "./src/codex-astra/extension.ts"
41
+ "./src/codex-astra/extension.ts",
42
+ "./src/responses-websocket/extension.ts"
42
43
  ]
43
44
  },
44
45
  "files": [
@@ -89,6 +90,8 @@
89
90
  "src/auto-mode/types.ts",
90
91
  "src/codex-astra/effort-planner.ts",
91
92
  "src/codex-astra/extension.ts",
93
+ "src/responses-websocket/extension.ts",
94
+ "src/responses-websocket/transport.ts",
92
95
  "src/model-scope.ts",
93
96
  "src/native-fallback.ts",
94
97
  "src/payload-rewrite.ts",
@@ -126,10 +129,10 @@
126
129
  "@earendil-works/pi-tui": ">=0.87.0"
127
130
  },
128
131
  "devDependencies": {
129
- "@earendil-works/pi-agent-core": "0.87.0",
130
- "@earendil-works/pi-ai": "0.87.0",
131
- "@earendil-works/pi-coding-agent": "0.87.0",
132
- "@earendil-works/pi-tui": "0.87.0",
132
+ "@earendil-works/pi-agent-core": "0.99.1",
133
+ "@earendil-works/pi-ai": "0.99.1",
134
+ "@earendil-works/pi-coding-agent": "0.99.1",
135
+ "@earendil-works/pi-tui": "0.99.1",
133
136
  "@types/node": "24.12.4",
134
137
  "bun": "1.2.23",
135
138
  "typescript": "5.9.3"
@@ -8,7 +8,7 @@ import {
8
8
  type RemoteV2ContextSource,
9
9
  } from "../types";
10
10
 
11
- export type ConfigFeature = "context" | "webSearch" | "imageGeneration" | "autoMode" | "compatibility" | "diagnostics";
11
+ export type ConfigFeature = "context" | "webSearch" | "imageGeneration" | "autoMode" | "compatibility" | "responsesWebSocket" | "diagnostics";
12
12
  export type SearchRoute = "unmanaged" | "local" | "hosted" | "standalone-alpha";
13
13
  export type DiagnosticsConfig = {
14
14
  level: "error" | "warn" | "info" | "debug";
@@ -42,6 +42,7 @@ export type EffectiveToolkitPolicy = {
42
42
  context: ContextPolicy;
43
43
  webSearch: { route: SearchRoute };
44
44
  imageGeneration: { enabled: boolean; defaultModel: string; allowedModels: string[] };
45
+ responsesWebSocket: { enabled: boolean };
45
46
  autoMode: AutoModePolicy;
46
47
  compatibility: { transport: "standard" | "codex-gateway" };
47
48
  diagnostics: DiagnosticsConfig;
@@ -74,7 +75,7 @@ export type ConfigDocumentSnapshot = {
74
75
  issues: ConfigIssue[];
75
76
  };
76
77
  export const CONFIG_FEATURES: readonly ConfigFeature[] = [
77
- "context", "webSearch", "imageGeneration", "autoMode", "compatibility", "diagnostics",
78
+ "context", "webSearch", "imageGeneration", "autoMode", "compatibility", "responsesWebSocket", "diagnostics",
78
79
  ];
79
80
 
80
81
  /** Independent per-operation defaults, preserving the existing shipped policy. */
@@ -103,6 +104,7 @@ export function createPolicyDefaults(): EffectiveToolkitPolicy {
103
104
  defaultModel: DEFAULT_IMAGE_GENERATION_MODEL,
104
105
  allowedModels: [DEFAULT_IMAGE_GENERATION_MODEL],
105
106
  },
107
+ responsesWebSocket: { enabled: false },
106
108
  autoMode: {
107
109
  ...autoOptions,
108
110
  available: false,
package/src/config/v2.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * {
14
14
  * "schemaVersion": 2,
15
15
  * "$schema": "…metadata only…",
16
- * "defaults": { "context": …, "webSearch": …, "imageGeneration": …, "autoMode": … },
16
+ * "defaults": { "context": …, "webSearch": …, "imageGeneration": …, "responsesWebSocket": …, "autoMode": … },
17
17
  * "models": { "provider/model-id": { "context": …, "webSearch": …, "autoMode": …, "compatibility": … } },
18
18
  * "diagnostics": { … }
19
19
  * }
@@ -29,7 +29,8 @@
29
29
  * raw values and unknown subtrees are never copied into diagnostics.
30
30
  * - `defaults.compatibility` is not a recognized scope: the standard transport is internal and
31
31
  * the Codex gateway transport stays an exact-model opt-in.
32
- * - Image output settings (`defaults.imageGeneration`) and `diagnostics` are global only.
32
+ * - Image output settings (`defaults.imageGeneration`), Responses WebSocket transport
33
+ * (`defaults.responsesWebSocket`), and `diagnostics` are global only.
33
34
  * - Exact model keys and model references use the legacy `provider/model-id` grammar: no
34
35
  * whitespace, globs or bracket patterns. Keys are trimmed like the legacy decoder, and two
35
36
  * raw keys that normalize to the same key are rejected instead of resolved last-wins.
@@ -120,7 +121,7 @@ const DIAGNOSTIC_LEVELS = ["error", "warn", "info", "debug"] as const;
120
121
  const LEGACY_ROOT_KEYS = ["compaction", "webSearch", "imageGeneration", "autoMode"] as const;
121
122
 
122
123
  const ROOT_KEYS = new Set(["$schema", "schemaVersion", "defaults", "models", "diagnostics"]);
123
- const DEFAULTS_KEYS = new Set(["context", "webSearch", "imageGeneration", "autoMode"]);
124
+ const DEFAULTS_KEYS = new Set(["context", "webSearch", "imageGeneration", "responsesWebSocket", "autoMode"]);
124
125
  const MODEL_OVERRIDE_KEYS = new Set(["context", "webSearch", "autoMode", "compatibility"]);
125
126
  const CONTEXT_KEYS = new Set(["mode", "remoteCompaction", "nativeFallback", "remoteWindows"]);
126
127
  const REMOTE_COMPACTION_KEYS = new Set(["model", "inputSource", "allowContinuityBreak", "apis"]);
@@ -128,6 +129,7 @@ const NATIVE_FALLBACK_KEYS = new Set(["enabled", "model", "thinkingLevel"]);
128
129
  const REMOTE_WINDOWS_KEYS = new Set(["leaveManagedMode", "reminderThresholdPercent"]);
129
130
  const WEB_SEARCH_KEYS = new Set(["route"]);
130
131
  const IMAGE_GENERATION_KEYS = new Set(["enabled", "defaultModel", "allowedModels"]);
132
+ const RESPONSES_WEBSOCKET_KEYS = new Set(["enabled"]);
131
133
  const AUTO_MODE_KEYS = new Set([
132
134
  "available",
133
135
  "reviewerModel",
@@ -170,6 +172,7 @@ const FEATURE_NAMES: ReadonlyMap<string, ConfigFeature> = new Map<string, Config
170
172
  ["compaction", "context"],
171
173
  ["webSearch", "webSearch"],
172
174
  ["imageGeneration", "imageGeneration"],
175
+ ["responsesWebSocket", "responsesWebSocket"],
173
176
  ["autoMode", "autoMode"],
174
177
  ["compatibility", "compatibility"],
175
178
  ["diagnostics", "diagnostics"],
@@ -194,6 +197,7 @@ const POLICY_LEAVES: readonly { leaf: string; feature: ConfigFeature }[] = [
194
197
  { leaf: "imageGeneration.enabled", feature: "imageGeneration" },
195
198
  { leaf: "imageGeneration.defaultModel", feature: "imageGeneration" },
196
199
  { leaf: "imageGeneration.allowedModels", feature: "imageGeneration" },
200
+ { leaf: "responsesWebSocket.enabled", feature: "responsesWebSocket" },
197
201
  { leaf: "autoMode.available", feature: "autoMode" },
198
202
  { leaf: "autoMode.reviewerModel", feature: "autoMode" },
199
203
  { leaf: "autoMode.gate", feature: "autoMode" },
@@ -841,6 +845,25 @@ function applyImageGenerationSettings(
841
845
  );
842
846
  }
843
847
 
848
+ function applyResponsesWebSocketSettings(
849
+ raw: Record<string, unknown>,
850
+ scope: Scope,
851
+ state: ResolutionState,
852
+ ): void {
853
+ reportUnknownKeys(raw, RESPONSES_WEBSOCKET_KEYS, scope, state, "responsesWebSocket");
854
+
855
+ applyField(
856
+ raw,
857
+ { key: "enabled", leaf: "responsesWebSocket.enabled", feature: "responsesWebSocket" },
858
+ scope,
859
+ state,
860
+ readBoolean,
861
+ (value) => {
862
+ state.policy.responsesWebSocket.enabled = value;
863
+ },
864
+ );
865
+ }
866
+
844
867
  function applyAutoModeSettings(
845
868
  raw: Record<string, unknown>,
846
869
  scope: Scope,
@@ -1149,6 +1172,15 @@ function applyImageGenerationScope(
1149
1172
  if (group) applyImageGenerationSettings(group.raw, group.scope, state);
1150
1173
  }
1151
1174
 
1175
+ function applyResponsesWebSocketScope(
1176
+ container: Record<string, unknown>,
1177
+ scope: Scope,
1178
+ state: ResolutionState,
1179
+ ): void {
1180
+ const group = openGroup(container, "responsesWebSocket", scope, state, "responsesWebSocket");
1181
+ if (group) applyResponsesWebSocketSettings(group.raw, group.scope, state);
1182
+ }
1183
+
1152
1184
  function applyAutoModeScope(
1153
1185
  container: Record<string, unknown>,
1154
1186
  scope: Scope,
@@ -1190,6 +1222,7 @@ function applyDefaults(doc: Record<string, unknown>, state: ResolutionState): vo
1190
1222
  applyContextScope(raw, scope, state);
1191
1223
  applyWebSearchScope(raw, scope, state);
1192
1224
  applyImageGenerationScope(raw, scope, state);
1225
+ applyResponsesWebSocketScope(raw, scope, state);
1193
1226
  applyAutoModeScope(raw, scope, state);
1194
1227
  }
1195
1228
 
package/src/config.ts CHANGED
@@ -208,6 +208,7 @@ function fromLegacy(loaded: LoadedToolkitConfig, model: WebSearchModel | undefin
208
208
  defaultModel: config.imageGeneration.models[0],
209
209
  allowedModels: [...config.imageGeneration.models],
210
210
  };
211
+ policy.responsesWebSocket = { enabled: false };
211
212
  const { enabled, models, reviewerModel, classifier, ...auto } = config.autoMode;
212
213
  policy.autoMode = {
213
214
  ...structuredClone(auto),
@@ -9,7 +9,7 @@ import {
9
9
  filterCodexGatewayHeaders,
10
10
  } from "../responses-headers";
11
11
  export { CODEX_AFFINITY_SCOPE } from "../responses-headers";
12
- import { normalizeBaseUrl } from "../runtime";
12
+ import { normalizeBaseUrl, resolveEffectiveModel, VIRTUAL_MODEL_API } from "../runtime";
13
13
  import { isExactModelAllowed } from "../model-scope";
14
14
  import {
15
15
  isNonEmptyString,
@@ -172,17 +172,27 @@ export async function resolveCodexContextProvider(
172
172
  modelOverride: ExtensionContext["model"] = ctx.model,
173
173
  gatewayModels: readonly string[] = [],
174
174
  ): Promise<CodexContextProviderResolution> {
175
- const model = modelOverride;
175
+ const selectedModel = modelOverride;
176
+ const selectedDescriptor = {
177
+ provider: selectedModel?.provider,
178
+ api: selectedModel?.api,
179
+ model: selectedModel?.id,
180
+ baseUrl: normalizeBaseUrl(selectedModel?.baseUrl),
181
+ };
182
+ const effective = await resolveEffectiveModel(ctx, selectedModel);
183
+ if (!effective.ok) return { ok: false, reason: "unsupported-model", ...selectedDescriptor };
184
+ const model = effective.model;
176
185
  const descriptor = {
177
- provider: model?.provider,
178
- api: model?.api,
179
- model: model?.id,
180
- baseUrl: normalizeBaseUrl(model?.baseUrl),
186
+ provider: model.provider,
187
+ api: model.api,
188
+ model: model.id,
189
+ baseUrl: normalizeBaseUrl(model.baseUrl),
181
190
  };
182
- if (!model) return { ok: false, reason: "unsupported-model" };
183
191
 
184
192
  const isNative = isNativeCodexModel(model);
185
- const isGateway = isCodexGatewayModel(model, gatewayModels);
193
+ const isLogicalVirtualGateway = selectedModel?.api === VIRTUAL_MODEL_API && isExactModelAllowed(selectedModel, gatewayModels);
194
+ const isGateway = isCodexGatewayModel(model, gatewayModels) ||
195
+ (isLogicalVirtualGateway && model.api === GATEWAY_API);
186
196
  if (!isNative && !isGateway) {
187
197
  return { ok: false, reason: "unsupported-model", ...descriptor };
188
198
  }
@@ -53,20 +53,22 @@ import {
53
53
  rewriteResponsesPayloadWithNativeReplay,
54
54
  hasInvalidatedCompactionContext,
55
55
  removeNativeCompactionRetainedMessages,
56
- serializeLiveTailToResponsesInput,
56
+ serializeLiveTailToResponsesInputWithLimits,
57
57
  } from "./payload-rewrite";
58
58
  import { getCompactionRequestExtras, rememberRequestContext } from "./request-context-cache";
59
59
  import { resolveWebSearchRoute } from "./web-search/types";
60
60
  import { executeRemoteV2Compaction } from "./remote-v2-client";
61
61
  import {
62
+ rememberPhysicalModel,
63
+ resolveEffectiveModel,
62
64
  resolveNativeCompactionEnvironment,
63
65
  resolveRemoteCompactionExecution,
64
66
  parseModelSpec,
65
67
  type RemoteCompactionExecution,
66
68
  } from "./runtime";
67
69
  import {
68
- serializeMessagesToCompactRequest,
69
- serializeMessagesToResponsesInput,
70
+ serializeMessagesToCompactRequestWithLimits,
71
+ serializeMessagesToResponsesInputWithLimits,
70
72
  type NativeCompactionRequestBody,
71
73
  type ResponsesInputItem,
72
74
  } from "./serializer";
@@ -183,6 +185,14 @@ function notifyWarning(ctx: ExtensionContext, message: string): void {
183
185
  }
184
186
  }
185
187
 
188
+ async function resolveContextCapabilityModel(
189
+ ctx: ExtensionContext,
190
+ model: ExtensionContext["model"] = ctx.model,
191
+ ): Promise<ExtensionContext["model"]> {
192
+ const effective = await resolveEffectiveModel(ctx, model);
193
+ return effective.ok ? effective.model : undefined;
194
+ }
195
+
186
196
  async function isRemoteContextActive(
187
197
  ctx: ExtensionContext,
188
198
  config: CompactionConfig,
@@ -362,14 +372,14 @@ async function runResponsesNativeCompact(
362
372
  }
363
373
  const input: ResponsesInputItem[] = [
364
374
  ...(cloneOpaqueWindow(details.compactedWindow) as ResponsesInputItem[]),
365
- ...serializeMessagesToResponsesInput(
375
+ ...(await serializeMessagesToResponsesInputWithLimits(
366
376
  compactor.currentModel,
367
377
  [...projection.messages.slice(summaryIndex + 1)],
368
378
  { firstSystemMessageIsUpdate: true },
369
- ),
379
+ )),
370
380
  ];
371
381
  request = {
372
- model: compactor.model,
382
+ model: compactor.requestModel ?? compactor.model,
373
383
  input,
374
384
  instructions,
375
385
  };
@@ -377,10 +387,10 @@ async function runResponsesNativeCompact(
377
387
  const liveTailEntries = branchEntries.slice(latestNativeCompaction.index + 1);
378
388
  const input: ResponsesInputItem[] = [
379
389
  ...(cloneOpaqueWindow(details.compactedWindow) as ResponsesInputItem[]),
380
- ...serializeLiveTailToResponsesInput({ model: compactor.currentModel, entries: liveTailEntries }),
390
+ ...(await serializeLiveTailToResponsesInputWithLimits({ model: compactor.currentModel, entries: liveTailEntries })),
381
391
  ];
382
392
  request = {
383
- model: compactor.model,
393
+ model: compactor.requestModel ?? compactor.model,
384
394
  input,
385
395
  instructions,
386
396
  };
@@ -397,17 +407,19 @@ async function runResponsesNativeCompact(
397
407
  if (!projection.ok) {
398
408
  return { outcome: "unprojected-input", reason: projection.reason };
399
409
  }
400
- request = serializeMessagesToCompactRequest({
410
+ const serialized = await serializeMessagesToCompactRequestWithLimits({
401
411
  model: compactor.currentModel,
402
412
  messages: [...projection.messages],
403
413
  instructions,
404
414
  });
415
+ request = { ...serialized, model: compactor.requestModel ?? serialized.model };
405
416
  } else {
406
- request = serializeMessagesToCompactRequest({
417
+ const serialized = await serializeMessagesToCompactRequestWithLimits({
407
418
  model: compactor.currentModel,
408
419
  messages: getLegacySessionContextMessages(event, ctx),
409
420
  instructions,
410
421
  });
422
+ request = { ...serialized, model: compactor.requestModel ?? serialized.model };
411
423
  }
412
424
  } else {
413
425
  writeDebugArtifact(
@@ -441,7 +453,7 @@ async function runResponsesNativeCompact(
441
453
  const extras = getCompactionRequestExtras({
442
454
  provider: consumer.provider,
443
455
  api: consumer.api,
444
- model: consumer.model,
456
+ model: consumer.requestModel ?? consumer.model,
445
457
  baseUrl: consumer.baseUrl,
446
458
  sessionId: getSessionId(ctx),
447
459
  }, compactor.currentModel);
@@ -591,8 +603,9 @@ async function handleSessionBeforeCompact(
591
603
 
592
604
  // Remote Context management owns this eligible Codex session. It persists a
593
605
  // no-summary boundary and deliberately never calls remote_compaction_v2.
594
- if (isCodexContextModel(ctx.model, config)) {
595
- if (await remoteContextActive(ctx, config)) {
606
+ const contextModel = await resolveContextCapabilityModel(ctx);
607
+ if (isCodexContextModel(contextModel, config)) {
608
+ if (await remoteContextActive(ctx, config, contextModel)) {
596
609
  try {
597
610
  dependencies.contextWindows.synchronize(ctx);
598
611
  if (event.reason === "manual" && dependencies.manualCompact && !dependencies.manualCompact.isMaintenance) {
@@ -864,6 +877,7 @@ async function handleContextInternal(
864
877
  }
865
878
 
866
879
  if (config.contextManagement === "remote") {
880
+ const contextModel = await resolveContextCapabilityModel(ctx);
867
881
  try {
868
882
  contextWindows.synchronize(ctx);
869
883
  } catch {
@@ -873,7 +887,7 @@ async function handleContextInternal(
873
887
  return undefined;
874
888
  }
875
889
 
876
- const remoteActive = await remoteContextActive(ctx, config);
890
+ const remoteActive = await remoteContextActive(ctx, config, contextModel);
877
891
  if (remoteActive) {
878
892
  try {
879
893
  // Measure the provider-visible current window, not Pi's durable transcript.
@@ -902,8 +916,8 @@ async function handleContextInternal(
902
916
  // older compaction-replay path. Gateway traffic must also fail closed when
903
917
  // its transport capability is unavailable; otherwise Pi could send an
904
918
  // ordinary unscoped request and lose CPA OAuth/session affinity.
905
- if (isCodexContextModel(ctx.model, config)) {
906
- if (isCodexGatewayModel(ctx.model, config.gatewayContextModels)) {
919
+ if (isCodexContextModel(contextModel, config)) {
920
+ if (isCodexGatewayModel(contextModel, config.gatewayContextModels)) {
907
921
  notifyRemoteContextFailure(ctx, "codex-context-unavailable");
908
922
  reportPiContextHookFailure(ctx, "codex-context-unavailable");
909
923
  ctx.abort();
@@ -982,6 +996,9 @@ async function handleBeforeProviderRequest(
982
996
  return undefined;
983
997
  }
984
998
 
999
+ const contextModel = config.contextManagement === "remote"
1000
+ ? await resolveContextCapabilityModel(ctx)
1001
+ : undefined;
985
1002
  if (config.contextManagement === "remote") {
986
1003
  try {
987
1004
  contextWindows.synchronize(ctx);
@@ -991,7 +1008,7 @@ async function handleBeforeProviderRequest(
991
1008
  return undefined;
992
1009
  }
993
1010
  }
994
- if (config.contextManagement === "remote" && await remoteContextActive(ctx, config)) {
1011
+ if (config.contextManagement === "remote" && await remoteContextActive(ctx, config, contextModel)) {
995
1012
  try {
996
1013
  return contextWindows.rewritePayload(event.payload, ctx);
997
1014
  } catch {
@@ -1000,12 +1017,12 @@ async function handleBeforeProviderRequest(
1000
1017
  return undefined;
1001
1018
  }
1002
1019
  }
1003
- if (isCodexContextModel(ctx.model, config)) {
1020
+ if (isCodexContextModel(contextModel, config)) {
1004
1021
  // Keep Codex Remote mutually exclusive with the legacy replay pipeline
1005
1022
  // when authentication or tool ownership is unavailable. Gateway traffic
1006
1023
  // cannot continue as an ordinary Responses request because that would
1007
1024
  // silently lose the required CPA session/account selection.
1008
- if (isCodexGatewayModel(ctx.model, config.gatewayContextModels)) {
1025
+ if (isCodexGatewayModel(contextModel, config.gatewayContextModels)) {
1009
1026
  notifyRemoteContextFailure(ctx, "codex-context-unavailable");
1010
1027
  ctx.abort();
1011
1028
  }
@@ -1057,7 +1074,7 @@ async function handleBeforeProviderRequest(
1057
1074
  {
1058
1075
  provider: runtime.provider,
1059
1076
  api: runtime.api,
1060
- model: runtime.model,
1077
+ model: runtime.requestModel ?? runtime.model,
1061
1078
  baseUrl: runtime.baseUrl,
1062
1079
  sessionId: getSessionId(ctx),
1063
1080
  },
@@ -1255,11 +1272,12 @@ export default function registerCompactionExtension(
1255
1272
  const config = resolved.config.compaction;
1256
1273
  if (resolved.invalidFeatures.length > 0) return;
1257
1274
  if (!config.enabled) return;
1275
+ const contextModel = await resolveContextCapabilityModel(ctx);
1258
1276
 
1259
1277
  let activationReason: string | undefined;
1260
1278
  // Only models the built-in Remote Context coverage targets may activate or
1261
1279
  // warn; everything else silently runs Pi's normal compaction path.
1262
- if (config.contextManagement === "remote" && isCodexContextModel(ctx.model, config) && !active) {
1280
+ if (config.contextManagement === "remote" && isCodexContextModel(contextModel, config) && !active) {
1263
1281
  if (syncOutcome.registrationState === "conflict") {
1264
1282
  activationReason = "tool-name-conflict";
1265
1283
  notifyRemoteContextFailure(ctx, activationReason);
@@ -1361,10 +1379,11 @@ export default function registerCompactionExtension(
1361
1379
  const resolved = contextOperation(dependencies.loadConfig, ctx);
1362
1380
  requireContextPolicy(resolved, ctx);
1363
1381
  const config = resolved.config.compaction;
1364
- if (!isCodexContextModel(ctx.model, config)) return;
1365
- const active = await remoteContextActive(ctx, config);
1382
+ const contextModel = await resolveContextCapabilityModel(ctx);
1383
+ if (!isCodexContextModel(contextModel, config)) return;
1384
+ const active = await remoteContextActive(ctx, config, contextModel);
1366
1385
  if (!active) {
1367
- if (isCodexGatewayModel(ctx.model, config.gatewayContextModels)) {
1386
+ if (isCodexGatewayModel(contextModel, config.gatewayContextModels)) {
1368
1387
  notifyRemoteContextFailure(ctx, "codex-context-unavailable");
1369
1388
  ctx.abort();
1370
1389
  }
@@ -1402,11 +1421,13 @@ export default function registerCompactionExtension(
1402
1421
  contextWindows.rewriteHeaders(event.headers, ctx);
1403
1422
  });
1404
1423
  pi.on("message_end", async (event, ctx) => {
1424
+ rememberPhysicalModel(ctx, event.message);
1405
1425
  const resolved = contextOperation(dependencies.loadConfig, ctx);
1406
1426
  requireContextPolicy(resolved, ctx);
1407
1427
  const config = resolved.config.compaction;
1408
- if (!isCodexContextModel(ctx.model, config) || !tools.isRegistered) return undefined;
1409
- if (!(await remoteContextActive(ctx, config))) return undefined;
1428
+ const contextModel = await resolveContextCapabilityModel(ctx);
1429
+ if (!isCodexContextModel(contextModel, config) || !tools.isRegistered) return undefined;
1430
+ if (!(await remoteContextActive(ctx, config, contextModel))) return undefined;
1410
1431
  const message = routeContextNamespaceToolMessage(event.message);
1411
1432
  return message === event.message ? undefined : { message };
1412
1433
  });
@@ -20,13 +20,17 @@ import {
20
20
  } from "node:path";
21
21
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
22
22
  import {
23
+ IMAGE_GENERATION_MIME_TYPE,
23
24
  MAX_IMAGE_PATH_CHARS,
24
25
  ImageGenerationError,
26
+ imageFileExtension,
25
27
  sanitizeImageDiagnostic,
28
+ type ImageGenerationMimeType,
26
29
  } from "./types";
27
30
 
28
31
  export type ExplicitOutputPlan = {
29
32
  path: string;
33
+ extension?: string;
30
34
  };
31
35
 
32
36
  function isPathInsideOrEqual(root: string, candidate: string): boolean {
@@ -105,10 +109,15 @@ export async function prepareExplicitOutputPath(args: {
105
109
  }): Promise<ExplicitOutputPlan | undefined> {
106
110
  if (args.rawPath === undefined) return undefined;
107
111
  const trimmed = args.rawPath.trim();
108
- if (!trimmed || trimmed.length > MAX_IMAGE_PATH_CHARS || extname(trimmed).toLowerCase() !== ".png") {
112
+ const extension = extname(trimmed).toLowerCase();
113
+ if (
114
+ !trimmed ||
115
+ trimmed.length > MAX_IMAGE_PATH_CHARS ||
116
+ ![".png", ".jpg", ".jpeg", ".webp"].includes(extension)
117
+ ) {
109
118
  throw new ImageGenerationError(
110
119
  "output-path-invalid",
111
- "outputPath must be a non-empty .png file path.",
120
+ "outputPath must be a non-empty .png, .jpg, .jpeg, or .webp file path.",
112
121
  );
113
122
  }
114
123
 
@@ -127,7 +136,7 @@ export async function prepareExplicitOutputPath(args: {
127
136
  realDirectory(args.agentDir),
128
137
  realDirectory(args.ctx.cwd),
129
138
  ]);
130
- if (isPathInsideOrEqual(agentRoot, target)) return { path: target };
139
+ if (isPathInsideOrEqual(agentRoot, target)) return { path: target, extension };
131
140
 
132
141
  if (isPathInsideOrEqual(projectRoot, target)) {
133
142
  if (!args.ctx.isProjectTrusted()) {
@@ -136,7 +145,7 @@ export async function prepareExplicitOutputPath(args: {
136
145
  "Writing generated images inside an untrusted project is not allowed.",
137
146
  );
138
147
  }
139
- return { path: target };
148
+ return { path: target, extension };
140
149
  }
141
150
 
142
151
  if (!args.ctx.hasUI) {
@@ -148,7 +157,7 @@ export async function prepareExplicitOutputPath(args: {
148
157
  const approved = await args.ctx.ui.confirm(
149
158
  "Write generated image outside safe roots?",
150
159
  [
151
- "The generated PNG will be copied to this external path without overwriting an existing file:",
160
+ "The generated image will be copied to this external path without overwriting an existing file:",
152
161
  "",
153
162
  target,
154
163
  "",
@@ -160,7 +169,7 @@ export async function prepareExplicitOutputPath(args: {
160
169
  if (!approved) {
161
170
  throw new ImageGenerationError("output-path-declined", "External output path was declined.");
162
171
  }
163
- return { path: target };
172
+ return { path: target, extension };
164
173
  }
165
174
 
166
175
  async function publishBytesNoOverwrite(bytes: Uint8Array, target: string): Promise<void> {
@@ -190,15 +199,18 @@ export async function saveCanonicalImage(args: {
190
199
  agentDir: string;
191
200
  sessionId: string;
192
201
  imageCallId: string;
202
+ mimeType?: ImageGenerationMimeType;
193
203
  }): Promise<string> {
194
204
  const root = getGeneratedImagesRoot(args.agentDir);
205
+ const mimeType = args.mimeType ?? IMAGE_GENERATION_MIME_TYPE;
206
+ const extension = imageFileExtension(mimeType);
195
207
  const sessionPart = sanitizePathPart(args.sessionId, "session");
196
208
  const imagePart = sanitizePathPart(args.imageCallId, "image_generation");
197
209
  const directory = join(root, sessionPart);
198
210
 
199
211
  for (let index = 1; index <= 1000; index += 1) {
200
212
  const suffix = index === 1 ? "" : `-${index}`;
201
- const target = join(directory, `${imagePart}${suffix}.png`);
213
+ const target = join(directory, `${imagePart}${suffix}.${extension}`);
202
214
  try {
203
215
  await publishBytesNoOverwrite(args.bytes, target);
204
216
  return target;
@@ -222,7 +234,16 @@ export async function saveCanonicalImage(args: {
222
234
  export async function copyImageToExplicitPath(args: {
223
235
  bytes: Uint8Array;
224
236
  plan: ExplicitOutputPlan;
237
+ mimeType?: ImageGenerationMimeType;
225
238
  }): Promise<void> {
239
+ const mimeType = args.mimeType ?? IMAGE_GENERATION_MIME_TYPE;
240
+ const extension = args.plan.extension ?? extname(args.plan.path).toLowerCase();
241
+ const expectedExtension = `.${imageFileExtension(mimeType)}`;
242
+ const extensionMatches = extension === expectedExtension ||
243
+ (mimeType === "image/jpeg" && extension === ".jpeg");
244
+ if (!extensionMatches) {
245
+ throw new Error(`Output path extension ${extension || "(none)"} does not match generated ${mimeType} data.`);
246
+ }
226
247
  const currentTarget = await resolveWriteTarget(args.plan.path);
227
248
  if (currentTarget !== args.plan.path) {
228
249
  throw new Error("Output path changed after approval.");
@@ -60,13 +60,15 @@ function dataUrl(reference: PreparedReferenceImage): string {
60
60
  }
61
61
 
62
62
  function jsonRequestBody(runtime: ResponsesRuntime, body: ImageGenerationRequestBody): Record<string, unknown> {
63
- const common = {
63
+ const common: Record<string, unknown> = {
64
64
  model: body.model,
65
65
  prompt: body.prompt,
66
66
  n: body.n,
67
67
  size: body.size,
68
- quality: body.quality,
69
68
  };
69
+ // `auto` is the Images API default, but some compatible gateways (including
70
+ // Grok image routes) reject the literal value instead of treating it as default.
71
+ if (body.quality !== "auto") common.quality = body.quality;
70
72
  if (body.action === "edit") {
71
73
  return {
72
74
  ...common,
@@ -84,7 +86,7 @@ function multipartRequestBody(body: ImageGenerationRequestBody): FormData {
84
86
  form.append("prompt", body.prompt);
85
87
  form.append("n", String(body.n));
86
88
  form.append("size", body.size);
87
- form.append("quality", body.quality);
89
+ if (body.quality !== "auto") form.append("quality", body.quality);
88
90
  form.append("output_format", "png");
89
91
  form.append("response_format", "b64_json");
90
92
  for (const [index, reference] of body.references.entries()) {
@@ -1,3 +1,4 @@
1
+ import { VIRTUAL_MODEL_API } from "../runtime";
1
2
  import type { ImageGenerationConfig } from "../types";
2
3
  import { IMAGE_GENERATION_CAPABLE_APIS, type ImageGenerationModel } from "./types";
3
4
 
@@ -9,10 +10,14 @@ import { IMAGE_GENERATION_CAPABLE_APIS, type ImageGenerationModel } from "./type
9
10
  export function isImageGenerationEnabledForModel(
10
11
  model: ImageGenerationModel | undefined,
11
12
  config: ImageGenerationConfig,
13
+ effectiveModel: ImageGenerationModel | undefined = model,
12
14
  ): boolean {
13
- return (
14
- config.enabled &&
15
- typeof model?.api === "string" &&
16
- (IMAGE_GENERATION_CAPABLE_APIS as readonly string[]).includes(model.api)
17
- );
15
+ if (!config.enabled) return false;
16
+ // A virtual model is only a candidate until execution resolves its physical
17
+ // model. The service performs that resolution and fails closed if it is not
18
+ // available; this keeps the tool discoverable instead of silently hiding a
19
+ // valid virtual route at startup.
20
+ if (model?.api === VIRTUAL_MODEL_API && effectiveModel === model) return true;
21
+ return typeof effectiveModel?.api === "string" &&
22
+ (IMAGE_GENERATION_CAPABLE_APIS as readonly string[]).includes(effectiveModel.api);
18
23
  }
@@ -2,6 +2,7 @@ import { StringEnum, Type, type Static } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { loadToolkitConfig, resolveToolkitConfig } from "../config";
4
4
  import { notifyConfigIssues } from "../config/notifications";
5
+ import { registerPhysicalModelObserver } from "../runtime";
5
6
  import { executeImageGeneration } from "./service";
6
7
  import { isImageGenerationEnabledForModel } from "./eligibility";
7
8
  import { renderImageGenerationResult } from "./render";
@@ -54,13 +55,13 @@ const GenerateImageParameters = Type.Object(
54
55
  ],
55
56
  {
56
57
  description:
57
- "Use null when the user did not explicitly request a destination; otherwise provide the requested explicit .png file path.",
58
+ "Use null when the user did not explicitly request a destination; otherwise provide an explicit .png, .jpg, .jpeg, or .webp file path matching the provider output format.",
58
59
  },
59
60
  ),
60
61
  ),
61
62
  size: Type.Optional(
62
63
  StringEnum(IMAGE_GENERATION_SIZES, {
63
- description: "Requested PNG dimensions, or auto.",
64
+ description: "Requested raster-image dimensions, or auto.",
64
65
  }),
65
66
  ),
66
67
  quality: Type.Optional(
@@ -123,13 +124,14 @@ export function registerImageGenerationExtension(
123
124
  ): void {
124
125
  if (registeredApis.has(pi)) return;
125
126
  registeredApis.add(pi);
127
+ registerPhysicalModelObserver(pi);
126
128
 
127
129
  pi.registerTool<typeof GenerateImageParameters, ImageGenerationToolDetails, Record<string, never>>({
128
130
  name: IMAGE_GENERATION_TOOL_NAME,
129
131
  label: "OpenAI Generate Image",
130
132
  description:
131
- "Generate PNG images, or edit from one to five user-approved local reference images, through an independent Images API request using a configured image model. This is a paid provider operation; batchSize runs independent requests concurrently.",
132
- promptSnippet: "Generate or edit PNG images through an independent Images API request and a configured imageGeneration.allowedModels entry.",
133
+ "Generate raster images, or edit from one to five user-approved local reference images, through an independent Images API request using a configured image model. PNG, JPEG, and WebP responses are validated and saved in their actual format. This is a paid provider operation; batchSize runs independent requests concurrently.",
134
+ promptSnippet: "Generate or edit raster images through an independent Images API request and a configured imageGeneration.allowedModels entry.",
133
135
  promptGuidelines: [
134
136
  "Use openai_generate_image when the user explicitly asks to create, draw, render, or edit a raster image and the active model speaks a Responses API.",
135
137
  "Do not call openai_generate_image speculatively: it consumes the user's provider or gateway image quota.",