@crewhaus/tool-image-generation 0.1.4 → 0.1.6
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/dist/index.d.ts +49 -0
- package/dist/index.js +137 -0
- package/package.json +11 -8
- package/src/index.test.ts +0 -169
- package/src/index.ts +0 -174
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog R3 — tool-image-generation. M4.1 of the heavy-hitter plan.
|
|
3
|
+
*
|
|
4
|
+
* Exposes an `ImageGenerate(prompt, size?, style?)` tool that calls a
|
|
5
|
+
* remote image-generation API. Today supports OpenAI's image-generation
|
|
6
|
+
* endpoint (DALL-E 3 family); Replicate / Flux / SD are stubs that the
|
|
7
|
+
* future provider router fills in.
|
|
8
|
+
*
|
|
9
|
+
* Why a remote API rather than a local model: this is a runtime layer,
|
|
10
|
+
* not an inference layer. Local image generation requires GPU + 5-20GB
|
|
11
|
+
* model weights — not realistic to ship by default. Operators who want
|
|
12
|
+
* a self-hosted backend point `provider: "replicate"` at a custom
|
|
13
|
+
* endpoint (Replicate, Together, or their own hosted Flux/SD).
|
|
14
|
+
*
|
|
15
|
+
* Returns: a URL string (when the provider returns one) or a base64
|
|
16
|
+
* data URI (DALL-E with response_format=b64_json). The model can
|
|
17
|
+
* include the URL in its reply; vision-capable models can see the
|
|
18
|
+
* image inline on subsequent turns via WebFetch.
|
|
19
|
+
*
|
|
20
|
+
* Pillar 3: this tool is non-destructive (`destructive: false`) — it
|
|
21
|
+
* doesn't write to the user's host filesystem; it issues a remote HTTP
|
|
22
|
+
* request. Tool output is classified post-execution by runtime-core's
|
|
23
|
+
* `tool` origin classifier (the generated image URL is host-untrusted).
|
|
24
|
+
*/
|
|
25
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
26
|
+
import type { RegisteredTool } from "@crewhaus/tool-catalog";
|
|
27
|
+
export type ImageGenerationProvider = "openai" | "replicate" | "mock";
|
|
28
|
+
export type ImageGenerationConfig = {
|
|
29
|
+
/** Which provider to route to. Defaults to "openai" when OPENAI_API_KEY is set. */
|
|
30
|
+
readonly provider?: ImageGenerationProvider;
|
|
31
|
+
/** Provider-specific model id. Defaults: openai → "dall-e-3", replicate → "stability-ai/sdxl". */
|
|
32
|
+
readonly model?: string;
|
|
33
|
+
/** Override the OpenAI base URL (for proxies / Azure OpenAI). */
|
|
34
|
+
readonly openaiBaseUrl?: string;
|
|
35
|
+
/** Override fetch implementation for tests. */
|
|
36
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
37
|
+
};
|
|
38
|
+
export declare class ImageGenerationError extends CrewhausError {
|
|
39
|
+
readonly name = "ImageGenerationError";
|
|
40
|
+
constructor(message: string, cause?: unknown);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Section-14 style config registration. The compiled bundle calls
|
|
44
|
+
* `registerImageGenerationConfig({ provider, model, ... })` at boot;
|
|
45
|
+
* the tool's execute() reads from registeredConfig at call time so
|
|
46
|
+
* env-driven defaults work without re-registering.
|
|
47
|
+
*/
|
|
48
|
+
export declare function registerImageGenerationConfig(config: ImageGenerationConfig): void;
|
|
49
|
+
export declare const imageGenerate: RegisteredTool;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog R3 — tool-image-generation. M4.1 of the heavy-hitter plan.
|
|
3
|
+
*
|
|
4
|
+
* Exposes an `ImageGenerate(prompt, size?, style?)` tool that calls a
|
|
5
|
+
* remote image-generation API. Today supports OpenAI's image-generation
|
|
6
|
+
* endpoint (DALL-E 3 family); Replicate / Flux / SD are stubs that the
|
|
7
|
+
* future provider router fills in.
|
|
8
|
+
*
|
|
9
|
+
* Why a remote API rather than a local model: this is a runtime layer,
|
|
10
|
+
* not an inference layer. Local image generation requires GPU + 5-20GB
|
|
11
|
+
* model weights — not realistic to ship by default. Operators who want
|
|
12
|
+
* a self-hosted backend point `provider: "replicate"` at a custom
|
|
13
|
+
* endpoint (Replicate, Together, or their own hosted Flux/SD).
|
|
14
|
+
*
|
|
15
|
+
* Returns: a URL string (when the provider returns one) or a base64
|
|
16
|
+
* data URI (DALL-E with response_format=b64_json). The model can
|
|
17
|
+
* include the URL in its reply; vision-capable models can see the
|
|
18
|
+
* image inline on subsequent turns via WebFetch.
|
|
19
|
+
*
|
|
20
|
+
* Pillar 3: this tool is non-destructive (`destructive: false`) — it
|
|
21
|
+
* doesn't write to the user's host filesystem; it issues a remote HTTP
|
|
22
|
+
* request. Tool output is classified post-execution by runtime-core's
|
|
23
|
+
* `tool` origin classifier (the generated image URL is host-untrusted).
|
|
24
|
+
*/
|
|
25
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
26
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
27
|
+
import { z } from "zod";
|
|
28
|
+
const inputSchema = z.object({
|
|
29
|
+
prompt: z
|
|
30
|
+
.string()
|
|
31
|
+
.min(1)
|
|
32
|
+
.describe("What to generate. Be specific; vague prompts produce vague images."),
|
|
33
|
+
size: z
|
|
34
|
+
.enum(["256x256", "512x512", "1024x1024", "1792x1024", "1024x1792"])
|
|
35
|
+
.optional()
|
|
36
|
+
.describe("Output dimensions. Defaults to 1024x1024."),
|
|
37
|
+
style: z.enum(["vivid", "natural"]).optional().describe("DALL-E 3 style. Defaults to vivid."),
|
|
38
|
+
responseFormat: z
|
|
39
|
+
.enum(["url", "b64_json"])
|
|
40
|
+
.optional()
|
|
41
|
+
.describe("How to return the image. Default 'url'; b64_json is useful for offline / no-CDN deployments."),
|
|
42
|
+
});
|
|
43
|
+
export class ImageGenerationError extends CrewhausError {
|
|
44
|
+
name = "ImageGenerationError";
|
|
45
|
+
constructor(message, cause) {
|
|
46
|
+
super("tool", message, cause);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
let registeredConfig;
|
|
50
|
+
/**
|
|
51
|
+
* Section-14 style config registration. The compiled bundle calls
|
|
52
|
+
* `registerImageGenerationConfig({ provider, model, ... })` at boot;
|
|
53
|
+
* the tool's execute() reads from registeredConfig at call time so
|
|
54
|
+
* env-driven defaults work without re-registering.
|
|
55
|
+
*/
|
|
56
|
+
export function registerImageGenerationConfig(config) {
|
|
57
|
+
registeredConfig = config;
|
|
58
|
+
}
|
|
59
|
+
export const imageGenerate = buildTool({
|
|
60
|
+
name: "ImageGenerate",
|
|
61
|
+
description: "Generate an image from a text prompt via a remote API. Returns a URL or base64 data URI. Use for: visual concepts, mockups, illustrations, social posts. Don't use for: 'edit this existing photo' (different tool needed).",
|
|
62
|
+
inputSchema,
|
|
63
|
+
destructive: false,
|
|
64
|
+
readOnly: false, // not idempotent — each call mints a new image
|
|
65
|
+
// Pillar 3 sink-side: the prompt is sent to a remote provider; lineage
|
|
66
|
+
// exfiltration via prompt smuggling is real.
|
|
67
|
+
scope: "external",
|
|
68
|
+
// FR-002 — declare the io-capability fact (remote image-gen API call).
|
|
69
|
+
ioCapability: "network",
|
|
70
|
+
execute: async (input) => {
|
|
71
|
+
const cfg = registeredConfig ?? {};
|
|
72
|
+
const provider = cfg.provider ?? defaultProvider(process.env);
|
|
73
|
+
const responseFormat = input.responseFormat ?? "url";
|
|
74
|
+
if (provider === "openai") {
|
|
75
|
+
return await generateOpenAI(input, cfg, responseFormat);
|
|
76
|
+
}
|
|
77
|
+
if (provider === "mock") {
|
|
78
|
+
return await generateMock(input);
|
|
79
|
+
}
|
|
80
|
+
throw new ImageGenerationError(`provider "${provider}" is not yet implemented in tool-image-generation v0`);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
function defaultProvider(env) {
|
|
84
|
+
if (env["OPENAI_API_KEY"])
|
|
85
|
+
return "openai";
|
|
86
|
+
if (env["CREWHAUS_IMAGE_PROVIDER"] === "mock")
|
|
87
|
+
return "mock";
|
|
88
|
+
return "openai"; // surfaces a clear "missing OPENAI_API_KEY" error at call time
|
|
89
|
+
}
|
|
90
|
+
async function generateOpenAI(input, cfg, responseFormat) {
|
|
91
|
+
const apiKey = process.env["OPENAI_API_KEY"];
|
|
92
|
+
if (!apiKey) {
|
|
93
|
+
throw new ImageGenerationError("OPENAI_API_KEY is not set — required for provider=openai. Set the env var or switch to provider=mock for offline testing.");
|
|
94
|
+
}
|
|
95
|
+
const baseUrl = cfg.openaiBaseUrl ?? "https://api.openai.com/v1";
|
|
96
|
+
const model = cfg.model ?? "dall-e-3";
|
|
97
|
+
const fetchFn = cfg.fetch ?? globalThis.fetch;
|
|
98
|
+
const body = JSON.stringify({
|
|
99
|
+
model,
|
|
100
|
+
prompt: input.prompt,
|
|
101
|
+
n: 1,
|
|
102
|
+
size: input.size ?? "1024x1024",
|
|
103
|
+
response_format: responseFormat,
|
|
104
|
+
...(model === "dall-e-3" ? { style: input.style ?? "vivid" } : {}),
|
|
105
|
+
});
|
|
106
|
+
const res = await fetchFn(`${baseUrl}/images/generations`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: {
|
|
109
|
+
Authorization: `Bearer ${apiKey}`,
|
|
110
|
+
"Content-Type": "application/json",
|
|
111
|
+
},
|
|
112
|
+
body,
|
|
113
|
+
});
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
const text = await res.text().catch(() => "");
|
|
116
|
+
throw new ImageGenerationError(`OpenAI image-generation request failed (${res.status} ${res.statusText}): ${text.slice(0, 500)}`);
|
|
117
|
+
}
|
|
118
|
+
const json = (await res.json());
|
|
119
|
+
const first = json.data?.[0];
|
|
120
|
+
if (!first) {
|
|
121
|
+
throw new ImageGenerationError("OpenAI response missing data[0] — unexpected format");
|
|
122
|
+
}
|
|
123
|
+
if (responseFormat === "url") {
|
|
124
|
+
if (typeof first.url !== "string") {
|
|
125
|
+
throw new ImageGenerationError("OpenAI response missing data[0].url");
|
|
126
|
+
}
|
|
127
|
+
return `image URL: ${first.url}`;
|
|
128
|
+
}
|
|
129
|
+
if (typeof first.b64_json !== "string") {
|
|
130
|
+
throw new ImageGenerationError("OpenAI response missing data[0].b64_json");
|
|
131
|
+
}
|
|
132
|
+
return `image base64 data: data:image/png;base64,${first.b64_json.slice(0, 80)}…[truncated ${first.b64_json.length} bytes total]`;
|
|
133
|
+
}
|
|
134
|
+
async function generateMock(input) {
|
|
135
|
+
// Deterministic stub — useful for tests + offline development.
|
|
136
|
+
return `mock image generated for prompt: "${input.prompt}" (no provider configured; set OPENAI_API_KEY or CREWHAUS_IMAGE_PROVIDER)`;
|
|
137
|
+
}
|
package/package.json
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/tool-image-generation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "M4.1 — image-generation tool (DALL-E 3, Replicate, future Flux/SD). Calls remote API; no host writes; non-destructive.",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
16
|
-
"@crewhaus/tool-builder": "0.1.
|
|
17
|
-
"@crewhaus/tool-catalog": "0.1.
|
|
18
|
+
"@crewhaus/errors": "0.1.6",
|
|
19
|
+
"@crewhaus/tool-builder": "0.1.6",
|
|
20
|
+
"@crewhaus/tool-catalog": "0.1.6",
|
|
18
21
|
"zod": "^3.23.8"
|
|
19
22
|
},
|
|
20
23
|
"license": "Apache-2.0",
|
|
@@ -35,5 +38,5 @@
|
|
|
35
38
|
"publishConfig": {
|
|
36
39
|
"access": "public"
|
|
37
40
|
},
|
|
38
|
-
"files": ["
|
|
41
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
39
42
|
}
|
package/src/index.test.ts
DELETED
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for tool-image-generation. The mock provider gives us
|
|
3
|
-
* deterministic test coverage; the openai provider is exercised via
|
|
4
|
-
* an injected fetch.
|
|
5
|
-
*/
|
|
6
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
-
import { ImageGenerationError, imageGenerate, registerImageGenerationConfig } from "./index";
|
|
8
|
-
|
|
9
|
-
let savedEnv: NodeJS.ProcessEnv;
|
|
10
|
-
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
savedEnv = { ...process.env };
|
|
13
|
-
process.env["OPENAI_API_KEY"] = undefined;
|
|
14
|
-
registerImageGenerationConfig({});
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
afterEach(() => {
|
|
18
|
-
process.env = savedEnv;
|
|
19
|
-
registerImageGenerationConfig({});
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe("imageGenerate — schema + flags", () => {
|
|
23
|
-
test("is non-destructive", () => {
|
|
24
|
-
expect(imageGenerate.destructive).toBe(false);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test("name is 'ImageGenerate'", () => {
|
|
28
|
-
expect(imageGenerate.name).toBe("ImageGenerate");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("schema requires non-empty prompt", () => {
|
|
32
|
-
expect(() => imageGenerate.inputSchema.parse({ prompt: "" })).toThrow();
|
|
33
|
-
expect(() => imageGenerate.inputSchema.parse({ prompt: "ok" })).not.toThrow();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("schema rejects unknown size", () => {
|
|
37
|
-
expect(() => imageGenerate.inputSchema.parse({ prompt: "x", size: "999x999" })).toThrow();
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
describe("imageGenerate — mock provider", () => {
|
|
42
|
-
test("returns a deterministic stub", async () => {
|
|
43
|
-
registerImageGenerationConfig({ provider: "mock" });
|
|
44
|
-
const out = await imageGenerate.execute({ prompt: "a lobster on a beach" });
|
|
45
|
-
expect(out).toContain("mock image generated");
|
|
46
|
-
expect(out).toContain("a lobster on a beach");
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe("imageGenerate — openai provider", () => {
|
|
51
|
-
test("throws ImageGenerationError when OPENAI_API_KEY is unset", async () => {
|
|
52
|
-
registerImageGenerationConfig({ provider: "openai" });
|
|
53
|
-
await expect(imageGenerate.execute({ prompt: "x" })).rejects.toThrow(ImageGenerationError);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("calls the openai endpoint with the configured model", async () => {
|
|
57
|
-
process.env["OPENAI_API_KEY"] = "sk-test";
|
|
58
|
-
let capturedUrl = "";
|
|
59
|
-
let capturedBody = "";
|
|
60
|
-
const mockFetch: typeof globalThis.fetch = async (input, init) => {
|
|
61
|
-
capturedUrl = String(input);
|
|
62
|
-
capturedBody = String(init?.body ?? "");
|
|
63
|
-
return new Response(JSON.stringify({ data: [{ url: "https://example.com/img.png" }] }), {
|
|
64
|
-
status: 200,
|
|
65
|
-
headers: { "content-type": "application/json" },
|
|
66
|
-
});
|
|
67
|
-
};
|
|
68
|
-
registerImageGenerationConfig({ provider: "openai", fetch: mockFetch });
|
|
69
|
-
|
|
70
|
-
const out = await imageGenerate.execute({ prompt: "lobster" });
|
|
71
|
-
expect(out).toContain("https://example.com/img.png");
|
|
72
|
-
expect(capturedUrl).toBe("https://api.openai.com/v1/images/generations");
|
|
73
|
-
const body = JSON.parse(capturedBody) as { model: string; prompt: string; size: string };
|
|
74
|
-
expect(body.model).toBe("dall-e-3");
|
|
75
|
-
expect(body.prompt).toBe("lobster");
|
|
76
|
-
expect(body.size).toBe("1024x1024");
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test("honors size + style overrides", async () => {
|
|
80
|
-
process.env["OPENAI_API_KEY"] = "sk-test";
|
|
81
|
-
let capturedBody = "";
|
|
82
|
-
const mockFetch: typeof globalThis.fetch = async (_input, init) => {
|
|
83
|
-
capturedBody = String(init?.body ?? "");
|
|
84
|
-
return new Response(JSON.stringify({ data: [{ url: "https://example.com/x.png" }] }), {
|
|
85
|
-
status: 200,
|
|
86
|
-
});
|
|
87
|
-
};
|
|
88
|
-
registerImageGenerationConfig({ provider: "openai", fetch: mockFetch });
|
|
89
|
-
await imageGenerate.execute({ prompt: "x", size: "1792x1024", style: "natural" });
|
|
90
|
-
const body = JSON.parse(capturedBody) as { size: string; style: string };
|
|
91
|
-
expect(body.size).toBe("1792x1024");
|
|
92
|
-
expect(body.style).toBe("natural");
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test("surfaces upstream errors", async () => {
|
|
96
|
-
process.env["OPENAI_API_KEY"] = "sk-test";
|
|
97
|
-
const mockFetch: typeof globalThis.fetch = async () =>
|
|
98
|
-
new Response("rate limited", { status: 429, statusText: "Too Many Requests" });
|
|
99
|
-
registerImageGenerationConfig({ provider: "openai", fetch: mockFetch });
|
|
100
|
-
await expect(imageGenerate.execute({ prompt: "x" })).rejects.toThrow(ImageGenerationError);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("error body read failure is swallowed by .catch(() => '')", async () => {
|
|
104
|
-
process.env["OPENAI_API_KEY"] = "sk-test";
|
|
105
|
-
// A non-ok response whose .text() itself rejects exercises the
|
|
106
|
-
// `.catch(() => "")` fallback on the error path (line ~145).
|
|
107
|
-
const badResponse = {
|
|
108
|
-
ok: false,
|
|
109
|
-
status: 500,
|
|
110
|
-
statusText: "Internal Server Error",
|
|
111
|
-
text: async () => {
|
|
112
|
-
throw new Error("stream already consumed");
|
|
113
|
-
},
|
|
114
|
-
} as unknown as Response;
|
|
115
|
-
const mockFetch: typeof globalThis.fetch = async () => badResponse;
|
|
116
|
-
registerImageGenerationConfig({ provider: "openai", fetch: mockFetch });
|
|
117
|
-
await expect(imageGenerate.execute({ prompt: "x" })).rejects.toThrow(
|
|
118
|
-
/OpenAI image-generation request failed \(500/,
|
|
119
|
-
);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test("b64_json response format returns a data URI preview", async () => {
|
|
123
|
-
process.env["OPENAI_API_KEY"] = "sk-test";
|
|
124
|
-
const longB64 = "A".repeat(500);
|
|
125
|
-
const mockFetch: typeof globalThis.fetch = async () =>
|
|
126
|
-
new Response(JSON.stringify({ data: [{ b64_json: longB64 }] }), { status: 200 });
|
|
127
|
-
registerImageGenerationConfig({ provider: "openai", fetch: mockFetch });
|
|
128
|
-
const out = await imageGenerate.execute({ prompt: "x", responseFormat: "b64_json" });
|
|
129
|
-
expect(out).toContain("data:image/png;base64,");
|
|
130
|
-
expect(out).toContain("[truncated 500 bytes total]");
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
describe("imageGenerate — defaultProvider env routing (no explicit provider)", () => {
|
|
135
|
-
test("OPENAI_API_KEY present → routes to openai (uses injected fetch)", async () => {
|
|
136
|
-
// No `provider` in config forces execute() through defaultProvider(env).
|
|
137
|
-
process.env["OPENAI_API_KEY"] = "sk-test";
|
|
138
|
-
process.env["CREWHAUS_IMAGE_PROVIDER"] = undefined;
|
|
139
|
-
let called = false;
|
|
140
|
-
const mockFetch: typeof globalThis.fetch = async () => {
|
|
141
|
-
called = true;
|
|
142
|
-
return new Response(JSON.stringify({ data: [{ url: "https://example.com/auto.png" }] }), {
|
|
143
|
-
status: 200,
|
|
144
|
-
});
|
|
145
|
-
};
|
|
146
|
-
registerImageGenerationConfig({ fetch: mockFetch });
|
|
147
|
-
const out = await imageGenerate.execute({ prompt: "auto-routed" });
|
|
148
|
-
expect(called).toBe(true);
|
|
149
|
-
expect(out).toContain("https://example.com/auto.png");
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
test("no API key but CREWHAUS_IMAGE_PROVIDER=mock → routes to mock", async () => {
|
|
153
|
-
process.env["OPENAI_API_KEY"] = undefined;
|
|
154
|
-
process.env["CREWHAUS_IMAGE_PROVIDER"] = "mock";
|
|
155
|
-
registerImageGenerationConfig({});
|
|
156
|
-
const out = await imageGenerate.execute({ prompt: "offline lobster" });
|
|
157
|
-
expect(out).toContain("mock image generated");
|
|
158
|
-
expect(out).toContain("offline lobster");
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test("neither env var set → falls through to openai, surfacing missing-key error", async () => {
|
|
162
|
-
process.env["OPENAI_API_KEY"] = undefined;
|
|
163
|
-
process.env["CREWHAUS_IMAGE_PROVIDER"] = undefined;
|
|
164
|
-
registerImageGenerationConfig({});
|
|
165
|
-
// defaultProvider returns "openai" (the documented fall-through); execute
|
|
166
|
-
// then throws the clear missing-key error from generateOpenAI.
|
|
167
|
-
await expect(imageGenerate.execute({ prompt: "x" })).rejects.toThrow(ImageGenerationError);
|
|
168
|
-
});
|
|
169
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Catalog R3 — tool-image-generation. M4.1 of the heavy-hitter plan.
|
|
3
|
-
*
|
|
4
|
-
* Exposes an `ImageGenerate(prompt, size?, style?)` tool that calls a
|
|
5
|
-
* remote image-generation API. Today supports OpenAI's image-generation
|
|
6
|
-
* endpoint (DALL-E 3 family); Replicate / Flux / SD are stubs that the
|
|
7
|
-
* future provider router fills in.
|
|
8
|
-
*
|
|
9
|
-
* Why a remote API rather than a local model: this is a runtime layer,
|
|
10
|
-
* not an inference layer. Local image generation requires GPU + 5-20GB
|
|
11
|
-
* model weights — not realistic to ship by default. Operators who want
|
|
12
|
-
* a self-hosted backend point `provider: "replicate"` at a custom
|
|
13
|
-
* endpoint (Replicate, Together, or their own hosted Flux/SD).
|
|
14
|
-
*
|
|
15
|
-
* Returns: a URL string (when the provider returns one) or a base64
|
|
16
|
-
* data URI (DALL-E with response_format=b64_json). The model can
|
|
17
|
-
* include the URL in its reply; vision-capable models can see the
|
|
18
|
-
* image inline on subsequent turns via WebFetch.
|
|
19
|
-
*
|
|
20
|
-
* Pillar 3: this tool is non-destructive (`destructive: false`) — it
|
|
21
|
-
* doesn't write to the user's host filesystem; it issues a remote HTTP
|
|
22
|
-
* request. Tool output is classified post-execution by runtime-core's
|
|
23
|
-
* `tool` origin classifier (the generated image URL is host-untrusted).
|
|
24
|
-
*/
|
|
25
|
-
import { CrewhausError } from "@crewhaus/errors";
|
|
26
|
-
import { buildTool } from "@crewhaus/tool-builder";
|
|
27
|
-
import type { RegisteredTool } from "@crewhaus/tool-catalog";
|
|
28
|
-
import { z } from "zod";
|
|
29
|
-
|
|
30
|
-
export type ImageGenerationProvider = "openai" | "replicate" | "mock";
|
|
31
|
-
|
|
32
|
-
export type ImageGenerationConfig = {
|
|
33
|
-
/** Which provider to route to. Defaults to "openai" when OPENAI_API_KEY is set. */
|
|
34
|
-
readonly provider?: ImageGenerationProvider;
|
|
35
|
-
/** Provider-specific model id. Defaults: openai → "dall-e-3", replicate → "stability-ai/sdxl". */
|
|
36
|
-
readonly model?: string;
|
|
37
|
-
/** Override the OpenAI base URL (for proxies / Azure OpenAI). */
|
|
38
|
-
readonly openaiBaseUrl?: string;
|
|
39
|
-
/** Override fetch implementation for tests. */
|
|
40
|
-
readonly fetch?: typeof globalThis.fetch;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
const inputSchema = z.object({
|
|
44
|
-
prompt: z
|
|
45
|
-
.string()
|
|
46
|
-
.min(1)
|
|
47
|
-
.describe("What to generate. Be specific; vague prompts produce vague images."),
|
|
48
|
-
size: z
|
|
49
|
-
.enum(["256x256", "512x512", "1024x1024", "1792x1024", "1024x1792"])
|
|
50
|
-
.optional()
|
|
51
|
-
.describe("Output dimensions. Defaults to 1024x1024."),
|
|
52
|
-
style: z.enum(["vivid", "natural"]).optional().describe("DALL-E 3 style. Defaults to vivid."),
|
|
53
|
-
responseFormat: z
|
|
54
|
-
.enum(["url", "b64_json"])
|
|
55
|
-
.optional()
|
|
56
|
-
.describe(
|
|
57
|
-
"How to return the image. Default 'url'; b64_json is useful for offline / no-CDN deployments.",
|
|
58
|
-
),
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
export class ImageGenerationError extends CrewhausError {
|
|
62
|
-
override readonly name = "ImageGenerationError";
|
|
63
|
-
constructor(message: string, cause?: unknown) {
|
|
64
|
-
super("tool", message, cause);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
let registeredConfig: ImageGenerationConfig | undefined;
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Section-14 style config registration. The compiled bundle calls
|
|
72
|
-
* `registerImageGenerationConfig({ provider, model, ... })` at boot;
|
|
73
|
-
* the tool's execute() reads from registeredConfig at call time so
|
|
74
|
-
* env-driven defaults work without re-registering.
|
|
75
|
-
*/
|
|
76
|
-
export function registerImageGenerationConfig(config: ImageGenerationConfig): void {
|
|
77
|
-
registeredConfig = config;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export const imageGenerate: RegisteredTool = buildTool({
|
|
81
|
-
name: "ImageGenerate",
|
|
82
|
-
description:
|
|
83
|
-
"Generate an image from a text prompt via a remote API. Returns a URL or base64 data URI. Use for: visual concepts, mockups, illustrations, social posts. Don't use for: 'edit this existing photo' (different tool needed).",
|
|
84
|
-
inputSchema,
|
|
85
|
-
destructive: false,
|
|
86
|
-
readOnly: false, // not idempotent — each call mints a new image
|
|
87
|
-
// Pillar 3 sink-side: the prompt is sent to a remote provider; lineage
|
|
88
|
-
// exfiltration via prompt smuggling is real.
|
|
89
|
-
scope: "external",
|
|
90
|
-
// FR-002 — declare the io-capability fact (remote image-gen API call).
|
|
91
|
-
ioCapability: "network",
|
|
92
|
-
execute: async (input) => {
|
|
93
|
-
const cfg = registeredConfig ?? {};
|
|
94
|
-
const provider = cfg.provider ?? defaultProvider(process.env);
|
|
95
|
-
const responseFormat = input.responseFormat ?? "url";
|
|
96
|
-
if (provider === "openai") {
|
|
97
|
-
return await generateOpenAI(input, cfg, responseFormat);
|
|
98
|
-
}
|
|
99
|
-
if (provider === "mock") {
|
|
100
|
-
return await generateMock(input);
|
|
101
|
-
}
|
|
102
|
-
throw new ImageGenerationError(
|
|
103
|
-
`provider "${provider}" is not yet implemented in tool-image-generation v0`,
|
|
104
|
-
);
|
|
105
|
-
},
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
function defaultProvider(env: NodeJS.ProcessEnv): ImageGenerationProvider {
|
|
109
|
-
if (env["OPENAI_API_KEY"]) return "openai";
|
|
110
|
-
if (env["CREWHAUS_IMAGE_PROVIDER"] === "mock") return "mock";
|
|
111
|
-
return "openai"; // surfaces a clear "missing OPENAI_API_KEY" error at call time
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async function generateOpenAI(
|
|
115
|
-
input: z.infer<typeof inputSchema>,
|
|
116
|
-
cfg: ImageGenerationConfig,
|
|
117
|
-
responseFormat: "url" | "b64_json",
|
|
118
|
-
): Promise<string> {
|
|
119
|
-
const apiKey = process.env["OPENAI_API_KEY"];
|
|
120
|
-
if (!apiKey) {
|
|
121
|
-
throw new ImageGenerationError(
|
|
122
|
-
"OPENAI_API_KEY is not set — required for provider=openai. Set the env var or switch to provider=mock for offline testing.",
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
const baseUrl = cfg.openaiBaseUrl ?? "https://api.openai.com/v1";
|
|
126
|
-
const model = cfg.model ?? "dall-e-3";
|
|
127
|
-
const fetchFn = cfg.fetch ?? globalThis.fetch;
|
|
128
|
-
const body = JSON.stringify({
|
|
129
|
-
model,
|
|
130
|
-
prompt: input.prompt,
|
|
131
|
-
n: 1,
|
|
132
|
-
size: input.size ?? "1024x1024",
|
|
133
|
-
response_format: responseFormat,
|
|
134
|
-
...(model === "dall-e-3" ? { style: input.style ?? "vivid" } : {}),
|
|
135
|
-
});
|
|
136
|
-
const res = await fetchFn(`${baseUrl}/images/generations`, {
|
|
137
|
-
method: "POST",
|
|
138
|
-
headers: {
|
|
139
|
-
Authorization: `Bearer ${apiKey}`,
|
|
140
|
-
"Content-Type": "application/json",
|
|
141
|
-
},
|
|
142
|
-
body,
|
|
143
|
-
});
|
|
144
|
-
if (!res.ok) {
|
|
145
|
-
const text = await res.text().catch(() => "");
|
|
146
|
-
throw new ImageGenerationError(
|
|
147
|
-
`OpenAI image-generation request failed (${res.status} ${res.statusText}): ${text.slice(0, 500)}`,
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
const json = (await res.json()) as {
|
|
151
|
-
data?: ReadonlyArray<{ url?: string; b64_json?: string }>;
|
|
152
|
-
};
|
|
153
|
-
const first = json.data?.[0];
|
|
154
|
-
if (!first) {
|
|
155
|
-
throw new ImageGenerationError("OpenAI response missing data[0] — unexpected format");
|
|
156
|
-
}
|
|
157
|
-
if (responseFormat === "url") {
|
|
158
|
-
if (typeof first.url !== "string") {
|
|
159
|
-
throw new ImageGenerationError("OpenAI response missing data[0].url");
|
|
160
|
-
}
|
|
161
|
-
return `image URL: ${first.url}`;
|
|
162
|
-
}
|
|
163
|
-
if (typeof first.b64_json !== "string") {
|
|
164
|
-
throw new ImageGenerationError("OpenAI response missing data[0].b64_json");
|
|
165
|
-
}
|
|
166
|
-
return `image base64 data: data:image/png;base64,${first.b64_json.slice(0, 80)}…[truncated ${
|
|
167
|
-
first.b64_json.length
|
|
168
|
-
} bytes total]`;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
async function generateMock(input: z.infer<typeof inputSchema>): Promise<string> {
|
|
172
|
-
// Deterministic stub — useful for tests + offline development.
|
|
173
|
-
return `mock image generated for prompt: "${input.prompt}" (no provider configured; set OPENAI_API_KEY or CREWHAUS_IMAGE_PROVIDER)`;
|
|
174
|
-
}
|