pi-openai-toolkit 0.17.3 → 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 +5 -5
- package/README.zh.md +5 -5
- 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 +72 -8
- package/src/image-generation/eligibility.ts +13 -8
- package/src/image-generation/extension.ts +22 -6
- package/src/image-generation/protocol.ts +228 -128
- package/src/image-generation/render.ts +16 -8
- package/src/image-generation/service.ts +384 -255
- package/src/image-generation/types.ts +117 -6
- package/src/payload-rewrite.ts +16 -1
- package/src/responses-headers.ts +13 -3
- package/src/runtime.ts +295 -28
- package/src/serializer.ts +214 -0
- package/src/supported-environment.ts +5 -0
- package/src/types.ts +9 -5
- package/src/web-search/extension.ts +4 -3
- package/src/web-search/types.ts +25 -3
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
|
|
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.
|
|
@@ -168,7 +168,7 @@ Toolkit does not fall back between routes. See [search configuration](docs/confi
|
|
|
168
168
|
|
|
169
169
|
### Generate an image
|
|
170
170
|
|
|
171
|
-
Requires
|
|
171
|
+
Requires an eligible Responses-capable session and may incur provider charges. The local Pi tool sends a separate Images API request to the current provider (`/images/generations` or `/images/edits`; Codex uses `/codex/images/...`) rather than adding an image tool to the conversation request. Enable it with an image model your provider supports:
|
|
172
172
|
|
|
173
173
|
```json
|
|
174
174
|
{
|
|
@@ -176,14 +176,14 @@ Requires a Responses session and may incur provider charges. Enable it with an i
|
|
|
176
176
|
"defaults": {
|
|
177
177
|
"imageGeneration": {
|
|
178
178
|
"enabled": true,
|
|
179
|
-
"defaultModel": "
|
|
180
|
-
"allowedModels": ["
|
|
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
|
|
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
|
-
| 图像生成 |
|
|
17
|
+
| 图像生成 | 通过独立的 Images API 请求生成或编辑栅格图片。 |
|
|
18
18
|
| 工具调用审查 | 通过 Toolkit 审批门禁,让审查模型判断指定调用是否可以执行。 |
|
|
19
19
|
|
|
20
20
|
本包沿用 Pi 已有的模型、认证和会话,不新增提供商或模型。
|
|
@@ -168,7 +168,7 @@ Toolkit 不会在路由之间自动回退。后端要求见[搜索配置](docs/c
|
|
|
168
168
|
|
|
169
169
|
### 生成图片
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
需要符合条件的 Responses-capable 会话,并可能产生服务商费用。Pi 工具会向当前服务商发送独立的 Images API 请求(`/images/generations` 或 `/images/edits`;Codex 使用 `/codex/images/...`),不会把生图 tool 加入普通对话请求。选择服务商支持的生图模型后启用:
|
|
172
172
|
|
|
173
173
|
```json
|
|
174
174
|
{
|
|
@@ -176,14 +176,14 @@ Toolkit 不会在路由之间自动回退。后端要求见[搜索配置](docs/c
|
|
|
176
176
|
"defaults": {
|
|
177
177
|
"imageGeneration": {
|
|
178
178
|
"enabled": true,
|
|
179
|
-
"defaultModel": "
|
|
180
|
-
"allowedModels": ["
|
|
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`
|
|
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
|
|
package/config.schema.json
CHANGED
package/docs/configuration.md
CHANGED
|
@@ -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": "
|
|
22
|
-
"allowedModels": ["
|
|
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.
|
|
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 `"
|
|
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.
|
|
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.
|
|
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.
|
|
130
|
-
"@earendil-works/pi-ai": "0.
|
|
131
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
132
|
-
"@earendil-works/pi-tui": "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
|
|
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
|
|
178
|
-
api: model
|
|
179
|
-
model: model
|
|
180
|
-
baseUrl: normalizeBaseUrl(model
|
|
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
|
|
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
|
}
|
package/src/extension-runtime.ts
CHANGED
|
@@ -53,20 +53,22 @@ import {
|
|
|
53
53
|
rewriteResponsesPayloadWithNativeReplay,
|
|
54
54
|
hasInvalidatedCompactionContext,
|
|
55
55
|
removeNativeCompactionRetainedMessages,
|
|
56
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
595
|
-
|
|
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(
|
|
906
|
-
if (isCodexGatewayModel(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1365
|
-
|
|
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(
|
|
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
|
-
|
|
1409
|
-
if (!(
|
|
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
|
-
|
|
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
|
|
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}
|
|
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.");
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { buildResponsesRequestHeaders } from "../responses-headers";
|
|
2
|
-
import type
|
|
2
|
+
import { normalizeBaseUrl, type ResponsesRuntime } from "../runtime";
|
|
3
3
|
import {
|
|
4
4
|
extractProviderErrorMessage,
|
|
5
|
-
|
|
5
|
+
parseImagesApiResponse,
|
|
6
6
|
type ImageGenerationRequestBody,
|
|
7
7
|
} from "./protocol";
|
|
8
|
+
import type { PreparedReferenceImage } from "./types";
|
|
8
9
|
import {
|
|
9
10
|
IMAGE_REQUEST_TIMEOUT_MS,
|
|
10
11
|
MAX_IMAGE_ERROR_BYTES,
|
|
@@ -39,8 +40,64 @@ export type ImageGenerationFetch = (
|
|
|
39
40
|
init?: RequestInit,
|
|
40
41
|
) => Promise<Response>;
|
|
41
42
|
|
|
42
|
-
function buildRequestHeaders(runtime: ResponsesRuntime): Headers {
|
|
43
|
-
return buildResponsesRequestHeaders(runtime, { accept: "application/json" });
|
|
43
|
+
function buildRequestHeaders(runtime: ResponsesRuntime, contentType: string | null = "application/json"): Headers {
|
|
44
|
+
return buildResponsesRequestHeaders(runtime, { accept: "application/json", contentType });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildImagesEndpoint(runtime: ResponsesRuntime, action: ImageGenerationRequestBody["action"]): string {
|
|
48
|
+
const baseUrl = normalizeBaseUrl(runtime.baseUrl);
|
|
49
|
+
if (!baseUrl) throw new Error("Image generation requires a provider base URL.");
|
|
50
|
+
if (baseUrl.endsWith("/responses") || baseUrl.endsWith("/codex/responses")) {
|
|
51
|
+
throw new Error("Image generation requires the provider base URL, not a resolved Responses URL.");
|
|
52
|
+
}
|
|
53
|
+
const suffix = runtime.api === "openai-codex-responses" ? "codex/images" : "images";
|
|
54
|
+
const baseAlreadyIncludesCodex = runtime.api === "openai-codex-responses" && baseUrl.endsWith("/codex");
|
|
55
|
+
return `${baseUrl}/${baseAlreadyIncludesCodex ? "images" : suffix}/${action === "edit" ? "edits" : "generations"}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function dataUrl(reference: PreparedReferenceImage): string {
|
|
59
|
+
return `data:${reference.mimeType};base64,${reference.bytes.toString("base64")}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function jsonRequestBody(runtime: ResponsesRuntime, body: ImageGenerationRequestBody): Record<string, unknown> {
|
|
63
|
+
const common: Record<string, unknown> = {
|
|
64
|
+
model: body.model,
|
|
65
|
+
prompt: body.prompt,
|
|
66
|
+
n: body.n,
|
|
67
|
+
size: body.size,
|
|
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;
|
|
72
|
+
if (body.action === "edit") {
|
|
73
|
+
return {
|
|
74
|
+
...common,
|
|
75
|
+
images: body.references.map((reference) => ({ image_url: dataUrl(reference) })),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return runtime.api === "openai-codex-responses"
|
|
79
|
+
? common
|
|
80
|
+
: { ...common, output_format: "png", response_format: "b64_json" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function multipartRequestBody(body: ImageGenerationRequestBody): FormData {
|
|
84
|
+
const form = new FormData();
|
|
85
|
+
form.append("model", body.model);
|
|
86
|
+
form.append("prompt", body.prompt);
|
|
87
|
+
form.append("n", String(body.n));
|
|
88
|
+
form.append("size", body.size);
|
|
89
|
+
if (body.quality !== "auto") form.append("quality", body.quality);
|
|
90
|
+
form.append("output_format", "png");
|
|
91
|
+
form.append("response_format", "b64_json");
|
|
92
|
+
for (const [index, reference] of body.references.entries()) {
|
|
93
|
+
const extension = reference.mimeType === "image/jpeg" ? "jpg" : reference.mimeType.slice("image/".length);
|
|
94
|
+
form.append(
|
|
95
|
+
"image",
|
|
96
|
+
new Blob([new Uint8Array(reference.bytes)], { type: reference.mimeType }),
|
|
97
|
+
`reference-${index + 1}.${extension}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return form;
|
|
44
101
|
}
|
|
45
102
|
|
|
46
103
|
async function readBoundedBody(
|
|
@@ -141,12 +198,16 @@ export async function requestGeneratedImage(args: {
|
|
|
141
198
|
? AbortSignal.any([args.signal, timeoutSignal])
|
|
142
199
|
: timeoutSignal;
|
|
143
200
|
|
|
201
|
+
const multipart = args.body.action === "edit" && args.runtime.api === "openai-responses";
|
|
202
|
+
const transportBody: BodyInit = multipart
|
|
203
|
+
? multipartRequestBody(args.body)
|
|
204
|
+
: JSON.stringify(jsonRequestBody(args.runtime, args.body));
|
|
144
205
|
let response: Response;
|
|
145
206
|
try {
|
|
146
|
-
response = await fetchFn(args.runtime.
|
|
207
|
+
response = await fetchFn(buildImagesEndpoint(args.runtime, args.body.action), {
|
|
147
208
|
method: "POST",
|
|
148
|
-
headers: buildRequestHeaders(args.runtime),
|
|
149
|
-
body:
|
|
209
|
+
headers: buildRequestHeaders(args.runtime, multipart ? null : "application/json"),
|
|
210
|
+
body: transportBody,
|
|
150
211
|
signal,
|
|
151
212
|
});
|
|
152
213
|
} catch (error) {
|
|
@@ -230,7 +291,7 @@ export async function requestGeneratedImage(args: {
|
|
|
230
291
|
};
|
|
231
292
|
}
|
|
232
293
|
|
|
233
|
-
const parsed =
|
|
294
|
+
const parsed = parseImagesApiResponse(payload);
|
|
234
295
|
if (!parsed.ok) {
|
|
235
296
|
return {
|
|
236
297
|
ok: false,
|
|
@@ -244,5 +305,8 @@ export async function requestGeneratedImage(args: {
|
|
|
244
305
|
|
|
245
306
|
export const _clientTest = {
|
|
246
307
|
buildRequestHeaders,
|
|
308
|
+
buildImagesEndpoint,
|
|
309
|
+
jsonRequestBody,
|
|
310
|
+
multipartRequestBody,
|
|
247
311
|
readBoundedBody,
|
|
248
312
|
};
|