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 CHANGED
@@ -14,7 +14,7 @@ Add Codex context windows, Responses compaction, hosted tools, and tool-call rev
14
14
  | Codex Remote Context | Start new windows with the adapted Codex protocol and retrieve earlier work through `history`. |
15
15
  | Remote Compaction v2 | Continue Responses sessions with encrypted server checkpoints. |
16
16
  | Web Search | Choose local `pi-web-access`, Responses `web_search`, or experimental CPA `web_run` per model. |
17
- | Image generation | Generate or edit images with the hosted Responses image tool. |
17
+ | Image generation | Generate or edit raster images through a separate Images API request. |
18
18
  | Tool-call review | Have a reviewer model approve selected calls through Toolkit's approval gate. |
19
19
 
20
20
  The package uses Pi's existing models, authentication, and sessions. It does not add a provider or model.
@@ -176,14 +176,14 @@ Requires an eligible Responses-capable session and may incur provider charges. T
176
176
  "defaults": {
177
177
  "imageGeneration": {
178
178
  "enabled": true,
179
- "defaultModel": "gpt-image-2.5",
180
- "allowedModels": ["gpt-image-2.5"]
179
+ "defaultModel": "grok-imagine-image-2.0",
180
+ "allowedModels": ["grok-imagine-image-2.0"]
181
181
  }
182
182
  }
183
183
  }
184
184
  ```
185
185
 
186
- Ask Pi to generate an image or edit explicitly supplied local references using `openai_generate_image`. The selected image model is sent in the independent request; the active conversation model remains the routing/auth model. Set the tool's optional `model` to another exact `allowedModels` entry, or use `batchSize` (1-10) for concurrent independent variants. Batch calls save canonical artifacts and cannot use a single-file `outputPath`; image settings are global. See [image configuration](docs/configuration.md#images).
186
+ Ask Pi to generate an image or edit explicitly supplied local references using `openai_generate_image`. The selected image model is sent in the independent request; the active conversation model remains the routing/auth model. PNG, JPEG, and WebP responses are validated and saved in their actual format. Set the tool's optional `model` to another exact `allowedModels` entry, or use `batchSize` (1-10) for concurrent independent variants. Batch calls save canonical artifacts and cannot use a single-file `outputPath`; when `quality` is `auto`, the field is omitted so compatible gateways can apply their own default. Image settings are global. See [image configuration](docs/configuration.md#images).
187
187
 
188
188
  ### Review tool calls automatically
189
189
 
package/README.zh.md CHANGED
@@ -14,7 +14,7 @@
14
14
  | Codex 远程上下文 | 通过适配的 Codex 协议切换窗口,用 `history` 找回较早的工作内容。 |
15
15
  | 远程压缩 v2 | 使用加密的服务端检查点继续 Responses 会话。 |
16
16
  | 联网搜索 | 按模型选择本地 `pi-web-access`、Responses `web_search` 或实验性的 CPA `web_run`。 |
17
- | 图像生成 | 使用 Responses 托管生图工具生成或编辑图片。 |
17
+ | 图像生成 | 通过独立的 Images API 请求生成或编辑栅格图片。 |
18
18
  | 工具调用审查 | 通过 Toolkit 审批门禁,让审查模型判断指定调用是否可以执行。 |
19
19
 
20
20
  本包沿用 Pi 已有的模型、认证和会话,不新增提供商或模型。
@@ -176,14 +176,14 @@ Toolkit 不会在路由之间自动回退。后端要求见[搜索配置](docs/c
176
176
  "defaults": {
177
177
  "imageGeneration": {
178
178
  "enabled": true,
179
- "defaultModel": "gpt-image-2.5",
180
- "allowedModels": ["gpt-image-2.5"]
179
+ "defaultModel": "grok-imagine-image-2.0",
180
+ "allowedModels": ["grok-imagine-image-2.0"]
181
181
  }
182
182
  }
183
183
  }
184
184
  ```
185
185
 
