@blockrun/clawrouter 0.12.31 → 0.12.33

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,27 @@ 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). ClawRouter reads the local file, converts it to base64, and sends it to BlockRun's `/v1/images/image2image` endpoint with automatic x402 payment.
123
+
124
+ **API endpoint:** `POST http://localhost:8402/v1/images/image2image` is also available for programmatic use — see [Image Generation docs](docs/image-generation.md#post-v1imagesimage2image) for API reference and code examples.
125
+
105
126
  ---
106
127
 
107
128
  ## ⚡ How It Works
@@ -330,17 +351,17 @@ npm test
330
351
 
331
352
  ## 📚 More Resources
332
353
 
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 |
354
+ | Resource | Description |
355
+ | ------------------------------------------------------ | ------------------------ |
356
+ | [Documentation](https://blockrun.ai/docs) | Full docs |
357
+ | [Model Pricing](https://blockrun.ai/models) | All models & prices |
358
+ | [Image Generation & Editing](docs/image-generation.md) | API examples, 5 models |
359
+ | [Routing Profiles](docs/routing-profiles.md) | ECO/AUTO/PREMIUM details |
360
+ | [Architecture](docs/architecture.md) | Technical deep dive |
361
+ | [Configuration](docs/configuration.md) | Environment variables |
362
+ | [vs OpenRouter](docs/vs-openrouter.md) | Why ClawRouter wins |
363
+ | [Features](docs/features.md) | All features |
364
+ | [Troubleshooting](docs/troubleshooting.md) | Common issues |
344
365
 
345
366
  ---
346
367
 
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");
@@ -5887,14 +5904,31 @@ async function startProxy(options) {
5887
5904
  await mkdir3(IMAGE_DIR, { recursive: true });
5888
5905
  const port2 = server.address()?.port ?? 8402;
5889
5906
  for (const img of result.data) {
5890
- const m = img.url?.match(/^data:(image\/\w+);base64,(.+)$/);
5891
- if (m) {
5892
- const [, mimeType, b64] = m;
5907
+ const dataUriMatch = img.url?.match(/^data:(image\/\w+);base64,(.+)$/);
5908
+ if (dataUriMatch) {
5909
+ const [, mimeType, b64] = dataUriMatch;
5893
5910
  const ext = mimeType === "image/jpeg" ? "jpg" : mimeType.split("/")[1] ?? "png";
5894
5911
  const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}.${ext}`;
5895
5912
  await writeFile2(join5(IMAGE_DIR, filename), Buffer.from(b64, "base64"));
5896
5913
  img.url = `http://localhost:${port2}/images/${filename}`;
5897
5914
  console.log(`[ClawRouter] Image saved \u2192 ${img.url}`);
5915
+ } else if (img.url?.startsWith("https://") || img.url?.startsWith("http://")) {
5916
+ try {
5917
+ const imgResp = await fetch(img.url);
5918
+ if (imgResp.ok) {
5919
+ const contentType = imgResp.headers.get("content-type") ?? "image/png";
5920
+ const ext = contentType.includes("jpeg") || contentType.includes("jpg") ? "jpg" : contentType.includes("webp") ? "webp" : "png";
5921
+ const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}.${ext}`;
5922
+ const buf = Buffer.from(await imgResp.arrayBuffer());
5923
+ await writeFile2(join5(IMAGE_DIR, filename), buf);
5924
+ img.url = `http://localhost:${port2}/images/${filename}`;
5925
+ console.log(`[ClawRouter] Image downloaded & saved \u2192 ${img.url}`);
5926
+ }
5927
+ } catch (downloadErr) {
5928
+ console.warn(
5929
+ `[ClawRouter] Failed to download image, using original URL: ${downloadErr instanceof Error ? downloadErr.message : String(downloadErr)}`
5930
+ );
5931
+ }
5898
5932
  }
5899
5933
  }
5900
5934
  }
@@ -5910,6 +5944,76 @@ async function startProxy(options) {
5910
5944
  }
5911
5945
  return;
5912
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 reqBody = Buffer.concat(chunks);
5953
+ try {
5954
+ const upstream = await payFetch(`${apiBase}/v1/images/image2image`, {
5955
+ method: "POST",
5956
+ headers: { "content-type": "application/json", "user-agent": USER_AGENT },
5957
+ body: reqBody
5958
+ });
5959
+ const text = await upstream.text();
5960
+ if (!upstream.ok) {
5961
+ res.writeHead(upstream.status, { "Content-Type": "application/json" });
5962
+ res.end(text);
5963
+ return;
5964
+ }
5965
+ let result;
5966
+ try {
5967
+ result = JSON.parse(text);
5968
+ } catch {
5969
+ res.writeHead(200, { "Content-Type": "application/json" });
5970
+ res.end(text);
5971
+ return;
5972
+ }
5973
+ if (result.data?.length) {
5974
+ await mkdir3(IMAGE_DIR, { recursive: true });
5975
+ const port2 = server.address()?.port ?? 8402;
5976
+ for (const img of result.data) {
5977
+ const dataUriMatch = img.url?.match(/^data:(image\/\w+);base64,(.+)$/);
5978
+ if (dataUriMatch) {
5979
+ const [, mimeType, b64] = dataUriMatch;
5980
+ const ext = mimeType === "image/jpeg" ? "jpg" : mimeType.split("/")[1] ?? "png";
5981
+ const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}.${ext}`;
5982
+ await writeFile2(join5(IMAGE_DIR, filename), Buffer.from(b64, "base64"));
5983
+ img.url = `http://localhost:${port2}/images/${filename}`;
5984
+ console.log(`[ClawRouter] Image saved \u2192 ${img.url}`);
5985
+ } else if (img.url?.startsWith("https://") || img.url?.startsWith("http://")) {
5986
+ try {
5987
+ const imgResp = await fetch(img.url);
5988
+ if (imgResp.ok) {
5989
+ const contentType = imgResp.headers.get("content-type") ?? "image/png";
5990
+ const ext = contentType.includes("jpeg") || contentType.includes("jpg") ? "jpg" : contentType.includes("webp") ? "webp" : "png";
5991
+ const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}.${ext}`;
5992
+ const buf = Buffer.from(await imgResp.arrayBuffer());
5993
+ await writeFile2(join5(IMAGE_DIR, filename), buf);
5994
+ img.url = `http://localhost:${port2}/images/${filename}`;
5995
+ console.log(`[ClawRouter] Image downloaded & saved \u2192 ${img.url}`);
5996
+ }
5997
+ } catch (downloadErr) {
5998
+ console.warn(
5999
+ `[ClawRouter] Failed to download image, using original URL: ${downloadErr instanceof Error ? downloadErr.message : String(downloadErr)}`
6000
+ );
6001
+ }
6002
+ }
6003
+ }
6004
+ }
6005
+ res.writeHead(200, { "Content-Type": "application/json" });
6006
+ res.end(JSON.stringify(result));
6007
+ } catch (err) {
6008
+ const msg = err instanceof Error ? err.message : String(err);
6009
+ console.error(`[ClawRouter] Image editing error: ${msg}`);
6010
+ if (!res.headersSent) {
6011
+ res.writeHead(502, { "Content-Type": "application/json" });
6012
+ res.end(JSON.stringify({ error: "Image editing failed", details: msg }));
6013
+ }
6014
+ }
6015
+ return;
6016
+ }
5913
6017
  if (req.url?.match(/^\/v1\/(?:x|partner)\//)) {
5914
6018
  try {
5915
6019
  await proxyPartnerRequest(req, res, apiBase, payFetch);
@@ -6524,6 +6628,177 @@ async function proxyRequest(req, res, apiBase, payFetch, options, routerOpts, de
6524
6628
  }
6525
6629
  return;
6526
6630
  }
6631
+ if (lastContent.startsWith("/img2img")) {
6632
+ const imgArgs = lastContent.slice("/img2img".length).trim();
6633
+ let img2imgModel = "openai/gpt-image-1";
6634
+ let img2imgSize = "1024x1024";
6635
+ let imagePath = null;
6636
+ let maskPath = null;
6637
+ let img2imgPrompt = imgArgs;
6638
+ const imageMatch = imgArgs.match(/--image\s+(\S+)/);
6639
+ if (imageMatch) {
6640
+ imagePath = imageMatch[1];
6641
+ img2imgPrompt = img2imgPrompt.replace(/--image\s+\S+/, "").trim();
6642
+ }
6643
+ const maskMatch = imgArgs.match(/--mask\s+(\S+)/);
6644
+ if (maskMatch) {
6645
+ maskPath = maskMatch[1];
6646
+ img2imgPrompt = img2imgPrompt.replace(/--mask\s+\S+/, "").trim();
6647
+ }
6648
+ const img2imgSizeMatch = imgArgs.match(/--size\s+(\d+x\d+)/);
6649
+ if (img2imgSizeMatch) {
6650
+ img2imgSize = img2imgSizeMatch[1];
6651
+ img2imgPrompt = img2imgPrompt.replace(/--size\s+\d+x\d+/, "").trim();
6652
+ }
6653
+ const img2imgModelMatch = imgArgs.match(/--model\s+(\S+)/);
6654
+ if (img2imgModelMatch) {
6655
+ const raw = img2imgModelMatch[1];
6656
+ const IMG2IMG_ALIASES = {
6657
+ "gpt-image": "openai/gpt-image-1",
6658
+ "gpt-image-1": "openai/gpt-image-1"
6659
+ };
6660
+ img2imgModel = IMG2IMG_ALIASES[raw] ?? raw;
6661
+ img2imgPrompt = img2imgPrompt.replace(/--model\s+\S+/, "").trim();
6662
+ }
6663
+ const usageText = [
6664
+ "Usage: /img2img --image <path> <prompt>",
6665
+ "",
6666
+ "Options:",
6667
+ " --image <path> Source image path (required)",
6668
+ " --mask <path> Mask image path (optional, white = area to edit)",
6669
+ " --model <model> Model (default: gpt-image-1)",
6670
+ " --size <WxH> Output size (default: 1024x1024)",
6671
+ "",
6672
+ "Models:",
6673
+ " gpt-image-1 OpenAI GPT Image 1 \u2014 $0.02/image",
6674
+ "",
6675
+ "Examples:",
6676
+ " /img2img --image ~/photo.png change background to starry sky",
6677
+ " /img2img --image ./cat.jpg --mask ./mask.png remove the background",
6678
+ " /img2img --image /tmp/portrait.png --size 1536x1024 add a hat"
6679
+ ].join("\n");
6680
+ const sendImg2ImgText = (text) => {
6681
+ const completionId = `chatcmpl-img2img-${Date.now()}`;
6682
+ const timestamp = Math.floor(Date.now() / 1e3);
6683
+ if (isStreaming) {
6684
+ res.writeHead(200, {
6685
+ "Content-Type": "text/event-stream",
6686
+ "Cache-Control": "no-cache",
6687
+ Connection: "keep-alive"
6688
+ });
6689
+ res.write(
6690
+ `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 }] })}
6691
+
6692
+ `
6693
+ );
6694
+ res.write(
6695
+ `data: ${JSON.stringify({ id: completionId, object: "chat.completion.chunk", created: timestamp, model: "clawrouter/img2img", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}
6696
+
6697
+ `
6698
+ );
6699
+ res.write("data: [DONE]\n\n");
6700
+ res.end();
6701
+ } else {
6702
+ res.writeHead(200, { "Content-Type": "application/json" });
6703
+ res.end(
6704
+ JSON.stringify({
6705
+ id: completionId,
6706
+ object: "chat.completion",
6707
+ created: timestamp,
6708
+ model: "clawrouter/img2img",
6709
+ choices: [
6710
+ {
6711
+ index: 0,
6712
+ message: { role: "assistant", content: text },
6713
+ finish_reason: "stop"
6714
+ }
6715
+ ],
6716
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
6717
+ })
6718
+ );
6719
+ }
6720
+ };
6721
+ if (!imagePath || !img2imgPrompt) {
6722
+ sendImg2ImgText(usageText);
6723
+ return;
6724
+ }
6725
+ let imageDataUri;
6726
+ let maskDataUri;
6727
+ try {
6728
+ imageDataUri = readImageFileAsDataUri(imagePath);
6729
+ if (maskPath) maskDataUri = readImageFileAsDataUri(maskPath);
6730
+ } catch (fileErr) {
6731
+ const fileErrMsg = fileErr instanceof Error ? fileErr.message : String(fileErr);
6732
+ sendImg2ImgText(`Failed to read image file: ${fileErrMsg}`);
6733
+ return;
6734
+ }
6735
+ console.log(
6736
+ `[ClawRouter] /img2img \u2192 ${img2imgModel} (${img2imgSize}): ${img2imgPrompt.slice(0, 80)}`
6737
+ );
6738
+ try {
6739
+ const img2imgBody = JSON.stringify({
6740
+ model: img2imgModel,
6741
+ prompt: img2imgPrompt,
6742
+ image: imageDataUri,
6743
+ ...maskDataUri ? { mask: maskDataUri } : {},
6744
+ size: img2imgSize,
6745
+ n: 1
6746
+ });
6747
+ const img2imgResponse = await payFetch(`${apiBase}/v1/images/image2image`, {
6748
+ method: "POST",
6749
+ headers: { "content-type": "application/json", "user-agent": USER_AGENT },
6750
+ body: img2imgBody
6751
+ });
6752
+ const img2imgResult = await img2imgResponse.json();
6753
+ let responseText;
6754
+ if (!img2imgResponse.ok || img2imgResult.error) {
6755
+ const errMsg = typeof img2imgResult.error === "string" ? img2imgResult.error : img2imgResult.error?.message ?? `HTTP ${img2imgResponse.status}`;
6756
+ responseText = `Image editing failed: ${errMsg}`;
6757
+ console.log(`[ClawRouter] /img2img error: ${errMsg}`);
6758
+ } else {
6759
+ const images = img2imgResult.data ?? [];
6760
+ if (images.length === 0) {
6761
+ responseText = "Image editing returned no results.";
6762
+ } else {
6763
+ const lines = [];
6764
+ for (const img of images) {
6765
+ if (img.url) {
6766
+ if (img.url.startsWith("data:")) {
6767
+ try {
6768
+ const hostedUrl = await uploadDataUriToHost(img.url);
6769
+ lines.push(hostedUrl);
6770
+ } catch (uploadErr) {
6771
+ console.error(
6772
+ `[ClawRouter] /img2img: failed to upload data URI: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`
6773
+ );
6774
+ lines.push("Image edited but upload failed. Try again.");
6775
+ }
6776
+ } else {
6777
+ lines.push(img.url);
6778
+ }
6779
+ }
6780
+ if (img.revised_prompt) lines.push(`Revised prompt: ${img.revised_prompt}`);
6781
+ }
6782
+ lines.push("", `Model: ${img2imgModel} | Size: ${img2imgSize}`);
6783
+ responseText = lines.join("\n");
6784
+ }
6785
+ console.log(`[ClawRouter] /img2img success: ${images.length} image(s)`);
6786
+ }
6787
+ sendImg2ImgText(responseText);
6788
+ } catch (err) {
6789
+ const errMsg = err instanceof Error ? err.message : String(err);
6790
+ console.error(`[ClawRouter] /img2img error: ${errMsg}`);
6791
+ if (!res.headersSent) {
6792
+ res.writeHead(500, { "Content-Type": "application/json" });
6793
+ res.end(
6794
+ JSON.stringify({
6795
+ error: { message: `Image editing failed: ${errMsg}`, type: "img2img_error" }
6796
+ })
6797
+ );
6798
+ }
6799
+ }
6800
+ return;
6801
+ }
6527
6802
  if (parsed.stream === true) {
6528
6803
  parsed.stream = false;
6529
6804
  bodyModified = true;
@@ -7899,7 +8174,7 @@ ClawRouter Partner APIs (v${VERSION})
7899
8174
  wallet,
7900
8175
  port: args.port,
7901
8176
  onReady: (port) => {
7902
- console.log(`[ClawRouter] Proxy listening on http://127.0.0.1:${port}`);
8177
+ console.log(`[ClawRouter] v${VERSION} | Proxy listening on http://127.0.0.1:${port}`);
7903
8178
  console.log(`[ClawRouter] Health check: http://127.0.0.1:${port}/health`);
7904
8179
  },
7905
8180
  onError: (error) => {