diff --git a/src/index.ts b/src/index.ts index 548d3bb..bae2127 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. diff --git a/src/proxy.routing-config-reuse.test.ts b/src/proxy.routing-config-reuse.test.ts new file mode 100644 index 0000000..e9db74b --- /dev/null +++ b/src/proxy.routing-config-reuse.test.ts @@ -0,0 +1,89 @@ +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 Record, + }; + + // 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 1b64506..561f70b 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,42 @@ 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 })); + } else { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ updated: false })); + } + } 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();