@blockrun/clawrouter 0.12.32 → 0.12.34

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 CHANGED
@@ -48,7 +48,7 @@ One wallet, 41+ models, zero API keys.
48
48
  | [vs OpenRouter](#-vs-openrouter) | Why ClawRouter wins |
49
49
  | [Support](#-support) | Telegram, X, founders |
50
50
 
51
- **API Docs:** [Image Generation](docs/image-generation.md) · [Architecture](docs/architecture.md) · [Configuration](docs/configuration.md)
51
+ **API Docs:** [Image Generation & Editing](docs/image-generation.md) · [Architecture](docs/architecture.md) · [Configuration](docs/configuration.md)
52
52
 
53
53
  ---
54
54
 
@@ -102,6 +102,35 @@ Generate images directly from chat with `/imagegen`:
102
102
 
103
103
  Default model: `nano-banana`. Images are returned as hosted URLs for compatibility with Telegram, Discord, and other clients.
104
104
 
105
+ ## ✏️ Image Editing (img2img)
106
+
107
+ Edit existing images with `/img2img` — pass a local file and describe what to change:
108
+
109
+ ```
110
+ /img2img --image ~/photo.png change the background to a starry sky
111
+ /img2img --image ./cat.jpg --mask ./mask.png remove the background
112
+ /img2img --image /tmp/portrait.png --size 1536x1024 add a hat
113
+ ```
114
+
115
+ | Option | Required | Description |
116
+ | ----------------- | -------- | ------------------------------------- |
117
+ | `--image <path>` | Yes | Local image file path (supports `~/`) |
118
+ | `--mask <path>` | No | Mask image (white = area to edit) |
119
+ | `--model <model>` | No | Model to use (default: `gpt-image-1`) |
120
+ | `--size <WxH>` | No | Output size (default: `1024x1024`) |
121
+
122
+ Supported model: `gpt-image-1` (OpenAI GPT Image 1, $0.02/image).
123
+
124
+ **API endpoint:** `POST http://localhost:8402/v1/images/image2image` — accepts local file paths, URLs, or base64 data URIs:
125
+
126
+ ```bash
127
+ curl -X POST http://localhost:8402/v1/images/image2image \
128
+ -H "Content-Type: application/json" \
129
+ -d '{"prompt":"add sunglasses","image":"~/photo.png"}'
130
+ ```
131
+
132
+ See [Image Generation & Editing docs](docs/image-generation.md#post-v1imagesimage2image) for full API reference and code examples.
133
+
105
134
  ---
106
135
 
107
136
  ## ⚡ How It Works
@@ -330,17 +359,17 @@ npm test
330
359
 
331
360
  ## 📚 More Resources
332
361
 
333
- | Resource | Description |
334
- | -------------------------------------------- | ------------------------ |
335
- | [Documentation](https://blockrun.ai/docs) | Full docs |
336
- | [Model Pricing](https://blockrun.ai/models) | All models & prices |
337
- | [Image Generation](docs/image-generation.md) | API examples, 5 models |
338
- | [Routing Profiles](docs/routing-profiles.md) | ECO/AUTO/PREMIUM details |
339
- | [Architecture](docs/architecture.md) | Technical deep dive |
340
- | [Configuration](docs/configuration.md) | Environment variables |
341
- | [vs OpenRouter](docs/vs-openrouter.md) | Why ClawRouter wins |
342
- | [Features](docs/features.md) | All features |
343
- | [Troubleshooting](docs/troubleshooting.md) | Common issues |
362
+ | Resource | Description |
363
+ | ------------------------------------------------------ | ------------------------ |
364
+ | [Documentation](https://blockrun.ai/docs) | Full docs |
365
+ | [Model Pricing](https://blockrun.ai/models) | All models & prices |
366
+ | [Image Generation & Editing](docs/image-generation.md) | API examples, 5 models |
367
+ | [Routing Profiles](docs/routing-profiles.md) | ECO/AUTO/PREMIUM details |
368
+ | [Architecture](docs/architecture.md) | Technical deep dive |
369
+ | [Configuration](docs/configuration.md) | Environment variables |
370
+ | [vs OpenRouter](docs/vs-openrouter.md) | Why ClawRouter wins |
371
+ | [Features](docs/features.md) | All features |
372
+ | [Troubleshooting](docs/troubleshooting.md) | Common issues |
344
373
 
345
374
  ---
346
375
 
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { finished } from "stream";
6
6
  import { homedir as homedir4 } from "os";
7
7
  import { join as join5 } from "path";
8
8
  import { mkdir as mkdir3, writeFile as writeFile2, readFile, stat as fsStat } from "fs/promises";
9
+ import { readFileSync, existsSync } from "fs";
9
10
  import { createPublicClient as createPublicClient2, http as http2 } from "viem";
10
11
  import { base as base2 } from "viem/chains";
11
12
  import { privateKeyToAccount as privateKeyToAccount3 } from "viem/accounts";
@@ -5614,6 +5615,22 @@ async function proxyPartnerRequest(req, res, apiBase, payFetch) {
5614
5615
  }).catch(() => {
5615
5616
  });
5616
5617
  }
5618
+ function readImageFileAsDataUri(filePath) {
5619
+ const resolved = filePath.startsWith("~/") ? join5(homedir4(), filePath.slice(2)) : filePath;
5620
+ if (!existsSync(resolved)) {
5621
+ throw new Error(`Image file not found: ${resolved}`);
5622
+ }
5623
+ const ext = resolved.split(".").pop()?.toLowerCase() ?? "png";
5624
+ const mimeMap = {
5625
+ png: "image/png",
5626
+ jpg: "image/jpeg",
5627
+ jpeg: "image/jpeg",
5628
+ webp: "image/webp"
5629
+ };
5630
+ const mime = mimeMap[ext] ?? "image/png";
5631
+ const data = readFileSync(resolved);
5632
+ return `data:${mime};base64,${data.toString("base64")}`;
5633
+ }
5617
5634
  async function uploadDataUriToHost(dataUri) {
5618
5635
  const match = dataUri.match(/^data:(image\/\w+);base64,(.+)$/);
5619
5636
  if (!match) throw new Error("Invalid data URI format");
@@ -5908,7 +5925,9 @@ async function startProxy(options) {
5908
5925
  console.log(`[ClawRouter] Image downloaded & saved \u2192 ${img.url}`);
5909
5926
  }
5910
5927
  } catch (downloadErr) {
5911
- console.warn(`[ClawRouter] Failed to download image, using original URL: ${downloadErr instanceof Error ? downloadErr.message : String(downloadErr)}`);
5928
+ console.warn(
5929
+ `[ClawRouter] Failed to download image, using original URL: ${downloadErr instanceof Error ? downloadErr.message : String(downloadErr)}`
5930
+ );
5912
5931
  }
5913
5932
  }
5914
5933
  }
@@ -5925,6 +5944,103 @@ async function startProxy(options) {
5925
5944
  }
5926
5945
  return;
5927
5946
  }
5947
+ if (req.url === "/v1/images/image2image" && req.method === "POST") {
5948
+ const chunks = [];
5949
+ for await (const chunk of req) {
5950
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
5951
+ }
5952
+ const rawBody = Buffer.concat(chunks);
5953
+ let reqBody;
5954
+ try {
5955
+ const parsed = JSON.parse(rawBody.toString());
5956
+ for (const field of ["image", "mask"]) {
5957
+ const val = parsed[field];
5958
+ if (typeof val !== "string" || !val) continue;
5959
+ if (val.startsWith("data:")) {
5960
+ } else if (val.startsWith("https://") || val.startsWith("http://")) {
5961
+ const imgResp = await fetch(val);
5962
+ if (!imgResp.ok) throw new Error(`Failed to download ${field} from ${val}: HTTP ${imgResp.status}`);
5963
+ const contentType = imgResp.headers.get("content-type") ?? "image/png";
5964
+ const buf = Buffer.from(await imgResp.arrayBuffer());
5965
+ parsed[field] = `data:${contentType};base64,${buf.toString("base64")}`;
5966
+ console.log(`[ClawRouter] img2img: downloaded ${field} URL \u2192 data URI (${buf.length} bytes)`);
5967
+ } else {
5968
+ parsed[field] = readImageFileAsDataUri(val);
5969
+ console.log(`[ClawRouter] img2img: read ${field} file \u2192 data URI`);
5970
+ }
5971
+ }
5972
+ if (!parsed.model) parsed.model = "openai/gpt-image-1";
5973
+ reqBody = JSON.stringify(parsed);
5974
+ } catch (parseErr) {
5975
+ const msg = parseErr instanceof Error ? parseErr.message : String(parseErr);
5976
+ res.writeHead(400, { "Content-Type": "application/json" });
5977
+ res.end(JSON.stringify({ error: "Invalid request", details: msg }));
5978
+ return;
5979
+ }
5980
+ try {
5981
+ const upstream = await payFetch(`${apiBase}/v1/images/image2image`, {
5982
+ method: "POST",
5983
+ headers: { "content-type": "application/json", "user-agent": USER_AGENT },
5984
+ body: reqBody
5985
+ });
5986
+ const text = await upstream.text();
5987
+ if (!upstream.ok) {
5988
+ res.writeHead(upstream.status, { "Content-Type": "application/json" });
5989
+ res.end(text);
5990
+ return;
5991
+ }
5992
+ let result;
5993
+ try {
5994
+ result = JSON.parse(text);
5995
+ } catch {
5996
+ res.writeHead(200, { "Content-Type": "application/json" });
5997
+ res.end(text);
5998
+ return;
5999
+ }
6000
+ if (result.data?.length) {
6001
+ await mkdir3(IMAGE_DIR, { recursive: true });
6002
+ const port2 = server.address()?.port ?? 8402;
6003
+ for (const img of result.data) {
6004
+ const dataUriMatch = img.url?.match(/^data:(image\/\w+);base64,(.+)$/);
6005
+ if (dataUriMatch) {
6006
+ const [, mimeType, b64] = dataUriMatch;
6007
+ const ext = mimeType === "image/jpeg" ? "jpg" : mimeType.split("/")[1] ?? "png";
6008
+ const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}.${ext}`;
6009
+ await writeFile2(join5(IMAGE_DIR, filename), Buffer.from(b64, "base64"));
6010
+ img.url = `http://localhost:${port2}/images/${filename}`;
6011
+ console.log(`[ClawRouter] Image saved \u2192 ${img.url}`);
6012
+ } else if (img.url?.startsWith("https://") || img.url?.startsWith("http://")) {
6013
+ try {
6014
+ const imgResp = await fetch(img.url);
6015
+ if (imgResp.ok) {
6016
+ const contentType = imgResp.headers.get("content-type") ?? "image/png";
6017
+ const ext = contentType.includes("jpeg") || contentType.includes("jpg") ? "jpg" : contentType.includes("webp") ? "webp" : "png";
6018
+ const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}.${ext}`;
6019
+ const buf = Buffer.from(await imgResp.arrayBuffer());
6020
+ await writeFile2(join5(IMAGE_DIR, filename), buf);
6021
+ img.url = `http://localhost:${port2}/images/${filename}`;
6022
+ console.log(`[ClawRouter] Image downloaded & saved \u2192 ${img.url}`);
6023
+ }
6024
+ } catch (downloadErr) {
6025
+ console.warn(
6026
+ `[ClawRouter] Failed to download image, using original URL: ${downloadErr instanceof Error ? downloadErr.message : String(downloadErr)}`
6027
+ );
6028
+ }
6029
+ }
6030
+ }
6031
+ }
6032
+ res.writeHead(200, { "Content-Type": "application/json" });
6033
+ res.end(JSON.stringify(result));
6034
+ } catch (err) {
6035
+ const msg = err instanceof Error ? err.message : String(err);
6036
+ console.error(`[ClawRouter] Image editing error: ${msg}`);
6037
+ if (!res.headersSent) {
6038
+ res.writeHead(502, { "Content-Type": "application/json" });
6039
+ res.end(JSON.stringify({ error: "Image editing failed", details: msg }));
6040
+ }
6041
+ }
6042
+ return;
6043
+ }
5928
6044
  if (req.url?.match(/^\/v1\/(?:x|partner)\//)) {
5929
6045
  try {
5930
6046
  await proxyPartnerRequest(req, res, apiBase, payFetch);
@@ -6539,6 +6655,177 @@ async function proxyRequest(req, res, apiBase, payFetch, options, routerOpts, de
6539
6655
  }
6540
6656
  return;
6541
6657
  }
6658
+ if (lastContent.startsWith("/img2img")) {
6659
+ const imgArgs = lastContent.slice("/img2img".length).trim();
6660
+ let img2imgModel = "openai/gpt-image-1";
6661
+ let img2imgSize = "1024x1024";
6662
+ let imagePath = null;
6663
+ let maskPath = null;
6664
+ let img2imgPrompt = imgArgs;
6665
+ const imageMatch = imgArgs.match(/--image\s+(\S+)/);
6666
+ if (imageMatch) {
6667
+ imagePath = imageMatch[1];
6668
+ img2imgPrompt = img2imgPrompt.replace(/--image\s+\S+/, "").trim();
6669
+ }
6670
+ const maskMatch = imgArgs.match(/--mask\s+(\S+)/);
6671
+ if (maskMatch) {
6672
+ maskPath = maskMatch[1];
6673
+ img2imgPrompt = img2imgPrompt.replace(/--mask\s+\S+/, "").trim();
6674
+ }
6675
+ const img2imgSizeMatch = imgArgs.match(/--size\s+(\d+x\d+)/);
6676
+ if (img2imgSizeMatch) {
6677
+ img2imgSize = img2imgSizeMatch[1];
6678
+ img2imgPrompt = img2imgPrompt.replace(/--size\s+\d+x\d+/, "").trim();
6679
+ }
6680
+ const img2imgModelMatch = imgArgs.match(/--model\s+(\S+)/);
6681
+ if (img2imgModelMatch) {
6682
+ const raw = img2imgModelMatch[1];
6683
+ const IMG2IMG_ALIASES = {
6684
+ "gpt-image": "openai/gpt-image-1",
6685
+ "gpt-image-1": "openai/gpt-image-1"
6686
+ };
6687
+ img2imgModel = IMG2IMG_ALIASES[raw] ?? raw;
6688
+ img2imgPrompt = img2imgPrompt.replace(/--model\s+\S+/, "").trim();
6689
+ }
6690
+ const usageText = [
6691
+ "Usage: /img2img --image <path> <prompt>",
6692
+ "",
6693
+ "Options:",
6694
+ " --image <path> Source image path (required)",
6695
+ " --mask <path> Mask image path (optional, white = area to edit)",
6696
+ " --model <model> Model (default: gpt-image-1)",
6697
+ " --size <WxH> Output size (default: 1024x1024)",
6698
+ "",
6699
+ "Models:",
6700
+ " gpt-image-1 OpenAI GPT Image 1 \u2014 $0.02/image",
6701
+ "",
6702
+ "Examples:",
6703
+ " /img2img --image ~/photo.png change background to starry sky",
6704
+ " /img2img --image ./cat.jpg --mask ./mask.png remove the background",
6705
+ " /img2img --image /tmp/portrait.png --size 1536x1024 add a hat"
6706
+ ].join("\n");
6707
+ const sendImg2ImgText = (text) => {
6708
+ const completionId = `chatcmpl-img2img-${Date.now()}`;
6709
+ const timestamp = Math.floor(Date.now() / 1e3);
6710
+ if (isStreaming) {
6711
+ res.writeHead(200, {
6712
+ "Content-Type": "text/event-stream",
6713
+ "Cache-Control": "no-cache",
6714
+ Connection: "keep-alive"
6715
+ });
6716
+ res.write(
6717
+ `data: ${JSON.stringify({ id: completionId, object: "chat.completion.chunk", created: timestamp, model: "clawrouter/img2img", choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] })}
6718
+
6719
+ `
6720
+ );
6721
+ res.write(
6722
+ `data: ${JSON.stringify({ id: completionId, object: "chat.completion.chunk", created: timestamp, model: "clawrouter/img2img", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}
6723
+
6724
+ `
6725
+ );
6726
+ res.write("data: [DONE]\n\n");
6727
+ res.end();
6728
+ } else {
6729
+ res.writeHead(200, { "Content-Type": "application/json" });
6730
+ res.end(
6731
+ JSON.stringify({
6732
+ id: completionId,
6733
+ object: "chat.completion",
6734
+ created: timestamp,
6735
+ model: "clawrouter/img2img",
6736
+ choices: [
6737
+ {
6738
+ index: 0,
6739
+ message: { role: "assistant", content: text },
6740
+ finish_reason: "stop"
6741
+ }
6742
+ ],
6743
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
6744
+ })
6745
+ );
6746
+ }
6747
+ };
6748
+ if (!imagePath || !img2imgPrompt) {
6749
+ sendImg2ImgText(usageText);
6750
+ return;
6751
+ }
6752
+ let imageDataUri;
6753
+ let maskDataUri;
6754
+ try {
6755
+ imageDataUri = readImageFileAsDataUri(imagePath);
6756
+ if (maskPath) maskDataUri = readImageFileAsDataUri(maskPath);
6757
+ } catch (fileErr) {
6758
+ const fileErrMsg = fileErr instanceof Error ? fileErr.message : String(fileErr);
6759
+ sendImg2ImgText(`Failed to read image file: ${fileErrMsg}`);
6760
+ return;
6761
+ }
6762
+ console.log(
6763
+ `[ClawRouter] /img2img \u2192 ${img2imgModel} (${img2imgSize}): ${img2imgPrompt.slice(0, 80)}`
6764
+ );
6765
+ try {
6766
+ const img2imgBody = JSON.stringify({
6767
+ model: img2imgModel,
6768
+ prompt: img2imgPrompt,
6769
+ image: imageDataUri,
6770
+ ...maskDataUri ? { mask: maskDataUri } : {},
6771
+ size: img2imgSize,
6772
+ n: 1
6773
+ });
6774
+ const img2imgResponse = await payFetch(`${apiBase}/v1/images/image2image`, {
6775
+ method: "POST",
6776
+ headers: { "content-type": "application/json", "user-agent": USER_AGENT },
6777
+ body: img2imgBody
6778
+ });
6779
+ const img2imgResult = await img2imgResponse.json();
6780
+ let responseText;
6781
+ if (!img2imgResponse.ok || img2imgResult.error) {
6782
+ const errMsg = typeof img2imgResult.error === "string" ? img2imgResult.error : img2imgResult.error?.message ?? `HTTP ${img2imgResponse.status}`;
6783
+ responseText = `Image editing failed: ${errMsg}`;
6784
+ console.log(`[ClawRouter] /img2img error: ${errMsg}`);
6785
+ } else {
6786
+ const images = img2imgResult.data ?? [];
6787
+ if (images.length === 0) {
6788
+ responseText = "Image editing returned no results.";
6789
+ } else {
6790
+ const lines = [];
6791
+ for (const img of images) {
6792
+ if (img.url) {
6793
+ if (img.url.startsWith("data:")) {
6794
+ try {
6795
+ const hostedUrl = await uploadDataUriToHost(img.url);
6796
+ lines.push(hostedUrl);
6797
+ } catch (uploadErr) {
6798
+ console.error(
6799
+ `[ClawRouter] /img2img: failed to upload data URI: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`
6800
+ );
6801
+ lines.push("Image edited but upload failed. Try again.");
6802
+ }
6803
+ } else {
6804
+ lines.push(img.url);
6805
+ }
6806
+ }
6807
+ if (img.revised_prompt) lines.push(`Revised prompt: ${img.revised_prompt}`);
6808
+ }
6809
+ lines.push("", `Model: ${img2imgModel} | Size: ${img2imgSize}`);
6810
+ responseText = lines.join("\n");
6811
+ }
6812
+ console.log(`[ClawRouter] /img2img success: ${images.length} image(s)`);
6813
+ }
6814
+ sendImg2ImgText(responseText);
6815
+ } catch (err) {
6816
+ const errMsg = err instanceof Error ? err.message : String(err);
6817
+ console.error(`[ClawRouter] /img2img error: ${errMsg}`);
6818
+ if (!res.headersSent) {
6819
+ res.writeHead(500, { "Content-Type": "application/json" });
6820
+ res.end(
6821
+ JSON.stringify({
6822
+ error: { message: `Image editing failed: ${errMsg}`, type: "img2img_error" }
6823
+ })
6824
+ );
6825
+ }
6826
+ }
6827
+ return;
6828
+ }
6542
6829
  if (parsed.stream === true) {
6543
6830
  parsed.stream = false;
6544
6831
  bodyModified = true;