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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ For requests routed through the `cloudsigma` or `cloudsigma-staging` provider ID
- injects `metadata.sticky_key` when absent
- injects a sanitized `metadata.requester_runtime` envelope when absent
- injects transport header `X-Session-Id`
- captures TaaS autorouter response headers
- injects correlation headers `X-OpenClaw-Session-Id`, `X-OpenClaw-Turn-Id`, `X-OpenClaw-Attempt`, and `X-OpenClaw-Agent-Id` (when available)
- 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`

## Startup compatibility
Expand Down Expand Up @@ -59,6 +61,17 @@ Example injected metadata:
"local_paths": "omitted_by_default"
},
"redaction_policy": "no_secrets;no_raw_local_paths;no_env_values;no_git_remotes;no_status_or_diffs;no_extra_params"
},
"openclaw_correlation": {
"schema_version": "2026-06-05",
"source": "openclaw-taas-affinity",
"plugin_version": "0.5.2",
"session_id": "oc:0123456789abcdef",
"sticky_key": "oc:0123456789abcdef",
"session_source_hint": "source:1a2b3c4d5e6f7890",
"agent_id": "new-agent-2",
"provider": "cloudsigma",
"model_id": "cloudsigma/auto"
}
}
}
Expand Down
67 changes: 63 additions & 4 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ 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.5.2"

// OpenClaw stores active registry state (including workspaceDir) on globalThis
// under this well-known symbol key.
Expand All @@ -40,6 +41,10 @@ type RequesterRuntime = Record<string, unknown>
type AutorouterCapture = {
sessionId: string
capturedAt: number
taasRequestId: string | null
taasTraceId: string | null
openclawTurnId: string | null
openclawAttempt: string | null
autorouterModel: string | null
autorouterAlgo: string | null
autorouterAlgoSource: string | null
Expand Down Expand Up @@ -162,6 +167,49 @@ function deriveAgentIdForCapture(ctx: { agentDir?: string; workspaceDir?: string
return seg
}

function buildCorrelationMetadata(
sessionId: string,
source: string,
ctx: ProviderWrapStreamFnContext,
agentId: string | null
): Record<string, unknown> {
const ctxRecord = ctx as unknown as Record<string, unknown>
const modelRecord = asRecord(ctxRecord.model)
const modelId = safeString(ctxRecord.modelId) ?? safeString(modelRecord?.id)
const provider = safeString(ctxRecord.provider)
return {
schema_version: "2026-06-05",
source: REQUESTER_RUNTIME_SOURCE,
plugin_version: PLUGIN_VERSION,
session_id: sessionId,
sticky_key: sessionId,
session_source_hint: stableHash(source, "source"),
...(agentId && { agent_id: agentId }),
...(provider && { provider }),
...(modelId && { model_id: modelId }),
}
}

function buildCorrelationHeaders(args: {
sessionId: string
turnId?: unknown
attempt?: unknown
agentId?: string | null
}): Record<string, string> {
const headers: Record<string, string> = {
"X-Session-Id": args.sessionId,
"X-OpenClaw-Session-Id": args.sessionId,
"X-OpenClaw-Plugin-Version": PLUGIN_VERSION,
}
const turnId = safeString(args.turnId)
const attempt = args.attempt === undefined || args.attempt === null ? undefined : String(args.attempt)
const agentId = safeString(args.agentId)
if (turnId) headers["X-OpenClaw-Turn-Id"] = turnId
if (attempt) headers["X-OpenClaw-Attempt"] = attempt
if (agentId) headers["X-OpenClaw-Agent-Id"] = agentId
return headers
}

function buildRequesterRuntime(
ctx: ProviderWrapStreamFnContext,
sessionId: string,
Expand Down Expand Up @@ -199,20 +247,23 @@ function buildRequesterRuntime(
function patchPayloadMetadata(
payload: Record<string, unknown>,
sessionId: string,
requesterRuntime?: RequesterRuntime
requesterRuntime?: RequesterRuntime,
correlation?: Record<string, unknown>
): Record<string, unknown> {
const existingMeta = asRecord(payload.metadata) ?? {}
const needsSessionId = !existingMeta.session_id
const needsStickyKey = !existingMeta.sticky_key
const needsRequesterRuntime = requesterRuntime && !existingMeta.requester_runtime
if (!needsSessionId && !needsStickyKey && !needsRequesterRuntime) return payload
const needsCorrelation = correlation && !existingMeta.openclaw_correlation
if (!needsSessionId && !needsStickyKey && !needsRequesterRuntime && !needsCorrelation) return payload
return {
...payload,
metadata: {
...existingMeta,
...(needsSessionId && { session_id: sessionId }),
...(needsStickyKey && { sticky_key: sessionId }),
...(needsRequesterRuntime && { requester_runtime: requesterRuntime }),
...(needsCorrelation && { openclaw_correlation: correlation }),
},
}
}
Expand Down Expand Up @@ -258,6 +309,10 @@ function captureAutorouterFromHeaders(
autorouterModel: lowered["x-taas-autorouter-model"] ?? null,
autorouterAlgo: lowered["x-taas-autorouter-mode"] ?? null,
autorouterAlgoSource: lowered["x-taas-autorouter-algorithm-source"] ?? null,
taasRequestId: lowered["x-request-id"] ?? lowered["x-taas-request-id"] ?? null,
taasTraceId: lowered["x-trace-id"] ?? lowered["x-taas-trace-id"] ?? null,
openclawTurnId: lowered["x-openclaw-turn-id"] ?? null,
openclawAttempt: lowered["x-openclaw-attempt"] ?? null,
thinkingApplied: lowered["x-taas-thinking-applied"] ?? null,
routedContextWindow:
parsedContextWindow && Number.isFinite(parsedContextWindow) && parsedContextWindow > 0
Expand Down Expand Up @@ -294,6 +349,7 @@ function buildWrapper(ctx: ProviderWrapStreamFnContext) {
const { sessionId, source } = resolveSessionId(ctx.workspaceDir)
const agentIdForCapture = deriveAgentIdForCapture(ctx as { agentDir?: string; workspaceDir?: string })
const requesterRuntime = buildRequesterRuntime(ctx, sessionId, source)
const correlation = buildCorrelationMetadata(sessionId, source, ctx, agentIdForCapture)

if (isDev) console.debug(`[taas-affinity] wrapStreamFn sessionId=${sessionId} source=${source}`)

Expand All @@ -304,7 +360,7 @@ function buildWrapper(ctx: ProviderWrapStreamFnContext) {
const onPayload: NonNullable<typeof options>["onPayload"] = async (payload, payloadModel) => {
const payloadRecord = asRecord(payload)
if (!payloadRecord) return prevOnPayload ? prevOnPayload(payload, payloadModel) : payload
const patched = patchPayloadMetadata(payloadRecord, sessionId, requesterRuntime)
const patched = patchPayloadMetadata(payloadRecord, sessionId, requesterRuntime, correlation)
return prevOnPayload ? prevOnPayload(patched, payloadModel) : patched
}
const prevOnResponse = options?.onResponse
Expand All @@ -323,13 +379,14 @@ function buildWrapper(ctx: ProviderWrapStreamFnContext) {
function buildTransportTurnState(ctx: ProviderResolveTransportTurnStateContext): ProviderTransportTurnState | null {
const activeSource = getActiveSessionSource() ?? fallbackSessionSource()
const sessionId = deriveSessionId(activeSource)
const agentId = deriveAgentIdForCapture(ctx as unknown as { agentDir?: string; workspaceDir?: string })
if (isDev) {
console.debug(
`[taas-affinity] resolveTransportTurnState sessionId=${sessionId} ` +
`source=${activeSource} turnId=${ctx.turnId} attempt=${ctx.attempt}`
)
}
return { headers: { "X-Session-Id": sessionId } }
return { headers: buildCorrelationHeaders({ sessionId, turnId: ctx.turnId, attempt: ctx.attempt, agentId }) }
}

export default {
Expand Down Expand Up @@ -382,6 +439,8 @@ export default {
patchPayloadMetadata,
resolveSessionId,
captureAutorouterFromHeaders,
buildCorrelationHeaders,
buildCorrelationMetadata,
getLastRouteForAgent,
getLastRouteForSession,
},
Expand Down
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.5.1",
"version": "0.5.2",
"description": "OpenClaw provider plugin — 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
15 changes: 15 additions & 0 deletions test/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ assert.equal(
"direction_2_gateway"
)
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.5.2")
assert.equal(capturedPayload.metadata.openclaw_correlation.session_id, capturedPayload.metadata.session_id)
assert.equal(capturedPayload.metadata.openclaw_correlation.sticky_key, capturedPayload.metadata.session_id)
assert.equal(capturedPayload.metadata.openclaw_correlation.provider, "cloudsigma")
assert.equal(capturedPayload.metadata.openclaw_correlation.model_id, "cloudsigma/test-model")

const transportState = provider.resolveTransportTurnState({
provider: "cloudsigma",
Expand All @@ -80,6 +87,10 @@ const transportState = provider.resolveTransportTurnState({
})

assert.match(transportState.headers["X-Session-Id"], /^oc:[a-f0-9]{16}$/)
assert.equal(transportState.headers["X-OpenClaw-Session-Id"], transportState.headers["X-Session-Id"])
assert.equal(transportState.headers["X-OpenClaw-Plugin-Version"], "0.5.2")
assert.equal(transportState.headers["X-OpenClaw-Turn-Id"], "turn-smoke")
assert.equal(transportState.headers["X-OpenClaw-Attempt"], "1")

console.log("smoke ok")

Expand Down Expand Up @@ -117,6 +128,8 @@ const captureStreamFn = async (_model, _context, options = {}) => {
"x-taas-autorouter-algorithm-source": "api_key_default",
"x-taas-thinking-applied": "medium",
"x-taas-routed-context-window": "128000",
"x-request-id": "taas-req-123",
"x-trace-id": "taas-trace-456",
},
},
_model
Expand Down Expand Up @@ -155,6 +168,8 @@ assert.equal(respondedPayload.capture.autorouterAlgo, "best_fit")
assert.equal(respondedPayload.capture.autorouterAlgoSource, "api_key_default")
assert.equal(respondedPayload.capture.thinkingApplied, "medium")
assert.equal(respondedPayload.capture.routedContextWindow, 128000)
assert.equal(respondedPayload.capture.taasRequestId, "taas-req-123")
assert.equal(respondedPayload.capture.taasTraceId, "taas-trace-456")

// Non-autorouted response should NOT overwrite (we explicitly drop it)
await captureWrapped("model", { messages: [] }, {})
Expand Down
Loading