186
- 让 Pi 通过 `openai_generate_image` 生成图片,或编辑明确提供的本地参考图。选中的生图模型会写入独立请求;当前对话模型仍只负责路由和认证。工具的可选 `model` 必须精确匹配 `allowedModels`,也可以使用 `batchSize`(1-10)并发生成多个独立变体。批量调用会保存 canonical artifacts,不能同时指定单文件 `outputPath`;生图设置全局生效。详见[生图配置](docs/configuration.md#images)。
186
+ 让 Pi 通过 `openai_generate_image` 生成图片,或编辑明确提供的本地参考图。选中的生图模型会写入独立请求;当前对话模型仍只负责路由和认证。PNG、JPEG 和 WebP 响应会按实际格式校验并保存。工具的可选 `model` 必须精确匹配 `allowedModels`,也可以使用 `batchSize`(1-10)并发生成多个独立变体。批量调用会保存 canonical artifacts,不能同时指定单文件 `outputPath`;`quality` 为 `auto` 时会省略该字段,让兼容网关使用自己的默认值。生图设置全局生效。详见[生图配置](docs/configuration.md#images)。
187
187
 
188
188
  ### 启用工具调用自动审查
189
189
 
@@ -57,7 +57,8 @@
57
57
  "items": {
58
58
  "enum": [
59
59
  "openai-responses",
60
- "openai-codex-responses"
60
+ "openai-codex-responses",
61
+ "azure-openai-responses"
61
62
  ]
62
63
  }
63
64
  },
@@ -18,8 +18,8 @@ Create the file and parent directory if absent. This example states the shipped
18
18
  "webSearch": { "route": "unmanaged" },
19
19
  "imageGeneration": {
20
20
  "enabled": false,
21
- "defaultModel": "gpt-image-2.5",
22
- "allowedModels": ["gpt-image-2.5"]
21
+ "defaultModel": "grok-imagine-image-2.0",
22
+ "allowedModels": ["grok-imagine-image-2.0"]
23
23
  },
24
24
  "autoMode": { "available": false }
25
25
  },
@@ -60,7 +60,7 @@ Place `context` under `defaults` or an exact `models` entry.
60
60
  | `remoteCompaction.model` | `null` | Exact producer reference; `null` uses the active model. In `remote-compaction`, it selects the synthetic checkpoint producer on the same effective base URL. In active `remote-windows`, manual `/compact` temporarily selects it for notes checkpoint duty on the same backend/account. |
61
61
  | `remoteCompaction.inputSource` | `"legacy"` | `"legacy"` preserves session/raw-branch input; `"pi-context-hook"` opts into Pi's ordered context projection. Checkpoint provenance must match. |
62
62
  | `remoteCompaction.allowContinuityBreak` | `false` | Allow restarting from Pi context after a foreign compaction entry. It does not make malformed opaque checkpoints replayable. |
63
- | `remoteCompaction.apis` | `["openai-responses", "openai-codex-responses"]` | May narrow this set; `[]` permits no remote-compaction API. Unsupported entries are errors. |
63
+ | `remoteCompaction.apis` | `["openai-responses", "openai-codex-responses", "azure-openai-responses"]` | May narrow this set; `[]` permits no remote-compaction API. Azure uses Pi's resolved `AZURE_OPENAI_*` environment values for the `/openai/v1` Responses path, deployment mapping, and `api-version`; unsupported entries are errors. |
64
64
  | `nativeFallback.enabled` | `true` | Enable the existing native-method fallback tier. |
65
65
  | `nativeFallback.model` | `null` | Optional summary-model reference on the remote-ineligible path. After a remote attempt fails, the existing producer-first fallback order is retained. |
