@blockrun/clawrouter 0.10.21 → 0.11.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/dist/cli.js CHANGED
@@ -566,6 +566,11 @@ function filterByToolCalling(models, hasTools, supportsToolCalling2) {
566
566
  const filtered = models.filter(supportsToolCalling2);
567
567
  return filtered.length > 0 ? filtered : models;
568
568
  }
569
+ function filterByVision(models, hasVision, supportsVision2) {
570
+ if (!hasVision) return models;
571
+ const filtered = models.filter(supportsVision2);
572
+ return filtered.length > 0 ? filtered : models;
573
+ }
569
574
  function getFallbackChainFiltered(tier, tierConfigs, estimatedTotalTokens, getContextWindow) {
570
575
  const fullChain = getFallbackChain(tier, tierConfigs);
571
576
  const filtered = fullChain.filter((modelId) => {
@@ -1899,6 +1904,8 @@ var MODEL_ALIASES = {
1899
1904
  // Google
1900
1905
  gemini: "google/gemini-2.5-pro",
1901
1906
  flash: "google/gemini-2.5-flash",
1907
+ "gemini-3.1-pro-preview": "google/gemini-3.1-pro",
1908
+ "google/gemini-3.1-pro-preview": "google/gemini-3.1-pro",
1902
1909
  // xAI
1903
1910
  grok: "xai/grok-3",
1904
1911
  "grok-fast": "xai/grok-4-fast-reasoning",
@@ -2138,6 +2145,7 @@ var BLOCKRUN_MODELS = [
2138
2145
  outputPrice: 5,
2139
2146
  contextWindow: 2e5,
2140
2147
  maxOutput: 8192,
2148
+ vision: true,
2141
2149
  agentic: true,
2142
2150
  toolCalling: true
2143
2151
  },
@@ -2150,6 +2158,7 @@ var BLOCKRUN_MODELS = [
2150
2158
  contextWindow: 2e5,
2151
2159
  maxOutput: 64e3,
2152
2160
  reasoning: true,
2161
+ vision: true,
2153
2162
  agentic: true,
2154
2163
  toolCalling: true
2155
2164
  },
@@ -2162,6 +2171,7 @@ var BLOCKRUN_MODELS = [
2162
2171
  contextWindow: 2e5,
2163
2172
  maxOutput: 32e3,
2164
2173
  reasoning: true,
2174
+ vision: true,
2165
2175
  agentic: true,
2166
2176
  toolCalling: true
2167
2177
  },
@@ -2221,6 +2231,7 @@ var BLOCKRUN_MODELS = [
2221
2231
  outputPrice: 2.5,
2222
2232
  contextWindow: 1e6,
2223
2233
  maxOutput: 65536,
2234
+ vision: true,
2224
2235
  toolCalling: true
2225
2236
  },
2226
2237
  {
@@ -2436,6 +2447,11 @@ function supportsToolCalling(modelId) {
2436
2447
  const model = BLOCKRUN_MODELS.find((m) => m.id === normalized);
2437
2448
  return model?.toolCalling ?? false;
2438
2449
  }
2450
+ function supportsVision(modelId) {
2451
+ const normalized = modelId.replace("blockrun/", "");
2452
+ const model = BLOCKRUN_MODELS.find((m) => m.id === normalized);
2453
+ return model?.vision ?? false;
2454
+ }
2439
2455
  function getModelContextWindow(modelId) {
2440
2456
  const normalized = modelId.replace("blockrun/", "");
2441
2457
  const model = BLOCKRUN_MODELS.find((m) => m.id === normalized);
@@ -3889,7 +3905,10 @@ var SessionStore = class {
3889
3905
  tier,
3890
3906
  createdAt: now,
3891
3907
  lastUsedAt: now,
3892
- requestCount: 1
3908
+ requestCount: 1,
3909
+ recentHashes: [],
3910
+ strikes: 0,
3911
+ escalated: false
3893
3912
  });
3894
3913
  }
3895
3914
  }
@@ -3941,6 +3960,43 @@ var SessionStore = class {
3941
3960
  }
3942
3961
  }
3943
3962
  }
3963
+ /**
3964
+ * Record a request content hash and detect repetitive patterns.
3965
+ * Returns true if escalation should be triggered (3+ consecutive similar requests).
3966
+ */
3967
+ recordRequestHash(sessionId, hash) {
3968
+ const entry = this.sessions.get(sessionId);
3969
+ if (!entry) return false;
3970
+ const prev = entry.recentHashes;
3971
+ if (prev.length > 0 && prev[prev.length - 1] === hash) {
3972
+ entry.strikes++;
3973
+ } else {
3974
+ entry.strikes = 0;
3975
+ }
3976
+ entry.recentHashes.push(hash);
3977
+ if (entry.recentHashes.length > 3) {
3978
+ entry.recentHashes.shift();
3979
+ }
3980
+ return entry.strikes >= 2 && !entry.escalated;
3981
+ }
3982
+ /**
3983
+ * Escalate session to next tier. Returns the new model/tier or null if already at max.
3984
+ */
3985
+ escalateSession(sessionId, tierConfigs) {
3986
+ const entry = this.sessions.get(sessionId);
3987
+ if (!entry) return null;
3988
+ const TIER_ORDER = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
3989
+ const currentIdx = TIER_ORDER.indexOf(entry.tier);
3990
+ if (currentIdx < 0 || currentIdx >= TIER_ORDER.length - 1) return null;
3991
+ const nextTier = TIER_ORDER[currentIdx + 1];
3992
+ const nextConfig = tierConfigs[nextTier];
3993
+ if (!nextConfig) return null;
3994
+ entry.model = nextConfig.primary;
3995
+ entry.tier = nextTier;
3996
+ entry.strikes = 0;
3997
+ entry.escalated = true;
3998
+ return { model: nextConfig.primary, tier: nextTier };
3999
+ }
3944
4000
  /**
3945
4001
  * Stop the cleanup interval.
3946
4002
  */
@@ -3967,6 +4023,11 @@ function deriveSessionId(messages) {
3967
4023
  const content = typeof firstUser.content === "string" ? firstUser.content : JSON.stringify(firstUser.content);
3968
4024
  return createHash3("sha256").update(content).digest("hex").slice(0, 8);
3969
4025
  }
4026
+ function hashRequestContent(lastUserContent, toolCallNames) {
4027
+ const normalized = lastUserContent.replace(/\s+/g, " ").trim().slice(0, 500);
4028
+ const toolSuffix = toolCallNames?.length ? `|tools:${toolCallNames.sort().join(",")}` : "";
4029
+ return createHash3("sha256").update(normalized + toolSuffix).digest("hex").slice(0, 12);
4030
+ }
3970
4031
 
3971
4032
  // src/updater.ts
3972
4033
  var NPM_REGISTRY = "https://registry.npmjs.org/@blockrun/clawrouter/latest";
@@ -5097,6 +5158,7 @@ async function proxyRequest(req, res, apiBase, payFetch, options, routerOpts, de
5097
5158
  const debugMode = req.headers["x-clawrouter-debug"] !== "false";
5098
5159
  let routingDecision;
5099
5160
  let hasTools = false;
5161
+ let hasVision = false;
5100
5162
  let isStreaming = false;
5101
5163
  let modelId = "";
5102
5164
  let maxTokens = 4096;
@@ -5238,6 +5300,154 @@ async function proxyRequest(req, res, apiBase, payFetch, options, routerOpts, de
5238
5300
  console.log(`[ClawRouter] /debug command \u2192 ${debugRouting.tier} | ${debugRouting.model}`);
5239
5301
  return;
5240
5302
  }
5303
+ if (lastContent.startsWith("/imagegen")) {
5304
+ const imageArgs = lastContent.slice("/imagegen".length).trim();
5305
+ let imageModel = "google/nano-banana";
5306
+ let imageSize = "1024x1024";
5307
+ let imagePrompt = imageArgs;
5308
+ const modelMatch = imageArgs.match(/--model\s+(\S+)/);
5309
+ if (modelMatch) {
5310
+ const raw = modelMatch[1];
5311
+ const IMAGE_MODEL_ALIASES = {
5312
+ "dall-e-3": "openai/dall-e-3",
5313
+ "dalle3": "openai/dall-e-3",
5314
+ "dalle": "openai/dall-e-3",
5315
+ "gpt-image": "openai/gpt-image-1",
5316
+ "gpt-image-1": "openai/gpt-image-1",
5317
+ "flux": "black-forest/flux-1.1-pro",
5318
+ "flux-pro": "black-forest/flux-1.1-pro",
5319
+ "banana": "google/nano-banana",
5320
+ "nano-banana": "google/nano-banana",
5321
+ "banana-pro": "google/nano-banana-pro",
5322
+ "nano-banana-pro": "google/nano-banana-pro"
5323
+ };
5324
+ imageModel = IMAGE_MODEL_ALIASES[raw] ?? raw;
5325
+ imagePrompt = imagePrompt.replace(/--model\s+\S+/, "").trim();
5326
+ }
5327
+ const sizeMatch = imageArgs.match(/--size\s+(\d+x\d+)/);
5328
+ if (sizeMatch) {
5329
+ imageSize = sizeMatch[1];
5330
+ imagePrompt = imagePrompt.replace(/--size\s+\d+x\d+/, "").trim();
5331
+ }
5332
+ if (!imagePrompt) {
5333
+ const errorText = [
5334
+ "Usage: /imagegen <prompt>",
5335
+ "",
5336
+ "Options:",
5337
+ " --model <model> Model to use (default: nano-banana)",
5338
+ " --size <WxH> Image size (default: 1024x1024)",
5339
+ "",
5340
+ "Models:",
5341
+ " nano-banana Google Gemini Flash \u2014 $0.05/image",
5342
+ " banana-pro Google Gemini Pro \u2014 $0.10/image (up to 4K)",
5343
+ " dall-e-3 OpenAI DALL-E 3 \u2014 $0.04/image",
5344
+ " gpt-image OpenAI GPT Image 1 \u2014 $0.02/image",
5345
+ " flux Black Forest Flux 1.1 Pro \u2014 $0.04/image",
5346
+ "",
5347
+ "Examples:",
5348
+ " /imagegen a cat wearing sunglasses",
5349
+ " /imagegen --model dall-e-3 a futuristic city at sunset",
5350
+ " /imagegen --model banana-pro --size 2048x2048 mountain landscape"
5351
+ ].join("\n");
5352
+ const completionId = `chatcmpl-image-${Date.now()}`;
5353
+ const timestamp = Math.floor(Date.now() / 1e3);
5354
+ if (isStreaming) {
5355
+ res.writeHead(200, {
5356
+ "Content-Type": "text/event-stream",
5357
+ "Cache-Control": "no-cache",
5358
+ Connection: "keep-alive"
5359
+ });
5360
+ res.write(`data: ${JSON.stringify({ id: completionId, object: "chat.completion.chunk", created: timestamp, model: "clawrouter/image", choices: [{ index: 0, delta: { role: "assistant", content: errorText }, finish_reason: null }] })}
5361
+
5362
+ `);
5363
+ res.write(`data: ${JSON.stringify({ id: completionId, object: "chat.completion.chunk", created: timestamp, model: "clawrouter/image", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}
5364
+
5365
+ `);
5366
+ res.write("data: [DONE]\n\n");
5367
+ res.end();
5368
+ } else {
5369
+ res.writeHead(200, { "Content-Type": "application/json" });
5370
+ res.end(JSON.stringify({
5371
+ id: completionId,
5372
+ object: "chat.completion",
5373
+ created: timestamp,
5374
+ model: "clawrouter/image",
5375
+ choices: [{ index: 0, message: { role: "assistant", content: errorText }, finish_reason: "stop" }],
5376
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
5377
+ }));
5378
+ }
5379
+ console.log(`[ClawRouter] /imagegen command \u2192 showing usage help`);
5380
+ return;
5381
+ }
5382
+ console.log(`[ClawRouter] /imagegen command \u2192 ${imageModel} (${imageSize}): ${imagePrompt.slice(0, 80)}...`);
5383
+ try {
5384
+ const imageUpstreamUrl = `${apiBase}/v1/images/generations`;
5385
+ const imageBody = JSON.stringify({ model: imageModel, prompt: imagePrompt, size: imageSize, n: 1 });
5386
+ const imageResponse = await payFetch(imageUpstreamUrl, {
5387
+ method: "POST",
5388
+ headers: { "content-type": "application/json", "user-agent": USER_AGENT },
5389
+ body: imageBody
5390
+ });
5391
+ const imageResult = await imageResponse.json();
5392
+ let responseText;
5393
+ if (!imageResponse.ok || imageResult.error) {
5394
+ const errMsg = typeof imageResult.error === "string" ? imageResult.error : imageResult.error?.message ?? `HTTP ${imageResponse.status}`;
5395
+ responseText = `Image generation failed: ${errMsg}`;
5396
+ console.log(`[ClawRouter] /imagegen error: ${errMsg}`);
5397
+ } else {
5398
+ const images = imageResult.data ?? [];
5399
+ if (images.length === 0) {
5400
+ responseText = "Image generation returned no results.";
5401
+ } else {
5402
+ const lines = [];
5403
+ for (const img of images) {
5404
+ if (img.url) lines.push(`![Generated Image](${img.url})`);
5405
+ if (img.revised_prompt) lines.push(`*Revised prompt: ${img.revised_prompt}*`);
5406
+ }
5407
+ lines.push("", `Model: ${imageModel} | Size: ${imageSize}`);
5408
+ responseText = lines.join("\n");
5409
+ }
5410
+ console.log(`[ClawRouter] /imagegen success: ${images.length} image(s) generated`);
5411
+ }
5412
+ const completionId = `chatcmpl-image-${Date.now()}`;
5413
+ const timestamp = Math.floor(Date.now() / 1e3);
5414
+ if (isStreaming) {
5415
+ res.writeHead(200, {
5416
+ "Content-Type": "text/event-stream",
5417
+ "Cache-Control": "no-cache",
5418
+ Connection: "keep-alive"
5419
+ });
5420
+ res.write(`data: ${JSON.stringify({ id: completionId, object: "chat.completion.chunk", created: timestamp, model: "clawrouter/image", choices: [{ index: 0, delta: { role: "assistant", content: responseText }, finish_reason: null }] })}
5421
+
5422
+ `);
5423
+ res.write(`data: ${JSON.stringify({ id: completionId, object: "chat.completion.chunk", created: timestamp, model: "clawrouter/image", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}
5424
+
5425
+ `);
5426
+ res.write("data: [DONE]\n\n");
5427
+ res.end();
5428
+ } else {
5429
+ res.writeHead(200, { "Content-Type": "application/json" });
5430
+ res.end(JSON.stringify({
5431
+ id: completionId,
5432
+ object: "chat.completion",
5433
+ created: timestamp,
5434
+ model: "clawrouter/image",
5435
+ choices: [{ index: 0, message: { role: "assistant", content: responseText }, finish_reason: "stop" }],
5436
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
5437
+ }));
5438
+ }
5439
+ } catch (err) {
5440
+ const errMsg = err instanceof Error ? err.message : String(err);
5441
+ console.error(`[ClawRouter] /imagegen error: ${errMsg}`);
5442
+ if (!res.headersSent) {
5443
+ res.writeHead(500, { "Content-Type": "application/json" });
5444
+ res.end(JSON.stringify({
5445
+ error: { message: `Image generation failed: ${errMsg}`, type: "image_error" }
5446
+ }));
5447
+ }
5448
+ }
5449
+ return;
5450
+ }
5241
5451
  if (parsed.stream === true) {
5242
5452
  parsed.stream = false;
5243
5453
  bodyModified = true;
@@ -5289,6 +5499,15 @@ async function proxyRequest(req, res, apiBase, payFetch, options, routerOpts, de
5289
5499
  if (hasTools && tools) {
5290
5500
  console.log(`[ClawRouter] Tools detected (${tools.length}), agentic mode via keywords`);
5291
5501
  }
5502
+ hasVision = parsedMessages.some((m) => {
5503
+ if (Array.isArray(m.content)) {
5504
+ return m.content.some((p) => p.type === "image_url");
5505
+ }
5506
+ return false;
5507
+ });
5508
+ if (hasVision) {
5509
+ console.log(`[ClawRouter] Vision content detected, filtering to vision-capable models`);
5510
+ }
5292
5511
  routingDecision = route(prompt, systemPrompt, maxTokens, {
5293
5512
  ...routerOpts,
5294
5513
  routingProfile: routingProfile ?? void 0
@@ -5330,6 +5549,43 @@ async function proxyRequest(req, res, apiBase, payFetch, options, routerOpts, de
5330
5549
  tier: existingSession.tier
5331
5550
  };
5332
5551
  }
5552
+ const lastAssistantMsg = [...parsedMessages].reverse().find((m) => m.role === "assistant");
5553
+ const toolCallNames = Array.isArray(lastAssistantMsg?.tool_calls) ? lastAssistantMsg.tool_calls.map((tc) => tc.function?.name).filter(Boolean) : void 0;
5554
+ const contentHash = hashRequestContent(prompt, toolCallNames);
5555
+ const shouldEscalate = sessionStore.recordRequestHash(
5556
+ effectiveSessionId,
5557
+ contentHash
5558
+ );
5559
+ if (shouldEscalate) {
5560
+ const activeTierConfigs = (() => {
5561
+ if (routingDecision.reasoning?.includes("agentic") && routerOpts.config.agenticTiers) {
5562
+ return routerOpts.config.agenticTiers;
5563
+ }
5564
+ if (routingProfile === "eco" && routerOpts.config.ecoTiers) {
5565
+ return routerOpts.config.ecoTiers;
5566
+ }
5567
+ if (routingProfile === "premium" && routerOpts.config.premiumTiers) {
5568
+ return routerOpts.config.premiumTiers;
5569
+ }
5570
+ return routerOpts.config.tiers;
5571
+ })();
5572
+ const escalation = sessionStore.escalateSession(
5573
+ effectiveSessionId,
5574
+ activeTierConfigs
5575
+ );
5576
+ if (escalation) {
5577
+ console.log(
5578
+ `[ClawRouter] \u26A1 3-strike escalation: ${existingSession.model} \u2192 ${escalation.model} (${existingSession.tier} \u2192 ${escalation.tier})`
5579
+ );
5580
+ parsed.model = escalation.model;
5581
+ modelId = escalation.model;
5582
+ routingDecision = {
5583
+ ...routingDecision,
5584
+ model: escalation.model,
5585
+ tier: escalation.tier
5586
+ };
5587
+ }
5588
+ }
5333
5589
  } else {
5334
5590
  parsed.model = routingDecision.model;
5335
5591
  modelId = routingDecision.model;
@@ -5549,7 +5805,14 @@ async function proxyRequest(req, res, apiBase, payFetch, options, routerOpts, de
5549
5805
  `[ClawRouter] Tool-calling filter: excluded ${toolExcluded.join(", ")} (no structured function call support)`
5550
5806
  );
5551
5807
  }
5552
- modelsToTry = toolFiltered.slice(0, MAX_FALLBACK_ATTEMPTS);
5808
+ const visionFiltered = filterByVision(toolFiltered, hasVision, supportsVision);
5809
+ const visionExcluded = toolFiltered.filter((m) => !visionFiltered.includes(m));
5810
+ if (visionExcluded.length > 0) {
5811
+ console.log(
5812
+ `[ClawRouter] Vision filter: excluded ${visionExcluded.join(", ")} (no vision support)`
5813
+ );
5814
+ }
5815
+ modelsToTry = visionFiltered.slice(0, MAX_FALLBACK_ATTEMPTS);
5553
5816
  modelsToTry = prioritizeNonRateLimited(modelsToTry);
5554
5817
  } else {
5555
5818
  modelsToTry = modelId ? [modelId] : [];
@@ -5595,9 +5858,7 @@ async function proxyRequest(req, res, apiBase, payFetch, options, routerOpts, de
5595
5858
  if (isPaymentErr && tryModel !== FREE_MODEL) {
5596
5859
  const freeIdx = modelsToTry.indexOf(FREE_MODEL);
5597
5860
  if (freeIdx > i + 1) {
5598
- console.log(
5599
- `[ClawRouter] Payment error \u2014 skipping to free model: ${FREE_MODEL}`
5600
- );
5861
+ console.log(`[ClawRouter] Payment error \u2014 skipping to free model: ${FREE_MODEL}`);
5601
5862
  i = freeIdx - 1;
5602
5863
  continue;
5603
5864
  }