Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
88 changes: 85 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -553,6 +562,27 @@ function patchPayloadMetadata(

const lastRouteBySessionId = new Map<string, AutorouterCapture>()
const lastRouteByAgentId = new Map<string, AutorouterCapture>()
const autorouterAlgorithmBySessionId = new Map<string, string>()

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) {
Expand Down Expand Up @@ -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
}

Expand All @@ -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 {
Expand All @@ -765,6 +814,9 @@ export default {
traceSessionBridge,
traceBridgeStats,
resetTraceBridgeStats,
setAutorouterAlgorithm,
getAutorouterAlgorithm,
autorouterAlgorithmBySessionId,
},
id: "openclaw-taas-affinity",
name: "CloudSigma TaaS Token Cache Optimizer",
Expand Down Expand Up @@ -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<string, unknown>
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 }) => {
Expand Down Expand Up @@ -886,5 +965,8 @@ export default {
traceSessionBridge,
traceBridgeStats,
resetTraceBridgeStats,
setAutorouterAlgorithm,
getAutorouterAlgorithm,
autorouterAlgorithmBySessionId,
},
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
39 changes: 38 additions & 1 deletion test/requester-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(), [
Expand All @@ -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<string, any>()
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<string, unknown>) => {
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()
}
})
23 changes: 19 additions & 4 deletions test/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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")
Expand Down Expand Up @@ -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 = {}) => {
Expand Down
Loading