diff --git a/README.md b/README.md index e6988ff..def0bee 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,25 @@ For basic usage, no configuration needed. For advanced options: | `CLAWROUTER_DISABLED` | `false` | Disable smart routing | | `BLOCKRUN_PROXY_PORT` | `8402` | Proxy port | | `BLOCKRUN_WALLET_KEY` | auto | Wallet private key | +| `BLOCKRUN_PAYMENT_MODE` | `wallet` | Payment backend: `wallet` or `clawcredit` | +| `CLAWCREDIT_API_TOKEN` | - | Required when `BLOCKRUN_PAYMENT_MODE=clawcredit` | +| `CLAWCREDIT_BASE_URL` | `https://api.claw.credit` | Claw Credit API base URL | +| `CLAWCREDIT_PAYMENT_CHAIN` | `BASE` | Chain passed to claw.credit `transaction.chain` | +| `CLAWCREDIT_PAYMENT_ASSET` | Base USDC | Asset passed to claw.credit `transaction.asset` | + +To pay BlockRun inference via claw.credit instead of local wallet signing: + +```bash +export BLOCKRUN_PAYMENT_MODE=clawcredit +export CLAWCREDIT_API_TOKEN=claw_xxx +``` + +OpenClaw automatically loads `~/.openclaw/.env` on startup. If you want a one-command setup (no manual `export`), run: + +```bash +# Installs/enables the plugin (if needed), writes ~/.openclaw/.env, restarts gateway +bash ~/.openclaw/extensions/clawrouter/scripts/setup-clawcredit.sh +``` **Full reference:** [docs/configuration.md](docs/configuration.md) diff --git a/docs/configuration.md b/docs/configuration.md index d7ff92f..312bcdc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,6 +22,13 @@ Complete reference for ClawRouter configuration options. | --------------------- | ------- | ------------------------------------------------------------------------ | | `BLOCKRUN_WALLET_KEY` | - | Ethereum private key (hex, 0x-prefixed). Used if no saved wallet exists. | | `BLOCKRUN_PROXY_PORT` | `8402` | Port for the local x402 proxy server. | +| `BLOCKRUN_PAYMENT_MODE` | `wallet` | Payment backend: `wallet` (local x402 signing) or `clawcredit` (claw.credit) | +| `CLAWCREDIT_API_TOKEN` | - | Required when `BLOCKRUN_PAYMENT_MODE=clawcredit` | +| `CLAWCREDIT_BASE_URL` | `https://api.claw.credit` | claw.credit API base URL | +| `CLAWCREDIT_PAYMENT_CHAIN` | `BASE` | Chain passed to claw.credit `transaction.chain` | +| `CLAWCREDIT_PAYMENT_ASSET` | Base USDC | Asset passed to claw.credit `transaction.asset` | + +> **Tip:** OpenClaw loads environment variables from `~/.openclaw/.env` on startup. You can put the variables above there (or run `scripts/setup-clawcredit.sh`). ### BLOCKRUN_WALLET_KEY diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 958e079..b3d9276 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -9,6 +9,11 @@ "type": "string", "description": "EVM wallet private key (0x...). Optional — auto-generated if not set." }, + "paymentMode": { + "type": "string", + "enum": ["wallet", "clawcredit"], + "description": "Payment backend. Defaults to wallet; set clawcredit to route payments via claw.credit." + }, "routing": { "type": "object", "description": "Override default routing configuration" diff --git a/openclaw.security.json b/openclaw.security.json index f0c25a3..e713fb6 100644 --- a/openclaw.security.json +++ b/openclaw.security.json @@ -11,6 +11,14 @@ "justification": "ClawRouter uses this wallet key to sign USDC payment transactions on Base L2. The key is used LOCALLY for cryptographic signing and is NEVER transmitted over the network. This is required for x402 protocol compliance.", "dataFlow": "local-only", "networkTransmission": false + }, + { + "type": "env-access", + "variable": "CLAWCREDIT_API_TOKEN", + "purpose": "authenticate claw.credit payment requests", + "justification": "When BLOCKRUN_PAYMENT_MODE=clawcredit is enabled, ClawRouter forwards payment authorization requests to claw.credit /v1/transaction/pay and must send this bearer token to authenticate the request.", + "dataFlow": "sent-to-claw-credit-api", + "networkTransmission": true } ], "securityNotes": [ diff --git a/package-lock.json b/package-lock.json index 8ba3773..f2b13cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.9.12", "license": "MIT", "dependencies": { + "@t54-labs/clawcredit-sdk": "^0.2.40", "viem": "^2.39.3" }, "bin": { @@ -5550,6 +5551,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@t54-labs/clawcredit-sdk": { + "version": "0.2.40", + "resolved": "https://registry.npmjs.org/@t54-labs/clawcredit-sdk/-/clawcredit-sdk-0.2.40.tgz", + "integrity": "sha512-rhqStBLloliN387W0Y7kkrgkdLwTAwX0kRUkBjy/NtO/K6++0iaFORPU9x5wpo8GeoJTdI9bZDRBj9GFSkSTZw==", + "license": "MIT", + "dependencies": { + "axios": "^1.6.0", + "uuid": "^9.0.0" + }, + "bin": { + "clawcredit": "bin/clawcredit.js", + "clawcredit-verify": "bin/verify.js" + } + }, "node_modules/@tinyhttp/content-disposition": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/@tinyhttp/content-disposition/-/content-disposition-2.2.4.tgz", @@ -6490,7 +6505,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/atomic-sleep": { @@ -6507,7 +6521,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", - "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -6706,7 +6719,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7146,7 +7158,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -7416,7 +7427,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -7546,7 +7556,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -7626,7 +7635,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7636,7 +7644,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7653,7 +7660,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -7666,7 +7672,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8422,7 +8427,6 @@ "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "dev": true, "funding": [ { "type": "individual", @@ -8473,7 +8477,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -8582,7 +8585,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8693,7 +8695,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -8718,7 +8719,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -8836,7 +8836,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8917,7 +8916,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8930,7 +8928,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -8967,7 +8964,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -9977,7 +9973,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10028,7 +10023,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -10038,7 +10032,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -11526,7 +11519,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true, "license": "MIT" }, "node_modules/punycode": { @@ -13302,6 +13294,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/validate-npm-package-name": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-6.0.2.tgz", diff --git a/package.json b/package.json index 278e497..c883d9e 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "url": "git+https://github.com/BlockRunAI/ClawRouter.git" }, "dependencies": { + "@t54-labs/clawcredit-sdk": "^0.2.40", "viem": "^2.39.3" }, "peerDependencies": { diff --git a/scripts/setup-clawcredit.sh b/scripts/setup-clawcredit.sh new file mode 100755 index 0000000..2c1d5a2 --- /dev/null +++ b/scripts/setup-clawcredit.sh @@ -0,0 +1,306 @@ +#!/usr/bin/env bash +set -euo pipefail + +PLUGIN_PKG="@blockrun/clawrouter" +PLUGIN_ID="clawrouter" + +DEFAULT_CLAWCREDIT_BASE_URL="https://api.claw.credit" +DEFAULT_CHAIN="BASE" +DEFAULT_ASSET_BASE_USDC="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + +usage() { + cat <<'EOF' +ClawRouter claw.credit Setup + +This script configures OpenClaw to pay BlockRun inference via claw.credit (SDK-backed). + +What it does: + - Installs/enables @blockrun/clawrouter (if needed) + - Writes/updates OpenClaw's global env file (~/.openclaw/.env) + - Restarts the OpenClaw gateway service + +Usage: + setup-clawcredit.sh [options] + +Options: + --token CLAWCREDIT_API_TOKEN (if omitted: tries to read from clawcredit.json; else prompts) + --agent OpenClaw agent id to read clawcredit.json from (default: main) + --chain Payment chain passed to claw.credit (default: BASE) + --asset Payment asset passed to claw.credit (default: Base USDC) + --base-url claw.credit API base URL (default: https://api.claw.credit) + --profile OpenClaw profile name (uses ~/.openclaw-) + --no-restart Do not restart the gateway + --dry-run Print actions without writing/exec'ing + -h, --help Show help + +Examples: + bash setup-clawcredit.sh + bash setup-clawcredit.sh --token claw_xxx + bash setup-clawcredit.sh --chain XRPL --asset USDC +EOF +} + +log() { printf '%s\n' "$*"; } +warn() { printf 'WARN: %s\n' "$*" >&2; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" +} + +is_tty() { + [[ -t 0 && -t 1 ]] +} + +resolve_state_dir() { + local profile="${1:-}" + local home="${HOME}" + + if [[ -n "${OPENCLAW_STATE_DIR:-}" ]]; then + printf '%s' "${OPENCLAW_STATE_DIR}" + return 0 + fi + + if [[ -n "$profile" ]]; then + printf '%s' "${home}/.openclaw-${profile}" + return 0 + fi + + if [[ -d "${home}/.openclaw" ]]; then + printf '%s' "${home}/.openclaw" + return 0 + fi + + if [[ -d "${home}/.moltbot" ]]; then + printf '%s' "${home}/.moltbot" + return 0 + fi + + # Default fallback (OpenClaw will create it on demand). + printf '%s' "${home}/.openclaw" +} + +OPENCLAW_PROFILE="" +AGENT_ID="main" +CLAWCREDIT_API_TOKEN="${CLAWCREDIT_API_TOKEN:-}" +CLAWCREDIT_PAYMENT_CHAIN="${CLAWCREDIT_PAYMENT_CHAIN:-}" +CLAWCREDIT_PAYMENT_ASSET="${CLAWCREDIT_PAYMENT_ASSET:-}" +CLAWCREDIT_BASE_URL="${CLAWCREDIT_BASE_URL:-}" +NO_RESTART="0" +DRY_RUN="0" + +while [[ $# -gt 0 ]]; do + case "$1" in + --token) + [[ $# -ge 2 ]] || die "--token requires a value" + CLAWCREDIT_API_TOKEN="$2" + shift 2 + ;; + --agent) + [[ $# -ge 2 ]] || die "--agent requires a value" + AGENT_ID="$2" + shift 2 + ;; + --chain) + [[ $# -ge 2 ]] || die "--chain requires a value" + CLAWCREDIT_PAYMENT_CHAIN="$2" + shift 2 + ;; + --asset) + [[ $# -ge 2 ]] || die "--asset requires a value" + CLAWCREDIT_PAYMENT_ASSET="$2" + shift 2 + ;; + --base-url) + [[ $# -ge 2 ]] || die "--base-url requires a value" + CLAWCREDIT_BASE_URL="$2" + shift 2 + ;; + --profile) + [[ $# -ge 2 ]] || die "--profile requires a value" + OPENCLAW_PROFILE="$2" + shift 2 + ;; + --no-restart) + NO_RESTART="1" + shift + ;; + --dry-run) + DRY_RUN="1" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "Unknown argument: $1 (use --help)" + ;; + esac +done + +need_cmd openclaw +need_cmd node + +STATE_DIR="$(resolve_state_dir "$OPENCLAW_PROFILE")" +ENV_FILE="${STATE_DIR}/.env" +EXT_DIR="${STATE_DIR}/extensions/${PLUGIN_ID}" +CLAWCREDIT_JSON="${STATE_DIR}/agents/${AGENT_ID}/agent/clawcredit.json" + +if [[ -z "$CLAWCREDIT_BASE_URL" ]]; then + CLAWCREDIT_BASE_URL="$DEFAULT_CLAWCREDIT_BASE_URL" +fi + +if [[ -z "$CLAWCREDIT_PAYMENT_CHAIN" ]]; then + CLAWCREDIT_PAYMENT_CHAIN="$DEFAULT_CHAIN" +fi + +CLAWCREDIT_PAYMENT_CHAIN="$(printf '%s' "$CLAWCREDIT_PAYMENT_CHAIN" | tr '[:lower:]' '[:upper:]')" + +if [[ -z "$CLAWCREDIT_PAYMENT_ASSET" ]]; then + if [[ "$CLAWCREDIT_PAYMENT_CHAIN" == "BASE" ]]; then + CLAWCREDIT_PAYMENT_ASSET="$DEFAULT_ASSET_BASE_USDC" + fi +fi + +if [[ -z "${CLAWCREDIT_API_TOKEN}" && -f "${CLAWCREDIT_JSON}" ]]; then + token_from_json="$(node -e " + const fs = require('fs'); + const p = process.argv[1]; + try { + const raw = fs.readFileSync(p, 'utf8'); + const j = JSON.parse(raw); + const t = typeof j.apiToken === 'string' ? j.apiToken.trim() : ''; + if (t) process.stdout.write(t); + } catch {} + " "${CLAWCREDIT_JSON}")" + if [[ -n "${token_from_json}" ]]; then + CLAWCREDIT_API_TOKEN="${token_from_json}" + log "→ Found claw.credit token in ${CLAWCREDIT_JSON}" + fi +fi + +if [[ -z "${CLAWCREDIT_API_TOKEN}" ]]; then + if ! is_tty; then + die "CLAWCREDIT_API_TOKEN not set and no token found at ${CLAWCREDIT_JSON}. Re-run with --token." + fi + printf "Enter CLAWCREDIT_API_TOKEN: " >&2 + read -r -s CLAWCREDIT_API_TOKEN + printf "\n" >&2 + if [[ -z "${CLAWCREDIT_API_TOKEN}" ]]; then + die "Empty token" + fi +fi + +if [[ -z "${CLAWCREDIT_PAYMENT_ASSET}" ]]; then + if ! is_tty; then + die "CLAWCREDIT_PAYMENT_ASSET is required for chain=${CLAWCREDIT_PAYMENT_CHAIN}. Re-run with --asset." + fi + printf "Enter CLAWCREDIT_PAYMENT_ASSET for chain=%s: " "${CLAWCREDIT_PAYMENT_CHAIN}" >&2 + read -r CLAWCREDIT_PAYMENT_ASSET + if [[ -z "${CLAWCREDIT_PAYMENT_ASSET}" ]]; then + die "Empty asset" + fi +fi + +log "" +log "ClawRouter claw.credit configuration" +log " OpenClaw state dir: ${STATE_DIR}" +log " Profile: ${OPENCLAW_PROFILE:-default}" +log " Agent: ${AGENT_ID}" +log " claw.credit baseUrl: ${CLAWCREDIT_BASE_URL}" +log " chain: ${CLAWCREDIT_PAYMENT_CHAIN}" +log " asset: ${CLAWCREDIT_PAYMENT_ASSET}" +log " env file: ${ENV_FILE}" +log "" + +if [[ "${DRY_RUN}" == "1" ]]; then + log "[dry-run] Would ensure plugin installed at: ${EXT_DIR}" + log "[dry-run] Would run: openclaw${OPENCLAW_PROFILE:+ --profile ${OPENCLAW_PROFILE}} plugins install ${PLUGIN_PKG}" + log "[dry-run] Would run: openclaw${OPENCLAW_PROFILE:+ --profile ${OPENCLAW_PROFILE}} plugins enable ${PLUGIN_ID}" + log "[dry-run] Would write env vars to: ${ENV_FILE}" + if [[ "${NO_RESTART}" != "1" ]]; then + log "[dry-run] Would run: openclaw${OPENCLAW_PROFILE:+ --profile ${OPENCLAW_PROFILE}} gateway restart" + fi + exit 0 +fi + +log "→ Installing/enabling ${PLUGIN_PKG}..." +if [[ ! -d "${EXT_DIR}" ]]; then + openclaw ${OPENCLAW_PROFILE:+ --profile "${OPENCLAW_PROFILE}"} plugins install "${PLUGIN_PKG}" +else + log " Plugin already installed: ${EXT_DIR}" +fi + +openclaw ${OPENCLAW_PROFILE:+ --profile "${OPENCLAW_PROFILE}"} plugins enable "${PLUGIN_ID}" + +log "→ Writing ${ENV_FILE}..." +mkdir -p "${STATE_DIR}" + +BLOCKRUN_PAYMENT_MODE="clawcredit" \ +CLAWCREDIT_API_TOKEN="${CLAWCREDIT_API_TOKEN}" \ +CLAWCREDIT_BASE_URL="${CLAWCREDIT_BASE_URL}" \ +CLAWCREDIT_PAYMENT_CHAIN="${CLAWCREDIT_PAYMENT_CHAIN}" \ +CLAWCREDIT_PAYMENT_ASSET="${CLAWCREDIT_PAYMENT_ASSET}" \ +node -e " + const fs = require('fs'); + const path = require('path'); + + const envPath = process.argv[1]; + const pairs = { + BLOCKRUN_PAYMENT_MODE: process.env.BLOCKRUN_PAYMENT_MODE, + CLAWCREDIT_API_TOKEN: process.env.CLAWCREDIT_API_TOKEN, + CLAWCREDIT_BASE_URL: process.env.CLAWCREDIT_BASE_URL, + CLAWCREDIT_PAYMENT_CHAIN: process.env.CLAWCREDIT_PAYMENT_CHAIN, + CLAWCREDIT_PAYMENT_ASSET: process.env.CLAWCREDIT_PAYMENT_ASSET, + }; + + let lines = []; + if (fs.existsSync(envPath)) { + lines = fs.readFileSync(envPath, 'utf8').split(/\r?\n/); + } + + const setLine = (key, value) => { + const encoded = JSON.stringify(String(value)); + const next = key + '=' + encoded; + // Keys here are fixed env var names, so no regex escaping is needed. + const re = new RegExp('^' + key + '='); // key= + const idx = lines.findIndex((l) => re.test(l)); + if (idx >= 0) { + lines[idx] = next; + } else { + lines.push(next); + } + }; + + for (const [k, v] of Object.entries(pairs)) { + if (v == null || String(v).trim() === '') continue; + setLine(k, v); + } + + // Trim trailing empty lines, then ensure file ends with newline. + while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + const out = lines.join('\\n') + '\\n'; + + fs.mkdirSync(path.dirname(envPath), { recursive: true }); + fs.writeFileSync(envPath, out, 'utf8'); + try { fs.chmodSync(envPath, 0o600); } catch {} +" "${ENV_FILE}" + +if [[ "${NO_RESTART}" != "1" ]]; then + log "→ Restarting OpenClaw gateway..." + if ! openclaw ${OPENCLAW_PROFILE:+ --profile "${OPENCLAW_PROFILE}"} gateway restart; then + warn "Gateway restart failed. Try:" + warn " openclaw${OPENCLAW_PROFILE:+ --profile ${OPENCLAW_PROFILE}} gateway install" + warn " openclaw${OPENCLAW_PROFILE:+ --profile ${OPENCLAW_PROFILE}} gateway start" + fi +else + log "→ Skipping gateway restart (--no-restart)" +fi + +log "" +log "✓ claw.credit mode enabled for BlockRun inference" +log "" +log "Quick checks:" +log " openclaw${OPENCLAW_PROFILE:+ --profile ${OPENCLAW_PROFILE}} gateway status" +log " curl -s \"http://127.0.0.1:8402/health?full=true\" | cat" diff --git a/src/clawcredit.ts b/src/clawcredit.ts new file mode 100644 index 0000000..855fb06 --- /dev/null +++ b/src/clawcredit.ts @@ -0,0 +1,169 @@ +/** + * Claw Credit payment backend for ClawRouter. + * + * Uses the official @t54-labs/clawcredit-sdk payment path so requests include + * SDK-generated metadata and richer audit context. + */ + +import { ClawCredit, withTrace } from "@t54-labs/clawcredit-sdk"; + +const DEFAULT_SERVICE_URL = "https://api.claw.credit"; + +export type ClawCreditConfig = { + baseUrl?: string; + apiToken: string; + chain: string; + asset: string; + agent?: string; + agentId?: string; +}; + +export type PreAuthParams = { + estimatedAmount: string; +}; + +type SdkClient = { + pay: (args: { + transaction: { + recipient: string; + amount: number; + chain: string; + asset: string; + amount_unit?: "human" | "atomic"; + }; + request_body: Record; + context?: { + reasoning_process?: string; + current_task?: string; + }; + idempotencyKey?: string; + }) => Promise<{ merchant_response?: unknown }>; +}; + +function headersToObject(headersInit?: HeadersInit): Record { + if (!headersInit) return {}; + const headers = new Headers(headersInit); + const out: Record = {}; + for (const [key, value] of headers.entries()) { + const lower = key.toLowerCase(); + if (lower === "host" || lower === "content-length" || lower === "connection") continue; + out[key] = value; + } + return out; +} + +function parseJsonBody(body: RequestInit["body"]): unknown { + if (body == null) return undefined; + + let raw = ""; + if (typeof body === "string") { + raw = body; + } else if (body instanceof Uint8Array) { + raw = Buffer.from(body).toString("utf-8"); + } else if (body instanceof ArrayBuffer) { + raw = Buffer.from(body).toString("utf-8"); + } else { + return undefined; + } + + if (!raw.trim()) return undefined; + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} + +function microsToUsd(estimatedAmount?: string): number { + const micros = Number(estimatedAmount ?? ""); + if (!Number.isFinite(micros) || micros <= 0) return 0.01; + return Number((micros / 1_000_000).toFixed(6)); +} + +function inferStatusCode(err: unknown): number { + const msg = err instanceof Error ? err.message : String(err); + const match = msg.match(/ClawCredit API Error:\s*(\d{3})\s*-/i); + if (match) return parseInt(match[1], 10); + if (/payment required/i.test(msg)) return 402; + if (/prequalification_pending/i.test(msg)) return 403; + if (/unauthorized/i.test(msg)) return 401; + return 502; +} + +/** + * Create a fetch wrapper that pays through claw.credit SDK instead of local x402 signing. + */ +export function createClawCreditFetch(config: ClawCreditConfig) { + const serviceUrl = (config.baseUrl || DEFAULT_SERVICE_URL).replace(/\/+$/, ""); + const chain = config.chain.toUpperCase(); + const asset = config.asset; + const apiToken = config.apiToken.trim(); + + if (!apiToken) { + throw new Error("CLAWCREDIT_API_TOKEN is required for claw.credit payment mode"); + } + + const credit = new ClawCredit({ + serviceUrl, + apiToken, + agent: config.agent, + agentId: config.agentId, + }) as SdkClient; + + return async ( + input: RequestInfo | URL, + init?: RequestInit, + preAuth?: PreAuthParams, + ): Promise => { + const upstreamUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = (init?.method || "POST").toUpperCase(); + const headers = headersToObject(init?.headers); + const idempotencyKey = new Headers(init?.headers).get("idempotency-key") || undefined; + const requestBody = parseJsonBody(init?.body); + const amountUsd = microsToUsd(preAuth?.estimatedAmount); + + try { + const result = await withTrace(async () => + credit.pay({ + transaction: { + recipient: upstreamUrl, + amount: amountUsd, + chain, + asset, + }, + request_body: { + http: { + url: upstreamUrl, + method, + headers, + }, + body: requestBody, + }, + context: { + current_task: "blockrun_inference_via_clawrouter", + reasoning_process: "Proxying BlockRun inference payment through claw.credit SDK", + }, + idempotencyKey, + }), + ); + + const merchantResponse = + result && typeof result === "object" && "merchant_response" in result + ? (result as { merchant_response?: unknown }).merchant_response + : result; + + return new Response(JSON.stringify(merchantResponse ?? {}), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } catch (err) { + const status = inferStatusCode(err); + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ error: message }), { + status, + headers: { "content-type": "application/json" }, + }); + } + }; +} diff --git a/src/cli.ts b/src/cli.ts index 1a6cc9b..7e34260 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,6 +18,10 @@ import { resolveOrGenerateWalletKey } from "./auth.js"; import { BalanceMonitor } from "./balance.js"; import { VERSION } from "./version.js"; +const CLAWCREDIT_DEFAULT_BASE_URL = "https://api.claw.credit"; +const CLAWCREDIT_DEFAULT_CHAIN = "BASE"; +const CLAWCREDIT_DEFAULT_ASSET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; + function printHelp(): void { console.log(` ClawRouter v${VERSION} - Smart LLM Router @@ -42,6 +46,11 @@ Examples: Environment Variables: BLOCKRUN_WALLET_KEY Private key for x402 payments (auto-generated if not set) + BLOCKRUN_PAYMENT_MODE wallet | clawcredit (default: wallet) + CLAWCREDIT_API_TOKEN Required when BLOCKRUN_PAYMENT_MODE=clawcredit + CLAWCREDIT_BASE_URL claw.credit API URL (default: https://api.claw.credit) + CLAWCREDIT_PAYMENT_CHAIN Chain for claw.credit transaction (default: BASE) + CLAWCREDIT_PAYMENT_ASSET Asset for claw.credit transaction (default: Base USDC) BLOCKRUN_PROXY_PORT Default proxy port (default: 8402) For more info: https://github.com/BlockRunAI/ClawRouter @@ -79,20 +88,50 @@ async function main(): Promise { process.exit(0); } - // Resolve wallet key - const { key: walletKey, address, source } = await resolveOrGenerateWalletKey(); + const paymentMode = (process.env.BLOCKRUN_PAYMENT_MODE || "wallet").trim().toLowerCase(); + const useClawCredit = paymentMode === "clawcredit"; + + let address = "clawcredit"; + let walletKey: string | undefined; + let clawCreditConfig: + | { baseUrl: string; apiToken: string; chain: string; asset: string } + | undefined; - if (source === "generated") { - console.log(`[ClawRouter] Generated new wallet: ${address}`); - } else if (source === "saved") { - console.log(`[ClawRouter] Using saved wallet: ${address}`); + if (useClawCredit) { + const apiToken = (process.env.CLAWCREDIT_API_TOKEN || "").trim(); + if (!apiToken) { + throw new Error("CLAWCREDIT_API_TOKEN is required when BLOCKRUN_PAYMENT_MODE=clawcredit"); + } + + clawCreditConfig = { + baseUrl: (process.env.CLAWCREDIT_BASE_URL || CLAWCREDIT_DEFAULT_BASE_URL).trim(), + apiToken, + chain: (process.env.CLAWCREDIT_PAYMENT_CHAIN || CLAWCREDIT_DEFAULT_CHAIN).trim().toUpperCase(), + asset: (process.env.CLAWCREDIT_PAYMENT_ASSET || CLAWCREDIT_DEFAULT_ASSET).trim(), + }; + console.log( + `[ClawRouter] Using claw.credit mode (${clawCreditConfig.baseUrl}, ${clawCreditConfig.chain})`, + ); } else { - console.log(`[ClawRouter] Using wallet from BLOCKRUN_WALLET_KEY: ${address}`); + // Resolve wallet key + const resolved = await resolveOrGenerateWalletKey(); + walletKey = resolved.key; + address = resolved.address; + + if (resolved.source === "generated") { + console.log(`[ClawRouter] Generated new wallet: ${resolved.address}`); + } else if (resolved.source === "saved") { + console.log(`[ClawRouter] Using saved wallet: ${resolved.address}`); + } else { + console.log(`[ClawRouter] Using wallet from BLOCKRUN_WALLET_KEY: ${resolved.address}`); + } } // Start the proxy const proxy = await startProxy({ + paymentMode: useClawCredit ? "clawcredit" : "wallet", walletKey, + clawCredit: clawCreditConfig, port: args.port, onReady: (port) => { console.log(`[ClawRouter] Proxy listening on http://127.0.0.1:${port}`); @@ -116,20 +155,24 @@ async function main(): Promise { }, }); - // Check balance - const monitor = new BalanceMonitor(address); - try { - const balance = await monitor.checkBalance(); - if (balance.isEmpty) { - console.log(`[ClawRouter] Wallet balance: $0.00 (using FREE model)`); - console.log(`[ClawRouter] Fund wallet for premium models: ${address}`); - } else if (balance.isLow) { - console.log(`[ClawRouter] Wallet balance: ${balance.balanceUSD} (low)`); - } else { - console.log(`[ClawRouter] Wallet balance: ${balance.balanceUSD}`); + if (!useClawCredit) { + // Check balance + const monitor = new BalanceMonitor(address); + try { + const balance = await monitor.checkBalance(); + if (balance.isEmpty) { + console.log(`[ClawRouter] Wallet balance: $0.00 (using FREE model)`); + console.log(`[ClawRouter] Fund wallet for premium models: ${address}`); + } else if (balance.isLow) { + console.log(`[ClawRouter] Wallet balance: ${balance.balanceUSD} (low)`); + } else { + console.log(`[ClawRouter] Wallet balance: ${balance.balanceUSD}`); + } + } catch { + console.log(`[ClawRouter] Wallet: ${address} (balance check pending)`); } - } catch { - console.log(`[ClawRouter] Wallet: ${address} (balance check pending)`); + } else { + console.log("[ClawRouter] Payments managed by claw.credit"); } console.log(`[ClawRouter] Ready - Ctrl+C to stop`); diff --git a/src/index.ts b/src/index.ts index aa8ca4c..c1fc834 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,6 +62,10 @@ import { VERSION } from "./version.js"; import { privateKeyToAccount } from "viem/accounts"; import { getStats, formatStatsAscii } from "./stats.js"; +const CLAWCREDIT_DEFAULT_BASE_URL = "https://api.claw.credit"; +const CLAWCREDIT_DEFAULT_CHAIN = "BASE"; +const CLAWCREDIT_DEFAULT_ASSET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; + /** * Detect if we're running in shell completion mode. * When `openclaw completion --shell zsh` runs, it loads plugins but only needs @@ -412,46 +416,94 @@ let activeProxyHandle: Awaited> | null = null; * treating activate() as an alias (def.register ?? def.activate). */ async function startProxyInBackground(api: OpenClawPluginApi): Promise { - // Resolve wallet key: saved file → env var → auto-generate - const { key: walletKey, address, source } = await resolveOrGenerateWalletKey(); - - // Log wallet source (brief - balance check happens after proxy starts) - if (source === "generated") { - api.logger.info(`Generated new wallet: ${address}`); - } else if (source === "saved") { - api.logger.info(`Using saved wallet: ${address}`); - } else { - api.logger.info(`Using wallet from BLOCKRUN_WALLET_KEY: ${address}`); - } + const paymentMode = (process.env.BLOCKRUN_PAYMENT_MODE || "wallet").trim().toLowerCase(); + const useClawCredit = paymentMode === "clawcredit"; - // Resolve routing config overrides from plugin config + // Resolve routing overrides from plugin config. const routingConfig = api.pluginConfig?.routing as Partial | undefined; - const proxy = await startProxy({ - walletKey, - routingConfig, - onReady: (port) => { - api.logger.info(`BlockRun x402 proxy listening on port ${port}`); - }, - onError: (error) => { - api.logger.error(`BlockRun proxy error: ${error.message}`); - }, - onRouted: (decision) => { - const cost = decision.costEstimate.toFixed(4); - const saved = (decision.savings * 100).toFixed(0); - api.logger.info( - `[${decision.tier}] ${decision.model} $${cost} (saved ${saved}%) | ${decision.reasoning}`, - ); - }, - onLowBalance: (info) => { - api.logger.warn(`[!] Low balance: ${info.balanceUSD}. Fund wallet: ${info.walletAddress}`); - }, - onInsufficientFunds: (info) => { - api.logger.error( - `[!] Insufficient funds. Balance: ${info.balanceUSD}, Needed: ${info.requiredUSD}. Fund wallet: ${info.walletAddress}`, - ); - }, - }); + let address = "clawcredit"; + let proxy: Awaited>; + if (useClawCredit) { + const apiToken = (process.env.CLAWCREDIT_API_TOKEN || "").trim(); + if (!apiToken) { + throw new Error("CLAWCREDIT_API_TOKEN is required when BLOCKRUN_PAYMENT_MODE=clawcredit"); + } + + const baseUrl = (process.env.CLAWCREDIT_BASE_URL || CLAWCREDIT_DEFAULT_BASE_URL).trim(); + const chain = (process.env.CLAWCREDIT_PAYMENT_CHAIN || CLAWCREDIT_DEFAULT_CHAIN) + .trim() + .toUpperCase(); + const asset = (process.env.CLAWCREDIT_PAYMENT_ASSET || CLAWCREDIT_DEFAULT_ASSET).trim(); + + api.logger.info( + `Using claw.credit payment mode (baseUrl=${baseUrl}, chain=${chain}, asset=${asset})`, + ); + + proxy = await startProxy({ + paymentMode: "clawcredit", + clawCredit: { + baseUrl, + apiToken, + chain, + asset, + }, + routingConfig, + onReady: (port) => { + api.logger.info(`BlockRun claw.credit proxy listening on port ${port}`); + }, + onError: (error) => { + api.logger.error(`BlockRun proxy error: ${error.message}`); + }, + onRouted: (decision) => { + const cost = decision.costEstimate.toFixed(4); + const saved = (decision.savings * 100).toFixed(0); + api.logger.info( + `[${decision.tier}] ${decision.model} $${cost} (saved ${saved}%) | ${decision.reasoning}`, + ); + }, + }); + } else { + // Resolve wallet key: saved file -> env var -> auto-generate. + const { key: walletKey, address: walletAddress, source } = await resolveOrGenerateWalletKey(); + address = walletAddress; + + // Log wallet source (brief - balance check happens after proxy starts) + if (source === "generated") { + api.logger.info(`Generated new wallet: ${walletAddress}`); + } else if (source === "saved") { + api.logger.info(`Using saved wallet: ${walletAddress}`); + } else { + api.logger.info(`Using wallet from BLOCKRUN_WALLET_KEY: ${walletAddress}`); + } + + proxy = await startProxy({ + paymentMode: "wallet", + walletKey, + routingConfig, + onReady: (port) => { + api.logger.info(`BlockRun x402 proxy listening on port ${port}`); + }, + onError: (error) => { + api.logger.error(`BlockRun proxy error: ${error.message}`); + }, + onRouted: (decision) => { + const cost = decision.costEstimate.toFixed(4); + const saved = (decision.savings * 100).toFixed(0); + api.logger.info( + `[${decision.tier}] ${decision.model} $${cost} (saved ${saved}%) | ${decision.reasoning}`, + ); + }, + onLowBalance: (info) => { + api.logger.warn(`[!] Low balance: ${info.balanceUSD}. Fund wallet: ${info.walletAddress}`); + }, + onInsufficientFunds: (info) => { + api.logger.error( + `[!] Insufficient funds. Balance: ${info.balanceUSD}, Needed: ${info.requiredUSD}. Fund wallet: ${info.walletAddress}`, + ); + }, + }); + } setActiveProxy(proxy); activeProxyHandle = proxy; @@ -459,24 +511,28 @@ async function startProxyInBackground(api: OpenClawPluginApi): Promise { api.logger.info(`ClawRouter ready — smart routing enabled`); api.logger.info(`Pricing: Simple ~$0.001 | Code ~$0.01 | Complex ~$0.05 | Free: $0`); - // Non-blocking balance check AFTER proxy is ready (won't hang startup) - const startupMonitor = new BalanceMonitor(address); - startupMonitor - .checkBalance() - .then((balance) => { - if (balance.isEmpty) { - api.logger.info(`Wallet: ${address} | Balance: $0.00`); - api.logger.info(`Using FREE model. Fund wallet for premium models.`); - } else if (balance.isLow) { - api.logger.info(`Wallet: ${address} | Balance: ${balance.balanceUSD} (low)`); - } else { - api.logger.info(`Wallet: ${address} | Balance: ${balance.balanceUSD}`); - } - }) - .catch(() => { - // Silently continue - balance will be checked per-request anyway - api.logger.info(`Wallet: ${address} | Balance: (checking...)`); - }); + if (!useClawCredit) { + // Non-blocking balance check AFTER proxy is ready (won't hang startup) + const startupMonitor = new BalanceMonitor(address); + startupMonitor + .checkBalance() + .then((balance) => { + if (balance.isEmpty) { + api.logger.info(`Wallet: ${address} | Balance: $0.00`); + api.logger.info(`Using FREE model. Fund wallet for premium models.`); + } else if (balance.isLow) { + api.logger.info(`Wallet: ${address} | Balance: ${balance.balanceUSD} (low)`); + } else { + api.logger.info(`Wallet: ${address} | Balance: ${balance.balanceUSD}`); + } + }) + .catch(() => { + // Silently continue - balance will be checked per-request anyway + api.logger.info(`Wallet: ${address} | Balance: (checking...)`); + }); + } else { + api.logger.info("Payments managed by claw.credit (local wallet not required)"); + } } /** diff --git a/src/provider.ts b/src/provider.ts index f66169d..bc172ac 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -34,7 +34,14 @@ export const blockrunProvider: ProviderPlugin = { label: "BlockRun", docsPath: "https://blockrun.ai/docs", aliases: ["br"], - envVars: ["BLOCKRUN_WALLET_KEY"], + envVars: [ + "BLOCKRUN_WALLET_KEY", + "BLOCKRUN_PAYMENT_MODE", + "CLAWCREDIT_API_TOKEN", + "CLAWCREDIT_BASE_URL", + "CLAWCREDIT_PAYMENT_CHAIN", + "CLAWCREDIT_PAYMENT_ASSET", + ], // Model definitions — dynamically set to proxy URL get models() { diff --git a/src/proxy.ts b/src/proxy.ts index 82c503b..ebfa781 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -26,6 +26,7 @@ import { finished } from "node:stream"; import type { AddressInfo } from "node:net"; import { privateKeyToAccount } from "viem/accounts"; import { createPaymentFetch, type PreAuthParams } from "./x402.js"; +import { createClawCreditFetch, type ClawCreditConfig } from "./clawcredit.js"; import { route, getFallbackChain, @@ -80,6 +81,7 @@ const HEALTH_CHECK_TIMEOUT_MS = 2_000; // Timeout for checking existing proxy const RATE_LIMIT_COOLDOWN_MS = 60_000; // 60 seconds cooldown for rate-limited models const PORT_RETRY_ATTEMPTS = 5; // Max attempts to bind port (handles TIME_WAIT) const PORT_RETRY_DELAY_MS = 1_000; // Delay between retry attempts +const DUMMY_WALLET_ADDRESS = "0x0000000000000000000000000000000000000000"; /** * Transform upstream payment errors into user-friendly messages. @@ -254,9 +256,11 @@ export function getProxyPort(): number { /** * Check if a proxy is already running on the given port. - * Returns the wallet address if running, undefined otherwise. + * Returns identity and payment mode if running, undefined otherwise. */ -async function checkExistingProxy(port: number): Promise { +async function checkExistingProxy( + port: number, +): Promise<{ wallet: string; paymentMode: PaymentMode } | undefined> { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS); @@ -267,9 +271,16 @@ async function checkExistingProxy(port: number): Promise { clearTimeout(timeoutId); if (response.ok) { - const data = (await response.json()) as { status?: string; wallet?: string }; + const data = (await response.json()) as { + status?: string; + wallet?: string; + paymentMode?: PaymentMode; + }; if (data.status === "ok" && data.wallet) { - return data.wallet; + return { + wallet: data.wallet, + paymentMode: data.paymentMode || "wallet", + }; } } return undefined; @@ -287,6 +298,10 @@ const PROVIDER_ERROR_PATTERNS = [ /billing/i, /insufficient.*balance/i, /credits/i, + /payment required/i, + /x402[_-]?payment[_-]?failed/i, + /merchant_status\"?\s*:\s*402/i, + /endpoint requires x402 payment/i, /quota.*exceeded/i, /rate.*limit/i, /model.*unavailable/i, @@ -666,8 +681,15 @@ export type InsufficientFundsInfo = { walletAddress: string; }; +export type PaymentMode = "wallet" | "clawcredit"; + export type ProxyOptions = { - walletKey: string; + /** Local wallet private key for direct x402 signing (required in wallet mode). */ + walletKey?: string; + /** Payment backend. Defaults to "wallet" for backwards compatibility. */ + paymentMode?: PaymentMode; + /** claw.credit payment configuration (required in clawcredit mode). */ + clawCredit?: ClawCreditConfig; apiBase?: string; /** Port to listen on (default: 8402) */ port?: number; @@ -781,22 +803,49 @@ function estimateAmount( */ export async function startProxy(options: ProxyOptions): Promise { const apiBase = options.apiBase ?? BLOCKRUN_API; + const paymentMode: PaymentMode = options.paymentMode ?? "wallet"; + const localBalanceEnabled = paymentMode === "wallet"; // Determine port: options.port > env var > default const listenPort = options.port ?? getProxyPort(); + let walletAddressForMode = "clawcredit"; + let payFetch: ( + input: RequestInfo | URL, + init?: RequestInit, + preAuth?: PreAuthParams, + ) => Promise; + let balanceMonitor: BalanceMonitor; + + if (paymentMode === "wallet") { + if (!options.walletKey) { + throw new Error("walletKey is required when paymentMode='wallet'"); + } + const account = privateKeyToAccount(options.walletKey as `0x${string}`); + walletAddressForMode = account.address; + payFetch = createPaymentFetch(options.walletKey as `0x${string}`).fetch; + balanceMonitor = new BalanceMonitor(account.address); + } else { + if (!options.clawCredit?.apiToken) { + throw new Error("clawCredit.apiToken is required when paymentMode='clawcredit'"); + } + payFetch = createClawCreditFetch(options.clawCredit); + balanceMonitor = new BalanceMonitor(DUMMY_WALLET_ADDRESS); + } + // Check if a proxy is already running on this port - const existingWallet = await checkExistingProxy(listenPort); - if (existingWallet) { + const existingProxy = await checkExistingProxy(listenPort); + if (existingProxy) { // Proxy already running — reuse it instead of failing with EADDRINUSE - const account = privateKeyToAccount(options.walletKey as `0x${string}`); - const balanceMonitor = new BalanceMonitor(account.address); const baseUrl = `http://127.0.0.1:${listenPort}`; - // Verify the existing proxy is using the same wallet (or warn if different) - if (existingWallet !== account.address) { + // Verify the existing proxy is using the same payment mode/identity. + if ( + existingProxy.wallet !== walletAddressForMode || + existingProxy.paymentMode !== paymentMode + ) { console.warn( - `[ClawRouter] Existing proxy on port ${listenPort} uses wallet ${existingWallet}, but current config uses ${account.address}. Reusing existing proxy.`, + `[ClawRouter] Existing proxy on port ${listenPort} uses mode=${existingProxy.paymentMode} identity=${existingProxy.wallet}, but current config uses mode=${paymentMode} identity=${walletAddressForMode}. Reusing existing proxy.`, ); } @@ -805,7 +854,7 @@ export async function startProxy(options: ProxyOptions): Promise { return { port: listenPort, baseUrl, - walletAddress: existingWallet, + walletAddress: existingProxy.wallet, balanceMonitor, close: async () => { // No-op: we didn't start this proxy, so we shouldn't close it @@ -813,13 +862,6 @@ export async function startProxy(options: ProxyOptions): Promise { }; } - // Create x402 payment-enabled fetch from wallet private key - const account = privateKeyToAccount(options.walletKey as `0x${string}`); - const { fetch: payFetch } = createPaymentFetch(options.walletKey as `0x${string}`); - - // Create balance monitor for pre-request checks - const balanceMonitor = new BalanceMonitor(account.address); - // Build router options (100% local — no external API calls for routing) const routingConfig = mergeRoutingConfig(options.routingConfig); const modelPricing = buildModelPricing(); @@ -875,17 +917,22 @@ export async function startProxy(options: ProxyOptions): Promise { const response: Record = { status: "ok", - wallet: account.address, + wallet: walletAddressForMode, + paymentMode, }; if (full) { - try { - const balanceInfo = await balanceMonitor.checkBalance(); - response.balance = balanceInfo.balanceUSD; - response.isLow = balanceInfo.isLow; - response.isEmpty = balanceInfo.isEmpty; - } catch { - response.balanceError = "Could not fetch balance"; + if (localBalanceEnabled) { + try { + const balanceInfo = await balanceMonitor.checkBalance(); + response.balance = balanceInfo.balanceUSD; + response.isLow = balanceInfo.isLow; + response.isEmpty = balanceInfo.isEmpty; + } catch { + response.balanceError = "Could not fetch balance"; + } + } else { + response.balance = "managed_by_clawcredit"; } } @@ -960,6 +1007,7 @@ export async function startProxy(options: ProxyOptions): Promise { balanceMonitor, sessionStore, responseCache, + localBalanceEnabled, ); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); @@ -993,11 +1041,15 @@ export async function startProxy(options: ProxyOptions): Promise { if (err.code === "EADDRINUSE") { // Port is in use - check if a proxy is actually running - const existingWallet = await checkExistingProxy(listenPort); - if (existingWallet) { + const existingProxy = await checkExistingProxy(listenPort); + if (existingProxy) { // Proxy is actually running - this is fine, reuse it console.log(`[ClawRouter] Existing proxy detected on port ${listenPort}, reusing`); - rejectAttempt({ code: "REUSE_EXISTING", wallet: existingWallet }); + rejectAttempt({ + code: "REUSE_EXISTING", + wallet: existingProxy.wallet, + paymentMode: existingProxy.paymentMode, + }); return; } @@ -1036,10 +1088,20 @@ export async function startProxy(options: ProxyOptions): Promise { await tryListen(attempt); break; // Success } catch (err: unknown) { - const error = err as { code?: string; wallet?: string; attempt?: number }; + const error = err as { + code?: string; + wallet?: string; + paymentMode?: PaymentMode; + attempt?: number; + }; if (error.code === "REUSE_EXISTING" && error.wallet) { // Proxy is running, reuse it + if (error.paymentMode && error.paymentMode !== paymentMode) { + console.warn( + `[ClawRouter] Existing proxy mode=${error.paymentMode} differs from requested mode=${paymentMode}. Reusing existing proxy.`, + ); + } const baseUrl = `http://127.0.0.1:${listenPort}`; options.onReady?.(listenPort); return { @@ -1124,7 +1186,7 @@ export async function startProxy(options: ProxyOptions): Promise { return { port, baseUrl, - walletAddress: account.address, + walletAddress: walletAddressForMode, balanceMonitor, close: () => new Promise((res, rej) => { @@ -1287,6 +1349,7 @@ async function proxyRequest( balanceMonitor: BalanceMonitor, sessionStore: SessionStore, responseCache: ResponseCache, + localBalanceEnabled: boolean, ): Promise { const startTime = Date.now(); @@ -1379,7 +1442,7 @@ async function proxyRequest( ); const existingSession = sessionId ? sessionStore.getSession(sessionId) : undefined; - if (existingSession) { + if (existingSession && existingSession.routingProfile === routingProfile) { // Use the session's pinned model instead of re-routing console.log( `[ClawRouter] Session ${sessionId?.slice(0, 8)}... using pinned model: ${existingSession.model}`, @@ -1389,6 +1452,13 @@ async function proxyRequest( bodyModified = true; sessionStore.touchSession(sessionId!); } else { + if (existingSession && sessionId) { + console.log( + `[ClawRouter] Session ${sessionId.slice(0, 8)}... profile changed (${existingSession.routingProfile ?? "unknown"} -> ${routingProfile}), re-routing`, + ); + sessionStore.clearSession(sessionId); + } + // No session or expired - route normally // Extract prompt from messages type ChatMessage = { role: string; content: string }; @@ -1431,7 +1501,12 @@ async function proxyRequest( // Pin this model to the session for future requests if (sessionId) { - sessionStore.setSession(sessionId, routingDecision.model, routingDecision.tier); + sessionStore.setSession( + sessionId, + routingDecision.model, + routingDecision.tier, + routingProfile ?? undefined, + ); console.log( `[ClawRouter] Session ${sessionId.slice(0, 8)}... pinned to model: ${routingDecision.model}`, ); @@ -1557,7 +1632,7 @@ async function proxyRequest( let estimatedCostMicros: bigint | undefined; const isFreeModel = modelId === FREE_MODEL; - if (modelId && !options.skipBalanceCheck && !isFreeModel) { + if (localBalanceEnabled && modelId && !options.skipBalanceCheck && !isFreeModel) { const estimated = estimateAmount(modelId, body.length, maxTokens); if (estimated) { estimatedCostMicros = BigInt(estimated); @@ -2055,7 +2130,7 @@ async function proxyRequest( } // --- Optimistic balance deduction after successful response --- - if (estimatedCostMicros !== undefined) { + if (localBalanceEnabled && estimatedCostMicros !== undefined) { balanceMonitor.deductEstimated(estimatedCostMicros); } @@ -2075,7 +2150,9 @@ async function proxyRequest( deduplicator.removeInflight(dedupKey); // Invalidate balance cache on payment failure (might be out of date) - balanceMonitor.invalidate(); + if (localBalanceEnabled) { + balanceMonitor.invalidate(); + } // Convert abort error to more descriptive timeout error if (err instanceof Error && err.name === "AbortError") { diff --git a/src/session.ts b/src/session.ts index 277fe51..95c63bd 100644 --- a/src/session.ts +++ b/src/session.ts @@ -9,6 +9,7 @@ export type SessionEntry = { model: string; tier: string; + routingProfile?: "free" | "eco" | "auto" | "premium"; createdAt: number; lastUsedAt: number; requestCount: number; @@ -72,7 +73,12 @@ export class SessionStore { /** * Pin a model to a session. */ - setSession(sessionId: string, model: string, tier: string): void { + setSession( + sessionId: string, + model: string, + tier: string, + routingProfile?: "free" | "eco" | "auto" | "premium", + ): void { if (!this.config.enabled || !sessionId) { return; } @@ -88,10 +94,12 @@ export class SessionStore { existing.model = model; existing.tier = tier; } + existing.routingProfile = routingProfile; } else { this.sessions.set(sessionId, { model, tier, + routingProfile, createdAt: now, lastUsedAt: now, requestCount: 1, diff --git a/src/types/clawcredit-sdk.d.ts b/src/types/clawcredit-sdk.d.ts new file mode 100644 index 0000000..6a85fed --- /dev/null +++ b/src/types/clawcredit-sdk.d.ts @@ -0,0 +1,8 @@ +declare module "@t54-labs/clawcredit-sdk" { + export class ClawCredit { + constructor(config?: Record); + pay(args: Record): Promise>; + } + + export function withTrace(fn: () => Promise): Promise; +} diff --git a/test/clawcredit-mode.ts b/test/clawcredit-mode.ts new file mode 100644 index 0000000..20875b1 --- /dev/null +++ b/test/clawcredit-mode.ts @@ -0,0 +1,185 @@ +/** + * Integration test for Claw Credit payment mode. + * + * Verifies the proxy can run without a local wallet key and route payment + * through claw.credit's /v1/transaction/pay endpoint. + * + * Usage: + * npx tsx test/clawcredit-mode.ts + */ + +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { startProxy } from "../src/proxy.js"; + +const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, msg: string): void { + if (condition) { + console.log(` ✓ ${msg}`); + passed++; + } else { + console.error(` ✗ FAIL: ${msg}`); + failed++; + } +} + +async function startMockClawCreditServer() { + let lastHeaders: Record = {}; + let lastPayload: Record | null = null; + + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + if (req.url !== "/v1/transaction/pay" || req.method !== "POST") { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "not_found" })); + return; + } + + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const raw = Buffer.concat(chunks).toString("utf-8"); + const body = JSON.parse(raw) as Record; + + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") headers[key.toLowerCase()] = value; + } + + lastHeaders = headers; + lastPayload = body; + + const merchantResponse = { + id: "chatcmpl-mock", + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: "openai/gpt-4o-mini", + choices: [ + { + index: 0, + message: { role: "assistant", content: "hello from mock claw.credit path" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }; + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + status: "success", + tx_hash: "mock-tx-hash", + chain: "BASE", + amount_charged: 0.01, + remaining_balance: 10.0, + merchant_response: merchantResponse, + actual_amount_charged: 0.01, + remaining_balance_usd: 10.0, + }), + ); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const port = (server.address() as AddressInfo).port; + + return { + port, + getLastHeaders: () => lastHeaders, + getLastPayload: () => lastPayload, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +async function run(): Promise { + console.log("\n═══ Claw Credit Mode Test ═══\n"); + + const credit = await startMockClawCreditServer(); + const proxy = await startProxy({ + // Intentionally no walletKey: claw.credit mode should not require one. + paymentMode: "clawcredit", + clawCredit: { + baseUrl: `http://127.0.0.1:${credit.port}`, + apiToken: "claw_test_token", + chain: "BASE", + asset: BASE_USDC, + }, + port: 0, + }); + + try { + const response = await fetch(`${proxy.baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "openai/gpt-4o", + messages: [{ role: "user", content: "Say hello" }], + max_tokens: 32, + }), + }); + + assert(response.ok, `proxy request succeeded (${response.status})`); + const json = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const content = json.choices?.[0]?.message?.content ?? ""; + assert(content.includes("mock claw.credit path"), "response came from claw.credit merchant_response"); + + const headers = credit.getLastHeaders(); + assert( + headers.authorization === "Bearer claw_test_token", + "claw.credit call included Authorization header", + ); + + const payload = credit.getLastPayload() as Record | null; + assert(payload != null, "claw.credit pay payload captured"); + if (payload) { + const tx = payload.transaction as Record; + const reqBody = payload.request_body as Record; + const http = reqBody.http as Record; + const audit = payload.audit_context as Record; + const sdkMeta = payload.sdk_meta as Record; + + assert(tx.chain === "BASE", "transaction.chain forwarded"); + assert(tx.asset === BASE_USDC, "transaction.asset forwarded"); + assert(typeof tx.amount === "number" && (tx.amount as number) > 0, "transaction.amount > 0"); + assert( + typeof tx.recipient === "string" && + (tx.recipient as string).includes("/v1/chat/completions"), + "transaction.recipient points to BlockRun chat endpoint", + ); + assert( + http.url === tx.recipient, + "request_body.http.url matches transaction.recipient", + ); + assert(typeof sdkMeta?.sdk_name === "string", "sdk_meta.sdk_name present"); + assert( + sdkMeta?.sdk_name === "@t54-labs/clawcredit-sdk", + "sdk_meta.sdk_name uses official clawcredit sdk identity", + ); + assert(typeof sdkMeta?.sdk_version === "string", "sdk_meta.sdk_version present"); + assert( + Array.isArray(audit?.stack_code) && audit.stack_code.length > 0, + "audit_context.stack_code captured", + ); + assert(typeof audit?.current_task === "string", "audit_context.current_task present"); + assert(typeof audit?.reasoning_process === "string", "audit_context.reasoning_process present"); + } + } finally { + await proxy.close(); + await credit.close(); + } + + console.log("\n═══════════════════════════════════"); + console.log(` ${passed} passed, ${failed} failed`); + console.log("═══════════════════════════════════\n"); + process.exit(failed > 0 ? 1 : 0); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/premium-compat.ts b/test/premium-compat.ts new file mode 100644 index 0000000..09d6df5 --- /dev/null +++ b/test/premium-compat.ts @@ -0,0 +1,180 @@ +/** + * Regression tests for premium payment compatibility issues: + * 1) x402 wrapped payment failures should be treated as provider errors and fallback. + * 2) Session pinning should not override routing profile switches (premium -> eco). + * + * Usage: + * bun run test/premium-compat.ts + */ + +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { startProxy } from "../src/proxy.js"; + +type MockState = { + modelCalls: string[]; + wrappedPaymentFailureModels: Set; +}; + +async function startMockApi(state: MockState): Promise<{ port: number; close: () => Promise }> { + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + try { + const body = JSON.parse(Buffer.concat(chunks).toString()) as { model?: string }; + const model = body.model || "unknown"; + state.modelCalls.push(model); + + if (state.wrappedPaymentFailureModels.has(model)) { + // Real-world shape observed in logs: 400 with embedded x402 payment failure details. + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: + 'Payment required: {"error":"x402_payment_failed","merchant_status":402,"merchant_body":"{\\"error\\":\\"Payment Required\\",\\"message\\":\\"This endpoint requires x402 payment\\"}"}', + }), + ); + return; + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + id: "chatcmpl-test", + object: "chat.completion", + created: Date.now(), + model, + choices: [{ index: 0, message: { role: "assistant", content: `ok:${model}` } }], + usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 }, + }), + ); + } catch { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid request body" })); + } + }); + + const port = 25000 + Math.floor(Math.random() * 1000); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { + server.removeListener("error", reject); + resolve(); + }); + }); + + return { + port, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +let passed = 0; +let failed = 0; +function assert(condition: boolean, msg: string): void { + if (condition) { + console.log(` ✓ ${msg}`); + passed++; + } else { + console.error(` ✗ FAIL: ${msg}`); + failed++; + } +} + +async function run(): Promise { + console.log("\n═══ Premium Compatibility Tests ═══\n"); + + const state: MockState = { + modelCalls: [], + wrappedPaymentFailureModels: new Set(), + }; + + const mockApi = await startMockApi(state); + const proxyPort = 26000 + Math.floor(Math.random() * 1000); + const proxy = await startProxy({ + walletKey: `0x${"1".repeat(64)}`, + apiBase: `http://127.0.0.1:${mockApi.port}`, + port: proxyPort, + skipBalanceCheck: true, + sessionConfig: { enabled: true, headerName: "x-session-id" }, + }); + + // Test 1: wrapped x402 payment failure must fallback to free model. + { + console.log("--- Test 1: wrapped x402 failure triggers fallback ---"); + state.modelCalls.length = 0; + state.wrappedPaymentFailureModels = new Set(["xai/grok-code-fast-1"]); + + const res = await fetch(`${proxy.baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "xai/grok-code-fast-1", + messages: [{ role: "user", content: "hello" }], + max_tokens: 128, + }), + }); + + assert(res.ok, `request succeeds via fallback: ${res.status}`); + assert( + state.modelCalls.join(",") === "xai/grok-code-fast-1,nvidia/gpt-oss-120b", + `fallback chain used expected models: ${state.modelCalls.join(", ")}`, + ); + } + + // Test 2: session pin should not cross routing profiles. + { + console.log("--- Test 2: session profile switch re-routes ---"); + state.modelCalls.length = 0; + state.wrappedPaymentFailureModels = new Set(); + const sessionId = `sess-${Date.now()}`; + const prompt = "Prove step by step that sqrt(2) is irrational."; + + const premiumRes = await fetch(`${proxy.baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-session-id": sessionId }, + body: JSON.stringify({ + model: "premium", + messages: [{ role: "user", content: prompt }], + max_tokens: 256, + }), + }); + assert(premiumRes.ok, `premium request ok: ${premiumRes.status}`); + + const ecoRes = await fetch(`${proxy.baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", "x-session-id": sessionId }, + body: JSON.stringify({ + model: "eco", + // Keep tier intent but alter body to avoid request dedup cache hits. + messages: [{ role: "user", content: `${prompt} Give a shorter version.` }], + max_tokens: 256, + }), + }); + assert(ecoRes.ok, `eco request ok: ${ecoRes.status}`); + + const firstModel = state.modelCalls[0]; + const secondModel = state.modelCalls[1]; + assert(!!firstModel && !!secondModel, `captured two model calls: ${state.modelCalls.join(", ")}`); + assert( + secondModel !== "anthropic/claude-sonnet-4", + `eco request should not reuse premium pinned model: ${secondModel}`, + ); + } + + await proxy.close(); + await mockApi.close(); + + console.log("\n═══════════════════════════════════"); + console.log(` ${passed} passed, ${failed} failed`); + console.log("═══════════════════════════════════\n"); + process.exit(failed > 0 ? 1 : 0); +} + +run().catch((err) => { + console.error("Test failed:", err); + process.exit(1); +});