66
66
  | `nativeFallback.thinkingLevel` | `"off"` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`; actual model support still applies. |
@@ -81,7 +81,7 @@ The SDK native compact call reports its intentional cancellation; the visible ha
81
81
 
82
82
  Only `models["provider/model-id"].compatibility.transport` is configurable: `"standard"` is the internal baseline and `"codex-gateway"` is an exact opt-in. There is no global `defaults.compatibility` and no built-in gateway model list.
83
83
 
84
- This selects an existing Toolkit protocol profile, independently of context mode. It does not change Pi's provider, API, endpoint, or model. The same decoded document supplies compatibility for search, image generation, and a separately named compaction producer. A successful config resolution is not a backend capability test.
84
+ This selects an existing Toolkit protocol profile, independently of context mode. It does not change Pi's provider, API, endpoint, or model. The same decoded document supplies compatibility for search, image generation, and a separately named compaction producer. A successful config resolution is not a backend capability test. When Pi selects a `pi-virtual` model, Toolkit uses Pi's runtime resolver (when available) to obtain the physical provider/model for capability, authentication, and endpoint checks; if that route cannot be verified, the feature fails closed instead of treating the virtual entry as an OpenAI model.
85
85
 
86
86
  ---
87
87
 
@@ -96,15 +96,15 @@ This selects an existing Toolkit protocol profile, independently of context mode
96
96
  | `hosted` | Use native Responses search with source annotations; suppress conflicting local and standalone tools. |
97
97
  | `standalone-alpha` | Experimental, explicit opt-in. Expose sequential `web_run` and call the provider-relative `/alpha/search` endpoint once per execution. |
98
98
 
99
- There is no v2 search master switch. `local` and `unmanaged` differ in payload cleanup and ownership. Missing auth, an unavailable route, a tool conflict, or invalid selected policy does not trigger another route. Standalone supports `search_query`, `image_query`, `open`, `click`, `find`, `screenshot`, `finance`, `weather`, `sports`, and `time`, with `response_length`. It sends a bounded command envelope rather than the full transcript; follow-up references depend on the provider's session/reference handling. The gateway must expose the endpoint and its standalone-search capability.
99
+ There is no v2 search master switch. `local` and `unmanaged` differ in payload cleanup and ownership. Missing auth, an unavailable route, a tool conflict, or invalid selected policy does not trigger another route. Hosted Responses search also accepts Pi's Azure Responses API; standalone-alpha remains restricted to the verified OpenAI/Codex `/alpha/search` routes. Standalone supports `search_query`, `image_query`, `open`, `click`, `find`, `screenshot`, `finance`, `weather`, `sports`, and `time`, with `response_length`. It sends a bounded command envelope rather than the full transcript; follow-up references depend on the provider's session/reference handling. The gateway must expose the endpoint and its standalone-search capability.
100
100
 
101
101
  ---
102
102
 
103
103
  ### Images
104
104
 
105
- Image settings are global-only under `defaults.imageGeneration`. `enabled` defaults to `false`. `defaultModel` defaults to `"gpt-image-2.5"`; `allowedModels` defaults to `["gpt-image-2.5"]`.
105
+ Image settings are global-only under `defaults.imageGeneration`. `enabled` defaults to `false`. `defaultModel` defaults to `"grok-imagine-image-2.0"`; `allowedModels` defaults to `["grok-imagine-image-2.0"]`.
106
106
 
107
- These are bare output-model IDs, not active-session model keys. Each normalized ID must contain 1-256 characters. The allowed list must remain nonempty after normalization and contain the explicit default. A caller's one-call `model` must be a member; policy and membership are checked before auth, local-reference preparation/upload, and paid dispatch. The active session remains the routing/auth model, while the selected image model is sent in a separate Images API request. Generation uses `/images/generations`; edits use `/images/edits` (Codex uses the corresponding `/codex/images/...` paths). The wrapper never injects a Responses `image_generation` tool. `batchSize` is a tool argument from 1-10; each item is an independent `n: 1` request, partial success preserves saved artifacts, and batch calls cannot use a single-file `outputPath`. Config does not grant permission to upload arbitrary local files or imply that a provider supports a particular image model.
107
+ These are bare output-model IDs, not active-session model keys. Each normalized ID must contain 1-256 characters. The allowed list must remain nonempty after normalization and contain the explicit default. A caller's one-call `model` must be a member; policy and membership are checked before auth, local-reference preparation/upload, and paid dispatch. The active session remains the routing/auth model, while the selected image model is sent in a separate Images API request. Generation uses `/images/generations`; edits use `/images/edits` (Codex uses the corresponding `/codex/images/...` paths). The wrapper never injects a Responses `image_generation` tool. Responses are accepted only when they contain one validated PNG, JPEG, or WebP `data[].b64_json` image; the canonical artifact uses the detected format extension. A literal `quality: "auto"` is omitted from the wire request for compatibility gateways that reject it. `batchSize` is a tool argument from 1-10; each item is an independent `n: 1` request, partial success preserves saved artifacts, and batch calls cannot use a single-file `outputPath`. Config does not grant permission to upload arbitrary local files or imply that a provider supports a particular image model.
108
108
 
109
109
  ---
110
110
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-toolkit",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -126,10 +126,10 @@
126
126
  "@earendil-works/pi-tui": ">=0.87.0"
127
127
  },
128
128
  "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",
129
+ "@earendil-works/pi-agent-core": "0.99.1",
130
+ "@earendil-works/pi-ai": "0.99.1",
131
+ "@earendil-works/pi-coding-agent": "0.99.1",
132
+ "@earendil-works/pi-tui": "0.99.1",
133
133
  "@types/node": "24.12.4",
134
134
  "bun": "1.2.23",
135
135
  "typescript": "5.9.3"
@@ -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.",