From 839f3fcba3af7abe4d738791c91773fbef5eeb77 Mon Sep 17 00:00:00 2001 From: cloudsigma Date: Thu, 30 Jul 2026 08:11:10 +0000 Subject: [PATCH] feat: add per-session AutoRouter algorithm override --- README.md | 3 +- index.ts | 88 ++++++++++++++++++++++++++++++++-- package-lock.json | 4 +- package.json | 2 +- test/requester-runtime.test.ts | 39 ++++++++++++++- test/smoke.mjs | 23 +++++++-- 6 files changed, 147 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f01e70f..e2a14e0 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ For requests routed through the `cloudsigma` or `cloudsigma-staging` provider ID - injects `metadata.openclaw_correlation` for request/run tracing - captures TaaS autorouter + request/trace response headers - exposes the latest route capture via gateway method `taas.autorouter.lastRoute` +- accepts a per-session request override via `taas.autorouter.setAlgorithm` - exposes privacy-safe bridge outcome counters via `taas.affinity.stats` (hits, misses, expiries, ambiguous traces, and direct invocation IDs); no trace or session values are returned ## Startup compatibility @@ -76,7 +77,7 @@ Example injected metadata: "openclaw_correlation": { "schema_version": "2026-06-05", "source": "openclaw-taas-affinity", - "plugin_version": "0.11.0", + "plugin_version": "0.12.0", "session_id": "oc:0123456789abcdef", "sticky_key": "oc:0123456789abcdef", "session_source_hint": "source:1a2b3c4d5e6f7890", diff --git a/index.ts b/index.ts index e58c67b..a1f8a31 100644 --- a/index.ts +++ b/index.ts @@ -29,9 +29,18 @@ const REQUESTER_RUNTIME_SCHEMA_VERSION = "2026-06-04" const REQUESTER_RUNTIME_SOURCE = "openclaw-taas-affinity" const GIT_PROBE_TIMEOUT_MS = 250 const LAST_ROUTE_LIMIT = 256 -const PLUGIN_VERSION = "0.11.0" +const AUTOROUTER_OVERRIDE_LIMIT = 256 +const PLUGIN_VERSION = "0.12.0" const TRACE_BRIDGE_TTL_MS = 30 * 60 * 1000 const TRACE_BRIDGE_LIMIT = 1024 +const AUTOROUTER_ALGORITHMS = new Set([ + "best_fit", + "price_performance", + "savings_curve", + "cost", + "ttft", + "tps", +]) const isDev = process.env.NODE_ENV === "development" || Boolean(process.env.OPENCLAW_DEBUG) @@ -553,6 +562,27 @@ function patchPayloadMetadata( const lastRouteBySessionId = new Map() const lastRouteByAgentId = new Map() +const autorouterAlgorithmBySessionId = new Map() + +function setAutorouterAlgorithm(sessionId: string, algorithm: string | null): void { + autorouterAlgorithmBySessionId.delete(sessionId) + if (algorithm === null) return + autorouterAlgorithmBySessionId.set(sessionId, algorithm) + while (autorouterAlgorithmBySessionId.size > AUTOROUTER_OVERRIDE_LIMIT) { + const oldest = autorouterAlgorithmBySessionId.keys().next().value as string | undefined + if (!oldest) break + autorouterAlgorithmBySessionId.delete(oldest) + } +} + +function getAutorouterAlgorithm(sessionId: string): string | undefined { + const algorithm = autorouterAlgorithmBySessionId.get(sessionId) + if (!algorithm) return undefined + // Refresh insertion order so active sessions survive bounded eviction. + autorouterAlgorithmBySessionId.delete(sessionId) + autorouterAlgorithmBySessionId.set(sessionId, algorithm) + return algorithm +} function pruneLastRouteMap(): void { if (lastRouteBySessionId.size > LAST_ROUTE_LIMIT) { @@ -719,7 +749,23 @@ function buildWrapper(ctx: ProviderWrapStreamFnContext) { } if (prevOnResponse) await prevOnResponse(response, responseModel) } - return inner(model, context, { ...options, onPayload, onResponse }) + const identity = getIdentity() + const autorouterAlgorithm = identity + ? getAutorouterAlgorithm(identity.sessionId) + : undefined + return inner(model, context, { + ...options, + ...(autorouterAlgorithm + ? { + headers: { + ...options?.headers, + "X-TaaS-Autorouter-Algorithm": autorouterAlgorithm, + }, + } + : {}), + onPayload, + onResponse, + }) } as typeof inner } @@ -745,7 +791,10 @@ function buildTransportTurnState(ctx: ProviderResolveTransportTurnStateContext): `mode=${identity.identityMode} attempt=${ctx.attempt}` ) } - return { headers: buildCorrelationHeaders({ sessionId: identity.sessionId, turnId: ctx.turnId, attempt: ctx.attempt, agentId }) } + const headers = buildCorrelationHeaders({ sessionId: identity.sessionId, turnId: ctx.turnId, attempt: ctx.attempt, agentId }) + const autorouterAlgorithm = getAutorouterAlgorithm(identity.sessionId) + if (autorouterAlgorithm) headers["X-TaaS-Autorouter-Algorithm"] = autorouterAlgorithm + return { headers } } export default { @@ -765,6 +814,9 @@ export default { traceSessionBridge, traceBridgeStats, resetTraceBridgeStats, + setAutorouterAlgorithm, + getAutorouterAlgorithm, + autorouterAlgorithmBySessionId, }, id: "openclaw-taas-affinity", name: "CloudSigma TaaS Token Cache Optimizer", @@ -837,6 +889,33 @@ export default { { scope: "operator.read" }, ) + if (typeof api.registerGatewayMethod === "function") api.registerGatewayMethod( + "taas.autorouter.setAlgorithm", + async ({ params, respond }) => { + const pp = (params ?? {}) as Record + const sessionId = safeString(pp.sessionId) + if (!sessionId) { + respond(false, undefined, { code: "invalid_request", message: "sessionId is required" }) + return + } + if (pp.algorithm !== null && typeof pp.algorithm !== "string") { + respond(false, undefined, { code: "invalid_request", message: "algorithm must be a supported algorithm or null" }) + return + } + const algorithm = pp.algorithm === null ? null : safeString(pp.algorithm) + if (algorithm !== null && (!algorithm || !AUTOROUTER_ALGORITHMS.has(algorithm))) { + respond(false, undefined, { + code: "invalid_request", + message: "unsupported AutoRouter algorithm", + }) + return + } + setAutorouterAlgorithm(sessionId, algorithm) + respond(true, { ok: true, sessionId, algorithm }) + }, + { scope: "operator.write" }, + ) + if (typeof api.registerGatewayMethod === "function") api.registerGatewayMethod( "taas.autorouter.lastRoute", async ({ params, respond }) => { @@ -886,5 +965,8 @@ export default { traceSessionBridge, traceBridgeStats, resetTraceBridgeStats, + setAutorouterAlgorithm, + getAutorouterAlgorithm, + autorouterAlgorithmBySessionId, }, } diff --git a/package-lock.json b/package-lock.json index f64c9d1..aef4ab7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openclaw-taas-affinity", - "version": "0.11.0", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openclaw-taas-affinity", - "version": "0.11.0", + "version": "0.12.0", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", diff --git a/package.json b/package.json index 1605145..dabe9ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw-taas-affinity", - "version": "0.11.0", + "version": "0.12.0", "description": "OpenClaw provider plugin \u2014 CloudSigma TaaS session affinity. Injects a stable X-Session-Id header per conversation so TaaS can pin the session to the same OAuth token / Bedrock region / Claude Code node, maximising prompt-cache hit rates.", "type": "module", "main": "dist/index.js", diff --git a/test/requester-runtime.test.ts b/test/requester-runtime.test.ts index be7c207..52a3806 100644 --- a/test/requester-runtime.test.ts +++ b/test/requester-runtime.test.ts @@ -169,7 +169,7 @@ test("privacy-safe affinity stats gateway method exposes counters without identi let response: any await handler({ params: {}, respond(ok: boolean, payload: any) { response = { ok, payload } } }) assert.equal(response.ok, true) - assert.equal(response.payload.pluginVersion, "0.11.0") + assert.equal(response.payload.pluginVersion, "0.12.0") assert.equal(response.payload.bridge.ttlMs, 30 * 60 * 1000) assert.equal(response.payload.bridge.limit, 1024) assert.deepEqual(Object.keys(response.payload.counters).sort(), [ @@ -182,3 +182,40 @@ test("privacy-safe affinity stats gateway method exposes counters without identi restore() } }) + +test("autorouter override gateway method validates, injects, clears, and stays session-scoped", async () => { + const { plugin, restore } = await loadPlugin() + try { + const methods = new Map() + let provider: any + plugin.register({ + registerProvider(candidate: any) { provider = candidate }, + registerGatewayMethod(name: string, handler: any) { methods.set(name, handler) }, + runtime: { system: { enqueueSystemEvent: () => true, requestHeartbeat: () => {} } }, + }) + const handler = methods.get("taas.autorouter.setAlgorithm") + const call = async (params: Record) => { + let response: any + await handler({ params, respond(ok: boolean, payload: any, error: any) { response = { ok, payload, error } } }) + return response + } + + assert.equal((await call({ sessionId: "session-a", algorithm: "cost" })).ok, true) + assert.equal( + provider.resolveTransportTurnState({ sessionId: "session-a", turnId: "turn-a", attempt: 1 }).headers["X-TaaS-Autorouter-Algorithm"], + "cost", + ) + assert.equal( + provider.resolveTransportTurnState({ sessionId: "session-b", turnId: "turn-b", attempt: 1 }).headers["X-TaaS-Autorouter-Algorithm"], + undefined, + ) + assert.equal((await call({ sessionId: "session-a", algorithm: "parity_scoring" })).ok, false) + assert.equal((await call({ sessionId: "session-a", algorithm: null })).ok, true) + assert.equal( + provider.resolveTransportTurnState({ sessionId: "session-a", turnId: "turn-c", attempt: 1 }).headers["X-TaaS-Autorouter-Algorithm"], + undefined, + ) + } finally { + restore() + } +}) diff --git a/test/smoke.mjs b/test/smoke.mjs index bf84636..2bf2201 100644 --- a/test/smoke.mjs +++ b/test/smoke.mjs @@ -68,7 +68,7 @@ const transportState = provider.resolveTransportTurnState({ sessionId: "smoke-lo assert.equal(transportState.headers["X-Session-Id"], "smoke-local-session") assert.equal(transportState.headers["X-OpenClaw-Session-Id"], transportState.headers["X-Session-Id"]) -assert.equal(transportState.headers["X-OpenClaw-Plugin-Version"], "0.11.0") +assert.equal(transportState.headers["X-OpenClaw-Plugin-Version"], "0.12.0") assert.equal(transportState.headers["X-OpenClaw-Turn-Id"], "turn-smoke") assert.equal(transportState.headers["X-OpenClaw-Attempt"], "1") @@ -93,7 +93,7 @@ assert.equal(capturedPayload.metadata.requester_runtime.tool_execution, "directi assert.equal("available_bridges" in capturedPayload.metadata.requester_runtime, false) assert.equal(capturedPayload.metadata.openclaw_correlation.schema_version, "2026-06-05") assert.equal(capturedPayload.metadata.openclaw_correlation.source, "openclaw-taas-affinity") -assert.equal(capturedPayload.metadata.openclaw_correlation.plugin_version, "0.11.0") +assert.equal(capturedPayload.metadata.openclaw_correlation.plugin_version, "0.12.0") assert.equal(capturedPayload.metadata.openclaw_correlation.session_id, localSessionA) assert.equal(capturedPayload.metadata.openclaw_correlation.sticky_key, localSessionA) assert.equal(capturedPayload.metadata.openclaw_correlation.provider, "cloudsigma") @@ -127,18 +127,33 @@ console.log("smoke ok") // X-TaaS-* headers and that taas.autorouter.lastRoute returns them. let registeredMethod let registeredHandler +const registeredMethods = new Map() const apiWithGateway = { registerProvider(candidate) { // keep previous provider too — second registration }, registerGatewayMethod(name, handler) { + registeredMethods.set(name, handler) registeredMethod = name registeredHandler = handler }, } plugin.register(apiWithGateway) -assert.equal(registeredMethod, "taas.autorouter.lastRoute", "gateway method registered") -assert.equal(typeof registeredHandler, "function", "handler is a function") +registeredHandler = registeredMethods.get("taas.autorouter.lastRoute") +assert.equal(typeof registeredHandler, "function", "lastRoute method registered") + +const setAlgorithmHandler = registeredMethods.get("taas.autorouter.setAlgorithm") +assert.equal(typeof setAlgorithmHandler, "function", "setAlgorithm method registered") +await setAlgorithmHandler({ + params: { sessionId: "capture-smoke-session", algorithm: "ttft" }, + respond: (ok) => assert.equal(ok, true), +}) +const overrideHeaders = provider.resolveTransportTurnState({ + sessionId: "capture-smoke-session", + turnId: "turn-override", + attempt: 1, +}).headers +assert.equal(overrideHeaders["X-TaaS-Autorouter-Algorithm"], "ttft") // Drive the wrapper through onResponse with synthetic autorouter headers. const captureStreamFn = async (_model, _context, options = {}) => {