From 1c439399bca10e6bc275924e525aeb8929d3200b Mon Sep 17 00:00:00 2001 From: kagura-agent Date: Fri, 10 Apr 2026 09:54:09 +0800 Subject: [PATCH 1/6] fix: apply routing config on proxy reuse path (fixes #147) When register() is called twice (common in OpenClaw), the second call with user config would hit the proxy reuse path and skip mergeRoutingConfig(). Custom routing config from openclaw.json was silently ignored. Fix: Add /__update-routing POST endpoint to the running proxy server. When the reuse path detects an existing proxy and options.routingConfig is provided, POST the config to update the running instance. Also adds /__routing-config GET endpoint for debugging. --- src/proxy.routing-config-reuse.test.ts | 87 ++++++++++++++++++++++++++ src/proxy.ts | 53 ++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/proxy.routing-config-reuse.test.ts diff --git a/src/proxy.routing-config-reuse.test.ts b/src/proxy.routing-config-reuse.test.ts new file mode 100644 index 00000000..84ffe68b --- /dev/null +++ b/src/proxy.routing-config-reuse.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { generatePrivateKey } from "viem/accounts"; + +import { startProxy } from "./proxy.js"; +import { DEFAULT_ROUTING_CONFIG } from "./router/index.js"; + +describe("startProxy routing config reuse", () => { + it("applies custom routing config when reusing an existing proxy", async () => { + const walletKey = generatePrivateKey(); + const port = 21000 + Math.floor(Math.random() * 10000); + + // Start the first proxy (uses DEFAULT_ROUTING_CONFIG) + const firstProxy = await startProxy({ + wallet: walletKey, + port, + skipBalanceCheck: true, + }); + + try { + // Verify initial config is the default + const initialRes = await fetch(`${firstProxy.baseUrl}/__routing-config`); + expect(initialRes.status).toBe(200); + const initialConfig = await initialRes.json(); + expect(initialConfig.version).toBe(DEFAULT_ROUTING_CONFIG.version); + + // Custom routing config with a modified tier + const customConfig: Parameters[0]["routingConfig"] = { + tiers: { + SIMPLE: { primary: "test-model-simple", fallback: ["test-fallback-1"] }, + } as any, + }; + + // Start proxy again on same port — enters reuse path + const secondProxy = await startProxy({ + wallet: walletKey, + port, + skipBalanceCheck: true, + routingConfig: customConfig, + }); + + // The second proxy's close is a no-op (reuse path) + await secondProxy.close(); + + // Verify the routing config was updated on the running proxy + const updatedRes = await fetch(`${firstProxy.baseUrl}/__routing-config`); + expect(updatedRes.status).toBe(200); + const updatedConfig = await updatedRes.json(); + expect(updatedConfig.tiers.SIMPLE.primary).toBe("test-model-simple"); + expect(updatedConfig.tiers.SIMPLE.fallback).toEqual(["test-fallback-1"]); + + // Other tiers should still have defaults (merged, not replaced) + expect(updatedConfig.tiers.COMPLEX.primary).toBe(DEFAULT_ROUTING_CONFIG.tiers.COMPLEX.primary); + } finally { + await firstProxy.close(); + } + }); + + it("leaves default routing config when reusing without routingConfig option", async () => { + const walletKey = generatePrivateKey(); + const port = 21000 + Math.floor(Math.random() * 10000); + + const firstProxy = await startProxy({ + wallet: walletKey, + port, + skipBalanceCheck: true, + }); + + try { + // Reuse without routingConfig + const secondProxy = await startProxy({ + wallet: walletKey, + port, + skipBalanceCheck: true, + }); + await secondProxy.close(); + + // Config should still be the default + const res = await fetch(`${firstProxy.baseUrl}/__routing-config`); + expect(res.status).toBe(200); + const config = await res.json(); + expect(config.version).toBe(DEFAULT_ROUTING_CONFIG.version); + expect(config.tiers).toEqual(DEFAULT_ROUTING_CONFIG.tiers); + } finally { + await firstProxy.close(); + } + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index 1b645062..3bc5121f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1621,6 +1621,26 @@ export async function startProxy(options: ProxyOptions): Promise { balanceMonitor = new BalanceMonitor(account.address); } + // If a routing config was provided, push it to the running proxy so it takes effect + if (options.routingConfig) { + try { + const updateRes = await fetch(`${baseUrl}/__update-routing`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ routingConfig: options.routingConfig }), + }); + if (!updateRes.ok) { + console.warn( + `[ClawRouter] Failed to update routing config on existing proxy: ${updateRes.status}`, + ); + } + } catch (err) { + console.warn( + `[ClawRouter] Could not update routing config on existing proxy: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + options.onReady?.(listenPort); return { @@ -1774,6 +1794,39 @@ export async function startProxy(options: ProxyOptions): Promise { return; } + // Internal endpoint: update routing config on a running proxy (used by reuse path) + if (req.method === "POST" && req.url === "/__update-routing") { + try { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + const body = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as { + routingConfig?: Partial; + }; + if (body.routingConfig) { + routerOpts.config = mergeRoutingConfig(body.routingConfig); + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ updated: true })); + } catch (err) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: `Invalid routing config: ${err instanceof Error ? err.message : String(err)}`, + }), + ); + } + return; + } + + // Internal endpoint: read current routing config (for testing/debugging) + if (req.method === "GET" && req.url === "/__routing-config") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(routerOpts.config)); + return; + } + // Cache stats endpoint if (req.url === "/cache" || req.url?.startsWith("/cache?")) { const stats = responseCache.getStats(); From 8b011c6835d1fe971efc1cb14260638b3570773f Mon Sep 17 00:00:00 2001 From: kagura-agent Date: Fri, 10 Apr 2026 12:04:17 +0800 Subject: [PATCH 2/6] style: fix prettier formatting --- src/proxy.routing-config-reuse.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/proxy.routing-config-reuse.test.ts b/src/proxy.routing-config-reuse.test.ts index 84ffe68b..f3ea56be 100644 --- a/src/proxy.routing-config-reuse.test.ts +++ b/src/proxy.routing-config-reuse.test.ts @@ -49,7 +49,9 @@ describe("startProxy routing config reuse", () => { expect(updatedConfig.tiers.SIMPLE.fallback).toEqual(["test-fallback-1"]); // Other tiers should still have defaults (merged, not replaced) - expect(updatedConfig.tiers.COMPLEX.primary).toBe(DEFAULT_ROUTING_CONFIG.tiers.COMPLEX.primary); + expect(updatedConfig.tiers.COMPLEX.primary).toBe( + DEFAULT_ROUTING_CONFIG.tiers.COMPLEX.primary, + ); } finally { await firstProxy.close(); } From b80ea2e26e00a3f7442f0c9d57ad55fee4050c34 Mon Sep 17 00:00:00 2001 From: kagura-agent Date: Fri, 10 Apr 2026 12:41:18 +0800 Subject: [PATCH 3/6] fix: return updated:false when routingConfig is missing --- src/proxy.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index 3bc5121f..561f70b9 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1806,9 +1806,12 @@ export async function startProxy(options: ProxyOptions): Promise { }; if (body.routingConfig) { routerOpts.config = mergeRoutingConfig(body.routingConfig); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ updated: true })); + } else { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ updated: false })); } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ updated: true })); } catch (err) { res.writeHead(400, { "Content-Type": "application/json" }); res.end( From a343b8e2f868912622ed69acf0950e9397ff6fd4 Mon Sep 17 00:00:00 2001 From: kagura-agent Date: Fri, 10 Apr 2026 13:04:39 +0800 Subject: [PATCH 4/6] fix: replace no-explicit-any in test --- src/proxy.routing-config-reuse.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/proxy.routing-config-reuse.test.ts b/src/proxy.routing-config-reuse.test.ts index f3ea56be..e9db74be 100644 --- a/src/proxy.routing-config-reuse.test.ts +++ b/src/proxy.routing-config-reuse.test.ts @@ -27,7 +27,7 @@ describe("startProxy routing config reuse", () => { const customConfig: Parameters[0]["routingConfig"] = { tiers: { SIMPLE: { primary: "test-model-simple", fallback: ["test-fallback-1"] }, - } as any, + } as Record, }; // Start proxy again on same port — enters reuse path From 13c417696a3e675dcdf3642e892746fa3840c941 Mon Sep 17 00:00:00 2001 From: kagura-agent Date: Fri, 10 Apr 2026 19:08:02 +0800 Subject: [PATCH 5/6] fix: remove unused readStringSafe variable --- src/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 548d3bb6..bae2127f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -516,9 +516,6 @@ function injectModelsConfig( } } -function readStringSafe(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} /** * Inject dummy auth profile for BlockRun into agent auth stores. From 1fed7cd591b966694ddf594f8235d2adda4852db Mon Sep 17 00:00:00 2001 From: kagura-agent Date: Fri, 10 Apr 2026 20:05:42 +0800 Subject: [PATCH 6/6] chore: retrigger CI