From 21529ddfc4fcd4d6e4387723a1990b89cb185717 Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Mon, 23 Feb 2026 20:53:02 -0500 Subject: [PATCH] feat: add partner API proxy + fix cost tracking for direct model picks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partner API integration (Phase B): - Add partner service registry and tool builder (src/partners/) - Add proxyPartnerRequest() for /v1/x/* and /v1/partner/* paths - Register partner tools + /partners command in OpenClaw plugin - Add `clawrouter partners [test]` CLI subcommand - Extend UsageEntry with partnerId/service fields Fix total cost tracking: - Direct model selections (non-routed) were not logged because routingDecision was undefined — only auto-routed requests counted - Now logs ALL requests: routed (existing tier) + direct (new DIRECT tier) - Add DIRECT to known tiers in stats display --- src/cli.ts | 52 ++++++++++++++++++ src/index.ts | 47 ++++++++++++++++ src/logger.ts | 4 ++ src/partners/index.ts | 8 +++ src/partners/registry.ts | 85 ++++++++++++++++++++++++++++ src/partners/tools.ts | 92 +++++++++++++++++++++++++++++++ src/proxy.ts | 116 +++++++++++++++++++++++++++++++++++++-- src/stats.ts | 2 +- 8 files changed, 401 insertions(+), 5 deletions(-) create mode 100644 src/partners/index.ts create mode 100644 src/partners/registry.ts create mode 100644 src/partners/tools.ts diff --git a/src/cli.ts b/src/cli.ts index 18935e7..1d0a1e2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,6 +18,7 @@ import { resolveOrGenerateWalletKey } from "./auth.js"; import { BalanceMonitor } from "./balance.js"; import { VERSION } from "./version.js"; import { runDoctor } from "./doctor.js"; +import { PARTNER_SERVICES } from "./partners/index.js"; function printHelp(): void { console.log(` @@ -26,6 +27,7 @@ ClawRouter v${VERSION} - Smart LLM Router Usage: clawrouter [options] clawrouter doctor [opus] [question] + clawrouter partners [test] Options: --version, -v Show version number @@ -35,6 +37,8 @@ Options: Commands: doctor AI-powered diagnostics (default: Sonnet ~$0.003) doctor opus Use Opus for deeper analysis (~$0.01) + partners List available partner APIs with pricing + partners test Test partner API endpoints (expect 402 = alive) Examples: # Start standalone proxy @@ -64,12 +68,16 @@ function parseArgs(args: string[]): { version: boolean; help: boolean; doctor: boolean; + partners: boolean; + partnersTest: boolean; port?: number; } { const result = { version: false, help: false, doctor: false, + partners: false, + partnersTest: false, port: undefined as number | undefined, }; @@ -81,6 +89,13 @@ function parseArgs(args: string[]): { result.help = true; } else if (arg === "doctor" || arg === "--doctor") { result.doctor = true; + } else if (arg === "partners") { + result.partners = true; + // Check for "test" subcommand + if (args[i + 1] === "test") { + result.partnersTest = true; + i++; + } } else if (arg === "--port" && args[i + 1]) { result.port = parseInt(args[i + 1], 10); i++; // Skip next arg @@ -126,6 +141,43 @@ async function main(): Promise { process.exit(0); } + if (args.partners) { + if (PARTNER_SERVICES.length === 0) { + console.log("No partner APIs available."); + process.exit(0); + } + + console.log(`\nClawRouter Partner APIs (v${VERSION})\n`); + + for (const svc of PARTNER_SERVICES) { + console.log(` ${svc.name} (${svc.partner})`); + console.log(` ${svc.description}`); + console.log(` Tool: blockrun_${svc.id}`); + console.log(` Method: ${svc.method} /v1${svc.proxyPath}`); + console.log(` Pricing: ${svc.pricing.perUnit} per ${svc.pricing.unit} (min ${svc.pricing.minimum}, max ${svc.pricing.maximum})`); + console.log(); + } + + if (args.partnersTest) { + console.log("Testing partner endpoints...\n"); + const apiBase = "https://blockrun.ai/api"; + for (const svc of PARTNER_SERVICES) { + const url = `${apiBase}/v1${svc.proxyPath}`; + try { + const response = await fetch(url, { method: "GET" }); + const status = response.status; + const ok = status === 402 ? "alive (402 = payment required)" : `status ${status}`; + console.log(` ${svc.id}: ${ok}`); + } catch (err) { + console.log(` ${svc.id}: error - ${err instanceof Error ? err.message : String(err)}`); + } + } + console.log(); + } + + process.exit(0); + } + // Resolve wallet key const { key: walletKey, address, source } = await resolveOrGenerateWalletKey(); diff --git a/src/index.ts b/src/index.ts index 07ee15f..2090060 100644 --- a/src/index.ts +++ b/src/index.ts @@ -665,6 +665,51 @@ const plugin: OpenClawPluginDefinition = { api.logger.info("BlockRun provider registered (30+ models via x402)"); + // Register partner API tools (Twitter/X lookup, etc.) + try { + const { buildPartnerTools, PARTNER_SERVICES } = await import("./partners/index.js"); + const proxyBaseUrl = `http://127.0.0.1:${runtimePort}`; + const partnerTools = buildPartnerTools(proxyBaseUrl); + for (const tool of partnerTools) { + api.registerTool(tool); + } + if (partnerTools.length > 0) { + api.logger.info(`Registered ${partnerTools.length} partner tool(s): ${partnerTools.map((t) => t.name).join(", ")}`); + } + + // Register /partners command + api.registerCommand({ + name: "partners", + description: "List available partner APIs and pricing", + acceptsArgs: false, + requireAuth: false, + handler: async () => { + if (PARTNER_SERVICES.length === 0) { + return { text: "No partner APIs available." }; + } + + const lines = [ + "**Partner APIs** (paid via your ClawRouter wallet)", + "", + ]; + + for (const svc of PARTNER_SERVICES) { + lines.push(`**${svc.name}** (${svc.partner})`); + lines.push(` ${svc.description}`); + lines.push(` Tool: \`${`blockrun_${svc.id}`}\``); + lines.push(` Pricing: ${svc.pricing.perUnit} per ${svc.pricing.unit} (min ${svc.pricing.minimum}, max ${svc.pricing.maximum})`); + lines.push(""); + } + + return { text: lines.join("\n") }; + }, + }); + } catch (err) { + api.logger.warn( + `Failed to register partner tools: ${err instanceof Error ? err.message : String(err)}`, + ); + } + // Register /wallet command for wallet management createWalletCommand() .then((walletCommand) => { @@ -807,3 +852,5 @@ export { SessionStore, getSessionId, DEFAULT_SESSION_CONFIG } from "./session.js export type { SessionEntry, SessionConfig } from "./session.js"; export { ResponseCache } from "./response-cache.js"; export type { CachedLLMResponse, ResponseCacheConfig } from "./response-cache.js"; +export { PARTNER_SERVICES, getPartnerService, buildPartnerTools } from "./partners/index.js"; +export type { PartnerServiceDefinition, PartnerToolDefinition } from "./partners/index.js"; diff --git a/src/logger.ts b/src/logger.ts index 2343c08..78705c1 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -20,6 +20,10 @@ export type UsageEntry = { baselineCost: number; savings: number; // 0-1 percentage latencyMs: number; + /** Partner service ID (e.g., "x_users_lookup") — only set for partner API calls */ + partnerId?: string; + /** Partner service name (e.g., "AttentionVC") — only set for partner API calls */ + service?: string; }; const LOG_DIR = join(homedir(), ".openclaw", "blockrun", "logs"); diff --git a/src/partners/index.ts b/src/partners/index.ts new file mode 100644 index 0000000..ccb0589 --- /dev/null +++ b/src/partners/index.ts @@ -0,0 +1,8 @@ +/** + * Partner API Integration — barrel export + */ + +export { PARTNER_SERVICES, getPartnerService } from "./registry.js"; +export type { PartnerServiceDefinition, PartnerServiceParam } from "./registry.js"; +export { buildPartnerTools } from "./tools.js"; +export type { PartnerToolDefinition } from "./tools.js"; diff --git a/src/partners/registry.ts b/src/partners/registry.ts new file mode 100644 index 0000000..b31b812 --- /dev/null +++ b/src/partners/registry.ts @@ -0,0 +1,85 @@ +/** + * Partner Service Registry + * + * Defines available partner APIs that can be called through ClawRouter's proxy. + * Partners provide specialized data (Twitter/X, etc.) via x402 micropayments. + * The same wallet used for LLM calls pays for partner API calls — zero extra setup. + */ + +export type PartnerServiceParam = { + name: string; + type: "string" | "string[]" | "number"; + description: string; + required: boolean; +}; + +export type PartnerServiceDefinition = { + /** Unique service ID used in tool names: blockrun_{id} */ + id: string; + /** Human-readable name */ + name: string; + /** Partner providing this service */ + partner: string; + /** Short description for tool listing */ + description: string; + /** Proxy path (relative to /v1) */ + proxyPath: string; + /** HTTP method */ + method: "GET" | "POST"; + /** Parameters for the tool's JSON Schema */ + params: PartnerServiceParam[]; + /** Pricing info for display */ + pricing: { + perUnit: string; + unit: string; + minimum: string; + maximum: string; + }; + /** Example usage for help text */ + example: { + input: Record; + description: string; + }; +}; + +/** + * All registered partner services. + * New partners are added here — the rest of the system picks them up automatically. + */ +export const PARTNER_SERVICES: PartnerServiceDefinition[] = [ + { + id: "x_users_lookup", + name: "Twitter/X User Lookup", + partner: "AttentionVC", + description: + "Look up Twitter/X user profiles by username. Returns follower counts, verification status, bio, and more. Accepts up to 100 usernames per request.", + proxyPath: "/x/users/lookup", + method: "POST", + params: [ + { + name: "usernames", + type: "string[]", + description: + 'Array of Twitter/X usernames to look up (without @ prefix). Example: ["elonmusk", "naval"]', + required: true, + }, + ], + pricing: { + perUnit: "$0.001", + unit: "user", + minimum: "$0.01 (10 users)", + maximum: "$0.10 (100 users)", + }, + example: { + input: { usernames: ["elonmusk", "naval", "balaboris"] }, + description: "Look up 3 Twitter/X user profiles", + }, + }, +]; + +/** + * Get a partner service by ID. + */ +export function getPartnerService(id: string): PartnerServiceDefinition | undefined { + return PARTNER_SERVICES.find((s) => s.id === id); +} diff --git a/src/partners/tools.ts b/src/partners/tools.ts new file mode 100644 index 0000000..3fd70d3 --- /dev/null +++ b/src/partners/tools.ts @@ -0,0 +1,92 @@ +/** + * Partner Tool Builder + * + * Converts partner service definitions into OpenClaw tool definitions. + * Each tool's execute() calls through the local proxy which handles + * x402 payment transparently using the same wallet. + */ + +import { PARTNER_SERVICES, type PartnerServiceDefinition } from "./registry.js"; + +/** OpenClaw tool definition shape (duck-typed) */ +export type PartnerToolDefinition = { + name: string; + description: string; + inputSchema: { + type: "object"; + properties: Record; + required: string[]; + }; + execute: (args: Record) => Promise; +}; + +/** + * Build a single partner tool from a service definition. + */ +function buildTool( + service: PartnerServiceDefinition, + proxyBaseUrl: string, +): PartnerToolDefinition { + // Build JSON Schema properties from service params + const properties: Record = {}; + const required: string[] = []; + + for (const param of service.params) { + const prop: Record = { + description: param.description, + }; + + if (param.type === "string[]") { + prop.type = "array"; + prop.items = { type: "string" }; + } else { + prop.type = param.type; + } + + properties[param.name] = prop; + if (param.required) { + required.push(param.name); + } + } + + return { + name: `blockrun_${service.id}`, + description: [ + service.description, + "", + `Partner: ${service.partner}`, + `Pricing: ${service.pricing.perUnit} per ${service.pricing.unit} (min: ${service.pricing.minimum}, max: ${service.pricing.maximum})`, + ].join("\n"), + inputSchema: { + type: "object", + properties, + required, + }, + execute: async (args: Record) => { + const url = `${proxyBaseUrl}/v1${service.proxyPath}`; + + const response = await fetch(url, { + method: service.method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(args), + }); + + if (!response.ok) { + const errText = await response.text().catch(() => ""); + throw new Error( + `Partner API error (${response.status}): ${errText || response.statusText}`, + ); + } + + return response.json(); + }, + }; +} + +/** + * Build OpenClaw tool definitions for all registered partner services. + * @param proxyBaseUrl - Local proxy base URL (e.g., "http://127.0.0.1:8402") + */ +export function buildPartnerTools(proxyBaseUrl: string): PartnerToolDefinition[] { + return PARTNER_SERVICES.map((service) => buildTool(service, proxyBaseUrl)); +} diff --git a/src/proxy.ts b/src/proxy.ts index 49cda9f..4f28248 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -940,6 +940,93 @@ function estimateAmount( return amountMicros.toString(); } +/** + * Proxy a partner API request through x402 payment flow. + * + * Simplified proxy for partner endpoints (/v1/x/*, /v1/partner/*). + * No smart routing, SSE, compression, or sessions — just collect body, + * forward via payFetch (which handles 402 automatically), and stream back. + */ +async function proxyPartnerRequest( + req: IncomingMessage, + res: ServerResponse, + apiBase: string, + payFetch: ( + input: RequestInfo | URL, + init?: RequestInit, + preAuth?: PreAuthParams, + ) => Promise, +): Promise { + const startTime = Date.now(); + const upstreamUrl = `${apiBase}${req.url}`; + + // Collect request body + const bodyChunks: Buffer[] = []; + for await (const chunk of req) { + bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const body = Buffer.concat(bodyChunks); + + // Forward headers (strip hop-by-hop) + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (key === "host" || key === "connection" || key === "transfer-encoding" || key === "content-length") + continue; + if (typeof value === "string") headers[key] = value; + } + if (!headers["content-type"]) headers["content-type"] = "application/json"; + headers["user-agent"] = USER_AGENT; + + console.log(`[ClawRouter] Partner request: ${req.method} ${req.url}`); + + const upstream = await payFetch(upstreamUrl, { + method: req.method ?? "POST", + headers, + body: body.length > 0 ? new Uint8Array(body) : undefined, + }); + + // Forward response headers + const responseHeaders: Record = {}; + upstream.headers.forEach((value, key) => { + if (key === "transfer-encoding" || key === "connection" || key === "content-encoding") return; + responseHeaders[key] = value; + }); + + res.writeHead(upstream.status, responseHeaders); + + // Stream response body + if (upstream.body) { + const reader = upstream.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + safeWrite(res, Buffer.from(value)); + } + } finally { + reader.releaseLock(); + } + } + + res.end(); + + const latencyMs = Date.now() - startTime; + console.log(`[ClawRouter] Partner response: ${upstream.status} (${latencyMs}ms)`); + + // Log partner usage (fire-and-forget) + logUsage({ + timestamp: new Date().toISOString(), + model: "partner", + tier: "PARTNER", + cost: 0, // Actual cost handled by x402 settlement + baselineCost: 0, + savings: 0, + latencyMs, + partnerId: (req.url?.split("?")[0] ?? "").replace(/^\/v1\//, "").replace(/\//g, "_") || "unknown", + service: "partner", + }).catch(() => {}); +} + /** * Start the local x402 proxy server. * @@ -1108,6 +1195,25 @@ export async function startProxy(options: ProxyOptions): Promise { return; } + // --- Handle partner API paths (/v1/x/*, /v1/partner/*) --- + if (req.url?.match(/^\/v1\/(?:x|partner)\//)) { + try { + await proxyPartnerRequest(req, res, apiBase, payFetch); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + options.onError?.(error); + if (!res.headersSent) { + res.writeHead(502, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: { message: `Partner proxy error: ${error.message}`, type: "partner_error" }, + }), + ); + } + } + return; + } + // Only proxy paths starting with /v1 if (!req.url?.startsWith("/v1")) { res.writeHead(404, { "Content-Type": "application/json" }); @@ -2357,11 +2463,13 @@ async function proxyRequest( // --- Usage logging (fire-and-forget) --- // Note: Recalculate cost using full body length (not just system+user message) // and apply 20% buffer to match actual x402 payment (see estimateAmount()) - if (routingDecision) { + // Log ALL requests: both auto-routed (routingDecision set) and direct model picks + const logModel = routingDecision?.model ?? modelId; + if (logModel) { // Use full body length for accurate cost (matches x402 payment estimation) const estimatedInputTokens = Math.ceil(body.length / 4); const accurateCosts = calculateModelCost( - routingDecision.model, + logModel, routerOpts.modelPricing, estimatedInputTokens, maxTokens, @@ -2372,8 +2480,8 @@ async function proxyRequest( const baselineWithBuffer = accurateCosts.baselineCost * 1.2; const entry: UsageEntry = { timestamp: new Date().toISOString(), - model: routingDecision.model, - tier: routingDecision.tier, + model: logModel, + tier: routingDecision?.tier ?? "DIRECT", cost: costWithBuffer, baselineCost: baselineWithBuffer, savings: accurateCosts.savings, diff --git a/src/stats.ts b/src/stats.ts index 1216b2a..269cd9b 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -243,7 +243,7 @@ export function formatStatsAscii(stats: AggregatedStats): string { lines.push("║ Routing by Tier: ║"); // Show all tiers found in data, ordered by known tiers first then others - const knownTiers = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]; + const knownTiers = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING", "DIRECT"]; const allTiers = Object.keys(stats.byTier); const otherTiers = allTiers.filter((t) => !knownTiers.includes(t)); const tierOrder = [...knownTiers.filter((t) => stats.byTier[t]), ...otherTiers];