hazo_images 1.1.0 → 1.2.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/CHANGE_LOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # hazo_images Change Log
2
2
 
3
+ ## 1.2.0 — 2026-05-30
4
+
5
+ ### Added
6
+ - New `hazo_images/runware` sub-export wrapping the Runware REST API.
7
+ - `createRunwareClient(opts?)` factory — `generateImage(params)` and `getBalance()`.
8
+ - `assemblePrompts(input)` helper for the prefix + character + image + suffix pattern.
9
+ - Four `HazoError` subclasses: `RunwareApiError`, `RunwareAuthError`, `RunwareRateLimitError` (carries `retryAfter`), `RunwareValidationError`. All codes are `HAZO_IMAGES_RUNWARE_*`.
10
+ - API key resolution: `opts.apiKey` → `HAZO_IMAGES_RUNWARE_API_KEY` → `RUNWARE_API_KEY` (legacy).
11
+ - Outbound HTTP goes through `fetchWithRequestId` from `hazo_core` (first outbound HTTP in this package).
12
+ - Configurable `baseUrl`, `defaultModel`, `timeoutMs` (default 60s), `fetch` (override for tests), `logger`.
13
+
14
+ ### Migration
15
+ - Extracted verbatim from `14.seo_monitor/lib/runware/{client,resolve-params}.ts`.
16
+ - seo_monitor migrated to consume `hazo_images/runware` in the same release; seo_monitor's old `lib/runware/client.ts` deleted.
17
+
3
18
  ## 1.1.0 (2026-05-29) — Wave 2 standardisation
4
19
 
5
20
  ### Changed
package/README.md CHANGED
@@ -16,7 +16,7 @@ npm install hazo_core
16
16
  npm install sharp
17
17
 
18
18
  # Optional — only needed if using uploadProcessedImage
19
- npm install hazo_files@^2.1.1
19
+ npm install hazo_files
20
20
  ```
21
21
 
22
22
  ## Usage
@@ -153,6 +153,50 @@ interface ProcessImageOptions {
153
153
  }
154
154
  ```
155
155
 
156
+ ## Runware (AI image generation)
157
+
158
+ `hazo_images/runware` wraps the Runware REST API behind a factory client.
159
+
160
+ ```ts
161
+ import { createRunwareClient } from 'hazo_images/runware';
162
+
163
+ const client = createRunwareClient({
164
+ // apiKey?: defaults to HAZO_IMAGES_RUNWARE_API_KEY then RUNWARE_API_KEY
165
+ defaultModel: 'runware:z-image@turbo',
166
+ timeoutMs: 60_000,
167
+ });
168
+
169
+ const { imageURL, cost, taskUUID } = await client.generateImage({
170
+ positivePrompt: 'a chibi explorer with a stopwatch',
171
+ width: 1024,
172
+ height: 1024,
173
+ });
174
+ ```
175
+
176
+ ### Error classes
177
+
178
+ | Class | Triggered by | Code(s) |
179
+ |---|---|---|
180
+ | `RunwareApiError` | network fail, 5xx, abort, empty result | `HAZO_IMAGES_RUNWARE_HTTP_FAILURE`, `HAZO_IMAGES_RUNWARE_TIMEOUT`, `HAZO_IMAGES_RUNWARE_EMPTY_RESULT` |
181
+ | `RunwareAuthError` | missing key, 401, 403 | `HAZO_IMAGES_RUNWARE_MISSING_KEY`, `HAZO_IMAGES_RUNWARE_AUTH_FAILED` |
182
+ | `RunwareRateLimitError` | 429 — exposes `retryAfter: number \| null` | `HAZO_IMAGES_RUNWARE_RATE_LIMITED` |
183
+ | `RunwareValidationError` | 400 or `errors[]` in body | upstream code or `HAZO_IMAGES_RUNWARE_BAD_REQUEST` |
184
+
185
+ ### `assemblePrompts`
186
+
187
+ Generic helper for the prefix + character + image + suffix pattern:
188
+
189
+ ```ts
190
+ import { assemblePrompts } from 'hazo_images/runware';
191
+
192
+ const { positive, negative } = assemblePrompts({
193
+ imagePrompt: 'astronaut on mars',
194
+ masterStylePrefix: 'cinematic,',
195
+ characterPositive: 'red spacesuit',
196
+ masterNegative: 'low quality, blurry',
197
+ });
198
+ ```
199
+
156
200
  ## Roadmap
