@suknna/pixelforge 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Suknna
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # PixelForge
2
+
3
+ PixelForge is an opencode plugin that adds background image generation tools. The model calls a tool with a prompt and an output path; PixelForge generates the image in the background, writes it to the workspace, and reports completion through an opencode TUI Toast plus an explicit job-status query tool.
4
+
5
+ ## Install
6
+
7
+ ```json
8
+ {
9
+ "$schema": "https://opencode.ai/config.json",
10
+ "plugin": ["@suknna/pixelforge"]
11
+ }
12
+ ```
13
+
14
+ For local development against a built checkout:
15
+
16
+ ```json
17
+ {
18
+ "$schema": "https://opencode.ai/config.json",
19
+ "plugin": ["./dist/index.js"]
20
+ }
21
+ ```
22
+
23
+ Restart opencode after changing plugin or config files. opencode loads plugins at startup and does not hot-reload them.
24
+
25
+ ## PixelForge Config
26
+
27
+ PixelForge reads `~/.config/opencode/pixelforge.json`. The plugin fails fast at startup if the file is missing or invalid.
28
+
29
+ Top-level fields:
30
+
31
+ - `defaultProfile` (required): the profile tried first for every job.
32
+ - `fallbackProfiles` (optional): an explicit, ordered list of profiles tried after the default. Only profiles listed here are ever attempted. Fallback happens **only** on transient errors (network failure, request timeout, HTTP `429`, HTTP `5xx`). Authentication, configuration, parameter, and moderation failures fail closed without fallback.
33
+ - `notifications` (optional): `tui`, `sessionMessage`, and `autoContinue` booleans.
34
+ - `profiles` (required): a map of profile name to provider config.
35
+
36
+ Secrets may be plain strings or environment references:
37
+
38
+ ```json
39
+ { "apiKey": { "env": "OPENAI_API_KEY" } }
40
+ ```
41
+
42
+ See [`examples/`](./examples) for complete multi-provider configurations. Supported providers: `openai-images`, `fal`, `replicate`, `stability-ai`.
43
+
44
+ ## Tools
45
+
46
+ PixelForge registers three tools. None of them expose the internal `profile` concept — provider and fallback selection stay entirely inside the config.
47
+
48
+ - `pixelforge_generate_image` — generate an image from a text prompt; returns a `jobId` immediately.
49
+ - `pixelforge_generate_image_from_image` — generate a new image from an existing base image plus a text prompt; returns a `jobId` immediately.
50
+ - `pixelforge_check_image_job` — report the status of a job by `jobId` (`queued`, `running`, `succeeded`, `failed`).
51
+
52
+ Generation runs in the background. The generation tools return before the file exists; use `pixelforge_check_image_job` to confirm completion.
53
+
54
+ ## Output Paths
55
+
56
+ Output paths must resolve inside the current opencode workspace. Relative paths are resolved from the session directory. Files are written atomically (temp file plus rename) so a visible output file always means a completed job.
57
+
58
+ ## Testing
59
+
60
+ Do not commit `.env`. PixelForge itself never reads `.env`. Unit tests are deterministic and need no credentials. Real provider integration tests read credentials directly from environment variables:
61
+
62
+ ```bash
63
+ PIXELFORGE_INTEGRATION=1 npm run test:integration
64
+ ```
65
+
66
+ Without `PIXELFORGE_INTEGRATION`, integration tests skip cleanly. Integration output is written under `.tmp/`.
@@ -0,0 +1,11 @@
1
+ import type { ImageAdapter, ResolvedProfile } from "../types.js";
2
+ /**
3
+ * fal adapter.
4
+ *
5
+ * Text-to-image posts to the configured `endpoint`; image-to-image posts to
6
+ * `imageToImageEndpoint` with the local base image inlined as a data URI.
7
+ * fal returns image URLs (or data URIs) which are downloaded/decoded to bytes.
8
+ */
9
+ export declare function createFalAdapter(profile: Extract<ResolvedProfile, {
10
+ provider: "fal";
11
+ }>): ImageAdapter;
@@ -0,0 +1,56 @@
1
+ import { loadLocalImage, toDataUri } from "../image-input.js";
2
+ import { decodeDataUri, fetchBytes, fetchJson } from "./http.js";
3
+ /**
4
+ * fal adapter.
5
+ *
6
+ * Text-to-image posts to the configured `endpoint`; image-to-image posts to
7
+ * `imageToImageEndpoint` with the local base image inlined as a data URI.
8
+ * fal returns image URLs (or data URIs) which are downloaded/decoded to bytes.
9
+ */
10
+ export function createFalAdapter(profile) {
11
+ return {
12
+ provider: "fal",
13
+ async generateImage(request) {
14
+ const response = await callFal(profile.endpoint, profile, {
15
+ prompt: request.prompt,
16
+ image_size: profile.imageSize,
17
+ output_format: profile.outputFormat,
18
+ }, request.signal);
19
+ return imageFromFalResponse(response);
20
+ },
21
+ async generateImageFromImage(request) {
22
+ const image = await loadLocalImage(request.baseImagePath);
23
+ const imageUrl = toDataUri(image);
24
+ const response = await callFal(profile.imageToImageEndpoint, profile, {
25
+ prompt: request.prompt,
26
+ image_url: imageUrl,
27
+ strength: request.strength ?? profile.defaultStrength,
28
+ image_size: profile.imageSize,
29
+ output_format: profile.outputFormat,
30
+ }, request.signal);
31
+ return imageFromFalResponse(response);
32
+ },
33
+ };
34
+ }
35
+ async function callFal(endpoint, profile, input, signal) {
36
+ const body = Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
37
+ return fetchJson(`https://fal.run/${endpoint}`, {
38
+ method: "POST",
39
+ signal,
40
+ headers: {
41
+ Authorization: `Key ${profile.credential}`,
42
+ "Content-Type": "application/json",
43
+ },
44
+ body: JSON.stringify(body),
45
+ }, "fal");
46
+ }
47
+ async function imageFromFalResponse(response) {
48
+ const image = response.images?.[0] ?? response.image;
49
+ const url = image?.url;
50
+ if (!url)
51
+ throw new Error("fal response did not include an image URL.");
52
+ if (url.startsWith("data:"))
53
+ return { ...decodeDataUri(url), provider: "fal" };
54
+ const downloaded = await fetchBytes(url, "fal");
55
+ return { ...downloaded, mimeType: image?.content_type ?? downloaded.mimeType, provider: "fal" };
56
+ }
@@ -0,0 +1,21 @@
1
+ import type { ProviderName } from "../types.js";
2
+ /**
3
+ * Shared HTTP helpers for provider adapters.
4
+ *
5
+ * Every network failure is funneled through the error classifier so the
6
+ * runner can make a single retry/fail-closed decision regardless of which
7
+ * provider produced the error.
8
+ */
9
+ export declare function fetchJson<T>(input: RequestInfo | URL, init: RequestInit, provider: ProviderName): Promise<T>;
10
+ export declare function fetchBytes(url: string, provider: ProviderName): Promise<{
11
+ bytes: Uint8Array;
12
+ mimeType: string;
13
+ }>;
14
+ export declare function decodeBase64Image(base64: string, mimeType?: string): {
15
+ bytes: Uint8Array;
16
+ mimeType: string;
17
+ };
18
+ export declare function decodeDataUri(dataUri: string): {
19
+ bytes: Uint8Array;
20
+ mimeType: string;
21
+ };
@@ -0,0 +1,52 @@
1
+ import { classifyHttpStatus, moderationError, PixelForgeError, toPixelForgeError } from "../errors.js";
2
+ /**
3
+ * Shared HTTP helpers for provider adapters.
4
+ *
5
+ * Every network failure is funneled through the error classifier so the
6
+ * runner can make a single retry/fail-closed decision regardless of which
7
+ * provider produced the error.
8
+ */
9
+ export async function fetchJson(input, init, provider) {
10
+ let response;
11
+ try {
12
+ response = await fetch(input, init);
13
+ }
14
+ catch (error) {
15
+ throw toPixelForgeError(error, provider);
16
+ }
17
+ const text = await response.text();
18
+ if (!response.ok)
19
+ throw providerHttpError(response.status, text, provider);
20
+ return text ? JSON.parse(text) : {};
21
+ }
22
+ export async function fetchBytes(url, provider) {
23
+ let response;
24
+ try {
25
+ response = await fetch(url);
26
+ }
27
+ catch (error) {
28
+ throw toPixelForgeError(error, provider);
29
+ }
30
+ if (!response.ok)
31
+ throw classifyHttpStatus(response.status, provider);
32
+ const mimeType = response.headers.get("content-type")?.split(";")[0] || "application/octet-stream";
33
+ return { bytes: new Uint8Array(await response.arrayBuffer()), mimeType };
34
+ }
35
+ export function decodeBase64Image(base64, mimeType = "image/png") {
36
+ return { bytes: Buffer.from(base64, "base64"), mimeType };
37
+ }
38
+ export function decodeDataUri(dataUri) {
39
+ const match = /^data:([^;]+);base64,(.+)$/.exec(dataUri);
40
+ const mimeType = match?.[1];
41
+ const base64 = match?.[2];
42
+ if (!mimeType || !base64) {
43
+ throw new PixelForgeError({ category: "provider", message: "Invalid data URI image output.", retryable: false });
44
+ }
45
+ return decodeBase64Image(base64, mimeType);
46
+ }
47
+ function providerHttpError(statusCode, body, provider) {
48
+ if (/moderation|safety|blocked|content policy/i.test(body)) {
49
+ return moderationError("Provider blocked the image request for safety reasons.", provider);
50
+ }
51
+ return classifyHttpStatus(statusCode, provider);
52
+ }
@@ -0,0 +1,6 @@
1
+ import type { ImageAdapter, ResolvedProfile } from "../types.js";
2
+ /**
3
+ * Maps a resolved profile to its provider adapter. The exhaustive switch keeps
4
+ * provider selection explicit — no provider is inferred or aliased.
5
+ */
6
+ export declare function createAdapter(profile: ResolvedProfile): ImageAdapter;
@@ -0,0 +1,20 @@
1
+ import { createFalAdapter } from "./fal.js";
2
+ import { createOpenAIImagesAdapter } from "./openai-images.js";
3
+ import { createReplicateAdapter } from "./replicate.js";
4
+ import { createStabilityAIAdapter } from "./stability-ai.js";
5
+ /**
6
+ * Maps a resolved profile to its provider adapter. The exhaustive switch keeps
7
+ * provider selection explicit — no provider is inferred or aliased.
8
+ */
9
+ export function createAdapter(profile) {
10
+ switch (profile.provider) {
11
+ case "openai-images":
12
+ return createOpenAIImagesAdapter(profile);
13
+ case "fal":
14
+ return createFalAdapter(profile);
15
+ case "replicate":
16
+ return createReplicateAdapter(profile);
17
+ case "stability-ai":
18
+ return createStabilityAIAdapter(profile);
19
+ }
20
+ }
@@ -0,0 +1,11 @@
1
+ import type { ImageAdapter, ResolvedProfile } from "../types.js";
2
+ /**
3
+ * OpenAI Images adapter.
4
+ *
5
+ * Text-to-image hits `/images/generations`; image-to-image hits
6
+ * `/images/edits` with multipart form data. Both paths read base64 image
7
+ * bytes from `data[0].b64_json`.
8
+ */
9
+ export declare function createOpenAIImagesAdapter(profile: Extract<ResolvedProfile, {
10
+ provider: "openai-images";
11
+ }>): ImageAdapter;
@@ -0,0 +1,62 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { decodeBase64Image, fetchJson } from "./http.js";
3
+ /**
4
+ * OpenAI Images adapter.
5
+ *
6
+ * Text-to-image hits `/images/generations`; image-to-image hits
7
+ * `/images/edits` with multipart form data. Both paths read base64 image
8
+ * bytes from `data[0].b64_json`.
9
+ */
10
+ export function createOpenAIImagesAdapter(profile) {
11
+ return {
12
+ provider: "openai-images",
13
+ async generateImage(request) {
14
+ const response = await fetchJson(`${profile.baseUrl.replace(/\/$/, "")}/images/generations`, {
15
+ method: "POST",
16
+ signal: request.signal,
17
+ headers: {
18
+ Authorization: `Bearer ${profile.credential}`,
19
+ "Content-Type": "application/json",
20
+ },
21
+ body: JSON.stringify({
22
+ model: profile.model,
23
+ prompt: request.prompt,
24
+ size: profile.size,
25
+ quality: profile.quality,
26
+ output_format: profile.outputFormat,
27
+ }),
28
+ }, "openai-images");
29
+ return imageFromOpenAIResponse(response, profile.outputFormat);
30
+ },
31
+ async generateImageFromImage(request) {
32
+ const form = new FormData();
33
+ form.set("model", profile.model);
34
+ form.set("prompt", request.prompt);
35
+ form.set("image[]", await fileFromPath(request.baseImagePath));
36
+ if (profile.size)
37
+ form.set("size", profile.size);
38
+ if (profile.quality)
39
+ form.set("quality", profile.quality);
40
+ if (profile.outputFormat)
41
+ form.set("output_format", profile.outputFormat);
42
+ const response = await fetchJson(`${profile.baseUrl.replace(/\/$/, "")}/images/edits`, {
43
+ method: "POST",
44
+ signal: request.signal,
45
+ headers: { Authorization: `Bearer ${profile.credential}` },
46
+ body: form,
47
+ }, "openai-images");
48
+ return imageFromOpenAIResponse(response, profile.outputFormat);
49
+ },
50
+ };
51
+ }
52
+ function imageFromOpenAIResponse(response, outputFormat = "png") {
53
+ const base64 = response.data?.[0]?.b64_json;
54
+ if (!base64)
55
+ throw new Error("OpenAI image response did not include data[0].b64_json.");
56
+ const mimeType = outputFormat === "jpeg" ? "image/jpeg" : outputFormat === "webp" ? "image/webp" : "image/png";
57
+ return { ...decodeBase64Image(base64, mimeType), provider: "openai-images" };
58
+ }
59
+ async function fileFromPath(filePath) {
60
+ const bytes = await new Response(createReadStream(filePath)).arrayBuffer();
61
+ return new File([bytes], filePath.split(/[\\/]/).at(-1) || "image.png");
62
+ }
@@ -0,0 +1,6 @@
1
+ import type { ImageAdapter, ResolvedProfile } from "../types.js";
2
+ type ReplicateProfile = Extract<ResolvedProfile, {
3
+ provider: "replicate";
4
+ }>;
5
+ export declare function createReplicateAdapter(profile: ReplicateProfile): ImageAdapter;
6
+ export {};
@@ -0,0 +1,67 @@
1
+ import { loadLocalImage, toDataUri } from "../image-input.js";
2
+ import { decodeDataUri, fetchBytes, fetchJson } from "./http.js";
3
+ export function createReplicateAdapter(profile) {
4
+ return {
5
+ provider: "replicate",
6
+ async generateImage(request) {
7
+ const prediction = await createPrediction(profile, { [profile.promptInputKey]: request.prompt }, request.signal);
8
+ return pollPrediction(profile, prediction, request.signal);
9
+ },
10
+ async generateImageFromImage(request) {
11
+ const image = await loadLocalImage(request.baseImagePath);
12
+ const input = {
13
+ [profile.promptInputKey]: request.prompt,
14
+ [profile.imageInputKey]: toDataUri(image),
15
+ };
16
+ if (profile.strengthInputKey) {
17
+ const strength = request.strength ?? profile.defaultStrength;
18
+ if (strength !== undefined)
19
+ input[profile.strengthInputKey] = strength;
20
+ }
21
+ const prediction = await createPrediction(profile, input, request.signal);
22
+ return pollPrediction(profile, prediction, request.signal);
23
+ },
24
+ };
25
+ }
26
+ async function createPrediction(profile, input, signal) {
27
+ return fetchJson("https://api.replicate.com/v1/predictions", {
28
+ method: "POST",
29
+ signal,
30
+ headers: {
31
+ Authorization: `Bearer ${profile.credential}`,
32
+ "Content-Type": "application/json",
33
+ },
34
+ body: JSON.stringify({ version: profile.version, input }),
35
+ }, "replicate");
36
+ }
37
+ async function pollPrediction(profile, prediction, signal) {
38
+ let current = prediction;
39
+ while (current.status === "starting" || current.status === "processing") {
40
+ await sleep(profile.pollIntervalMs, signal);
41
+ const pollUrl = current.urls?.get;
42
+ if (!pollUrl)
43
+ throw new Error("Replicate prediction did not include urls.get.");
44
+ current = await fetchJson(pollUrl, { method: "GET", signal, headers: { Authorization: `Bearer ${profile.credential}` } }, "replicate");
45
+ }
46
+ if (current.status !== "succeeded") {
47
+ throw new Error(current.error || `Replicate prediction ended with status ${current.status}.`);
48
+ }
49
+ return outputToImageResult(current.output);
50
+ }
51
+ async function outputToImageResult(output) {
52
+ const value = Array.isArray(output) ? output[0] : typeof output === "object" ? output?.url : output;
53
+ if (!value)
54
+ throw new Error("Replicate prediction succeeded without image output.");
55
+ if (value.startsWith("data:"))
56
+ return { ...decodeDataUri(value), provider: "replicate" };
57
+ return { ...(await fetchBytes(value, "replicate")), provider: "replicate" };
58
+ }
59
+ function sleep(ms, signal) {
60
+ return new Promise((resolve, reject) => {
61
+ const timer = setTimeout(resolve, ms);
62
+ signal.addEventListener("abort", () => {
63
+ clearTimeout(timer);
64
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
65
+ }, { once: true });
66
+ });
67
+ }
@@ -0,0 +1,11 @@
1
+ import type { ImageAdapter, ResolvedProfile } from "../types.js";
2
+ type StabilityProfile = Extract<ResolvedProfile, {
3
+ provider: "stability-ai";
4
+ }>;
5
+ /**
6
+ * Stability returns base64 image payloads. Text-to-image uses a JSON body;
7
+ * image-to-image uploads the base image as multipart form data with an
8
+ * explicit strength parameter.
9
+ */
10
+ export declare function createStabilityAIAdapter(profile: StabilityProfile): ImageAdapter;
11
+ export {};
@@ -0,0 +1,57 @@
1
+ import { blobFromImage, loadLocalImage } from "../image-input.js";
2
+ import { decodeBase64Image, fetchJson } from "./http.js";
3
+ /**
4
+ * Stability returns base64 image payloads. Text-to-image uses a JSON body;
5
+ * image-to-image uploads the base image as multipart form data with an
6
+ * explicit strength parameter.
7
+ */
8
+ export function createStabilityAIAdapter(profile) {
9
+ return {
10
+ provider: "stability-ai",
11
+ async generateImage(request) {
12
+ const response = await fetchJson(`${profile.baseUrl.replace(/\/$/, "")}${profile.textToImagePath}`, {
13
+ method: "POST",
14
+ signal: request.signal,
15
+ headers: {
16
+ Authorization: `Bearer ${profile.credential}`,
17
+ "Content-Type": "application/json",
18
+ Accept: "application/json",
19
+ },
20
+ body: JSON.stringify({
21
+ prompt: request.prompt,
22
+ output_format: profile.outputFormat,
23
+ width: profile.width,
24
+ height: profile.height,
25
+ }),
26
+ }, "stability-ai");
27
+ return imageFromStabilityResponse(response, profile.outputFormat);
28
+ },
29
+ async generateImageFromImage(request) {
30
+ const image = await loadLocalImage(request.baseImagePath);
31
+ const form = new FormData();
32
+ form.set("prompt", request.prompt);
33
+ form.set("output_format", profile.outputFormat ?? "png");
34
+ const strength = request.strength ?? profile.defaultStrength;
35
+ if (strength !== undefined)
36
+ form.set("strength", String(strength));
37
+ form.set("image", blobFromImage(image), image.fileName);
38
+ const response = await fetchJson(`${profile.baseUrl.replace(/\/$/, "")}${profile.imageToImagePath}`, {
39
+ method: "POST",
40
+ signal: request.signal,
41
+ headers: {
42
+ Authorization: `Bearer ${profile.credential}`,
43
+ Accept: "application/json",
44
+ },
45
+ body: form,
46
+ }, "stability-ai");
47
+ return imageFromStabilityResponse(response, profile.outputFormat);
48
+ },
49
+ };
50
+ }
51
+ function imageFromStabilityResponse(response, outputFormat = "png") {
52
+ const base64 = response.image ?? response.images?.[0] ?? response.artifacts?.[0]?.base64;
53
+ if (!base64)
54
+ throw new Error("Stability AI response did not include an image.");
55
+ const mimeType = outputFormat === "jpeg" ? "image/jpeg" : outputFormat === "webp" ? "image/webp" : "image/png";
56
+ return { ...decodeBase64Image(base64, mimeType), provider: "stability-ai" };
57
+ }
@@ -0,0 +1,5 @@
1
+ import type { PixelForgeConfig, RawPixelForgeConfig } from "./types.js";
2
+ export declare const DEFAULT_CONFIG_PATH: string;
3
+ export declare function loadPixelForgeConfig(configPath?: string): Promise<PixelForgeConfig>;
4
+ export declare function parseConfig(text: string, source?: string): RawPixelForgeConfig;
5
+ export declare function resolveConfig(raw: RawPixelForgeConfig, env?: NodeJS.ProcessEnv): PixelForgeConfig;
package/dist/config.js ADDED
@@ -0,0 +1,103 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { configurationError } from "./errors.js";
5
+ export const DEFAULT_CONFIG_PATH = path.join(homedir(), ".config", "opencode", "pixelforge.json");
6
+ const DEFAULT_NOTIFICATIONS = { tui: true, sessionMessage: true, autoContinue: false };
7
+ export async function loadPixelForgeConfig(configPath = DEFAULT_CONFIG_PATH) {
8
+ const text = await readFile(configPath, "utf8").catch((error) => {
9
+ throw configurationError(`Cannot read PixelForge config at ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
10
+ });
11
+ const raw = parseConfig(text, configPath);
12
+ return resolveConfig(raw);
13
+ }
14
+ export function parseConfig(text, source = "pixelforge.json") {
15
+ try {
16
+ return JSON.parse(text);
17
+ }
18
+ catch (error) {
19
+ throw configurationError(`Invalid JSON in ${source}: ${error instanceof Error ? error.message : String(error)}`);
20
+ }
21
+ }
22
+ export function resolveConfig(raw, env = process.env) {
23
+ if (!raw || typeof raw !== "object")
24
+ throw configurationError("PixelForge config must be a JSON object.");
25
+ if (!raw.defaultProfile)
26
+ throw configurationError("PixelForge config requires defaultProfile.");
27
+ if (!raw.profiles || typeof raw.profiles !== "object")
28
+ throw configurationError("PixelForge config requires profiles.");
29
+ if (!raw.profiles[raw.defaultProfile]) {
30
+ throw configurationError(`defaultProfile '${raw.defaultProfile}' is not defined in profiles.`);
31
+ }
32
+ const fallbackProfiles = raw.fallbackProfiles ?? [];
33
+ if (!Array.isArray(fallbackProfiles))
34
+ throw configurationError("fallbackProfiles must be an array.");
35
+ for (const name of fallbackProfiles) {
36
+ if (!raw.profiles[name])
37
+ throw configurationError(`fallbackProfiles contains unknown profile '${name}'.`);
38
+ }
39
+ const profiles = {};
40
+ for (const [profileName, profile] of Object.entries(raw.profiles)) {
41
+ profiles[profileName] = resolveProfile(profileName, profile, env);
42
+ }
43
+ return {
44
+ defaultProfile: raw.defaultProfile,
45
+ fallbackProfiles,
46
+ notifications: raw.notifications ?? { ...DEFAULT_NOTIFICATIONS },
47
+ profiles,
48
+ };
49
+ }
50
+ function resolveProfile(profileName, profile, env) {
51
+ validateTimeout(profileName, profile.timeoutMs);
52
+ validateStrength(profileName, profile.defaultStrength);
53
+ switch (profile.provider) {
54
+ case "openai-images":
55
+ requireString(profileName, "model", profile.model);
56
+ requireString(profileName, "baseUrl", profile.baseUrl);
57
+ requireString(profileName, "size", profile.size);
58
+ return { ...profile, profileName, credential: resolveSecret(profile.apiKey, env, `${profileName}.apiKey`) };
59
+ case "fal":
60
+ requireString(profileName, "endpoint", profile.endpoint);
61
+ requireString(profileName, "imageToImageEndpoint", profile.imageToImageEndpoint);
62
+ return { ...profile, profileName, credential: resolveSecret(profile.apiKey, env, `${profileName}.apiKey`) };
63
+ case "replicate":
64
+ requireString(profileName, "version", profile.version);
65
+ requireString(profileName, "promptInputKey", profile.promptInputKey);
66
+ requireString(profileName, "imageInputKey", profile.imageInputKey);
67
+ validateTimeout(profileName, profile.pollIntervalMs, "pollIntervalMs");
68
+ return { ...profile, profileName, credential: resolveSecret(profile.apiToken, env, `${profileName}.apiToken`) };
69
+ case "stability-ai":
70
+ requireString(profileName, "baseUrl", profile.baseUrl);
71
+ requireString(profileName, "textToImagePath", profile.textToImagePath);
72
+ requireString(profileName, "imageToImagePath", profile.imageToImagePath);
73
+ return { ...profile, profileName, credential: resolveSecret(profile.apiKey, env, `${profileName}.apiKey`) };
74
+ }
75
+ }
76
+ function resolveSecret(value, env, field) {
77
+ if (typeof value === "string" && value.length > 0)
78
+ return value;
79
+ if (typeof value === "object" && value && "env" in value && value.env) {
80
+ const resolved = env[value.env];
81
+ if (!resolved)
82
+ throw configurationError(`${field} references missing environment variable ${value.env}.`);
83
+ return resolved;
84
+ }
85
+ throw configurationError(`${field} must be a non-empty string or { "env": "VAR" }.`);
86
+ }
87
+ function validateTimeout(profileName, timeoutMs, field = "timeoutMs") {
88
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
89
+ throw configurationError(`${profileName}.${field} must be a positive integer.`);
90
+ }
91
+ }
92
+ function validateStrength(profileName, strength) {
93
+ if (strength === undefined)
94
+ return;
95
+ if (typeof strength !== "number" || strength < 0 || strength > 1) {
96
+ throw configurationError(`${profileName}.defaultStrength must be between 0 and 1.`);
97
+ }
98
+ }
99
+ function requireString(profileName, field, value) {
100
+ if (typeof value !== "string" || value.length === 0) {
101
+ throw configurationError(`${profileName}.${field} must be a non-empty string.`);
102
+ }
103
+ }
@@ -0,0 +1,15 @@
1
+ import type { ErrorCategory, ProviderError, ProviderName } from "./types.js";
2
+ export declare class PixelForgeError extends Error implements ProviderError {
3
+ readonly category: ErrorCategory;
4
+ readonly retryable: boolean;
5
+ readonly statusCode?: number;
6
+ readonly provider?: ProviderName;
7
+ constructor(input: ProviderError);
8
+ }
9
+ export declare function classifyHttpStatus(statusCode: number, provider?: ProviderName): PixelForgeError;
10
+ export declare function networkError(message: string, provider?: ProviderName): PixelForgeError;
11
+ export declare function timeoutError(provider?: ProviderName): PixelForgeError;
12
+ export declare function moderationError(message: string, provider?: ProviderName): PixelForgeError;
13
+ export declare function configurationError(message: string): PixelForgeError;
14
+ export declare function filesystemError(message: string): PixelForgeError;
15
+ export declare function toPixelForgeError(error: unknown, provider?: ProviderName): PixelForgeError;