Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/brand-numbers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Brand numbers

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

# Fails when a marketing number in this repo disagrees with brand-numbers.json.
#
# --check is deliberately OFFLINE. It compares against the committed snapshot
# and never fetches, so a blockrun.ai deploy in progress cannot fail this repo's
# CI. Pulling a newer artifact is a separate, deliberate act:
#
# node scripts/sync-brand-numbers.mjs --refresh
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: node scripts/sync-brand-numbers.mjs --check
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# hermes-plugin-clawrouter

ClawRouter for [Hermes](https://github.com/NousResearch/hermes-agent) — 55+ LLMs from 9 providers, x402 USDC micropayments, smart routing. One local proxy, one wallet, no API keys.
ClawRouter for [Hermes](https://github.com/NousResearch/hermes-agent) — <!-- br:models.chatVisible -->66<!-- /br:models.chatVisible --> LLMs from 9 providers, x402 USDC micropayments, smart routing. One local proxy, one wallet, no API keys.

Current catalog headliners: Claude Fable 5 / Opus 4.8 / Sonnet 5, GPT-5.6 (Terra / Sol / Luna), Gemini 3.1 Pro / 3.5 Flash, Grok 4.5, DeepSeek V4 Pro, GLM-5.2, Kimi K2.7, MiniMax M3 — plus 8 free NVIDIA-hosted models that cost nothing to run. Image, video, and web-search tools bill through the same wallet.

Expand Down
34 changes: 34 additions & 0 deletions brand-numbers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"$schema": "https://blockrun.ai/brand/numbers.schema.json",
"version": 1,
"models": {
"chatVisible": 66,
"totalVisible": 86,
"free": 8,
"freeWithheld": 17,
"image": 8,
"video": 5,
"music": 1,
"speech": 5,
"soundfx": 1,
"withFallback": 44,
"withFallbackAllEntries": 75
},
"clawrouter": {
"dimensions": 15,
"tiers": 4,
"profiles": 4,
"aliases": 202
},
"mcp": {
"tools": 19
},
"chains": {
"rpc": 40
},
"savings": {
"baselineModel": "anthropic/claude-opus-5",
"ecoVsBaselinePct": 98,
"autoVsBaselinePct": 87
}
}
2 changes: 1 addition & 1 deletion docs/01-vision-analyze-connection-error-custom-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ dominates latency exactly as before.

*If you'd rather not run and maintain your own local proxy, a ready-made option is
[ClawRouter](https://github.com/BlockRunAI/ClawRouter) — a one-command Hermes plugin
that serves a local OpenAI-compatible endpoint and gives you 55+ models — many
that serves a local OpenAI-compatible endpoint and gives you <!-- br:models.chatVisible -->66<!-- /br:models.chatVisible --> models — many
vision-capable — behind it. It's one way to implement Fix 2; the diagnosis above
stands regardless of which proxy you use.*

Expand Down
2 changes: 1 addition & 1 deletion docs/02-oauth-vision-provider-falls-back-to-auto.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ avoiding the broken OAuth branch.

*If you'd rather not assemble and maintain your own proxy,
[ClawRouter](https://github.com/BlockRunAI/ClawRouter) is a ready-made Hermes plugin
that exposes 55+ models (MiniMax included) as a single local `api_key` provider — one
that exposes <!-- br:models.chatVisible -->66<!-- /br:models.chatVisible --> models (MiniMax included) as a single local `api_key` provider — one
way to implement the robust setup above. The diagnosis holds regardless of which proxy
you use; track the upstream fix in
[hermes-agent#38685](https://github.com/NousResearch/hermes-agent/issues/38685).*
Expand Down
6 changes: 3 additions & 3 deletions docs/03-one-endpoint-gpt-claude-gemini-deepseek.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: "Run GPT-5, Claude, Gemini and DeepSeek in Nous Hermes From One Endpoint"
description: "Stop wiring a separate provider, key and OAuth flow for every model in Hermes Agent. Point Hermes at one OpenAI-compatible gateway and switch between 55+ models from the /model picker."
description: "Stop wiring a separate provider, key and OAuth flow for every model in Hermes Agent. Point Hermes at one OpenAI-compatible gateway and switch between <!-- br:models.chatVisible -->66<!-- /br:models.chatVisible --> models from the /model picker."
keywords:
- hermes multiple llm providers
- hermes one endpoint all models
Expand Down Expand Up @@ -76,7 +76,7 @@ blockrun/xai/grok-4.5
blockrun/minimax/minimax-m3
blockrun/moonshot/kimi-k3
blockrun/deepseek/deepseek-v4-pro
…46 curated entries (55+ models via the gateway)
…46 curated entries (<!-- br:models.chatVisible -->66<!-- /br:models.chatVisible --> models via the gateway)
```

Set the model to `blockrun/auto` and the gateway's router picks a model per request
Expand Down Expand Up @@ -167,4 +167,4 @@ Z.AI/GLM, NVIDIA-hosted open models, and more — plus a few free tiers.

*Last reviewed against Hermes Agent v0.18.x. The single-endpoint pattern is provider-
agnostic; ClawRouter is the implementation that adds non-custodial pay-per-call
billing and 55+ models behind the one URL.*
billing and <!-- br:models.chatVisible -->66<!-- /br:models.chatVisible --> models behind the one URL.*
4 changes: 2 additions & 2 deletions docs/04-pay-per-call-llm-no-api-keys-hermes.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: "Pay-Per-Call LLM Access for Hermes Agents — No API Keys, No Subscriptions"
description: "Funding and rotating an API key for every model your Hermes agent uses is a chore and a liability. Here's how to give Hermes keyless, pay-per-call access to 55+ models with USDC micropayments from a wallet you control."
description: "Funding and rotating an API key for every model your Hermes agent uses is a chore and a liability. Here's how to give Hermes keyless, pay-per-call access to <!-- br:models.chatVisible -->66<!-- /br:models.chatVisible --> models with USDC micropayments from a wallet you control."
keywords:
- hermes no api key llm
- hermes pay per use llm
Expand Down Expand Up @@ -49,7 +49,7 @@ Applied to LLM access, that means:

[ClawRouter](https://github.com/BlockRunAI/ClawRouter) implements this for Hermes. It
runs a local OpenAI-compatible gateway that signs an x402 payment for each upstream
call and routes to 55+ models:
call and routes to <!-- br:models.chatVisible -->66<!-- /br:models.chatVisible --> models:

```bash
curl -fsSL https://raw.githubusercontent.com/BlockRunAI/ClawRouter-Hermes/main/scripts/install.sh | bash
Expand Down
270 changes: 270 additions & 0 deletions scripts/sync-brand-numbers.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
#!/usr/bin/env node
/**
* Sync marketing numbers from BlockRun's canonical brand artifact.
*
* This file is copied byte-for-byte into every public repo as
* scripts/sync-brand-numbers.mjs. It is a copy rather than an npm package on
* purpose: a package would mean 37 dependency bumps, and several consuming
* repos have no package.json at all. Zero dependencies, plain Node.
*
* node scripts/sync-brand-numbers.mjs rewrite markers in place
* node scripts/sync-brand-numbers.mjs --check exit 1 on drift, write nothing
* node scripts/sync-brand-numbers.mjs --refresh re-fetch the artifact first
*
* --check NEVER touches the network. PR CI must be deterministic and offline:
* if it fetched, a deploy in progress would fail every repo in the org at once.
* Freshness is the fan-out job's problem, not the pull request's.
*
* Markers look like: <!-- br:models.chatVisible -->66<!-- /br:models.chatVisible -->
* and wrap the WHOLE token, so a badge URL, its alt text and the prose number
* can all regenerate from one key.
*/
import { existsSync, lstatSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
import { join, relative, extname } from "node:path";

const ROOT = process.cwd();
const SNAPSHOT = join(ROOT, "brand-numbers.json");
// ORIGIN is tried first because it IS the truth — the mirror can only ever be
// as fresh as the last time someone refreshed it. The mirror exists so a repo
// can still sync while blockrun.ai is down, not to front the origin.
//
// The mirror is awesome-blockrun's own brand-numbers.json: that repo consumes
// the artifact like every other, and its snapshot doubles as the org's copy.
// One file, one role per repo, nothing to keep in step by hand.
const ORIGIN = "https://blockrun.ai/brand/numbers.json";
const MIRROR =
"https://raw.githubusercontent.com/BlockRunAI/awesome-blockrun/main/brand-numbers.json";

const argv = new Set(process.argv.slice(2));
const check = argv.has("--check");
const refresh = argv.has("--refresh");

const SKIP_DIRS = new Set([
"node_modules", ".git", "dist", "build", "out", ".next", "coverage",
"vendor", "target", "__pycache__", ".venv", "venv",
]);
// .txt is here for llms.txt, which is a first-class marketing surface: it is
// what agents read to find out what BlockRun serves. Scanning other .txt files
// costs a read and changes nothing — only files with markers are ever written.
const TEXT_EXT = new Set([".md", ".mdx", ".txt"]);

/* ── 1. numbers ──────────────────────────────────────────────────────────── */

async function loadNumbers() {
if (!refresh) {
try {
return JSON.parse(readFileSync(SNAPSHOT, "utf8"));
} catch {
fail(
`no brand-numbers.json in ${ROOT}\n` +
` run with --refresh once to seed it from ${ORIGIN}`,
);
}
}
for (const url of [ORIGIN, MIRROR]) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) continue;
const json = await res.json();
writeFileSync(SNAPSHOT, `${JSON.stringify(json, null, 2)}\n`);
return json;
} catch {
/* try the next source */
}
}
fail(`could not refresh from ${MIRROR} or ${ORIGIN}`);
}

/** Flatten nested numbers into dotted keys, ignoring $comment / rationale prose. */
function flatten(obj, prefix = "") {
return Object.entries(obj).flatMap(([k, v]) => {
if (k.startsWith("$")) return [];
const key = `${prefix}${k}`;
if (v && typeof v === "object" && !Array.isArray(v)) return flatten(v, `${key}.`);
if (v === null) return [];
return [[key, v]];
});
}

/* ── 2. renderers ────────────────────────────────────────────────────────── */

/**
* How a key becomes text. Default is the bare value.
*
* A marker may carry an `@modifier` — `<!-- br:mcp.tools@badge -->` — which
* selects a renderer without changing which number is looked up. The modifier
* is what makes a key reusable: the same mcp.tools appears as a shields badge
* at the top of a README and as a bare "19 tools" in a table two screens down,
* and one marker still keeps the badge URL, its alt text and the label in step.
*
* Renderers are registered under the FULL marker name so a badge's label is
* written out rather than guessed from the key.
*/
const badge = (label) => (n) =>
`<img src="https://img.shields.io/badge/${label}-${n}-5B9BF6?style=flat-square&labelColor=0B0A0F" alt="${n} ${label}">`;

const RENDER = {
"mcp.tools@badge": badge("tools"),
"models.totalVisible@badge": badge("models"),
"models.chatVisible@badge": badge("models"),
};
const render = (marker, value) => (RENDER[marker] ?? String)(value);

/** `mcp.tools@badge` looks up `mcp.tools`. Unmodified markers are unaffected. */
const keyOf = (marker) => marker.split("@")[0];

/* ── 3. marker rewriting ─────────────────────────────────────────────────── */

const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const OPEN_ANY = /<!--\s*br:([A-Za-z0-9_.@]+)\s*-->/g;
const CLOSE_ANY = /<!--\s*\/br:([A-Za-z0-9_.@]+)\s*-->/g;

/** Byte ranges of fenced code blocks — markers inside them are documentation. */
function fencedRanges(text) {
const ranges = [];
const fence = /^(\s*)(`{3,}|~{3,})[^\n]*$/gm;
let open = null;
for (let m; (m = fence.exec(text)); ) {
if (open === null) open = m.index;
else {
ranges.push([open, m.index + m[0].length]);
open = null;
}
}
return ranges;
}

function syncFile(file, numbers, problems) {
const before = readFileSync(file, "utf8");
const rel = relative(ROOT, file);
const fenced = fencedRanges(before);
const inFence = (i) => fenced.some(([a, b]) => i >= a && i < b);
const known = new Map(numbers);
const used = new Set();

// Markers actually present, so a file is only ever rewritten for what it uses
// and an @modifier is carried through to the renderer verbatim.
const markers = new Set();
// A marker naming a key that does not exist is an error, never a silent
// no-op: a typo'd marker would otherwise sit there looking synced forever.
for (const [re, shown] of [
[OPEN_ANY, (n) => `<!-- br:${n} -->`],
[CLOSE_ANY, (n) => `<!-- /br:${n} -->`],
]) {
for (const m of before.matchAll(re)) {
if (inFence(m.index)) continue;
markers.add(m[1]);
if (!known.has(keyOf(m[1]))) problems.push(`${rel}: unknown key ${shown(m[1])}`);
}
}

let after = before;
for (const marker of markers) {
const key = keyOf(marker);
if (!known.has(key)) continue;
const value = known.get(key);
const pair = new RegExp(
`(<!--\\s*br:${esc(marker)}\\s*-->)([\\s\\S]*?)(<!--\\s*/br:${esc(marker)}\\s*-->)`,
"g",
);
after = after.replace(pair, (whole, open, inner, close, offset) => {
if (inFence(offset)) return whole;
// Nesting means the closing tag of an inner marker would be consumed by
// the outer one. Refuse rather than produce mangled output.
if (/<!--\s*\/?br:/.test(inner)) {
problems.push(`${rel}: nested marker inside br:${marker}`);
return whole;
}
used.add(marker);
return open + render(marker, value) + close;
});

// An opening tag with no partner silently swallows the rest of the file on
// a naive regex, so catch it explicitly.
const opens = [...before.matchAll(new RegExp(`<!--\\s*br:${esc(marker)}\\s*-->`, "g"))]
.filter((m) => !inFence(m.index)).length;
const closes = [...before.matchAll(new RegExp(`<!--\\s*/br:${esc(marker)}\\s*-->`, "g"))]
.filter((m) => !inFence(m.index)).length;
if (opens !== closes) problems.push(`${rel}: unbalanced marker br:${marker} (${opens} open, ${closes} close)`);
}

return { before, after, changed: before !== after, used };
}

/* ── 4. walk ─────────────────────────────────────────────────────────────── */

function* walk(dir) {
for (const name of readdirSync(dir)) {
if (SKIP_DIRS.has(name)) continue;
const p = join(dir, name);
// lstat, not stat: a symlinked directory is reached by its real path or not
// at all. blockrun's docs/ -> awesome-blockrun/docs is exactly the case that
// matters — following it would edit a submodule's files behind the skip
// below, and a link pointing at an ancestor would recurse forever.
const s = lstatSync(p);
if (s.isSymbolicLink()) continue;
if (s.isDirectory()) {
// A nested repo is a submodule or vendored checkout: it carries its own
// brand-numbers.json and syncs itself. Rewriting its markers from THIS
// repo's snapshot would dirty a submodule nobody asked us to touch, and
// would report drift that belongs to another repo's CI.
if (existsSync(join(p, ".git"))) continue;
yield* walk(p);
} else if (TEXT_EXT.has(extname(name))) yield p;
}
}

function fail(msg) {
console.error(`brand-numbers: ${msg}`);
process.exit(1);
}

/* ── 5. run ──────────────────────────────────────────────────────────────── */

const raw = await loadNumbers();
const numbers = flatten(raw);
const problems = [];
const drifted = [];
const everUsed = new Set();

for (const file of walk(ROOT)) {
const { before, after, changed, used } = syncFile(file, numbers, problems);
used.forEach((k) => everUsed.add(k));
if (!changed) continue;
drifted.push({ file: relative(ROOT, file), before, after });
if (!check) writeFileSync(file, after);
}

if (problems.length) {
for (const p of problems) console.error(` ${p}`);
fail(`${problems.length} marker problem(s)`);
}

if (check) {
if (drifted.length === 0) {
console.log(`brand-numbers: up to date (${everUsed.size} keys in use)`);
process.exit(0);
}
console.error("brand-numbers: these files disagree with brand-numbers.json\n");
for (const { file, before, after } of drifted) {
const b = before.split("\n");
const a = after.split("\n");
for (let i = 0; i < Math.max(b.length, a.length); i++) {
if (b[i] !== a[i]) {
console.error(` ${file}:${i + 1}`);
console.error(` - ${(b[i] ?? "").trim()}`);
console.error(` + ${(a[i] ?? "").trim()}`);
}
}
}
console.error(
"\n fix with: node scripts/sync-brand-numbers.mjs && git commit -am 'chore: sync brand numbers'",
);
process.exit(1);
}

console.log(
drifted.length
? `brand-numbers: updated ${drifted.length} file(s)`
: `brand-numbers: already up to date (${everUsed.size} keys in use)`,
);
Loading
Loading