pi-openai-toolkit 0.7.2 → 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 +32 -2
- package/README_zh.md +35 -2
- 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/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/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
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { buildResponsesRequestHeaders } from "../responses-headers";
|
|
2
|
+
import type { ResponsesRuntime } from "../runtime";
|
|
3
|
+
import {
|
|
4
|
+
extractProviderErrorMessage,
|
|
5
|
+
parseImageGenerationResponse,
|
|
6
|
+
type ImageGenerationRequestBody,
|
|
7
|
+
} from "./protocol";
|
|
8
|
+
import {
|
|
9
|
+
IMAGE_REQUEST_TIMEOUT_MS,
|
|
10
|
+
MAX_IMAGE_ERROR_BYTES,
|
|
11
|
+
MAX_IMAGE_RESPONSE_BYTES,
|
|
12
|
+
sanitizeImageDiagnostic,
|
|
13
|
+
type ParsedGeneratedImage,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
export type ImageGenerationClientFailureReason =
|
|
17
|
+
| "aborted"
|
|
18
|
+
| "timeout"
|
|
19
|
+
| "authentication"
|
|
20
|
+
| "rate-limit"
|
|
21
|
+
| "request-rejected"
|
|
22
|
+
| "backend-unavailable"
|
|
23
|
+
| "network"
|
|
24
|
+
| "oversized-response"
|
|
25
|
+
| "malformed-response"
|
|
26
|
+
| "no-image";
|
|
27
|
+
|
|
28
|
+
export type ImageGenerationClientResult =
|
|
29
|
+
| { ok: true; image: ParsedGeneratedImage; status: number }
|
|
30
|
+
| {
|
|
31
|
+
ok: false;
|
|
32
|
+
reason: ImageGenerationClientFailureReason;
|
|
33
|
+
status?: number;
|
|
34
|
+
errorMessage: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type ImageGenerationFetch = (
|
|
38
|
+
input: string | URL | Request,
|
|
39
|
+
init?: RequestInit,
|
|
40
|
+
) => Promise<Response>;
|
|
41
|
+
|
|
42
|
+
function buildRequestHeaders(runtime: ResponsesRuntime): Headers {
|
|
43
|
+
return buildResponsesRequestHeaders(runtime, { accept: "application/json" });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readBoundedBody(
|
|
47
|
+
response: Response,
|
|
48
|
+
maxBytes: number,
|
|
49
|
+
): Promise<{ ok: true; bytes: Uint8Array } | { ok: false }> {
|
|
50
|
+
const contentLength = response.headers.get("content-length");
|
|
51
|
+
if (contentLength !== null) {
|
|
52
|
+
const parsed = Number(contentLength);
|
|
53
|
+
if (Number.isFinite(parsed) && parsed > maxBytes) return { ok: false };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!response.body) return { ok: true, bytes: new Uint8Array() };
|
|
57
|
+
const reader = response.body.getReader();
|
|
58
|
+
const chunks: Uint8Array[] = [];
|
|
59
|
+
let total = 0;
|
|
60
|
+
try {
|
|
61
|
+
while (true) {
|
|
62
|
+
const next = await reader.read();
|
|
63
|
+
if (next.done) break;
|
|
64
|
+
if (!next.value) continue;
|
|
65
|
+
total += next.value.byteLength;
|
|
66
|
+
if (total > maxBytes) {
|
|
67
|
+
await reader.cancel().catch(() => undefined);
|
|
68
|
+
return { ok: false };
|
|
69
|
+
}
|
|
70
|
+
chunks.push(next.value);
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
reader.releaseLock();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const bytes = new Uint8Array(total);
|
|
77
|
+
let offset = 0;
|
|
78
|
+
for (const chunk of chunks) {
|
|
79
|
+
bytes.set(chunk, offset);
|
|
80
|
+
offset += chunk.byteLength;
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, bytes };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseJson(bytes: Uint8Array): unknown {
|
|
86
|
+
const text = new TextDecoder().decode(bytes);
|
|
87
|
+
return JSON.parse(text) as unknown;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function providerFailureText(value: unknown): string {
|
|
91
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
|
|
92
|
+
const candidate = value as Record<string, unknown>;
|
|
93
|
+
const error = candidate.error && typeof candidate.error === "object" && !Array.isArray(candidate.error)
|
|
94
|
+
? candidate.error as Record<string, unknown>
|
|
95
|
+
: candidate;
|
|
96
|
+
return [error.code, error.type, error.message]
|
|
97
|
+
.filter((item): item is string => typeof item === "string")
|
|
98
|
+
.join(" ")
|
|
99
|
+
.toLowerCase();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function mapHttpFailure(status: number, payload?: unknown): ImageGenerationClientFailureReason {
|
|
103
|
+
if (status === 401 || status === 403) return "authentication";
|
|
104
|
+
const providerFailure = providerFailureText(payload);
|
|
105
|
+
if (
|
|
106
|
+
status === 429 ||
|
|
107
|
+
/(?:rate|usage|billing)[-_ ]?limit|quota|insufficient[-_ ]?(?:quota|credit)/.test(providerFailure)
|
|
108
|
+
) {
|
|
109
|
+
return "rate-limit";
|
|
110
|
+
}
|
|
111
|
+
if (status === 400 || status === 404 || status === 409 || status === 422) {
|
|
112
|
+
return "request-rejected";
|
|
113
|
+
}
|
|
114
|
+
if (status >= 500) return "backend-unavailable";
|
|
115
|
+
return "request-rejected";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function defaultHttpMessage(status: number, reason = mapHttpFailure(status)): string {
|
|
119
|
+
switch (reason) {
|
|
120
|
+
case "authentication":
|
|
121
|
+
return "Image generation authentication failed for the current model provider.";
|
|
122
|
+
case "rate-limit":
|
|
123
|
+
return "Image generation was rate-limited or the provider image quota was exhausted.";
|
|
124
|
+
case "backend-unavailable":
|
|
125
|
+
return "Image generation is temporarily unavailable at the configured gateway or upstream provider.";
|
|
126
|
+
default:
|
|
127
|
+
return `Image generation request was rejected (HTTP ${status}).`;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function requestGeneratedImage(args: {
|
|
132
|
+
runtime: ResponsesRuntime;
|
|
133
|
+
body: ImageGenerationRequestBody;
|
|
134
|
+
signal?: AbortSignal;
|
|
135
|
+
fetchFn?: ImageGenerationFetch;
|
|
136
|
+
timeoutMs?: number;
|
|
137
|
+
}): Promise<ImageGenerationClientResult> {
|
|
138
|
+
const fetchFn = args.fetchFn ?? globalThis.fetch;
|
|
139
|
+
const timeoutSignal = AbortSignal.timeout(args.timeoutMs ?? IMAGE_REQUEST_TIMEOUT_MS);
|
|
140
|
+
const signal = args.signal
|
|
141
|
+
? AbortSignal.any([args.signal, timeoutSignal])
|
|
142
|
+
: timeoutSignal;
|
|
143
|
+
|
|
144
|
+
let response: Response;
|
|
145
|
+
try {
|
|
146
|
+
response = await fetchFn(args.runtime.responsesUrl, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: buildRequestHeaders(args.runtime),
|
|
149
|
+
body: JSON.stringify(args.body),
|
|
150
|
+
signal,
|
|
151
|
+
});
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (args.signal?.aborted) {
|
|
154
|
+
return { ok: false, reason: "aborted", errorMessage: "Image generation was cancelled." };
|
|
155
|
+
}
|
|
156
|
+
if (timeoutSignal.aborted) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
reason: "timeout",
|
|
160
|
+
errorMessage: "Image generation timed out. The request was not retried automatically.",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
reason: "network",
|
|
166
|
+
errorMessage: sanitizeImageDiagnostic(
|
|
167
|
+
error instanceof Error ? error.message : error,
|
|
168
|
+
"Image generation network request failed. The request was not retried automatically.",
|
|
169
|
+
),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let bounded: Awaited<ReturnType<typeof readBoundedBody>>;
|
|
174
|
+
try {
|
|
175
|
+
bounded = await readBoundedBody(
|
|
176
|
+
response,
|
|
177
|
+
response.ok ? MAX_IMAGE_RESPONSE_BYTES : MAX_IMAGE_ERROR_BYTES,
|
|
178
|
+
);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (args.signal?.aborted) {
|
|
181
|
+
return { ok: false, reason: "aborted", errorMessage: "Image generation was cancelled." };
|
|
182
|
+
}
|
|
183
|
+
if (timeoutSignal.aborted) {
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
reason: "timeout",
|
|
187
|
+
errorMessage: "Image generation timed out. The request was not retried automatically.",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
reason: "network",
|
|
193
|
+
status: response.status,
|
|
194
|
+
errorMessage: sanitizeImageDiagnostic(
|
|
195
|
+
error instanceof Error ? error.message : error,
|
|
196
|
+
"Image generation response could not be read. The request was not retried automatically.",
|
|
197
|
+
),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (!bounded.ok) {
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
reason: "oversized-response",
|
|
204
|
+
status: response.status,
|
|
205
|
+
errorMessage: "Image generation response exceeded the configured size limit.",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let payload: unknown;
|
|
210
|
+
try {
|
|
211
|
+
payload = parseJson(bounded.bytes);
|
|
212
|
+
} catch {
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
reason: "malformed-response",
|
|
216
|
+
status: response.status,
|
|
217
|
+
errorMessage: response.ok
|
|
218
|
+
? "Image generation returned invalid JSON."
|
|
219
|
+
: defaultHttpMessage(response.status),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
const reason = mapHttpFailure(response.status, payload);
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
reason,
|
|
228
|
+
status: response.status,
|
|
229
|
+
errorMessage: extractProviderErrorMessage(payload, defaultHttpMessage(response.status, reason)),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const parsed = parseImageGenerationResponse(payload);
|
|
234
|
+
if (!parsed.ok) {
|
|
235
|
+
return {
|
|
236
|
+
ok: false,
|
|
237
|
+
reason: parsed.reason,
|
|
238
|
+
status: response.status,
|
|
239
|
+
errorMessage: parsed.errorMessage,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return { ok: true, image: parsed.image, status: response.status };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export const _clientTest = {
|
|
246
|
+
buildRequestHeaders,
|
|
247
|
+
readBoundedBody,
|
|
248
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { isExactModelAllowed } from "../model-scope";
|
|
2
|
+
import type { ImageGenerationConfig } from "../types";
|
|
3
|
+
import {
|
|
4
|
+
IMAGE_GENERATION_CAPABLE_APIS,
|
|
5
|
+
type ImageGenerationModel,
|
|
6
|
+
} from "./types";
|
|
7
|
+
|
|
8
|
+
export function isImageGenerationEnabledForModel(
|
|
9
|
+
model: ImageGenerationModel | undefined,
|
|
10
|
+
config: ImageGenerationConfig,
|
|
11
|
+
): boolean {
|
|
12
|
+
return (
|
|
13
|
+
config.enabled &&
|
|
14
|
+
typeof model?.api === "string" &&
|
|
15
|
+
(IMAGE_GENERATION_CAPABLE_APIS as readonly string[]).includes(model.api) &&
|
|
16
|
+
isExactModelAllowed(model, config.models)
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { StringEnum, Type, type Static } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { loadToolkitConfig } from "../config";
|
|
4
|
+
import { executeImageGeneration } from "./service";
|
|
5
|
+
import { isImageGenerationEnabledForModel } from "./eligibility";
|
|
6
|
+
import { renderImageGenerationResult } from "./render";
|
|
7
|
+
import {
|
|
8
|
+
IMAGE_GENERATION_QUALITIES,
|
|
9
|
+
IMAGE_GENERATION_SIZES,
|
|
10
|
+
IMAGE_GENERATION_TOOL_NAME,
|
|
11
|
+
MAX_IMAGE_PATH_CHARS,
|
|
12
|
+
MAX_IMAGE_PROMPT_CHARS,
|
|
13
|
+
MAX_REFERENCE_IMAGE_COUNT,
|
|
14
|
+
ImageGenerationError,
|
|
15
|
+
sanitizeImageDiagnostic,
|
|
16
|
+
type ImageGenerationDetails,
|
|
17
|
+
} from "./types";
|
|
18
|
+
|
|
19
|
+
const registeredApis = new WeakSet<object>();
|
|
20
|
+
|
|
21
|
+
const GenerateImageParameters = Type.Object(
|
|
22
|
+
{
|
|
23
|
+
prompt: Type.String({
|
|
24
|
+
minLength: 1,
|
|
25
|
+
maxLength: MAX_IMAGE_PROMPT_CHARS,
|
|
26
|
+
description: "Image generation or edit prompt. Preserve the user's requested subject and constraints.",
|
|
27
|
+
}),
|
|
28
|
+
referenceImagePaths: Type.Optional(
|
|
29
|
+
Type.Union(
|
|
30
|
+
[
|
|
31
|
+
Type.Null(),
|
|
32
|
+
Type.Array(Type.String({ minLength: 1, maxLength: MAX_IMAGE_PATH_CHARS }), {
|
|
33
|
+
minItems: 1,
|
|
34
|
+
maxItems: MAX_REFERENCE_IMAGE_COUNT,
|
|
35
|
+
}),
|
|
36
|
+
],
|
|
37
|
+
{
|
|
38
|
+
description:
|
|
39
|
+
"Use null when the user did not explicitly identify reference images; otherwise provide one to five user-identified local image paths.",
|
|
40
|
+
},
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
outputPath: Type.Optional(
|
|
44
|
+
Type.Union(
|
|
45
|
+
[
|
|
46
|
+
Type.Null(),
|
|
47
|
+
Type.String({
|
|
48
|
+
minLength: 1,
|
|
49
|
+
maxLength: MAX_IMAGE_PATH_CHARS,
|
|
50
|
+
}),
|
|
51
|
+
],
|
|
52
|
+
{
|
|
53
|
+
description:
|
|
54
|
+
"Use null when the user did not explicitly request a destination; otherwise provide the requested explicit .png file path.",
|
|
55
|
+
},
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
size: Type.Optional(
|
|
59
|
+
StringEnum(IMAGE_GENERATION_SIZES, {
|
|
60
|
+
description: "Requested PNG dimensions, or auto.",
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
quality: Type.Optional(
|
|
64
|
+
StringEnum(IMAGE_GENERATION_QUALITIES, {
|
|
65
|
+
description: "Requested image quality, or auto.",
|
|
66
|
+
}),
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
{ additionalProperties: false },
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
type GenerateImageToolParams = Static<typeof GenerateImageParameters>;
|
|
73
|
+
|
|
74
|
+
function syncImageGenerationTool(
|
|
75
|
+
pi: ExtensionAPI,
|
|
76
|
+
model: Parameters<typeof isImageGenerationEnabledForModel>[0],
|
|
77
|
+
config: Parameters<typeof isImageGenerationEnabledForModel>[1],
|
|
78
|
+
): void {
|
|
79
|
+
const eligible = isImageGenerationEnabledForModel(model, config);
|
|
80
|
+
const activeTools = pi.getActiveTools();
|
|
81
|
+
const active = activeTools.includes(IMAGE_GENERATION_TOOL_NAME);
|
|
82
|
+
if (eligible && !active) {
|
|
83
|
+
pi.setActiveTools([...activeTools, IMAGE_GENERATION_TOOL_NAME]);
|
|
84
|
+
} else if (!eligible && active) {
|
|
85
|
+
pi.setActiveTools(activeTools.filter((name) => name !== IMAGE_GENERATION_TOOL_NAME));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function registerImageGenerationExtension(
|
|
90
|
+
pi: ExtensionAPI,
|
|
91
|
+
loadConfig: typeof loadToolkitConfig = loadToolkitConfig,
|
|
92
|
+
executeImage: typeof executeImageGeneration = executeImageGeneration,
|
|
93
|
+
): void {
|
|
94
|
+
if (registeredApis.has(pi)) return;
|
|
95
|
+
registeredApis.add(pi);
|
|
96
|
+
|
|
97
|
+
pi.registerTool<typeof GenerateImageParameters, ImageGenerationDetails, Record<string, never>>({
|
|
98
|
+
name: IMAGE_GENERATION_TOOL_NAME,
|
|
99
|
+
label: "OpenAI Generate Image",
|
|
100
|
+
description:
|
|
101
|
+
"Generate a PNG image, or edit from one to five user-approved local reference images, through the current allowlisted Responses model and the hosted gpt-image-2 image_generation tool. This is a paid provider operation.",
|
|
102
|
+
promptSnippet: "Generate or edit PNG images through the current allowlisted Responses model and gpt-image-2.",
|
|
103
|
+
promptGuidelines: [
|
|
104
|
+
"Use openai_generate_image when the user explicitly asks to create, draw, render, or edit a raster image.",
|
|
105
|
+
"Do not call openai_generate_image speculatively: it consumes the user's provider or gateway image quota.",
|
|
106
|
+
"Keep the image prompt faithful to the user's requested subject and constraints; do not invent unrequested style details.",
|
|
107
|
+
"Set referenceImagePaths to null unless the user explicitly identified local files; never invent paths or placeholder strings, and remember upload requires user approval.",
|
|
108
|
+
"Set outputPath to null unless the user explicitly asks for a destination; never invent a destination or placeholder string, and otherwise use the default Pi agent artifact.",
|
|
109
|
+
"Do not substitute Python, browser automation, shell scripts, or unrelated image tools for an eligible image request.",
|
|
110
|
+
],
|
|
111
|
+
parameters: GenerateImageParameters,
|
|
112
|
+
executionMode: "sequential",
|
|
113
|
+
async execute(toolCallId, params: GenerateImageToolParams, signal, _onUpdate, ctx) {
|
|
114
|
+
try {
|
|
115
|
+
const result = await executeImage({
|
|
116
|
+
params,
|
|
117
|
+
toolCallId,
|
|
118
|
+
signal,
|
|
119
|
+
ctx,
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
content: [{ type: "text", text: result.text }],
|
|
123
|
+
details: result.details,
|
|
124
|
+
};
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof ImageGenerationError) {
|
|
127
|
+
throw new Error(error.message);
|
|
128
|
+
}
|
|
129
|
+
throw new Error(
|
|
130
|
+
sanitizeImageDiagnostic(error instanceof Error ? error.message : error, "Image generation failed."),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
renderResult: renderImageGenerationResult,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
pi.on("session_start", (_event, ctx) => {
|
|
138
|
+
const { config } = loadConfig();
|
|
139
|
+
syncImageGenerationTool(pi, ctx.model, config.imageGeneration);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
pi.on("model_select", (event) => {
|
|
143
|
+
const { config } = loadConfig();
|
|
144
|
+
syncImageGenerationTool(pi, event.model, config.imageGeneration);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
148
|
+
const { config } = loadConfig();
|
|
149
|
+
syncImageGenerationTool(pi, ctx.model, config.imageGeneration);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export default function imageGenerationExtension(pi: ExtensionAPI): void {
|
|
154
|
+
registerImageGenerationExtension(pi);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const _extensionTest = {
|
|
158
|
+
GenerateImageParameters,
|
|
159
|
+
syncImageGenerationTool,
|
|
160
|
+
};
|