157
201
 
158
202
  | Version | Scope |
@@ -58,7 +58,14 @@ const nextConfig = {
58
58
  module.exports = nextConfig;
59
59
  ```
60
60
 
61
- ## 6. (Optional) Drop in `hazo_images_config.ini`
61
+ ## 6. (Optional) Environment variables for hazo_images/runware
62
+
63
+ Only needed if using the `hazo_images/runware` sub-export (Runware REST API client):
64
+
65
+ - `HAZO_IMAGES_RUNWARE_API_KEY` (preferred) — API key from [runware.ai](https://runware.ai)
66
+ - `RUNWARE_API_KEY` (legacy fallback) — older projects may use this; new code should prefer `HAZO_IMAGES_RUNWARE_API_KEY`
67
+
68
+ ## 7. (Optional) Drop in `hazo_images_config.ini`
62
69
 
63
70
  The package ships `config/hazo_images_config.ini.sample` with sections
64
71
  for `[general]`, `[log.overrides]`, and `[processing]` defaults. Copy
@@ -71,7 +78,7 @@ Per `D-020`, a per-environment overlay file at
71
78
  `config/hazo_images_config.<HAZO_ENV>.ini` is layered on top of the
72
79
  base file when `HAZO_ENV` is set.
73
80
 
74
- ## 7. Verify
81
+ ## 8. Verify
75
82
 
76
83
  ```ts
77
84
  import { processImage } from 'hazo_images/server';
@@ -0,0 +1,83 @@
1
+ import { HazoCoreLogger, HazoError } from 'hazo_core';
2
+
3
+ interface CreateRunwareClientOptions {
4
+ apiKey?: string;
5
+ baseUrl?: string;
6
+ defaultModel?: string;
7
+ timeoutMs?: number;
8
+ fetch?: typeof fetch;
9
+ logger?: HazoCoreLogger;
10
+ }
11
+ interface GenerateImageParams {
12
+ positivePrompt: string;
13
+ negativePrompt?: string;
14
+ model?: string;
15
+ width: number;
16
+ height: number;
17
+ numberResults?: number;
18
+ outputFormat?: 'PNG' | 'JPEG' | 'WEBP';
19
+ outputType?: 'URL' | 'base64Data';
20
+ }
21
+ interface GenerateImageResult {
22
+ imageURL?: string;
23
+ imageBase64Data?: string;
24
+ cost: number | null;
25
+ taskUUID: string;
26
+ }
27
+ interface RunwareClient {
28
+ generateImage(params: GenerateImageParams): Promise<GenerateImageResult>;
29
+ getBalance(): Promise<number>;
30
+ }
31
+ interface AssemblePromptsInput {
32
+ imagePrompt: string;
33
+ masterStylePrefix?: string | null;
34
+ masterStyleSuffix?: string | null;
35
+ characterPositive?: string | null;
36
+ masterNegative?: string | null;
37
+ characterNegative?: string | null;
38
+ callNegative?: string | null;
39
+ }
40
+ interface ResolvedPrompts {
41
+ positive: string;
42
+ negative: string | undefined;
43
+ }
44
+
45
+ declare function createRunwareClient(opts?: CreateRunwareClientOptions): RunwareClient;
46
+
47
+ declare function assemblePrompts(input: AssemblePromptsInput): ResolvedPrompts;
48
+
49
+ declare const RUNWARE_ERROR_CODES: {
50
+ readonly MISSING_KEY: "HAZO_IMAGES_RUNWARE_MISSING_KEY";
51
+ readonly AUTH_FAILED: "HAZO_IMAGES_RUNWARE_AUTH_FAILED";
52
+ readonly RATE_LIMITED: "HAZO_IMAGES_RUNWARE_RATE_LIMITED";
53
+ readonly HTTP_FAILURE: "HAZO_IMAGES_RUNWARE_HTTP_FAILURE";
54
+ readonly TIMEOUT: "HAZO_IMAGES_RUNWARE_TIMEOUT";
55
+ readonly EMPTY_RESULT: "HAZO_IMAGES_RUNWARE_EMPTY_RESULT";
56
+ readonly BAD_REQUEST: "HAZO_IMAGES_RUNWARE_BAD_REQUEST";
57
+ readonly MISSING_MODEL: "HAZO_IMAGES_RUNWARE_MISSING_MODEL";
58
+ };
59
+ type RunwareErrorCode = (typeof RUNWARE_ERROR_CODES)[keyof typeof RUNWARE_ERROR_CODES];
60
+ interface RunwareErrorContext {
61
+ status?: number;
62
+ body?: unknown;
63
+ taskUUID?: string;
64
+ code?: string;
65
+ cause?: unknown;
66
+ }
67
+ declare class RunwareApiError extends HazoError {
68
+ constructor(code: string, message: string, context?: RunwareErrorContext);
69
+ }
70
+ declare class RunwareAuthError extends HazoError {
71
+ constructor(code: string, message: string, context?: RunwareErrorContext);
72
+ }
73
+ declare class RunwareRateLimitError extends HazoError {
74
+ retryAfter: number | null;
75
+ constructor(message: string, context: RunwareErrorContext & {
76
+ retryAfter: number | null;
77
+ });
78
+ }
79
+ declare class RunwareValidationError extends HazoError {
80
+ constructor(code: string, message: string, context?: RunwareErrorContext);
81
+ }
82
+
83
+ export { type AssemblePromptsInput, type CreateRunwareClientOptions, type GenerateImageParams, type GenerateImageResult, RUNWARE_ERROR_CODES, type ResolvedPrompts, RunwareApiError, RunwareAuthError, type RunwareClient, type RunwareErrorCode, RunwareRateLimitError, RunwareValidationError, assemblePrompts, createRunwareClient };
@@ -0,0 +1,430 @@
1
+ // src/runware/client.ts
2
+ import { fetchWithRequestId } from "hazo_core";
3
+
4
+ // ../node_modules/uuid/dist/esm-node/rng.js
5
+ import crypto from "crypto";
6
+ var rnds8Pool = new Uint8Array(256);
7
+ var poolPtr = rnds8Pool.length;
8
+ function rng() {
9
+ if (poolPtr > rnds8Pool.length - 16) {
10
+ crypto.randomFillSync(rnds8Pool);
11
+ poolPtr = 0;
12
+ }
13
+ return rnds8Pool.slice(poolPtr, poolPtr += 16);
14
+ }
15
+
16
+ // ../node_modules/uuid/dist/esm-node/regex.js
17
+ var regex_default = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i;
18
+
19
+ // ../node_modules/uuid/dist/esm-node/validate.js
20
+ function validate(uuid) {
21
+ return typeof uuid === "string" && regex_default.test(uuid);
22
+ }
23
+ var validate_default = validate;
24
+
25
+ // ../node_modules/uuid/dist/esm-node/stringify.js
26
+ var byteToHex = [];
27
+ for (let i = 0; i < 256; ++i) {
28
+ byteToHex.push((i + 256).toString(16).substr(1));
29
+ }
30
+ function stringify(arr, offset = 0) {
31
+ const uuid = (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + "-" + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + "-" + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + "-" + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + "-" + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase();
32
+ if (!validate_default(uuid)) {
33
+ throw TypeError("Stringified UUID is invalid");
34
+ }
35
+ return uuid;
36
+ }
37
+ var stringify_default = stringify;
38
+
39
+ // ../node_modules/uuid/dist/esm-node/v4.js
40
+ function v4(options, buf, offset) {
41
+ options = options || {};
42
+ const rnds = options.random || (options.rng || rng)();
43
+ rnds[6] = rnds[6] & 15 | 64;
44
+ rnds[8] = rnds[8] & 63 | 128;
45
+ if (buf) {
46
+ offset = offset || 0;
47
+ for (let i = 0; i < 16; ++i) {
48
+ buf[offset + i] = rnds[i];
49
+ }
50
+ return buf;
51
+ }
52
+ return stringify_default(rnds);
53
+ }
54
+ var v4_default = v4;
55
+
56
+ // src/utils/uuid.ts
57
+ import { generateRequestId } from "hazo_core";
58
+ var uuidv4 = v4_default;
59
+ function generateTaskUUID() {
60
+ return uuidv4();
61
+ }
62
+
63
+ // src/utils/logger.ts
64
+ import { createLogger } from "hazo_core";
65
+ var console_logger = {
66
+ info: (m, d) => d ? console.log(`[hazo_images] ${m}`, d) : console.log(`[hazo_images] ${m}`),
67
+ debug: (m, d) => d ? console.debug(`[hazo_images] ${m}`, d) : console.debug(`[hazo_images] ${m}`),
68
+ warn: (m, d) => d ? console.warn(`[hazo_images] ${m}`, d) : console.warn(`[hazo_images] ${m}`),
69
+ error: (m, d) => d ? console.error(`[hazo_images] ${m}`, d) : console.error(`[hazo_images] ${m}`)
70
+ };
71
+ function build_default_logger() {
72
+ try {
73
+ return createLogger("hazo_images");
74
+ } catch {
75
+ return console_logger;
76
+ }
77
+ }
78
+ var current_logger = null;
79
+ function get_logger() {
80
+ if (!current_logger) {
81
+ current_logger = build_default_logger();
82
+ }
83
+ return current_logger;
84
+ }
85
+
86
+ // src/runware/errors.ts
87
+ import { HazoError } from "hazo_core";
88
+ var RUNWARE_ERROR_CODES = {
89
+ MISSING_KEY: "HAZO_IMAGES_RUNWARE_MISSING_KEY",
90
+ AUTH_FAILED: "HAZO_IMAGES_RUNWARE_AUTH_FAILED",
91
+ RATE_LIMITED: "HAZO_IMAGES_RUNWARE_RATE_LIMITED",
92
+ HTTP_FAILURE: "HAZO_IMAGES_RUNWARE_HTTP_FAILURE",
93
+ TIMEOUT: "HAZO_IMAGES_RUNWARE_TIMEOUT",
94
+ EMPTY_RESULT: "HAZO_IMAGES_RUNWARE_EMPTY_RESULT",
95
+ BAD_REQUEST: "HAZO_IMAGES_RUNWARE_BAD_REQUEST",
96
+ MISSING_MODEL: "HAZO_IMAGES_RUNWARE_MISSING_MODEL"
97
+ };
98
+ var RunwareApiError = class extends HazoError {
99
+ constructor(code, message, context = {}) {
100
+ super({ code, pkg: "hazo_images", message, context });
101
+ this.name = "RunwareApiError";
102
+ }
103
+ };
104
+ var RunwareAuthError = class extends HazoError {
105
+ constructor(code, message, context = {}) {
106
+ super({ code, pkg: "hazo_images", message, context });
107
+ this.name = "RunwareAuthError";
108
+ }
109
+ };
110
+ var RunwareRateLimitError = class extends HazoError {
111
+ retryAfter;
112
+ constructor(message, context) {
113
+ const { retryAfter, ...rest } = context;
114
+ super({
115
+ code: RUNWARE_ERROR_CODES.RATE_LIMITED,
116
+ pkg: "hazo_images",
117
+ message,
118
+ context: { ...rest, retryAfter }
119
+ });
120
+ this.name = "RunwareRateLimitError";
121
+ this.retryAfter = retryAfter;
122
+ }
123
+ };
124
+ var RunwareValidationError = class extends HazoError {
125
+ constructor(code, message, context = {}) {
126
+ super({ code, pkg: "hazo_images", message, context });
127
+ this.name = "RunwareValidationError";
128
+ }
129
+ };
130
+
131
+ // src/runware/client.ts
132
+ var DEFAULT_BASE_URL = "https://api.runware.ai/v1";
133
+ var DEFAULT_TIMEOUT_MS = 6e4;
134
+ function resolveApiKey(optsApiKey) {
135
+ if (optsApiKey) return optsApiKey;
136
+ if (process.env.HAZO_IMAGES_RUNWARE_API_KEY) return process.env.HAZO_IMAGES_RUNWARE_API_KEY;
137
+ if (process.env.RUNWARE_API_KEY) return process.env.RUNWARE_API_KEY;
138
+ throw new RunwareAuthError(
139
+ RUNWARE_ERROR_CODES.MISSING_KEY,
140
+ "Runware API key not found. Set HAZO_IMAGES_RUNWARE_API_KEY or RUNWARE_API_KEY, or pass apiKey to createRunwareClient."
141
+ );
142
+ }
143
+ function createRunwareClient(opts = {}) {
144
+ const logger = opts.logger ?? get_logger();
145
+ const cfg = {
146
+ baseUrl: opts.baseUrl ?? DEFAULT_BASE_URL,
147
+ defaultModel: opts.defaultModel,
148
+ timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
149
+ fetchImpl: opts.fetch ?? fetchWithRequestId,
150
+ optsApiKey: opts.apiKey,
151
+ logger
152
+ };
153
+ logger.info("runware.client.created", {
154
+ baseUrl: cfg.baseUrl,
155
+ defaultModel: cfg.defaultModel ?? null,
156
+ timeoutMs: cfg.timeoutMs
157
+ });
158
+ return {
159
+ async generateImage(params) {
160
+ const start = Date.now();
161
+ const apiKey = resolveApiKey(cfg.optsApiKey);
162
+ const model = params.model ?? cfg.defaultModel;
163
+ if (!model) {
164
+ const err = new RunwareValidationError(
165
+ RUNWARE_ERROR_CODES.MISSING_MODEL,
166
+ "Runware generateImage requires a `model` (pass per-call or as defaultModel to createRunwareClient).",
167
+ { taskUUID: "pre-task" }
168
+ );
169
+ cfg.logger.error("runware.generate_image.failed", {
170
+ errorCode: err.code,
171
+ errorClass: err.name,
172
+ durationMs: Date.now() - start
173
+ });
174
+ throw err;
175
+ }
176
+ const taskUUID = generateTaskUUID();
177
+ cfg.logger.info("runware.generate_image.started", { model, taskUUID });
178
+ const task = {
179
+ taskType: "imageInference",
180
+ taskUUID,
181
+ positivePrompt: params.positivePrompt,
182
+ ...params.negativePrompt ? { negativePrompt: params.negativePrompt } : {},
183
+ model,
184
+ width: params.width,
185
+ height: params.height,
186
+ numberResults: params.numberResults ?? 1,
187
+ outputFormat: params.outputFormat ?? "PNG",
188
+ outputType: params.outputType ?? "URL"
189
+ };
190
+ const controller = new AbortController();
191
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs);
192
+ let res;
193
+ try {
194
+ res = await cfg.fetchImpl(cfg.baseUrl, {
195
+ method: "POST",
196
+ headers: {
197
+ "Content-Type": "application/json",
198
+ Authorization: `Bearer ${apiKey}`
199
+ },
200
+ body: JSON.stringify([task]),
201
+ signal: controller.signal
202
+ });
203
+ } catch (err) {
204
+ clearTimeout(timer);
205
+ let thrownErr;
206
+ if (err instanceof DOMException && err.name === "AbortError") {
207
+ thrownErr = new RunwareApiError(
208
+ RUNWARE_ERROR_CODES.TIMEOUT,
209
+ `Runware request timed out after ${cfg.timeoutMs}ms`,
210
+ { taskUUID }
211
+ );
212
+ } else {
213
+ thrownErr = new RunwareApiError(
214
+ RUNWARE_ERROR_CODES.HTTP_FAILURE,
215
+ "Runware network error",
216
+ { taskUUID }
217
+ );
218
+ }
219
+ cfg.logger.error("runware.generate_image.failed", {
220
+ taskUUID,
221
+ model,
222
+ errorCode: thrownErr.code,
223
+ errorClass: thrownErr.name,
224
+ durationMs: Date.now() - start
225
+ });
226
+ throw thrownErr;
227
+ }
228
+ clearTimeout(timer);
229
+ if (!res.ok) {
230
+ let bodyText;
231
+ let bodyJson;
232
+ try {
233
+ bodyText = await res.clone().text();
234
+ bodyJson = JSON.parse(bodyText);
235
+ } catch {
236
+ bodyJson = bodyText;
237
+ }
238
+ const errorRecord = bodyJson?.errors?.[0];
239
+ let thrownErr;
240
+ if (res.status === 401 || res.status === 403) {
241
+ thrownErr = new RunwareAuthError(
242
+ RUNWARE_ERROR_CODES.AUTH_FAILED,
243
+ `Runware auth failed (HTTP ${res.status})`,
244
+ { status: res.status, body: bodyJson, taskUUID }
245
+ );
246
+ } else if (res.status === 429) {
247
+ const ra = res.headers.get("retry-after");
248
+ const retryAfter = ra && /^\d+$/.test(ra) ? parseInt(ra, 10) : null;
249
+ thrownErr = new RunwareRateLimitError(`Runware rate limited (HTTP 429)`, {
250
+ status: 429,
251
+ body: bodyJson,
252
+ taskUUID,
253
+ retryAfter
254
+ });
255
+ } else if (res.status === 400 || errorRecord) {
256
+ thrownErr = new RunwareValidationError(
257
+ errorRecord?.code ? `HAZO_IMAGES_RUNWARE_${errorRecord.code.toUpperCase()}` : RUNWARE_ERROR_CODES.BAD_REQUEST,
258
+ errorRecord?.message ?? `Runware validation failed (HTTP ${res.status})`,
259
+ { status: res.status, body: bodyJson, taskUUID, code: errorRecord?.code }
260
+ );
261
+ } else {
262
+ thrownErr = new RunwareApiError(
263
+ RUNWARE_ERROR_CODES.HTTP_FAILURE,
264
+ `Runware HTTP ${res.status}`,
265
+ { status: res.status, body: bodyJson, taskUUID }
266
+ );
267
+ }
268
+ cfg.logger.error("runware.generate_image.failed", {
269
+ taskUUID,
270
+ model,
271
+ errorCode: thrownErr.code,
272
+ errorClass: thrownErr.name,
273
+ durationMs: Date.now() - start
274
+ });
275
+ throw thrownErr;
276
+ }
277
+ const body = await res.json();
278
+ const apiErrors = body.errors;
279
+ if (apiErrors && apiErrors.length > 0) {
280
+ const firstErr = apiErrors[0];
281
+ const thrownErr = new RunwareValidationError(
282
+ firstErr.code ? `HAZO_IMAGES_RUNWARE_${firstErr.code.toUpperCase()}` : RUNWARE_ERROR_CODES.BAD_REQUEST,
283
+ firstErr.message ?? "Runware returned an API error",
284
+ { body, taskUUID, code: firstErr.code }
285
+ );
286
+ cfg.logger.error("runware.generate_image.failed", {
287
+ taskUUID,
288
+ model,
289
+ errorCode: thrownErr.code,
290
+ errorClass: thrownErr.name,
291
+ durationMs: Date.now() - start
292
+ });
293
+ throw thrownErr;
294
+ }
295
+ const first = body.data?.[0];
296
+ if (!first?.imageURL && !first?.imageBase64Data) {
297
+ const thrownErr = new RunwareApiError(
298
+ RUNWARE_ERROR_CODES.EMPTY_RESULT,
299
+ "Runware returned no image data",
300
+ { body, taskUUID }
301
+ );
302
+ cfg.logger.error("runware.generate_image.failed", {
303
+ taskUUID,
304
+ model,
305
+ errorCode: thrownErr.code,
306
+ errorClass: thrownErr.name,
307
+ durationMs: Date.now() - start
308
+ });
309
+ throw thrownErr;
310
+ }
311
+ const result = {
312
+ imageURL: first.imageURL,
313
+ imageBase64Data: first.imageBase64Data,
314
+ cost: typeof first.cost === "number" ? first.cost : null,
315
+ taskUUID
316
+ };
317
+ cfg.logger.info("runware.generate_image.completed", {
318
+ taskUUID,
319
+ model,
320
+ cost: result.cost,
321
+ durationMs: Date.now() - start
322
+ });
323
+ return result;
324
+ },
325
+ async getBalance() {
326
+ const start = Date.now();
327
+ const apiKey = resolveApiKey(cfg.optsApiKey);
328
+ const taskUUID = generateTaskUUID();
329
+ const controller = new AbortController();
330
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs);
331
+ let res;
332
+ try {
333
+ res = await cfg.fetchImpl(cfg.baseUrl, {
334
+ method: "POST",
335
+ headers: {
336
+ "Content-Type": "application/json",
337
+ Authorization: `Bearer ${apiKey}`
338
+ },
339
+ body: JSON.stringify([{ taskType: "getUser", taskUUID }]),
340
+ signal: controller.signal
341
+ });
342
+ } catch (err) {
343
+ clearTimeout(timer);
344
+ if (err instanceof DOMException && err.name === "AbortError") {
345
+ const thrownErr2 = new RunwareApiError(
346
+ RUNWARE_ERROR_CODES.TIMEOUT,
347
+ `Runware getBalance timed out`,
348
+ { taskUUID }
349
+ );
350
+ cfg.logger.error("runware.get_balance.failed", {
351
+ taskUUID,
352
+ errorCode: thrownErr2.code,
353
+ errorClass: thrownErr2.name,
354
+ durationMs: Date.now() - start
355
+ });
356
+ throw thrownErr2;
357
+ }
358
+ const thrownErr = new RunwareApiError(
359
+ RUNWARE_ERROR_CODES.HTTP_FAILURE,
360
+ "Runware getBalance network error",
361
+ { taskUUID }
362
+ );
363
+ cfg.logger.error("runware.get_balance.failed", {
364
+ taskUUID,
365
+ errorCode: thrownErr.code,
366
+ errorClass: thrownErr.name,
367
+ durationMs: Date.now() - start
368
+ });
369
+ throw thrownErr;
370
+ }
371
+ clearTimeout(timer);
372
+ if (!res.ok) {
373
+ const thrownErr = new RunwareApiError(
374
+ RUNWARE_ERROR_CODES.HTTP_FAILURE,
375
+ `Runware getBalance HTTP ${res.status}`,
376
+ { status: res.status, taskUUID }
377
+ );
378
+ cfg.logger.error("runware.get_balance.failed", {
379
+ taskUUID,
380
+ errorCode: thrownErr.code,
381
+ errorClass: thrownErr.name,
382
+ durationMs: Date.now() - start
383
+ });
384
+ throw thrownErr;
385
+ }
386
+ const body = await res.json();
387
+ const balance = body.data?.[0]?.balance ?? 0;
388
+ cfg.logger.info("runware.get_balance.completed", {
389
+ taskUUID,
390
+ balance,
391
+ durationMs: Date.now() - start
392
+ });
393
+ return balance;
394
+ }
395
+ };
396
+ }
397
+
398
+ // src/runware/prompt.ts
399
+ function assemblePrompts(input) {
400
+ const {
401
+ imagePrompt,
402
+ masterStylePrefix,
403
+ masterStyleSuffix,
404
+ characterPositive,
405
+ masterNegative,
406
+ characterNegative,
407
+ callNegative
408
+ } = input;
409
+ const positiveParts = [];
410
+ if (masterStylePrefix) positiveParts.push(masterStylePrefix);
411
+ if (characterPositive) positiveParts.push(characterPositive);
412
+ positiveParts.push(imagePrompt);
413
+ if (masterStyleSuffix) positiveParts.push(masterStyleSuffix);
414
+ const positive = positiveParts.join(" ");
415
+ const negativeParts = [];
416
+ if (masterNegative) negativeParts.push(masterNegative);
417
+ if (characterNegative) negativeParts.push(characterNegative);
418
+ if (callNegative) negativeParts.push(callNegative);
419
+ const negative = negativeParts.length > 0 ? negativeParts.join(", ") : void 0;
420
+ return { positive, negative };
421
+ }
422
+ export {
423
+ RUNWARE_ERROR_CODES,
424
+ RunwareApiError,
425
+ RunwareAuthError,
426
+ RunwareRateLimitError,
427
+ RunwareValidationError,
428
+ assemblePrompts,
429
+ createRunwareClient
430
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_images",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Image processing pipeline for the hazo ecosystem — Sharp wrapper, thumbnail generation, EXIF handling, and hazo_files integration helper",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,6 +22,11 @@
22
22
  "types": "./dist/ui/index.d.ts",
23
23
  "import": "./dist/ui/index.js",
24
24
  "default": "./dist/ui/index.js"
25
+ },
26
+ "./runware": {
27
+ "types": "./dist/runware/index.d.ts",
28
+ "import": "./dist/runware/index.js",
29
+ "default": "./dist/runware/index.js"
25
30
  }
26
31
  },
27
32
  "files": [
@@ -35,7 +40,9 @@
35
40
  "build": "tsup",
36
41
  "dev": "tsup --watch",
37
42
  "typecheck": "tsc --noEmit",
38
- "lint": "tsc --noEmit"
43
+ "lint": "tsc --noEmit",
44
+ "test": "node --experimental-vm-modules ../node_modules/jest/bin/jest.js --config jest.config.cjs",
45
+ "test:watch": "node --experimental-vm-modules ../node_modules/jest/bin/jest.js --config jest.config.cjs --watch"
39
46
  },
40
47
  "dependencies": {
41
48
  "ini": "^4.1.0",
@@ -63,9 +70,13 @@
63
70
  }
64
71
  },
65
72
  "devDependencies": {
73
+ "@types/jest": "^30.0.0",
66
74
  "@types/node": "^22.10.0",
67
75
  "@types/react": "^19.0.0",
68
- "hazo_core": "^1.0.0",
76
+ "hazo_core": "^1.0.1",
77
+ "jest": "^30.2.0",
78
+ "jest-environment-node": "^30.2.0",
79
+ "ts-jest": "^29.4.5",
69
80
  "tsup": "^8.0.0",
70
81
  "typescript": "^5.7.2"
71
82
  },