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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ All notable changes to ClawRouter.
- Endpoint count 84 → 83, removed the Chat section, added the required-param pre-check note up front.
- Fixed param names that the agent would otherwise feed incorrectly and trip the pre-check 400: `social/mindshare` is `q` + `interval` (not `project` + `window`), `search/*` family is `q` (not `query`), `token/holders` + `token/transfers` need `address` AND `chain`, `onchain/gas-price` needs `chain`, `onchain/tx` needs `hash` + `chain`, exchange family universally needs `pair`, market family uses `symbol`, prediction-market endpoints use their specific identifier params (`market_slug`, `event_slug`, `condition_id`, `market_ticker`, `event_ticker`, `ticker`, `address`), and `fund/ranking` + `project/defi/*` need `metric`.
- Reworded the example flows that previously used wrong param names (`search/project?query=ethena` → `?q=ethena`, mindshare example fixed).

- **Phone skill — voice/call `from` is now optional with server-side auto-pick.** blockrun moved `from` from required → optional on `/v1/voice/call`. After payment verification the server picks a caller-ID from the wallet's owned numbers: 0 active → `403 no_active_number` with buy-first hint, exactly 1 → auto-used, 2+ → `400 ambiguous_from` listing all candidates. The prior skill said "Otherwise Bland picks one" — wrong, and would have made agents leave `from` off and confuse users with the 403/400 responses. Updated `skills/phone/SKILL.md` with the actual auto-pick rule table and the ownership-mismatch 403 behavior.
- **No code change for `real_face_asset_id`, no new partner tools, no proxy whitelist change.** Surf + Phone are both base+skill integrations (per the v0.12.193 rule). All blockrun upstream changes here flow through the existing `proxyPaidApiRequest` path transparently.

