pi-openai-toolkit 0.7.1 → 0.8.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 +33 -3
- package/README_zh.md +36 -3
- package/extensions/image-generation.ts +1 -0
- package/package.json +20 -4
- package/src/compact-client.ts +4 -51
- package/src/config.ts +31 -1
- package/src/extension-runtime.ts +2 -4
- package/src/image-generation/artifacts.ts +237 -0
- package/src/image-generation/client.ts +248 -0
- package/src/image-generation/eligibility.ts +18 -0
- package/src/image-generation/extension.ts +160 -0
- package/src/image-generation/protocol.ts +405 -0
- package/src/image-generation/references.ts +194 -0
- package/src/image-generation/render.ts +252 -0
- package/src/image-generation/service.ts +243 -0
- package/src/image-generation/types.ts +184 -0
- package/src/model-scope.ts +17 -0
- package/src/payload-rewrite.ts +17 -146
- package/src/remote-v2-client.ts +7 -51
- package/src/responses-headers.ts +67 -0
- package/src/runtime.ts +43 -4
- package/src/types.ts +14 -0
- package/src/web-search/types.ts +3 -5
- package/tsconfig.check.json +12 -0
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# pi-openai-toolkit
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<strong>OpenAI-focused toolkit for <a href="https://github.com/earendil-works/pi">Pi</a>:
|
|
4
|
+
<strong>OpenAI-focused toolkit for <a href="https://github.com/earendil-works/pi">Pi</a>: Remote Compaction v2, Hosted Web Search & Native Image Generation</strong>
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
`pi-openai-toolkit` is an all-in-one OpenAI enhancement package for **Pi (>= 0.84.x)**. It bundles
|
|
13
|
+
`pi-openai-toolkit` is an all-in-one OpenAI enhancement package for **Pi (>= 0.84.x)**. It bundles three extensions designed for OpenAI Responses endpoints:
|
|
14
14
|
|
|
15
15
|
1. **Remote Responses Compaction v2** (`extensions/compaction.ts`): Server-side opaque context compaction using the `remote_compaction_v2` SSE streaming protocol. Preserves context fidelity without lossy text re-summarization.
|
|
16
16
|
2. **Model-Scoped Web Search** (`extensions/web-search.ts`): Toolkit-owned hosted Web Search for an exact `provider/model-id` allowlist configured with `webSearch.models`, across the supported `openai-responses` and `openai-codex-responses` APIs.
|
|
17
|
+
3. **Native Image Generation** (`extensions/image-generation.ts`): A sequential local tool backed by the hosted Responses `image_generation` tool. It supports text-to-image and explicit local-reference edits while keeping binary image data out of session history.
|
|
17
18
|
|
|
18
19
|
## Installation
|
|
19
20
|
|
|
@@ -57,6 +58,9 @@ pi --no-extensions -e /absolute/path/to/pi-openai-toolkit/extensions/compaction.
|
|
|
57
58
|
|
|
58
59
|
# Web Search only
|
|
59
60
|
pi --no-extensions -e /absolute/path/to/pi-openai-toolkit/extensions/web-search.ts
|
|
61
|
+
|
|
62
|
+
# Image generation only
|
|
63
|
+
pi --no-extensions -e /absolute/path/to/pi-openai-toolkit/extensions/image-generation.ts
|
|
60
64
|
```
|
|
61
65
|
|
|
62
66
|
> ⚠️ **Conflict Warning**: Do not run `pi-remote-compact`, `@lll9p/pi-better-compaction`, or standalone `pi-openai-web-search` alongside `pi-openai-toolkit`. Duplicate hooks cause race conditions and duplicated system prompts.
|
|
@@ -108,6 +112,12 @@ C:\Users\<user>\.pi\agent\extensions\pi-openai-toolkit\config.json
|
|
|
108
112
|
"models": [
|
|
109
113
|
"uwoacrimson/gpt-5.6-luna"
|
|
110
114
|
]
|
|
115
|
+
},
|
|
116
|
+
"imageGeneration": {
|
|
117
|
+
"enabled": true,
|
|
118
|
+
"models": [
|
|
119
|
+
"uwoacrimson/gpt-5.6-luna"
|
|
120
|
+
]
|
|
111
121
|
}
|
|
112
122
|
}
|
|
113
123
|
```
|
|
@@ -137,6 +147,13 @@ C:\Users\<user>\.pi\agent\extensions\pi-openai-toolkit\config.json
|
|
|
137
147
|
- `enabled` (*boolean*, default: `true`): Enable or disable native OpenAI Web Search injection.
|
|
138
148
|
- `models` (*string[]*): Exact `provider/model-id` allowlist. Toolkit Web Search is used only for listed models running on `openai-responses` or `openai-codex-responses`. An empty list disables toolkit Web Search for all models.
|
|
139
149
|
|
|
150
|
+
#### `imageGeneration`
|
|
151
|
+
|
|
152
|
+
- `enabled` (*boolean*, default: `true`): Enable or disable the `openai_generate_image` local tool.
|
|
153
|
+
- `models` (*string[]*, default: `[]`): Exact `provider/model-id` allowlist. The tool is active only for listed models using `openai-responses` or `openai-codex-responses`. The allowlist is independent from Web Search and compaction.
|
|
154
|
+
|
|
155
|
+
Image generation remains opt-in through the empty-by-default `models` allowlist because each successful provider request may incur a charge. The request uses the current model's resolved Responses endpoint, authentication, and headers, while the hosted image tool itself is fixed to `gpt-image-2`; Pi does not switch the active conversation model.
|
|
156
|
+
|
|
140
157
|
---
|
|
141
158
|
|
|
142
159
|
## Core Mechanics
|
|
@@ -163,7 +180,7 @@ Content-Type: application/json
|
|
|
163
180
|
- **Validation Criteria**: Must return a valid SSE stream with `response.completed` (status `completed`) containing exactly one output item of `type: "compaction"` with non-empty `encrypted_content`.
|
|
164
181
|
- **Remote Model Override**: `compaction.remoteCompactModel` can assign a same-gateway Responses model as the checkpoint producer while the active model remains the consumer. Its registered model metadata, authentication, headers, and effective base URL are used only for the synthetic compact request. Existing and recursive checkpoints remain replayable by the active model.
|
|
165
182
|
- **Zero Loss Replay**: The opaque encrypted item is stored in `CompactionEntry.details.compactedWindow`. On subsequent requests, it is transparently prepended after fresh preamble instructions alongside live turns, eliminating lossy text re-summaries.
|
|
166
|
-
- **Fail-Closed Replay Guard**: Replay
|
|
183
|
+
- **Fail-Closed Replay Guard**: Replay anchors on the Pi-authored compaction summary item by content and replaces it with the opaque compacted window; the post-compaction context is preserved verbatim, so transient messages injected by other extensions no longer block replay. If the summary anchor cannot be found, the toolkit aborts the provider request, shows an error notification, and always writes a content-free `*-replay-failure.json` artifact even when `logProviderPayloads` is disabled. The sentinel-only payload is never sent to the model.
|
|
167
184
|
- **Graceful Multi-Tier Fallback**:
|
|
168
185
|
1. Compatible Responses endpoints → Remote v2 Opaque Compaction.
|
|
169
186
|
2. Gateway failure or non-Responses model → Configured `compaction.model` fallback.
|
|
@@ -188,6 +205,19 @@ For allowlisted models using `openai-responses` or `openai-codex-responses`, the
|
|
|
188
205
|
- **Zero-Latency**: Uses Pi's existing connection, auth headers, and base URL without standalone HTTP overhead.
|
|
189
206
|
- **Toolkit Ownership**: For an eligible model, the outgoing payload removes the local function tool named `web_search` so native search is the only search path. Switching to an ineligible model restores that local tool's previous active state.
|
|
190
207
|
|
|
208
|
+
### 4. Native Image Generation and Editing
|
|
209
|
+
|
|
210
|
+
For an allowlisted Responses model, Pi exposes the sequential `openai_generate_image` tool:
|
|
211
|
+
|
|
212
|
+
- **Isolated request**: Sends one non-streaming, `store: false` Responses request containing only the image prompt and explicitly approved references. It does not forward recent conversation history and does not automatically retry after dispatch, avoiding accidental duplicate charges.
|
|
213
|
+
- **Generation and editing**: Supports text-to-image or editing with 1-5 explicitly supplied local PNG, JPEG, or WebP reference paths. Reference upload always requires interactive confirmation; headless sessions reject reference uploads.
|
|
214
|
+
- **Same route, fixed image model**: Reuses the current model's effective base URL, authentication, and provider headers, but requests the hosted `gpt-image-2` tool. It does not use a separate Codex OAuth flow or switch the active Pi model.
|
|
215
|
+
- **PNG artifacts**: Validates the returned PNG and saves it under `<Pi agent dir>/generated-images/<session-id>/<image-call-id>.png`. Existing files are never overwritten.
|
|
216
|
+
- **Safe optional copy**: A relative output path is allowed only in a trusted project. Paths outside both the trusted project and Pi agent directory require interactive confirmation. Output is PNG-only and existing destinations are rejected.
|
|
217
|
+
- **Compact session data**: Tool results contain paths and bounded metadata, never image base64. The Pi TUI preview reads the canonical artifact from disk when image display is supported; deleting the artifact removes the preview. Explicit reference path strings remain part of the tool-call arguments, but reference bytes do not.
|
|
218
|
+
- **Headless behavior**: Text-to-image with the default artifact path remains available without a UI. Reference uploads and external output paths that require approval are rejected in headless sessions.
|
|
219
|
+
- **Billing and verification boundary**: The configured gateway/provider remains the billing authority; the tool does not synthesize Pi usage or cost metadata. Deterministic tests and smoke checks never dispatch image generation, and no live paid probe runs without separate explicit approval.
|
|
220
|
+
|
|
191
221
|
---
|
|
192
222
|
|
|
193
223
|
## Development & Testing
|
package/README_zh.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# pi-openai-toolkit
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<strong>专为 <a href="https://github.com/earendil-works/pi">Pi</a> 打造的 OpenAI
|
|
4
|
+
<strong>专为 <a href="https://github.com/earendil-works/pi">Pi</a> 打造的 OpenAI 增强工具包:远端无损压缩 v2、原生托管联网搜索与图像生成</strong>
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
@@ -10,10 +10,11 @@
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
`pi-openai-toolkit` 是专为 **Pi (>= 0.84.x)** 打造的 OpenAI
|
|
13
|
+
`pi-openai-toolkit` 是专为 **Pi (>= 0.84.x)** 打造的 OpenAI 增强工具包,集成了三项针对 OpenAI Responses 协议的扩展:
|
|
14
14
|
|
|
15
15
|
1. **远端无损压缩 (Remote Compaction v2)** (`extensions/compaction.ts`): 基于 `remote_compaction_v2` SSE 流式协议实现的服务端密文无损会话压缩,彻底告别会话变长后丢失细节的文本二次摘要。
|
|
16
16
|
2. **按模型启用的联网搜索** (`extensions/web-search.ts`): 通过 `webSearch.models` 精确匹配 `provider/model-id`,由 Toolkit 为符合条件的 `openai-responses` 与 `openai-codex-responses` 会话提供并拥有原生联网搜索。
|
|
17
|
+
3. **原生图像生成** (`extensions/image-generation.ts`): 注册由 Responses 托管 `image_generation` 工具驱动的本地顺序工具,支持文生图和显式本地参考图编辑,同时避免把图像二进制写入会话历史。
|
|
17
18
|
|
|
18
19
|
## 安装使用
|
|
19
20
|
|
|
@@ -57,6 +58,9 @@ pi --no-extensions -e /本地绝对路径/pi-openai-toolkit/extensions/compactio
|
|
|
57
58
|
|
|
58
59
|
# 仅加载原生联网搜索
|
|
59
60
|
pi --no-extensions -e /本地绝对路径/pi-openai-toolkit/extensions/web-search.ts
|
|
61
|
+
|
|
62
|
+
# 仅加载原生图像生成
|
|
63
|
+
pi --no-extensions -e /本地绝对路径/pi-openai-toolkit/extensions/image-generation.ts
|
|
60
64
|
```
|
|
61
65
|
|
|
62
66
|
> ⚠️ **冲突提示**:请勿同时启用 `pi-remote-compact`、`@lll9p/pi-better-compaction` 或独立的 `pi-openai-web-search`。多重钩子会产生竞态或注入重复的 System Prompt。
|
|
@@ -108,6 +112,12 @@ C:\Users\<user>\.pi\agent\extensions\pi-openai-toolkit\config.json
|
|
|
108
112
|
"models": [
|
|
109
113
|
"uwoacrimson/gpt-5.6-luna"
|
|
110
114
|
]
|
|
115
|
+
},
|
|
116
|
+
"imageGeneration": {
|
|
117
|
+
"enabled": true,
|
|
118
|
+
"models": [
|
|
119
|
+
"uwoacrimson/gpt-5.6-luna"
|
|
120
|
+
]
|
|
111
121
|
}
|
|
112
122
|
}
|
|
113
123
|
```
|
|
@@ -137,6 +147,13 @@ C:\Users\<user>\.pi\agent\extensions\pi-openai-toolkit\config.json
|
|
|
137
147
|
- `enabled` (*boolean*, 默认 `true`): 是否启用原生 OpenAI 联网搜索注入。
|
|
138
148
|
- `models` (*string[]*): 精确的 `provider/model-id` 模型白名单。只有列表中的模型在 `openai-responses` 或 `openai-codex-responses` 端点上运行时,才会使用 Toolkit Web Search。空列表表示所有模型都不启用 Toolkit Web Search。
|
|
139
149
|
|
|
150
|
+
#### `imageGeneration`(图像生成配置)
|
|
151
|
+
|
|
152
|
+
- `enabled` (*boolean*, 默认 `true`): 是否启用本地工具 `openai_generate_image`。
|
|
153
|
+
- `models` (*string[]*, 默认 `[]`): 精确的 `provider/model-id` 白名单。只有列表中的模型使用 `openai-responses` 或 `openai-codex-responses` 时才会激活工具;它与联网搜索、压缩配置相互独立。
|
|
154
|
+
|
|
155
|
+
图像生成仍通过默认空白的 `models` 白名单显式启用,因为成功的 Provider 请求可能产生费用。请求复用当前模型解析后的 Responses 端点、认证和请求头,托管图像工具固定使用 `gpt-image-2`;Pi 不会切换当前会话模型。
|
|
156
|
+
|
|
140
157
|
---
|
|
141
158
|
|
|
142
159
|
## 核心工作原理解析
|
|
@@ -163,7 +180,7 @@ Content-Type: application/json
|
|
|
163
180
|
- **校验标准**:接收 SSE 流并验证 `response.completed` 事件(状态为 `completed`),且返回且仅返回一个包含非空 `encrypted_content` 密文的 `type: "compaction"` 数据块。
|
|
164
181
|
- **远端压缩模型覆盖**:`compaction.remoteCompactModel` 可以让同一网关上的另一个 Responses 模型负责生成 checkpoint,而当前活动模型仍是 checkpoint 的消费者。synthetic 压缩请求会使用覆盖模型在注册表中的模型元数据、认证、请求头和实际 Base URL;旧 checkpoint、递归压缩 checkpoint 都仍由当前活动模型重放。
|
|
165
182
|
- **无损重放机制**:密文数据完整存入 `CompactionEntry.details.compactedWindow`。在后续对话中,插件会自动将密文插入在最新的系统指令之后与最新几轮消息之前,实现完全服务端无损上下文还原。
|
|
166
|
-
- **重放失败保护(Fail-Closed
|
|
183
|
+
- **重放失败保护(Fail-Closed)**:重放以 Pi 生成的 compaction summary 哨兵为内容锚点,将其替换为密文压缩窗口;压缩后的其余上下文一律原样保留,因此其他扩展注入的临时消息不再阻断重放。若找不到哨兵锚点,插件会中止 provider 请求、弹出错误通知,并且即使 `logProviderPayloads` 关闭也会写入一份不含内容的安全失败记录 `*-replay-failure.json`。只含哨兵占位符的请求体永远不会发送给模型。
|
|
167
184
|
- **三级优雅降级**:
|
|
168
185
|
1. 支持的 Responses 端点 → 触发 Remote v2 密文无损压缩;
|
|
169
186
|
2. 网关不支持或报错 → 降级调用用户指定的 `compaction.model`;
|
|
@@ -188,6 +205,19 @@ Content-Type: application/json
|
|
|
188
205
|
- **零额外网络延迟**:复用 Pi 当前活跃的 Provider 配置、认证凭据与 Base URL;
|
|
189
206
|
- **Toolkit 接管搜索**:对于符合条件的模型,发给 Provider 的载荷会移除名为 `web_search` 的本地 Function Tool,只保留原生搜索;切换到不符合条件的模型后,会恢复该本地工具原先的 active 状态。
|
|
190
207
|
|
|
208
|
+
### 4. 原生图像生成与编辑
|
|
209
|
+
|
|
210
|
+
当当前 Responses 模型在白名单中时,Pi 会启用顺序工具 `openai_generate_image`:
|
|
211
|
+
|
|
212
|
+
- **隔离请求**:仅发送一次非流式、`store: false` 的 Responses 请求,请求体只包含图像提示词和用户显式批准的参考图,不会附带最近会话历史;发送后不自动重试,避免重复计费。
|
|
213
|
+
- **生成与编辑**:支持文生图,或使用 1–5 个显式指定的本地 PNG、JPEG、WebP 文件编辑。上传参考图前必须经过交互确认;Headless 会话拒绝上传参考图。
|
|
214
|
+
- **复用当前路由,固定图像模型**:使用当前模型实际生效的 Base URL、认证和 Provider 请求头,但托管图像工具固定请求 `gpt-image-2`。不会走独立 Codex OAuth,也不会切换 Pi 当前模型。
|
|
215
|
+
- **PNG Artifact**:校验返回的 PNG,并保存到 `<Pi agent dir>/generated-images/<session-id>/<image-call-id>.png`,绝不覆盖已有文件。
|
|
216
|
+
- **安全的可选副本**:相对输出路径只允许写入可信项目;同时位于可信项目和 Pi Agent 目录之外的路径必须交互确认。仅支持 PNG,已有目标文件会被拒绝。
|
|
217
|
+
- **紧凑会话数据**:工具结果只包含路径和有界元数据,不包含图像 Base64。终端支持图像显示时,Pi TUI 会从规范 Artifact 路径读取预览;删除 Artifact 后预览随即消失。显式参考路径字符串仍属于工具调用参数,但参考图字节不会写入会话。
|
|
218
|
+
- **Headless 行为**:不带参考图并使用默认 Artifact 路径的文生图可在无 UI 环境运行;需要确认的参考图上传和外部输出路径会在 Headless 会话中被拒绝。
|
|
219
|
+
- **计费与验证边界**:实际计费以配置的 Gateway/Provider 为准,工具不会自行构造 Pi usage/cost 元数据。确定性测试和冒烟检查不会发起生图请求;未经单独明确批准,也不会执行真实付费探针。
|
|
220
|
+
|
|
191
221
|
---
|
|
192
222
|
|
|
193
223
|
## 测试与验证
|
|
@@ -201,6 +231,9 @@ bun test
|
|
|
201
231
|
|
|
202
232
|
# Pi 冒烟测试
|
|
203
233
|
npm run test:pi
|
|
234
|
+
|
|
235
|
+
# 检查 npm 发布包内容
|
|
236
|
+
npm pack --dry-run
|
|
204
237
|
```
|
|
205
238
|
|
|
206
239
|
---
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "../src/image-generation/extension.ts";
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-openai-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "OpenAI-focused Pi toolkit providing remote Responses compaction and native
|
|
6
|
+
"description": "OpenAI-focused Pi toolkit providing remote Responses compaction, native Web Search, and native image generation.",
|
|
7
7
|
"author": "awoaCrim and contributors",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"repository": {
|
|
@@ -24,18 +24,21 @@
|
|
|
24
24
|
"openai",
|
|
25
25
|
"compaction",
|
|
26
26
|
"web-search",
|
|
27
|
+
"image-generation",
|
|
27
28
|
"codex",
|
|
28
29
|
"responses"
|
|
29
30
|
],
|
|
30
31
|
"pi": {
|
|
31
32
|
"extensions": [
|
|
32
33
|
"./extensions/compaction.ts",
|
|
33
|
-
"./extensions/web-search.ts"
|
|
34
|
+
"./extensions/web-search.ts",
|
|
35
|
+
"./extensions/image-generation.ts"
|
|
34
36
|
]
|
|
35
37
|
},
|
|
36
38
|
"files": [
|
|
37
39
|
"extensions/compaction.ts",
|
|
38
40
|
"extensions/web-search.ts",
|
|
41
|
+
"extensions/image-generation.ts",
|
|
39
42
|
"src/compact-client.ts",
|
|
40
43
|
"src/config.ts",
|
|
41
44
|
"src/debug.ts",
|
|
@@ -43,9 +46,20 @@
|
|
|
43
46
|
"src/deferred-tool-carryover.ts",
|
|
44
47
|
"src/inline-compaction.ts",
|
|
45
48
|
"src/extension-runtime.ts",
|
|
49
|
+
"src/image-generation/artifacts.ts",
|
|
50
|
+
"src/image-generation/client.ts",
|
|
51
|
+
"src/image-generation/eligibility.ts",
|
|
52
|
+
"src/image-generation/extension.ts",
|
|
53
|
+
"src/image-generation/protocol.ts",
|
|
54
|
+
"src/image-generation/references.ts",
|
|
55
|
+
"src/image-generation/render.ts",
|
|
56
|
+
"src/image-generation/service.ts",
|
|
57
|
+
"src/image-generation/types.ts",
|
|
58
|
+
"src/model-scope.ts",
|
|
46
59
|
"src/native-fallback.ts",
|
|
47
60
|
"src/payload-rewrite.ts",
|
|
48
61
|
"src/provider-headers.ts",
|
|
62
|
+
"src/responses-headers.ts",
|
|
49
63
|
"src/remote-v2-client.ts",
|
|
50
64
|
"src/request-context-cache.ts",
|
|
51
65
|
"src/runtime.ts",
|
|
@@ -71,12 +85,14 @@
|
|
|
71
85
|
"peerDependencies": {
|
|
72
86
|
"@earendil-works/pi-agent-core": "*",
|
|
73
87
|
"@earendil-works/pi-ai": "*",
|
|
74
|
-
"@earendil-works/pi-coding-agent": ">=0.80.0"
|
|
88
|
+
"@earendil-works/pi-coding-agent": ">=0.80.0",
|
|
89
|
+
"@earendil-works/pi-tui": ">=0.80.0"
|
|
75
90
|
},
|
|
76
91
|
"devDependencies": {
|
|
77
92
|
"@earendil-works/pi-agent-core": "0.84.3",
|
|
78
93
|
"@earendil-works/pi-ai": "0.84.3",
|
|
79
94
|
"@earendil-works/pi-coding-agent": "0.84.3",
|
|
95
|
+
"@earendil-works/pi-tui": "0.84.3",
|
|
80
96
|
"@types/node": "24.12.4",
|
|
81
97
|
"bun": "1.2.23",
|
|
82
98
|
"typescript": "5.9.3"
|
package/src/compact-client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { writeDebugArtifact } from "./debug";
|
|
2
|
-
import {
|
|
2
|
+
import { buildResponsesRequestHeaders } from "./responses-headers";
|
|
3
3
|
import type { NativeCompactionRuntime } from "./runtime";
|
|
4
4
|
import type { NativeCompactionRequestBody } from "./serializer";
|
|
5
5
|
import type { ArtifactContext, CompactionConfig } from "./types";
|
|
@@ -112,57 +112,10 @@ export function extractCompactedSummaryText(output: readonly unknown[]): string
|
|
|
112
112
|
return joined.length > 0 ? joined : undefined;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
|
|
116
|
-
const parts = token.split(".");
|
|
117
|
-
if (parts.length !== 3) {
|
|
118
|
-
return undefined;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
try {
|
|
122
|
-
const payloadText = Buffer.from(parts[1]!, "base64url").toString("utf8");
|
|
123
|
-
const payload = JSON.parse(payloadText);
|
|
124
|
-
return isRecord(payload) ? payload : undefined;
|
|
125
|
-
} catch {
|
|
126
|
-
return undefined;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function extractCodexAccountId(token: string): string | undefined {
|
|
131
|
-
const payload = decodeJwtPayload(token);
|
|
132
|
-
const authClaims = payload?.["https://api.openai.com/auth"];
|
|
133
|
-
if (!isRecord(authClaims)) {
|
|
134
|
-
return undefined;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const accountId = authClaims.chatgpt_account_id;
|
|
138
|
-
return typeof accountId === "string" && accountId.trim().length > 0 ? accountId.trim() : undefined;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function buildCodexUserAgent(): string {
|
|
142
|
-
const platform = typeof process !== "undefined" ? process.platform : "browser";
|
|
143
|
-
const arch = typeof process !== "undefined" ? process.arch : "unknown";
|
|
144
|
-
return `pi (${platform}; ${arch})`;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
115
|
function toHeaders(runtime: NativeCompactionRuntime): Record<string, string> {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (!headers.has("authorization")) {
|
|
152
|
-
headers.set("authorization", `Bearer ${runtime.apiKey}`);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (runtime.api === "openai-codex-responses") {
|
|
156
|
-
const accountId = extractCodexAccountId(runtime.apiKey);
|
|
157
|
-
if (accountId) {
|
|
158
|
-
headers.set("chatgpt-account-id", accountId);
|
|
159
|
-
}
|
|
160
|
-
headers.set("originator", "pi");
|
|
161
|
-
headers.set("user-agent", buildCodexUserAgent());
|
|
162
|
-
headers.set("openai-beta", "responses=experimental");
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return Object.fromEntries(headers.entries());
|
|
116
|
+
return Object.fromEntries(
|
|
117
|
+
buildResponsesRequestHeaders(runtime, { accept: JSON_CONTENT_TYPE }).entries(),
|
|
118
|
+
);
|
|
166
119
|
}
|
|
167
120
|
|
|
168
121
|
function writeCompactArtifact(
|
package/src/config.ts
CHANGED
|
@@ -6,11 +6,13 @@ import {
|
|
|
6
6
|
DEFAULT_COMPACTION_CONFIG,
|
|
7
7
|
DEFAULT_TOOLKIT_CONFIG,
|
|
8
8
|
DEFAULT_AUTO_COMPACTION_CONFIG,
|
|
9
|
+
DEFAULT_IMAGE_GENERATION_CONFIG,
|
|
9
10
|
DEFAULT_WEB_SEARCH_CONFIG,
|
|
10
11
|
RESPONSES_COMPACT_CAPABLE_APIS,
|
|
11
12
|
THINKING_LEVELS,
|
|
12
13
|
TOOLKIT_ID,
|
|
13
14
|
type CompactionConfig,
|
|
15
|
+
type ImageGenerationConfig,
|
|
14
16
|
type LoadedToolkitConfig,
|
|
15
17
|
type ToolkitConfig,
|
|
16
18
|
type WebSearchConfig,
|
|
@@ -19,7 +21,7 @@ import {
|
|
|
19
21
|
export const CONFIG_DIR = path.join(os.homedir(), ".pi", "agent", "extensions", TOOLKIT_ID);
|
|
20
22
|
export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
21
23
|
|
|
22
|
-
const TOP_LEVEL_FIELDS = new Set(["compaction", "webSearch"]);
|
|
24
|
+
const TOP_LEVEL_FIELDS = new Set(["compaction", "webSearch", "imageGeneration"]);
|
|
23
25
|
const COMPACTION_FIELDS = new Set([
|
|
24
26
|
"enabled",
|
|
25
27
|
"allowCompactionContinuityBreak",
|
|
@@ -37,6 +39,7 @@ const COMPACTION_FIELDS = new Set([
|
|
|
37
39
|
]);
|
|
38
40
|
const AUTO_COMPACTION_FIELDS = new Set(["enabled", "continuation", "unsupportedFallback", "reserveTokens"]);
|
|
39
41
|
const WEB_SEARCH_FIELDS = new Set(["enabled", "models"]);
|
|
42
|
+
const IMAGE_GENERATION_FIELDS = new Set(["enabled", "models"]);
|
|
40
43
|
|
|
41
44
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
42
45
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
@@ -193,6 +196,10 @@ function cloneDefaults(): ToolkitConfig {
|
|
|
193
196
|
...DEFAULT_WEB_SEARCH_CONFIG,
|
|
194
197
|
models: [...DEFAULT_WEB_SEARCH_CONFIG.models],
|
|
195
198
|
},
|
|
199
|
+
imageGeneration: {
|
|
200
|
+
...DEFAULT_IMAGE_GENERATION_CONFIG,
|
|
201
|
+
models: [...DEFAULT_IMAGE_GENERATION_CONFIG.models],
|
|
202
|
+
},
|
|
196
203
|
};
|
|
197
204
|
}
|
|
198
205
|
|
|
@@ -305,6 +312,21 @@ function applyWebSearchConfig(
|
|
|
305
312
|
}
|
|
306
313
|
}
|
|
307
314
|
|
|
315
|
+
function applyImageGenerationConfig(
|
|
316
|
+
raw: Record<string, unknown>,
|
|
317
|
+
resolved: ImageGenerationConfig,
|
|
318
|
+
warnings: string[],
|
|
319
|
+
): void {
|
|
320
|
+
warnUnknownFields(raw, IMAGE_GENERATION_FIELDS, "imageGeneration", warnings);
|
|
321
|
+
resolved.enabled =
|
|
322
|
+
toBoolean(raw.enabled, "imageGeneration.enabled", warnings) ?? resolved.enabled;
|
|
323
|
+
|
|
324
|
+
const models = toModelAllowlist(raw.models, "imageGeneration.models", warnings);
|
|
325
|
+
if (models !== undefined) {
|
|
326
|
+
resolved.models = models;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
308
330
|
/**
|
|
309
331
|
* Load the canonical toolkit config from
|
|
310
332
|
* `~/.pi/agent/extensions/pi-openai-toolkit/config.json`.
|
|
@@ -335,6 +357,14 @@ export function loadToolkitConfig(configPath: string = CONFIG_PATH): LoadedToolk
|
|
|
335
357
|
warnings.push("Ignoring webSearch: expected a JSON object.");
|
|
336
358
|
}
|
|
337
359
|
}
|
|
360
|
+
|
|
361
|
+
if (raw.imageGeneration !== undefined) {
|
|
362
|
+
if (isRecord(raw.imageGeneration)) {
|
|
363
|
+
applyImageGenerationConfig(raw.imageGeneration, resolved.imageGeneration, warnings);
|
|
364
|
+
} else {
|
|
365
|
+
warnings.push("Ignoring imageGeneration: expected a JSON object.");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
338
368
|
}
|
|
339
369
|
|
|
340
370
|
resolved.compaction.artifactRoot = resolveConfiguredPath(
|
package/src/extension-runtime.ts
CHANGED
|
@@ -548,12 +548,10 @@ async function handleBeforeProviderRequest(event: BeforeProviderRequestEvent, ct
|
|
|
548
548
|
firstKeptEntryIndex: rewrite.segments.firstKeptEntryIndex,
|
|
549
549
|
originalInputItems: payload.input.length,
|
|
550
550
|
rewrittenInputItems: rewrite.rewrittenPayload.input.length,
|
|
551
|
-
|
|
552
|
-
trailingPreambleItems: rewrite.segments.trailingPreamble.length,
|
|
551
|
+
leadingItems: rewrite.segments.leading.length,
|
|
553
552
|
compactionSummaryItems: rewrite.segments.compactionSummary.length,
|
|
554
|
-
preCompactionKeptItems: rewrite.segments.preCompactionKeptWindow.input.length,
|
|
555
553
|
compactedItems: rewrite.segments.compactedWindow.length,
|
|
556
|
-
|
|
554
|
+
postItems: rewrite.segments.post.length,
|
|
557
555
|
payload: rewrite.rewrittenPayload,
|
|
558
556
|
originalPayload: payload,
|
|
559
557
|
},
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
link,
|
|
4
|
+
mkdir,
|
|
5
|
+
open,
|
|
6
|
+
realpath,
|
|
7
|
+
stat,
|
|
8
|
+
unlink,
|
|
9
|
+
} from "node:fs/promises";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import {
|
|
12
|
+
basename,
|
|
13
|
+
dirname,
|
|
14
|
+
extname,
|
|
15
|
+
isAbsolute,
|
|
16
|
+
join,
|
|
17
|
+
relative,
|
|
18
|
+
resolve,
|
|
19
|
+
sep,
|
|
20
|
+
} from "node:path";
|
|
21
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import {
|
|
23
|
+
MAX_IMAGE_PATH_CHARS,
|
|
24
|
+
ImageGenerationError,
|
|
25
|
+
sanitizeImageDiagnostic,
|
|
26
|
+
} from "./types";
|
|
27
|
+
|
|
28
|
+
export type ExplicitOutputPlan = {
|
|
29
|
+
path: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function isPathInsideOrEqual(root: string, candidate: string): boolean {
|
|
33
|
+
const rel = relative(root, candidate);
|
|
34
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sanitizePathPart(value: string, fallback: string): string {
|
|
38
|
+
const sanitized = value
|
|
39
|
+
.trim()
|
|
40
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
41
|
+
.replace(/^[.-]+|[.-]+$/g, "")
|
|
42
|
+
.slice(0, 120);
|
|
43
|
+
return sanitized || fallback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function realDirectory(path: string): Promise<string> {
|
|
47
|
+
return realpath(path).catch(() => resolve(path));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function resolveWriteTarget(candidate: string): Promise<string> {
|
|
51
|
+
const suffix: string[] = [];
|
|
52
|
+
let current = resolve(candidate);
|
|
53
|
+
while (true) {
|
|
54
|
+
try {
|
|
55
|
+
const info = await stat(current);
|
|
56
|
+
if (suffix.length > 0 && !info.isDirectory()) {
|
|
57
|
+
throw new ImageGenerationError(
|
|
58
|
+
"output-path-invalid",
|
|
59
|
+
`Output parent is not a directory: ${current}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const existing = await realpath(current);
|
|
63
|
+
return resolve(existing, ...suffix);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error instanceof ImageGenerationError) throw error;
|
|
66
|
+
if (
|
|
67
|
+
error &&
|
|
68
|
+
typeof error === "object" &&
|
|
69
|
+
"code" in error &&
|
|
70
|
+
error.code !== "ENOENT"
|
|
71
|
+
) {
|
|
72
|
+
throw new ImageGenerationError(
|
|
73
|
+
"output-path-invalid",
|
|
74
|
+
`Unable to inspect output path: ${candidate}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const parent = dirname(current);
|
|
78
|
+
if (parent === current) {
|
|
79
|
+
throw new ImageGenerationError(
|
|
80
|
+
"output-path-invalid",
|
|
81
|
+
`Unable to resolve output path: ${candidate}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
suffix.unshift(basename(current));
|
|
85
|
+
current = parent;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
91
|
+
if (signal?.aborted) {
|
|
92
|
+
throw new ImageGenerationError("aborted", "Image generation was cancelled.");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function getGeneratedImagesRoot(agentDir: string): string {
|
|
97
|
+
return join(resolve(agentDir), "generated-images");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function prepareExplicitOutputPath(args: {
|
|
101
|
+
rawPath?: string;
|
|
102
|
+
agentDir: string;
|
|
103
|
+
ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "isProjectTrusted" | "ui">;
|
|
104
|
+
signal?: AbortSignal;
|
|
105
|
+
}): Promise<ExplicitOutputPlan | undefined> {
|
|
106
|
+
if (args.rawPath === undefined) return undefined;
|
|
107
|
+
const trimmed = args.rawPath.trim();
|
|
108
|
+
if (!trimmed || trimmed.length > MAX_IMAGE_PATH_CHARS || extname(trimmed).toLowerCase() !== ".png") {
|
|
109
|
+
throw new ImageGenerationError(
|
|
110
|
+
"output-path-invalid",
|
|
111
|
+
"outputPath must be a non-empty .png file path.",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
throwIfAborted(args.signal);
|
|
116
|
+
const lexical = isAbsolute(trimmed) ? resolve(trimmed) : resolve(args.ctx.cwd, trimmed);
|
|
117
|
+
const target = await resolveWriteTarget(lexical);
|
|
118
|
+
const targetInfo = await stat(target).catch(() => undefined);
|
|
119
|
+
if (targetInfo) {
|
|
120
|
+
throw new ImageGenerationError(
|
|
121
|
+
"output-path-invalid",
|
|
122
|
+
`Output path already exists and will not be overwritten: ${target}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const [agentRoot, projectRoot] = await Promise.all([
|
|
127
|
+
realDirectory(args.agentDir),
|
|
128
|
+
realDirectory(args.ctx.cwd),
|
|
129
|
+
]);
|
|
130
|
+
if (isPathInsideOrEqual(agentRoot, target)) return { path: target };
|
|
131
|
+
|
|
132
|
+
if (isPathInsideOrEqual(projectRoot, target)) {
|
|
133
|
+
if (!args.ctx.isProjectTrusted()) {
|
|
134
|
+
throw new ImageGenerationError(
|
|
135
|
+
"output-path-invalid",
|
|
136
|
+
"Writing generated images inside an untrusted project is not allowed.",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return { path: target };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!args.ctx.hasUI) {
|
|
143
|
+
throw new ImageGenerationError(
|
|
144
|
+
"output-path-approval-required",
|
|
145
|
+
"Writing outside the Pi agent root or trusted project requires interactive approval.",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const approved = await args.ctx.ui.confirm(
|
|
149
|
+
"Write generated image outside safe roots?",
|
|
150
|
+
[
|
|
151
|
+
"The generated PNG will be copied to this external path without overwriting an existing file:",
|
|
152
|
+
"",
|
|
153
|
+
target,
|
|
154
|
+
"",
|
|
155
|
+
"Continue?",
|
|
156
|
+
].join("\n"),
|
|
157
|
+
{ signal: args.signal },
|
|
158
|
+
);
|
|
159
|
+
throwIfAborted(args.signal);
|
|
160
|
+
if (!approved) {
|
|
161
|
+
throw new ImageGenerationError("output-path-declined", "External output path was declined.");
|
|
162
|
+
}
|
|
163
|
+
return { path: target };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function publishBytesNoOverwrite(bytes: Uint8Array, target: string): Promise<void> {
|
|
167
|
+
const parent = dirname(target);
|
|
168
|
+
await mkdir(parent, { recursive: true });
|
|
169
|
+
const tempPath = join(parent, `.${basename(target)}.${randomUUID()}.tmp`);
|
|
170
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
|
171
|
+
try {
|
|
172
|
+
handle = await open(tempPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
|
|
173
|
+
await handle.writeFile(bytes);
|
|
174
|
+
await handle.sync();
|
|
175
|
+
await handle.close();
|
|
176
|
+
handle = undefined;
|
|
177
|
+
await link(tempPath, target);
|
|
178
|
+
} finally {
|
|
179
|
+
await handle?.close().catch(() => undefined);
|
|
180
|
+
await unlink(tempPath).catch(() => undefined);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isAlreadyExistsError(error: unknown): boolean {
|
|
185
|
+
return !!error && typeof error === "object" && "code" in error && error.code === "EEXIST";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function saveCanonicalImage(args: {
|
|
189
|
+
bytes: Uint8Array;
|
|
190
|
+
agentDir: string;
|
|
191
|
+
sessionId: string;
|
|
192
|
+
imageCallId: string;
|
|
193
|
+
}): Promise<string> {
|
|
194
|
+
const root = getGeneratedImagesRoot(args.agentDir);
|
|
195
|
+
const sessionPart = sanitizePathPart(args.sessionId, "session");
|
|
196
|
+
const imagePart = sanitizePathPart(args.imageCallId, "image_generation");
|
|
197
|
+
const directory = join(root, sessionPart);
|
|
198
|
+
|
|
199
|
+
for (let index = 1; index <= 1000; index += 1) {
|
|
200
|
+
const suffix = index === 1 ? "" : `-${index}`;
|
|
201
|
+
const target = join(directory, `${imagePart}${suffix}.png`);
|
|
202
|
+
try {
|
|
203
|
+
await publishBytesNoOverwrite(args.bytes, target);
|
|
204
|
+
return target;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (isAlreadyExistsError(error)) continue;
|
|
207
|
+
throw new ImageGenerationError(
|
|
208
|
+
"artifact-write-failed",
|
|
209
|
+
sanitizeImageDiagnostic(
|
|
210
|
+
error instanceof Error ? error.message : error,
|
|
211
|
+
"Failed to persist the generated image artifact.",
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
throw new ImageGenerationError(
|
|
217
|
+
"artifact-write-failed",
|
|
218
|
+
"Failed to reserve a unique generated image artifact path.",
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function copyImageToExplicitPath(args: {
|
|
223
|
+
bytes: Uint8Array;
|
|
224
|
+
plan: ExplicitOutputPlan;
|
|
225
|
+
}): Promise<void> {
|
|
226
|
+
const currentTarget = await resolveWriteTarget(args.plan.path);
|
|
227
|
+
if (currentTarget !== args.plan.path) {
|
|
228
|
+
throw new Error("Output path changed after approval.");
|
|
229
|
+
}
|
|
230
|
+
await publishBytesNoOverwrite(args.bytes, args.plan.path);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export const _artifactTest = {
|
|
234
|
+
isPathInsideOrEqual,
|
|
235
|
+
publishBytesNoOverwrite,
|
|
236
|
+
resolveWriteTarget,
|
|
237
|
+
};
|