hffmnnj.generate-image 0.0.0-stage → 0.1.1
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 +135 -2
- package/assets/banner.png +0 -0
- package/index.ts +3 -0
- package/package.json +35 -4
- package/src/api.ts +428 -0
- package/src/errors.ts +89 -0
- package/src/files.ts +169 -0
- package/src/images.ts +37 -0
- package/src/index.ts +70 -0
- package/src/options.ts +75 -0
- package/src/service.ts +56 -0
- package/src/windows-files.ts +301 -0
package/README.md
CHANGED
|
@@ -1,3 +1,136 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://unpkg.com/hffmnnj.generate-image/assets/banner.png" alt="Generate Image for OpenCode" width="100%">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
# Generate Image for OpenCode
|
|
6
|
+
|
|
7
|
+
Native OpenCode V2 image generation and editing through the active OpenAI ChatGPT OAuth connection. One call generates or edits one image. There is no separate CLI login, API key, endpoint override, or API-key billing path.
|
|
8
|
+
|
|
9
|
+
- npm package and plugin ID: `hffmnnj.generate-image`
|
|
10
|
+
- Tool `generate_image`, permission `generate_image`; Code Mode only (`codemode: true`)
|
|
11
|
+
- Built for OpenCode V2 with `@opencode/plugin` 2.0.25
|
|
12
|
+
- Fixed endpoint `https://chatgpt.com/backend-api/codex/responses`
|
|
13
|
+
- Image models: `gpt-image-2.5-sunburst` (default) and `gpt-image-2.5-flare`
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
An active OpenAI connection in OpenCode with a fresh ChatGPT OAuth credential is required for every call. The connected subscription must allow image generation. API keys and expired credentials are rejected with a reconnect message. Image generation consumes subscription quota.
|
|
18
|
+
|
|
19
|
+
OpenCode owns sign-in, token refresh, and credential storage. This plugin reads the active connection, does not create accounts or accept credentials directly, and does not persist credentials itself.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
Add this object entry to the existing `plugins` array in your OpenCode config. Keep unrelated entries unchanged.
|
|
24
|
+
If upgrading from a local checkout, replace its existing entry rather than
|
|
25
|
+
loading both copies.
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"$schema": "https://opencode.ai/config.json",
|
|
30
|
+
"plugins": [
|
|
31
|
+
{
|
|
32
|
+
"package": "hffmnnj.generate-image"
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Plugin options are optional:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"package": "hffmnnj.generate-image",
|
|
43
|
+
"options": { "responsesModel": "gpt-6.1-sol" }
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`responsesModel` controls the outer Responses API model only. If it is omitted, the plugin uses the current OpenAI session model ID; for a non-OpenAI or unset session model it uses `gpt-6.1-sol`. If your session uses an alias that is not a valid API model ID, set the actual ID explicitly. An invalid session ID fails before OAuth resolution instead of silently falling back. No model, endpoint, provider, or authentication fallback is attempted after a rejection.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
In Code Mode:
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
const image = await tools.generate_image({
|
|
55
|
+
prompt: "A red square app icon on a white background",
|
|
56
|
+
outputPath: "assets/icons/red-square.png",
|
|
57
|
+
});
|
|
58
|
+
return image;
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Generate from a reference image:
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
return await tools.generate_image({
|
|
65
|
+
prompt: "Change the background to deep blue",
|
|
66
|
+
action: "edit",
|
|
67
|
+
referenceImages: ["assets/icons/red-square.png"],
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Only the prompt and the listed reference images are sent. Session history is not included. The tool writes one new PNG inside the session project and returns its path, `file://` URI, actual dimensions, byte size, and one inline `image/png` attachment for chat. It never overwrites an existing file.
|
|
72
|
+
|
|
73
|
+
## Parameters
|
|
74
|
+
|
|
75
|
+
Input is strict. Unknown keys such as `endpoint`, `transparent`, or `responsesModel` are rejected.
|
|
76
|
+
|
|
77
|
+
| Field | Type and constraints | Default |
|
|
78
|
+
| --- | --- | --- |
|
|
79
|
+
| `prompt` | trimmed string, 1 to 32,000 characters | required |
|
|
80
|
+
| `model` | `gpt-image-2.5-sunburst` or `gpt-image-2.5-flare` | `gpt-image-2.5-sunburst` |
|
|
81
|
+
| `quality` | `auto`, `low`, `medium`, `high`, `xhigh`, or `max` | `auto` |
|
|
82
|
+
| `size` | `auto` or `WxH` with each edge divisible by 16, 655,360 to 8,294,400 total pixels, maximum edge 3840, maximum 3:1 aspect ratio | `1024x1024` |
|
|
83
|
+
| `referenceImages` | 0 to 5 session/project-relative `.png`, `.jpg`, `.jpeg`, or `.webp` files; no hidden, empty, absolute, backslash, or traversal segments; at most 240 characters and 32 segments | `[]` |
|
|
84
|
+
| `outputPath` | new session/project-relative `.png`; same path rules; must not exist | `generated-images/<uuid>.png` |
|
|
85
|
+
| `action` | `generate`, `edit`, or `auto`; `edit` requires at least one reference | `generate` |
|
|
86
|
+
|
|
87
|
+
Plugin options are also strict:
|
|
88
|
+
|
|
89
|
+
| Option | Constraints | Default |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| `timeoutMs` | integer from 1,000 to 900,000 milliseconds | `600000` |
|
|
92
|
+
| `responsesModel` | GPT API identifier with a `gpt-` prefix, letters/digits/dot/underscore/hyphen only, at most 200 characters; no provider prefix, whitespace, or `#` variant | current OpenAI session model ID, otherwise `gpt-6.1-sol` |
|
|
93
|
+
|
|
94
|
+
Set `responsesModel` in plugin options, not in `tools.generate_image` input. A syntactically valid model alias can still be rejected by the provider. An explicit override changes only the outer Responses model ID; it does not change the endpoint, credentials, or image model.
|
|
95
|
+
|
|
96
|
+
## Output
|
|
97
|
+
|
|
98
|
+
The result contains `path`, `uri`, `model`, `quality`, `requestedSize`, `action`, `mime: "image/png"`, actual `width`, `height`, `bytes`, and `transparent` (whether the returned PNG has alpha). If the backend resolves to a different size than requested, `warning` says so and the actual resolution is preserved.
|
|
99
|
+
|
|
100
|
+
The PNG is delivered once as native file content, using the same validated bytes saved on disk. Structured output contains metadata only, never image bytes or a base64 string. Code Mode forwards the attachment separately, so callers should return the result as shown above rather than read and attach the saved image again. Clients that render native image attachments can display it directly in chat. This is one multimodal image, not zero image-token usage; it is not a display-only attachment.
|
|
101
|
+
|
|
102
|
+
There is no `transparent` input and no chroma-key or green-screen processing. Alpha is preserved when the provider returns it, but it cannot be requested.
|
|
103
|
+
|
|
104
|
+
## Platform and file safety
|
|
105
|
+
|
|
106
|
+
Safe file access is implemented for Linux using `/proc/self/fd` descriptor-relative traversal and for Windows x64 on local NTFS volumes using NT handle-relative access. Other platforms fail closed. Windows requires x64 and a local NTFS volume; UNC and network drives are rejected. Windows x64 support is implemented but remains unverified on Windows; treat it as experimental and validate it on your own host before relying on it.
|
|
107
|
+
|
|
108
|
+
Paths are resolved inside the session project. Hidden, empty, absolute, backslash, and traversal segments are rejected. Symlinked or reparse-point ancestors, hardlinked references, and non-regular files are rejected. Output is created exclusively with mode `0600` on Linux and an owner-only DACL on Windows. A pre-existing output is never replaced, and output parents are checked again after the network call.
|
|
109
|
+
|
|
110
|
+
The plugin is not a sandbox against arbitrary same-user or privileged filesystem mutation. Containment assumes stable directory topology during an operation: a same-user process can rename an already-open directory outside the project on Linux. On Windows, reparse-point checks are not atomic with subsequent opens or I/O, and a sufficiently timed mutation and restoration can evade them.
|
|
111
|
+
|
|
112
|
+
References are capped at 20 MiB each and 40 MiB aggregate, including decoded expansion. Responses are capped at 96 MiB on the wire, 32 MiB for the image, 20,000 events, and 100,000 lines. Decoded images are limited to 16 megapixels.
|
|
113
|
+
|
|
114
|
+
## Authorization, retries, and refresh
|
|
115
|
+
|
|
116
|
+
Each invocation reads the active `openai` connection and resolves a fresh credential. Only OAuth credentials with a finite future expiry and printable access token are accepted. API keys, missing connections, and expired credentials produce a reconnect message. The `chatgpt-account-id` header is sent when the credential supplies an account ID.
|
|
117
|
+
|
|
118
|
+
OpenCode performs native refresh at its own expiry window and persists rotated tokens. This plugin never refreshes tokens, persists credentials, or logs them. Calls within one loaded module use a local FIFO gate around the active lookup and native resolution, so each queued call re-reads active state. This is not a platform-wide singleflight guarantee: ordinary model calls, separately loaded module instances, and other processes are not serialized.
|
|
119
|
+
|
|
120
|
+
The endpoint is fixed. Redirects and arbitrary downloads are not followed. HTTP 401 maps to reconnect, 429 to quota or rate limiting, and other statuses to sanitized errors. Quota-consuming POSTs are not automatically retried. There is no API-key fallback, no alternate endpoint, and no automatic provider fallback. Tool errors are sanitized; provider text and credentials do not cross the tool boundary.
|
|
121
|
+
|
|
122
|
+
## Cancellation
|
|
123
|
+
|
|
124
|
+
Cancellation or `timeoutMs` stops waiting on OAuth work, network work, Sharp metadata, PNG encoding, and alpha inspection. It also prevents later image requests and output writes. Native codec work that has already started may continue settling in the background. A partially written output can remain if the exclusive write itself fails. No automatic retry is attempted.
|
|
125
|
+
|
|
126
|
+
## Response compatibility
|
|
127
|
+
|
|
128
|
+
Success responses may arrive as an SSE stream or as a strict whole completed Responses JSON object. Unsupported content types, HTML, raw images, and unrecognized bodies fail closed. Exactly one completed image is required. Provider error or incomplete envelopes never produce a saved image, and Sharp validates the actual bytes before any output is written.
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
bun install
|
|
134
|
+
bun run typecheck
|
|
135
|
+
bun test tests/unit
|
|
136
|
+
```
|
|
Binary file
|
package/index.ts
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hffmnnj.generate-image",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Native OpenCode V2 image generation using its active ChatGPT OAuth connection",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": { ".": "./src/index.ts" },
|
|
7
|
+
"files": [
|
|
8
|
+
"index.ts",
|
|
9
|
+
"src",
|
|
10
|
+
"assets/banner.png"
|
|
11
|
+
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/hffmnnj/opencode-generate-image.git"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/hffmnnj/opencode-generate-image#readme",
|
|
17
|
+
"bugs": { "url": "https://github.com/hffmnnj/opencode-generate-image/issues" },
|
|
18
|
+
"publishConfig": { "access": "public" },
|
|
19
|
+
"engines": { "bun": ">=1.3.0" },
|
|
20
|
+
"scripts": {
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"test": "bun test tests/unit",
|
|
23
|
+
"test:host": "bun test tests/host",
|
|
24
|
+
"check": "bun run typecheck && bun test tests/unit && bun test tests/host"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@opencode/plugin": "2.0.25",
|
|
28
|
+
"sharp": "0.35.4",
|
|
29
|
+
"zod": "4.1.8"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@opencode/sdk": "2.0.25",
|
|
33
|
+
"@types/bun": "1.3.5",
|
|
34
|
+
"@types/node": "22.13.9",
|
|
35
|
+
"typescript": "5.9.3"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { abortable, CANCELLED, checkSignal, ImageError, providerErrorReason, RECONNECT } from "./errors.ts";
|
|
2
|
+
import { DEFAULT_RESPONSES_MODEL, IMAGE_ENDPOINT, LIMITS, responsesModelSchema, USER_AGENT, type ImageInput } from "./options.ts";
|
|
3
|
+
|
|
4
|
+
export interface ImageCredential { readonly access: string; readonly accountID?: string }
|
|
5
|
+
export interface ImageAuth { resolve(): Promise<ImageCredential> }
|
|
6
|
+
export interface ImageBackend {
|
|
7
|
+
generate(input: ImageInput, references: readonly string[], signal: AbortSignal): Promise<Buffer>;
|
|
8
|
+
}
|
|
9
|
+
const record = (value: unknown): value is Record<string, unknown> =>
|
|
10
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
11
|
+
|
|
12
|
+
// Diagnostics have fixed keys and saturating counters, never provider strings.
|
|
13
|
+
// The existing event limit also bounds JSON-output diagnostic counters; it does
|
|
14
|
+
// not impose a new payload acceptance limit.
|
|
15
|
+
const boundedCount = (count: number) => Math.min(count, LIMITS.streamEvents);
|
|
16
|
+
const itemCounts = () => ({
|
|
17
|
+
message: 0, reasoning: 0, function_call: 0, image_generation_call: 0, other: 0,
|
|
18
|
+
image_functions: { image_generation: 0, image_gen: 0, imagegen: 0 },
|
|
19
|
+
});
|
|
20
|
+
type ItemCounts = ReturnType<typeof itemCounts>;
|
|
21
|
+
|
|
22
|
+
function countItem(counts: ItemCounts, item: unknown): void {
|
|
23
|
+
switch (record(item) ? item.type : undefined) {
|
|
24
|
+
case "message": counts.message = boundedCount(counts.message + 1); break;
|
|
25
|
+
case "reasoning": counts.reasoning = boundedCount(counts.reasoning + 1); break;
|
|
26
|
+
case "image_generation_call": counts.image_generation_call = boundedCount(counts.image_generation_call + 1); break;
|
|
27
|
+
case "function_call":
|
|
28
|
+
counts.function_call = boundedCount(counts.function_call + 1);
|
|
29
|
+
// Local CLI hosted-tool name and Codex ext/image-generation/src/lib.rs's
|
|
30
|
+
// namespace/tool constants. Exact matches only; no dynamic name buckets.
|
|
31
|
+
if (record(item)) switch (item.name) {
|
|
32
|
+
case "image_generation": counts.image_functions.image_generation = boundedCount(counts.image_functions.image_generation + 1); break;
|
|
33
|
+
case "image_gen": counts.image_functions.image_gen = boundedCount(counts.image_functions.image_gen + 1); break;
|
|
34
|
+
case "imagegen": counts.image_functions.imagegen = boundedCount(counts.image_functions.imagegen + 1); break;
|
|
35
|
+
}
|
|
36
|
+
break;
|
|
37
|
+
default: counts.other = boundedCount(counts.other + 1);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Read at most 16 KiB of error JSON; malformed/unknown bodies remain generic.
|
|
42
|
+
* Reading shares the caller's cancellation/timeout. Never wait for peer cancellation.
|
|
43
|
+
*/
|
|
44
|
+
async function rejectionReason(response: Response, signal: AbortSignal): Promise<string | undefined> {
|
|
45
|
+
const maxBytes = 16 * 1024;
|
|
46
|
+
const length = response.headers.get("content-length");
|
|
47
|
+
if (length && (!/^\d+$/.test(length) || Number(length) > maxBytes)) {
|
|
48
|
+
void response.body?.cancel().catch(() => {});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const reader = response.body?.getReader();
|
|
52
|
+
if (!reader) return;
|
|
53
|
+
const chunks: Uint8Array[] = [];
|
|
54
|
+
let total = 0;
|
|
55
|
+
try {
|
|
56
|
+
while (true) {
|
|
57
|
+
checkSignal(signal);
|
|
58
|
+
const next = await abortable(reader.read(), signal);
|
|
59
|
+
if (next.done) break;
|
|
60
|
+
total += next.value.byteLength;
|
|
61
|
+
if (total > maxBytes) return;
|
|
62
|
+
if (next.value.byteLength) chunks.push(next.value);
|
|
63
|
+
}
|
|
64
|
+
checkSignal(signal);
|
|
65
|
+
return providerErrorReason(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, total))));
|
|
66
|
+
} catch {
|
|
67
|
+
checkSignal(signal);
|
|
68
|
+
return;
|
|
69
|
+
} finally {
|
|
70
|
+
void reader.cancel().catch(() => {});
|
|
71
|
+
reader.releaseLock();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function validateResponsesModel(model: string): string {
|
|
76
|
+
const parsed = responsesModelSchema.safeParse(model);
|
|
77
|
+
if (!parsed.success) throw new ImageError("Invalid Responses model. Use a GPT API identifier without provider prefixes or # variants; set the generate-image plugin responsesModel option to the actual API model ID for a session alias. No endpoint, provider or authentication fallback was attempted.");
|
|
78
|
+
return parsed.data;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function imageRequest(input: ImageInput, references: readonly string[], responsesModel = DEFAULT_RESPONSES_MODEL) {
|
|
82
|
+
// Use the Responses image-generation tool rather than the separate /images API.
|
|
83
|
+
return {
|
|
84
|
+
model: validateResponsesModel(responsesModel),
|
|
85
|
+
instructions: "You are an image generation assistant. Generate exactly one image.",
|
|
86
|
+
input: [{ role: "user", content: [
|
|
87
|
+
{ type: "input_text", text: input.prompt },
|
|
88
|
+
...references.map(image_url => ({ type: "input_image", image_url, detail: "high" })),
|
|
89
|
+
] }],
|
|
90
|
+
tools: [{ type: "image_generation", model: input.model, quality: input.quality,
|
|
91
|
+
size: input.size, action: input.action, output_format: "png" }],
|
|
92
|
+
tool_choice: { type: "image_generation" }, stream: true, store: false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Incremental SSE framing; a retained image is released only after whole-response completion. */
|
|
97
|
+
export class ImageStreamDecoder {
|
|
98
|
+
private lineParts: string[] = [];
|
|
99
|
+
private partialLine = "";
|
|
100
|
+
private data: string[] = [];
|
|
101
|
+
private readonly utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
102
|
+
private total = 0;
|
|
103
|
+
private events = 0;
|
|
104
|
+
private lineCount = 0;
|
|
105
|
+
private complete = false;
|
|
106
|
+
private ended = false;
|
|
107
|
+
private image: string | undefined;
|
|
108
|
+
private readonly items = new Map<string, string>();
|
|
109
|
+
private readonly doneCounts = itemCounts();
|
|
110
|
+
private finalCounts: ItemCounts | null = null;
|
|
111
|
+
private finalOutput: number | null = null;
|
|
112
|
+
private finalImageCalls: number | null = null;
|
|
113
|
+
private streamedImageItems = 0;
|
|
114
|
+
private streamedImageResults = 0;
|
|
115
|
+
|
|
116
|
+
private exactlyOneError(): ImageError {
|
|
117
|
+
return new ImageError("Expected exactly one completed image; no image was saved. Image response counts: " + JSON.stringify({
|
|
118
|
+
count_limit: LIMITS.streamEvents,
|
|
119
|
+
final_output: this.finalOutput, final_image_generation_call: this.finalImageCalls,
|
|
120
|
+
streamed_image_items: this.streamedImageItems, streamed_image_results: this.streamedImageResults,
|
|
121
|
+
done: this.doneCounts, final: this.finalCounts,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
push(bytes: Uint8Array): void {
|
|
126
|
+
this.total += bytes.byteLength;
|
|
127
|
+
if (this.total > LIMITS.responseBytes) throw new ImageError("Image response exceeds the 96 MiB limit; no image was saved.");
|
|
128
|
+
try { this.lines(this.utf8.decode(bytes, { stream: true })); }
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (error instanceof ImageError) throw error;
|
|
131
|
+
throw new ImageError("Malformed image response stream; no image was saved.");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private lines(text: string): void {
|
|
136
|
+
let cursor = 0;
|
|
137
|
+
let end: number;
|
|
138
|
+
while ((end = text.indexOf("\n", cursor)) !== -1) {
|
|
139
|
+
if (++this.lineCount > LIMITS.streamLines) throw new ImageError("Too many image response lines; no image was saved.");
|
|
140
|
+
this.partialLine += text.slice(cursor, end);
|
|
141
|
+
let line = this.lineParts.length ? [...this.lineParts, this.partialLine].join("") : this.partialLine;
|
|
142
|
+
this.lineParts = [];
|
|
143
|
+
this.partialLine = "";
|
|
144
|
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
145
|
+
cursor = end + 1;
|
|
146
|
+
if (line === "") this.event();
|
|
147
|
+
else if (line.startsWith("data:")) {
|
|
148
|
+
if (this.data.length >= LIMITS.eventDataLines) throw new ImageError("Too many data lines in an image event; no image was saved.");
|
|
149
|
+
this.data.push(line.slice(5).replace(/^ /, ""));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (cursor < text.length) this.partialLine += text.slice(cursor);
|
|
153
|
+
if (this.partialLine.length >= 64 * 1024) {
|
|
154
|
+
// Flatten periodically: an adversarial one-byte stream must not retain millions of string ropes.
|
|
155
|
+
this.lineParts.push(Buffer.from(this.partialLine).toString("utf8"));
|
|
156
|
+
this.partialLine = "";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private event(): void {
|
|
161
|
+
if (this.data.length === 0) return;
|
|
162
|
+
const data = this.data.join("\n");
|
|
163
|
+
this.data = [];
|
|
164
|
+
if (++this.events > LIMITS.streamEvents) throw new ImageError("Too many image response events; no image was saved.");
|
|
165
|
+
if (this.ended) throw new ImageError("Unexpected data after image response ended; no image was saved.");
|
|
166
|
+
if (data === "[DONE]") {
|
|
167
|
+
if (!this.complete) throw new ImageError("Image response ended before completion; no image was saved.");
|
|
168
|
+
this.ended = true;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
let event: unknown;
|
|
172
|
+
try { event = JSON.parse(data); }
|
|
173
|
+
catch { throw new ImageError("Malformed image response event; no image was saved."); }
|
|
174
|
+
this.acceptEvent(event);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** JSON success uses a synthetic completed event, not a second image validator. */
|
|
178
|
+
static fromCompletedResponse(response: unknown): Buffer {
|
|
179
|
+
const decoder = new ImageStreamDecoder();
|
|
180
|
+
decoder.acceptEvent({ type: "response.completed", response });
|
|
181
|
+
return decoder.finish();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private acceptEvent(event: unknown): void {
|
|
185
|
+
if (!record(event) || typeof event.type !== "string") throw new ImageError("Malformed image response event; no image was saved.");
|
|
186
|
+
if (["error", "response.failed", "response.incomplete", "response.refusal.done", "response.refusal.delta"].includes(event.type))
|
|
187
|
+
throw new ImageError("OpenAI image generation failed or was refused; no image was saved.");
|
|
188
|
+
if (event.type === "response.output_item.done") {
|
|
189
|
+
countItem(this.doneCounts, event.item);
|
|
190
|
+
this.addItem(event.item);
|
|
191
|
+
// Only successfully validated streamed items count here. Final-output
|
|
192
|
+
// validation must not overwrite the evidence from earlier done events.
|
|
193
|
+
if (record(event.item) && event.item.type === "image_generation_call") {
|
|
194
|
+
this.streamedImageItems = boundedCount(this.streamedImageItems + 1);
|
|
195
|
+
this.streamedImageResults = boundedCount(this.items.size);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (event.type === "response.completed") {
|
|
199
|
+
if (this.complete || !record(event.response) || event.response.status !== "completed" || !Array.isArray(event.response.output)
|
|
200
|
+
|| event.response.error != null || event.response.incomplete_details != null)
|
|
201
|
+
throw new ImageError("Image response did not complete correctly; no image was saved.");
|
|
202
|
+
this.finalOutput = boundedCount(event.response.output.length);
|
|
203
|
+
this.finalCounts = itemCounts();
|
|
204
|
+
for (const item of event.response.output) countItem(this.finalCounts, item);
|
|
205
|
+
const finalImages = event.response.output.filter(item => record(item) && item.type === "image_generation_call");
|
|
206
|
+
this.finalImageCalls = boundedCount(finalImages.length);
|
|
207
|
+
for (const item of event.response.output) this.addItem(item);
|
|
208
|
+
// Native Luna can omit a completed done-event image from final output.
|
|
209
|
+
// CLI cli.py:593–605 likewise retains done-event results. Require whole
|
|
210
|
+
// completion and scan every final item above before selecting the sole
|
|
211
|
+
// validated result; final image items still require their full result.
|
|
212
|
+
const image = this.items.values().next().value;
|
|
213
|
+
if (finalImages.length > 1 || this.items.size !== 1 || typeof image !== "string")
|
|
214
|
+
throw this.exactlyOneError();
|
|
215
|
+
this.image = image;
|
|
216
|
+
this.complete = true;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private addItem(item: unknown): void {
|
|
221
|
+
if (!record(item)) throw new ImageError("Malformed image response item; no image was saved.");
|
|
222
|
+
if (item.type !== "image_generation_call") {
|
|
223
|
+
if (Array.isArray(item.content) && item.content.some(part => record(part) && part.type === "refusal"))
|
|
224
|
+
throw new ImageError("OpenAI image generation was refused; no image was saved.");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (item.status !== "completed" || typeof item.result !== "string" || !item.result
|
|
228
|
+
|| item.result.length > Math.ceil(LIMITS.imageBytes / 3) * 4)
|
|
229
|
+
throw new ImageError("Missing, incomplete or oversized image result; no image was saved.");
|
|
230
|
+
const id = typeof item.id === "string" && item.id ? item.id : "image";
|
|
231
|
+
const prior = this.items.get(id);
|
|
232
|
+
if (prior !== undefined && prior !== item.result) throw new ImageError("Conflicting image results; no image was saved.");
|
|
233
|
+
this.items.set(id, item.result);
|
|
234
|
+
if (this.items.size > 1) throw this.exactlyOneError();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
finish(): Buffer {
|
|
238
|
+
try { this.lines(this.utf8.decode()); }
|
|
239
|
+
catch { throw new ImageError("Malformed image response stream; no image was saved."); }
|
|
240
|
+
if ((this.lineParts.join("") + this.partialLine).trim() || this.data.length || !this.complete || !this.image)
|
|
241
|
+
throw new ImageError("Incomplete image response stream; no image was saved.");
|
|
242
|
+
const encoded = this.image;
|
|
243
|
+
if (encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded))
|
|
244
|
+
throw new ImageError("Invalid image encoding; no image was saved.");
|
|
245
|
+
const bytes = Buffer.from(encoded, "base64");
|
|
246
|
+
if (bytes.length > LIMITS.imageBytes || bytes.toString("base64") !== encoded)
|
|
247
|
+
throw new ImageError("Invalid or oversized image encoding; no image was saved.");
|
|
248
|
+
return bytes;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function decodeImageStream(text: string): Buffer {
|
|
253
|
+
const decoder = new ImageStreamDecoder();
|
|
254
|
+
decoder.push(Buffer.from(text));
|
|
255
|
+
return decoder.finish();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
type SuccessMediaType = "text/event-stream" | "application/json" | "text/plain" | "unknown";
|
|
259
|
+
|
|
260
|
+
function successMediaType(headers: Headers): SuccessMediaType {
|
|
261
|
+
const mediaType = (headers.get("content-type") ?? "").split(";", 1)[0]?.trim().toLowerCase();
|
|
262
|
+
if (mediaType === "text/event-stream" || mediaType === "application/json" || mediaType === "text/plain") return mediaType;
|
|
263
|
+
// Never interpolate an arbitrary provider-controlled header into a tool diagnostic.
|
|
264
|
+
if (mediaType === "text/html") throw new ImageError("Unsupported image response media type (text/html); no image was saved.");
|
|
265
|
+
if (mediaType === "application/octet-stream") throw new ImageError("Unsupported image response media type (application/octet-stream); no image was saved.");
|
|
266
|
+
if (/^image\/[a-z0-9!#$&^_.+-]+$/.test(mediaType ?? "")) throw new ImageError("Unsupported image response media type (raw image); no image was saved.");
|
|
267
|
+
return "unknown";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Only the fixed authenticated Responses endpoint (or loopback fixture) uses this.
|
|
271
|
+
* CLI cli.py:532–579 and Codex codex-api/src/sse/responses.rs parse SSE without a
|
|
272
|
+
* media-type check. Require an explicit 1 KiB SSE prefix for mislabeled or
|
|
273
|
+
* unknown/missing types, retaining every stream/payload check. JSON requires EOF
|
|
274
|
+
* and a completed Responses envelope; known HTML/binary/image labels are rejected.
|
|
275
|
+
*/
|
|
276
|
+
class ImageResponseDecoder {
|
|
277
|
+
private readonly stream = new ImageStreamDecoder();
|
|
278
|
+
private mode: "sse" | "json" | undefined;
|
|
279
|
+
private prefix = Buffer.alloc(0);
|
|
280
|
+
private readonly chunks: Buffer[] = [];
|
|
281
|
+
private jsonBlock: Buffer | undefined;
|
|
282
|
+
private jsonUsed = 0;
|
|
283
|
+
private total = 0;
|
|
284
|
+
|
|
285
|
+
constructor(private readonly mediaType: SuccessMediaType) {
|
|
286
|
+
if (mediaType === "text/event-stream") this.mode = "sse";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
push(bytes: Uint8Array): void {
|
|
290
|
+
this.total += bytes.byteLength;
|
|
291
|
+
if (this.total > LIMITS.responseBytes) throw new ImageError("Image response exceeds the 96 MiB limit; no image was saved.");
|
|
292
|
+
if (!bytes.byteLength) return;
|
|
293
|
+
if (this.mode) { this.consume(bytes); return; }
|
|
294
|
+
const taken = Math.min(bytes.byteLength, 1024 - this.prefix.length);
|
|
295
|
+
this.prefix = Buffer.concat([this.prefix, bytes.subarray(0, taken)]);
|
|
296
|
+
// latin1 preserves non-ASCII bytes rather than masking them into ASCII tokens.
|
|
297
|
+
const prefix = this.prefix.toString("latin1");
|
|
298
|
+
if (/^(?:\r?\n)*(?:data:|event:|:)/.test(prefix)) this.mode = "sse";
|
|
299
|
+
else if ((this.mediaType === "application/json" || this.mediaType === "unknown") && /^[\x20\t\r\n]*\{/.test(prefix)) this.mode = "json";
|
|
300
|
+
if (!this.mode) {
|
|
301
|
+
if (this.prefix.length === 1024) throw this.formatError();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
this.consume(this.prefix);
|
|
305
|
+
this.prefix = Buffer.alloc(0);
|
|
306
|
+
this.consume(bytes.subarray(taken));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private consume(bytes: Uint8Array): void {
|
|
310
|
+
if (!bytes.byteLength) return;
|
|
311
|
+
if (this.mode === "sse") this.stream.push(bytes);
|
|
312
|
+
else {
|
|
313
|
+
// Bound retained chunks too: one-byte JSON reads must not retain millions
|
|
314
|
+
// of tiny arrays or a large backing buffer for a small slice.
|
|
315
|
+
let offset = 0;
|
|
316
|
+
while (offset < bytes.byteLength) {
|
|
317
|
+
this.jsonBlock ??= Buffer.allocUnsafe(64 * 1024);
|
|
318
|
+
const taken = Math.min(bytes.byteLength - offset, this.jsonBlock.length - this.jsonUsed);
|
|
319
|
+
this.jsonBlock.set(bytes.subarray(offset, offset + taken), this.jsonUsed);
|
|
320
|
+
offset += taken;
|
|
321
|
+
this.jsonUsed += taken;
|
|
322
|
+
if (this.jsonUsed === this.jsonBlock.length) {
|
|
323
|
+
this.chunks.push(this.jsonBlock);
|
|
324
|
+
this.jsonBlock = undefined;
|
|
325
|
+
this.jsonUsed = 0;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private formatError(): ImageError {
|
|
332
|
+
if (this.mediaType === "unknown") return new ImageError("Response body is neither a completed Responses image object nor a complete image event stream; no image was saved.");
|
|
333
|
+
return new ImageError(this.mediaType === "application/json"
|
|
334
|
+
? "Unexpected image response body for application/json; expected a completed Responses JSON object or an image event stream; no image was saved."
|
|
335
|
+
: "Unexpected image response body for text/plain; expected an image event stream; no image was saved.");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
finish(): Buffer {
|
|
339
|
+
if (this.mode === "sse") return this.stream.finish();
|
|
340
|
+
if (this.mode !== "json") throw this.formatError();
|
|
341
|
+
if (this.jsonBlock) this.chunks.push(this.jsonBlock.subarray(0, this.jsonUsed));
|
|
342
|
+
let response: unknown;
|
|
343
|
+
try { response = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(this.chunks))); }
|
|
344
|
+
catch { throw this.mediaType === "unknown" ? this.formatError() : new ImageError("Malformed Responses JSON body; no image was saved."); }
|
|
345
|
+
if (record(response) && (response.error != null || response.detail != null)) {
|
|
346
|
+
const reason = providerErrorReason(response);
|
|
347
|
+
throw new ImageError(reason ? `OpenAI image request rejected: ${reason}; no automatic retry was attempted.`
|
|
348
|
+
: "OpenAI image generation failed; no image was saved.");
|
|
349
|
+
}
|
|
350
|
+
if (!record(response) || response.object !== "response" || typeof response.id !== "string" || !response.id
|
|
351
|
+
|| response.incomplete_details != null)
|
|
352
|
+
throw this.mediaType === "unknown" ? this.formatError() : new ImageError("Invalid completed Responses JSON object; no image was saved.");
|
|
353
|
+
return ImageStreamDecoder.fromCompletedResponse(response);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Fixed production URL. The only override is a constructor-only loopback fixture. */
|
|
358
|
+
export class CodexImageBackend implements ImageBackend {
|
|
359
|
+
constructor(private readonly auth: ImageAuth, private readonly endpoint: string = IMAGE_ENDPOINT,
|
|
360
|
+
private readonly responsesModel: string = DEFAULT_RESPONSES_MODEL) {
|
|
361
|
+
validateResponsesModel(responsesModel);
|
|
362
|
+
let url: URL;
|
|
363
|
+
try { url = new URL(endpoint); } catch { throw new ImageError("Untrusted image endpoint."); }
|
|
364
|
+
if (endpoint !== IMAGE_ENDPOINT && !(url.protocol === "http:" && url.hostname === "127.0.0.1"
|
|
365
|
+
&& url.port && url.pathname === "/responses" && !url.username && !url.password && !url.search && !url.hash))
|
|
366
|
+
throw new ImageError("Untrusted image endpoint.");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async generate(input: ImageInput, references: readonly string[], signal: AbortSignal): Promise<Buffer> {
|
|
370
|
+
checkSignal(signal);
|
|
371
|
+
let credential: ImageCredential;
|
|
372
|
+
try { credential = await abortable(this.auth.resolve(), signal); }
|
|
373
|
+
catch { throw new ImageError(signal.aborted ? CANCELLED : RECONNECT); }
|
|
374
|
+
checkSignal(signal);
|
|
375
|
+
let response: Response;
|
|
376
|
+
try {
|
|
377
|
+
response = await fetch(this.endpoint, {
|
|
378
|
+
method: "POST", redirect: "error", signal,
|
|
379
|
+
headers: { authorization: `Bearer ${credential.access}`, "content-type": "application/json",
|
|
380
|
+
accept: "text/event-stream", "user-agent": USER_AGENT, originator: "opencode",
|
|
381
|
+
...(credential.accountID ? { "chatgpt-account-id": credential.accountID } : {}) },
|
|
382
|
+
body: JSON.stringify(imageRequest(input, references, this.responsesModel)),
|
|
383
|
+
});
|
|
384
|
+
} catch { throw new ImageError(signal.aborted ? CANCELLED : "Image request failed; no automatic retry was attempted."); }
|
|
385
|
+
if (!response.ok) {
|
|
386
|
+
// Auth/quota errors retain their established messages and need no body inspection.
|
|
387
|
+
const reason = response.status === 401 || response.status === 429 ? undefined : await rejectionReason(response, signal);
|
|
388
|
+
if (response.status === 401 || response.status === 429) void response.body?.cancel().catch(() => {});
|
|
389
|
+
checkSignal(signal);
|
|
390
|
+
throw new ImageError(response.status === 401 ? RECONNECT
|
|
391
|
+
: response.status === 429 ? "ChatGPT image quota/rate limit reached; no automatic retry was attempted."
|
|
392
|
+
: reason ? `OpenAI image request rejected: ${reason}; no automatic retry was attempted.`
|
|
393
|
+
: `OpenAI image request failed (HTTP ${response.status}); no automatic retry was attempted.`);
|
|
394
|
+
}
|
|
395
|
+
let mediaType: SuccessMediaType;
|
|
396
|
+
try { mediaType = successMediaType(response.headers); }
|
|
397
|
+
catch (error) {
|
|
398
|
+
void response.body?.cancel().catch(() => {});
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
const length = response.headers.get("content-length");
|
|
402
|
+
if (length && (!/^\d+$/.test(length) || Number(length) > LIMITS.responseBytes)) {
|
|
403
|
+
void response.body?.cancel().catch(() => {});
|
|
404
|
+
throw new ImageError("Image response exceeds the 96 MiB limit; no image was saved.");
|
|
405
|
+
}
|
|
406
|
+
const reader = response.body?.getReader();
|
|
407
|
+
if (!reader) throw new ImageError("Missing image response body; no image was saved.");
|
|
408
|
+
const decoder = new ImageResponseDecoder(mediaType);
|
|
409
|
+
try {
|
|
410
|
+
while (true) {
|
|
411
|
+
checkSignal(signal);
|
|
412
|
+
const next = await abortable(reader.read(), signal);
|
|
413
|
+
if (next.done) break;
|
|
414
|
+
decoder.push(next.value);
|
|
415
|
+
}
|
|
416
|
+
checkSignal(signal);
|
|
417
|
+
return decoder.finish();
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (signal.aborted) throw new ImageError(CANCELLED);
|
|
420
|
+
if (error instanceof ImageError) throw error;
|
|
421
|
+
throw new ImageError("Image response stream failed; no automatic retry was attempted.");
|
|
422
|
+
} finally {
|
|
423
|
+
// Do not wait indefinitely for the peer to acknowledge cancellation.
|
|
424
|
+
void reader.cancel().catch(() => {});
|
|
425
|
+
reader.releaseLock();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|