Expand Down Expand Up @@ -87,7 +88,7 @@ All notable changes to ClawRouter.
- **Bug 1 — phantom $0.54 charge on 4xx voice POST.** First smoke test POSTed `/v1/voice/call` with empty `{}` body to exercise routing without spending money. BlockRun returned 400 (Zod validation: "expected string, received undefined"). The wallet wasn't charged, but the telemetry hook saw `paymentStore.amountUsd = 0` and fell back to `estimatePhoneCost("/v1/voice/call") = $0.54`. Stats would record a phantom voice call. Fix: gate the fallback on `upstream.status` being 2xx — any 4xx/5xx skips the fallback and logs `$0`.
- **Bug 2 — GET poll miscounted as another $0.54 voice call.** After placing a real call, polling `GET /v1/voice/call/{call_id}` for transcript status (free upstream) was being logged at $0.54 because the longest-prefix match on `voice/call/` triggered the same fallback row as the initiating POST. Every 30s poll would inflate stats by $0.54. Fix: also gate the fallback on `req.method === "POST"` — GET polls log `$0`.
- **Refactor**: gate logic was originally inline inside `proxyPaidApiRequest`. Pulled it out into `resolvePhoneTelemetryCost(args)` so the rules are independently testable (the call site is now four lines passing an args bag through the helper). Adds 8 vitest cases covering: paid-amount-wins, 4xx phantom guard, GET poll guard, 5xx guard, missing-method guard, non-phone-passthrough, and the original "successful POST with empty paymentStore → fallback" path. Without the helper extraction, locking these gates in tests would have required a full integration test with a mocked upstream — too heavy for telemetry-only logic.
- **Tests** — new `src/proxy.phone-routing.test.ts` (regex matching for /v1/phone/*, /v1/voice/*, /v1/voice/call/{id} poll, plus negative case for /v1/phonebook), `src/proxy.phone-pricing.test.ts` (longest-prefix matching + the 8 `resolvePhoneTelemetryCost` gate cases above), `src/parse-call-args.test.ts` (both flag forms, quoted task spans, E.164 first-token detection). Total 31 new test cases; all 569 vitest tests green; typecheck + lint clean.
- **Tests** — new `src/proxy.phone-routing.test.ts` (regex matching for /v1/phone/\*, /v1/voice/\*, /v1/voice/call/{id} poll, plus negative case for /v1/phonebook), `src/proxy.phone-pricing.test.ts` (longest-prefix matching + the 8 `resolvePhoneTelemetryCost` gate cases above), `src/parse-call-args.test.ts` (both flag forms, quoted task spans, E.164 first-token detection). Total 31 new test cases; all 569 vitest tests green; typecheck + lint clean.
- **Smoke test record** (free-tier verification before the real call): list-numbers ($0.001) returned an existing wallet-owned number `+15707043521` (PA, expires 2026-06-15); lookup ($0.01) on that same number returned full Twilio carrier metadata (`type: nonFixedVoip`, `carrier_name: Twilio - SMS/MMS-SVR`); negative test `/v1/phonebook/test` correctly rejected by the partner regex (502 from chat-completion fallback rather than partner routing); CLI table formatting + expiry-warning logic verified by `clawrouter phone numbers list`.

---
Expand Down
30 changes: 15 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,16 +307,16 @@ Verify phone numbers and place AI-powered outbound voice calls directly from cha

Calls are **fire-and-forget**: the request returns a `call_id` and `poll_url` immediately. The call itself runs in the cloud for up to 30 minutes. Poll `GET /v1/voice/call/{call_id}` (or `clawrouter share`/transcripts dashboard) to retrieve the transcript and recording when status is `completed`.

| Operation | Provider | Price |
| --------------------------------- | -------- | ---------------------------- |
| Phone lookup (carrier, line type) | Twilio | $0.01 |
| Fraud check (SIM-swap, fwd) | Twilio | $0.05 |
| Buy phone number (30-day lease) | Twilio | $5.00 |
| Renew lease (+30 days) | Twilio | $5.00 |
| List wallet's owned numbers | Twilio | $0.001 |
| Release a number | Twilio | free |
| **AI voice call (≤30 min)** | Bland.ai | **$0.54 flat per call** |
| Poll call status / transcript | Bland.ai | free |
| Operation | Provider | Price |
| --------------------------------- | -------- | ----------------------- |
| Phone lookup (carrier, line type) | Twilio | $0.01 |
| Fraud check (SIM-swap, fwd) | Twilio | $0.05 |
| Buy phone number (30-day lease) | Twilio | $5.00 |
| Renew lease (+30 days) | Twilio | $5.00 |
| List wallet's owned numbers | Twilio | $0.001 |
| Release a number | Twilio | free |
| **AI voice call (≤30 min)** | Bland.ai | **$0.54 flat per call** |
| Poll call status / transcript | Bland.ai | free |

**CLI for wallet-owned numbers:**

Expand Down Expand Up @@ -354,11 +354,11 @@ Surf is BlockRun's unified crypto data API — **84 endpoints across 13 domains*

ClawRouter ships Surf as a **skill, not as typed wrappers**. The proxy whitelists `/v1/surf/*` so any call through the local proxy is paid x402 from the same wallet; the agent reads `skills/surf/SKILL.md` for the endpoint catalog and crafts the HTTP call. No `blockrun_surf_*` tool definitions to maintain; a new Surf endpoint requires zero ClawRouter release.

| Tier | Cost | Examples |
| ---- | --------: | ------------------------------------------------------------- |
| 1 | **$0.001**| prices, rankings, lists, news |
| 2 | **$0.005**| orderbooks, candles, search, wallet details, social mindshare |
| 3 | **$0.020**| on-chain SQL / query / schema, chat completions |
| Tier | Cost | Examples |
| ---- | ---------: | ------------------------------------------------------------- |
| 1 | **$0.001** | prices, rankings, lists, news |
| 2 | **$0.005** | orderbooks, candles, search, wallet details, social mindshare |
| 3 | **$0.020** | on-chain SQL / query / schema, chat completions |

**Usage (HTTP):**

Expand Down
179 changes: 169 additions & 10 deletions scripts/update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -324,32 +324,173 @@ for stale in "$HOME/.openclaw/extensions/clawrouter.backup."* "$HOME/.openclaw/e
[ -d "$stale" ] && rm -rf "$stale"
done

apply_scoped_model_trim() {
local rejected_path="$1"
if [ -z "$rejected_path" ] || [ ! -f "$rejected_path" ] || [ ! -f "$CONFIG_PATH" ]; then
return 1
fi

CONFIG_PATH="$CONFIG_PATH" REJECTED_CONFIG_PATH="$rejected_path" node <<'NODE'
const fs = require('fs');

const activePath = process.env.CONFIG_PATH;
const rejectedPath = process.env.REJECTED_CONFIG_PATH;

function fail(message) {
console.log(` Skipped scoped config trim: ${message}`);
process.exit(1);
}

function byteSize(value) {
return Buffer.byteLength(JSON.stringify(value ?? null, null, 2));
}

function objectKeys(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value).sort() : [];
}

function clone(value) {
return JSON.parse(JSON.stringify(value));
}

function getBlockrunModels(config) {
return config?.models?.providers?.blockrun?.models;
}

const active = JSON.parse(fs.readFileSync(activePath, 'utf8'));
const rejected = JSON.parse(fs.readFileSync(rejectedPath, 'utf8'));

const activeTopKeys = objectKeys(active);
const rejectedTopKeys = objectKeys(rejected);
if (activeTopKeys.join('\0') !== rejectedTopKeys.join('\0')) {
fail('top-level config keys changed');
}

for (const key of ['auth', 'channels', 'gateway', 'plugins', 'models']) {
if (!(key in active) || !(key in rejected)) fail(`missing required ${key} section`);
}

const activeModels = getBlockrunModels(active);
const rejectedModels = getBlockrunModels(rejected);
if (!Array.isArray(activeModels) || !Array.isArray(rejectedModels)) {
fail('blockrun model list is missing or invalid');
}

if (activeModels.length <= rejectedModels.length) {
fail(`model count did not shrink (${activeModels.length} -> ${rejectedModels.length})`);
}

if (rejectedModels.length < 20 || rejectedModels.length > 100) {
fail(`unexpected curated model count (${rejectedModels.length})`);
}

let activeCursor = 0;
for (const model of rejectedModels) {
const id = model?.id;
if (typeof id !== 'string' || id.length === 0) fail('rejected model list contains an invalid id');
const nextIndex = activeModels.findIndex((candidate, index) => index >= activeCursor && candidate?.id === id);
if (nextIndex === -1) fail(`rejected model ${id} is not present in active model list order`);
activeCursor = nextIndex + 1;
}

for (const key of activeTopKeys) {
if (key === 'models') continue;
const delta = Math.abs(byteSize(active[key]) - byteSize(rejected[key]));
if (delta > 2048) fail(`non-model section changed too much: ${key}`);
}

const activeWithoutModelList = clone(active.models);
const rejectedWithoutModelList = clone(rejected.models);
activeWithoutModelList.providers.blockrun.models = [];
rejectedWithoutModelList.providers.blockrun.models = [];
const residualModelDelta = Math.abs(byteSize(activeWithoutModelList) - byteSize(rejectedWithoutModelList));
if (residualModelDelta > 4096) {
fail('models section changed beyond the blockrun model list');
}

const totalDrop = byteSize(active) - byteSize(rejected);
const modelListDrop = byteSize(activeModels) - byteSize(rejectedModels);
if (totalDrop <= 0 || modelListDrop / totalDrop < 0.65) {
fail('size drop is not primarily from the blockrun model list');
}

active.models.providers.blockrun.models = rejectedModels;
const tmpPath = `${activePath}.tmp.${process.pid}`;
fs.writeFileSync(tmpPath, JSON.stringify(active, null, 2));
fs.renameSync(tmpPath, activePath);

console.log(
` ✓ Applied scoped BlockRun model-list trim (${activeModels.length} -> ${rejectedModels.length})`,
);
NODE
}

handle_openclaw_install_failure() {
local exit_code="$1"
if [ "$exit_code" -eq 124 ]; then
echo " (install command timed out — this is normal with OpenClaw v2026.4.5)"
if [ -f "$PLUGIN_DIR/package.json" ]; then
echo " Plugin package.json is present; treating install as completed before the hang."
return 0
fi
echo " Plugin package.json is missing after timeout; continuing with direct npm install."
OPENCLAW_INSTALL_RECOVERABLE=1
return "$exit_code"
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if grep -q "Config write rejected: .*size-drop:" "$OPENCLAW_INSTALL_LOG"; then
local rejected_path
rejected_path=$(node -e "const fs=require('fs'); const text=fs.readFileSync(process.argv[1],'utf8'); const m=text.match(/Rejected payload saved to ([^\\s]+)\\./); if (m) console.log(m[1]);" "$OPENCLAW_INSTALL_LOG")
echo " ⚠ OpenClaw rejected a config size-drop during plugin registration."
if apply_scoped_model_trim "$rejected_path"; then
echo " Continuing with direct npm install after the scoped config update."
else
echo " Continuing with direct npm install while preserving your existing config."
fi
OPENCLAW_INSTALL_RECOVERABLE=1
return 0
fi

return "$exit_code"
}

echo "→ Installing latest ClawRouter..."
# `--force` is required when the plugin is already installed at the same path
# (which is always true on update). Without it, OpenClaw exits non-zero with
# "plugin already exists" and our EXIT trap rolls back, stranding the user on
# the previous version. `--force` is idempotent for both fresh + upgrade flows.
#
# OpenClaw can also reject its own config rewrite when it would shrink a large
# user config. Treat that as recoverable: keep the user's restored config and
# install ClawRouter files directly from npm below.
OPENCLAW_INSTALL_RECOVERABLE=0
OPENCLAW_INSTALL_LOG="$(mktemp)"
#
# Run with timeout — openclaw plugins install may hang after printing
# "Installed plugin: clawrouter" in OpenClaw v2026.4.5 (parallel plugin loading).
# 120s is enough for slow connections; the install itself completes in ~30s.
if command -v timeout >/dev/null 2>&1; then
timeout 120 openclaw plugins install --force @blockrun/clawrouter || {
timeout 120 openclaw plugins install --force @blockrun/clawrouter 2>&1 | tee "$OPENCLAW_INSTALL_LOG" || {
exit_code=$?
if [ $exit_code -eq 124 ]; then
echo " (install command timed out — this is normal with OpenClaw v2026.4.5)"
echo " Plugin was installed successfully before the hang."
else
exit $exit_code
fi
handle_openclaw_install_failure "$exit_code" || {
[ "$OPENCLAW_INSTALL_RECOVERABLE" = "1" ] || exit $exit_code
}
}
else
openclaw plugins install --force @blockrun/clawrouter
openclaw plugins install --force @blockrun/clawrouter 2>&1 | tee "$OPENCLAW_INSTALL_LOG" || {
exit_code=$?
handle_openclaw_install_failure "$exit_code" || {
[ "$OPENCLAW_INSTALL_RECOVERABLE" = "1" ] || exit $exit_code
}
}
fi
rm -f "$OPENCLAW_INSTALL_LOG"

# Install is complete — clear the rollback trap immediately.
# From this point on, Ctrl+C or errors should NOT roll back the install.
trap - EXIT INT TERM
if [ "$OPENCLAW_INSTALL_RECOVERABLE" != "1" ]; then
trap - EXIT INT TERM
fi

# Restore credentials after plugin install (always restore to preserve user's channels)
if [ -n "$CREDS_BACKUP" ] && [ -d "$CREDS_BACKUP" ]; then
Expand Down Expand Up @@ -422,7 +563,25 @@ force_install_from_npm() {
return 1
}

if [ -d "$PLUGIN_DIR" ] && [ -f "$PLUGIN_DIR/package.json" ]; then
if [ "$OPENCLAW_INSTALL_RECOVERABLE" = "1" ]; then
LATEST_VER=$(npm view @blockrun/clawrouter@latest version 2>/dev/null || echo "")
if [ -z "$LATEST_VER" ]; then
echo " ✗ Could not resolve latest ClawRouter version from npm"
exit 1
fi
force_install_from_npm "$LATEST_VER"
if [ ! -f "$PLUGIN_DIR/package.json" ]; then
echo " ✗ ClawRouter package.json missing after npm fallback"
exit 1
fi
INSTALLED_VER=$(node -e "try{const p=require('$PLUGIN_DIR/package.json');console.log(p.version);}catch{console.log('');}" 2>/dev/null || echo "")
if [ -z "$INSTALLED_VER" ]; then
echo " ✗ Could not verify ClawRouter version after npm fallback"
exit 1
fi
echo " ✓ ClawRouter v${INSTALLED_VER} installed"
trap - EXIT INT TERM
elif [ -d "$PLUGIN_DIR" ] && [ -f "$PLUGIN_DIR/package.json" ]; then
INSTALLED_VER=$(node -e "try{const p=require('$PLUGIN_DIR/package.json');console.log(p.version);}catch{console.log('');}" 2>/dev/null || echo "")
LATEST_VER=$(npm view @blockrun/clawrouter@latest version 2>/dev/null || echo "")
if [ -n "$LATEST_VER" ] && [ -n "$INSTALLED_VER" ] && [ "$INSTALLED_VER" != "$LATEST_VER" ]; then
Expand Down
Loading
Loading