Skip to content

Commit 693b3db

Browse files
committed
feat(relay): add plan-based gating, KV error logging, and request logging
Worker script improvements: - Plan-based gating: WebSocket transport requires paid plan (RELAY_PLAN=paid) - KV error logging: non-429/403 upstream errors logged to KV with 7-day TTL - Request logging: HTTP and WebSocket requests logged on paid plan - GET health endpoint returns plan info and available transports
1 parent 0511865 commit 693b3db

2 files changed

Lines changed: 97 additions & 28 deletions

File tree

packages/core/src/relay.ts

Lines changed: 96 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,11 @@ export async function sendViaRelay(options: {
985985
}
986986

987987
export const WORKER_SCRIPT = `
988+
function getPlanConfig(env) {
989+
const paid = (env.RELAY_PLAN || '').toLowerCase() === 'paid'
990+
return { paid, allowWebSocket: paid, logRequests: paid }
991+
}
992+
988993
async function hashBody(body) {
989994
const bytes = new TextEncoder().encode(body)
990995
const digest = await crypto.subtle.digest('SHA-256', bytes)
@@ -1032,7 +1037,7 @@ async function resolveBody(env, payload) {
10321037
return { error: 'unknown mode', status: 400 }
10331038
}
10341039
1035-
async function prepareUpstream(env, payload) {
1040+
async function prepareUpstream(env, payload, config) {
10361041
if ((payload.protocol !== 1 && payload.protocol !== 2) || payload.type !== 'request' || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {
10371042
return { error: 'invalid payload', status: 400 }
10381043
}
@@ -1045,14 +1050,16 @@ async function prepareUpstream(env, payload) {
10451050
}
10461051
10471052
const stateWrite = writeState(env, payload.affinity, { body, hash: payload.next_hash, revision: payload.revision }).catch(() => {})
1048-
console.log(JSON.stringify({
1049-
relay: 'opencode-anthropic-auth',
1050-
transport: 'relay',
1051-
mode: payload.mode,
1052-
revision: payload.revision,
1053-
affinity: String(payload.affinity).slice(0, 12),
1054-
bodyBytes: body.length,
1055-
}))
1053+
if (config.logRequests) {
1054+
console.log(JSON.stringify({
1055+
relay: 'opencode-anthropic-auth',
1056+
transport: 'http',
1057+
mode: payload.mode,
1058+
revision: payload.revision,
1059+
affinity: String(payload.affinity).slice(0, 12),
1060+
bodyBytes: body.length,
1061+
}))
1062+
}
10561063
10571064
return { body, stateWrite }
10581065
}
@@ -1105,16 +1112,8 @@ async function prepareWebSocketUpstream(env, state, payload) {
11051112
11061113
const nextState = { body, hash: payload.next_hash, revision: payload.revision }
11071114
const checkpoint = checkpointWebSocketState(env, payload, body, nextState)
1108-
const logAccepted = () => console.log(JSON.stringify({
1109-
relay: 'opencode-anthropic-auth',
1110-
transport: 'websocket',
1111-
mode: payload.mode,
1112-
revision: payload.revision,
1113-
affinity: String(payload.affinity).slice(0, 12),
1114-
bodyBytes: body.length,
1115-
}))
1116-
1117-
return { body, state: nextState, checkpoint, logAccepted }
1115+
1116+
return { body, state: nextState, checkpoint }
11181117
}
11191118
11201119
function headersToObject(headers) {
@@ -1123,18 +1122,51 @@ function headersToObject(headers) {
11231122
return result
11241123
}
11251124
1126-
async function handleRelayPayload(env, payload) {
1127-
const prepared = await prepareUpstream(env, payload)
1125+
const SKIP_ERROR_LOG_STATUSES = new Set([429, 403])
1126+
1127+
async function logUpstreamError(env, ctx, upstream, meta) {
1128+
if (!upstream.status || upstream.status < 400 || SKIP_ERROR_LOG_STATUSES.has(upstream.status)) return
1129+
try {
1130+
const body = await upstream.clone().text()
1131+
const key = 'error:' + Date.now() + ':' + (meta.id || meta.affinity || 'unknown')
1132+
const entry = JSON.stringify({
1133+
ts: new Date().toISOString(),
1134+
status: upstream.status,
1135+
statusText: upstream.statusText,
1136+
transport: meta.transport,
1137+
mode: meta.mode,
1138+
affinity: meta.affinity,
1139+
id: meta.id,
1140+
bodyBytes: meta.bodyBytes,
1141+
responseBody: body.slice(0, 50000),
1142+
responseHeaders: headersToObject(upstream.headers),
1143+
})
1144+
const kvWrite = env.RELAY_STATE.put(key, entry, { expirationTtl: 604800 }).catch(() => {})
1145+
if (ctx?.waitUntil) ctx.waitUntil(kvWrite)
1146+
else void kvWrite
1147+
console.error(JSON.stringify({
1148+
relay: 'opencode-anthropic-auth',
1149+
event: 'upstream_error',
1150+
status: upstream.status,
1151+
transport: meta.transport,
1152+
affinity: String(meta.affinity || '').slice(0, 12),
1153+
responsePreview: body.slice(0, 500),
1154+
}))
1155+
} catch {}
1156+
}
1157+
1158+
async function handleRelayPayload(env, payload, config) {
1159+
const prepared = await prepareUpstream(env, payload, config)
11281160
if (prepared.error) return prepared
11291161
const upstream = await fetch(payload.upstream.url, {
11301162
method: payload.upstream.method || 'POST',
11311163
headers: payload.upstream.headers,
11321164
body: prepared.body,
11331165
})
1134-
return { upstream, stateWrite: prepared.stateWrite }
1166+
return { upstream, stateWrite: prepared.stateWrite, bodyBytes: prepared.body.length }
11351167
}
11361168
1137-
async function handleWebSocket(socket, env, ctx, payload, getState, setState) {
1169+
async function handleWebSocket(socket, env, ctx, payload, getState, setState, config) {
11381170
const heartbeat = setInterval(() => {
11391171
try {
11401172
socket.send(JSON.stringify({ protocol: 2, type: 'keepalive' }))
@@ -1151,7 +1183,16 @@ async function handleWebSocket(socket, env, ctx, payload, getState, setState) {
11511183
11521184
setState(result.state)
11531185
socket.send(JSON.stringify({ protocol: 2, type: 'accepted', id: payload.id, hash: result.state.hash, revision: result.state.revision }))
1154-
ctx?.waitUntil?.(deferWorkerTask(result.logAccepted))
1186+
if (config.logRequests) {
1187+
console.log(JSON.stringify({
1188+
relay: 'opencode-anthropic-auth',
1189+
transport: 'websocket',
1190+
mode: payload.mode,
1191+
revision: payload.revision,
1192+
affinity: String(payload.affinity).slice(0, 12),
1193+
bodyBytes: result.body.length,
1194+
}))
1195+
}
11551196
11561197
const upstreamPromise = fetch(payload.upstream.url, {
11571198
method: payload.upstream.method || 'POST',
@@ -1160,6 +1201,19 @@ async function handleWebSocket(socket, env, ctx, payload, getState, setState) {
11601201
})
11611202
ctx?.waitUntil?.(result.checkpoint)
11621203
const upstream = await upstreamPromise
1204+
// Log non-429/403 errors to KV for debugging
1205+
if (upstream.status >= 400 && !SKIP_ERROR_LOG_STATUSES.has(upstream.status)) {
1206+
const errorClone = upstream.clone()
1207+
const errorLog = logUpstreamError(env, ctx, errorClone, {
1208+
transport: 'websocket',
1209+
mode: payload.mode,
1210+
affinity: payload.affinity,
1211+
id: payload.id,
1212+
bodyBytes: result.body.length,
1213+
})
1214+
if (ctx?.waitUntil) ctx.waitUntil(errorLog)
1215+
else void errorLog
1216+
}
11631217
socket.send(JSON.stringify({
11641218
protocol: 2,
11651219
type: 'response_start',
@@ -1187,7 +1241,12 @@ async function handleWebSocket(socket, env, ctx, payload, getState, setState) {
11871241
11881242
export default {
11891243
async fetch(request, env, ctx) {
1244+
const config = getPlanConfig(env)
1245+
11901246
if (request.headers.get('Upgrade') === 'websocket') {
1247+
if (!config.allowWebSocket) {
1248+
return new Response('WebSocket transport requires Workers Paid plan. Use HTTP transport or upgrade your plan.', { status: 403 })
1249+
}
11911250
const url = new URL(request.url)
11921251
const token = url.searchParams.get('token')
11931252
const affinity = url.searchParams.get('affinity')
@@ -1233,15 +1292,19 @@ export default {
12331292
}
12341293
payload.affinity = affinity
12351294
busy = true
1236-
const run = handleWebSocket(server, env, ctx, payload, () => state, (nextState) => { state = nextState }).finally(() => { busy = false })
1295+
const run = handleWebSocket(server, env, ctx, payload, () => state, (nextState) => { state = nextState }, config).finally(() => { busy = false })
12371296
ctx?.waitUntil?.(run)
12381297
if (!ctx?.waitUntil) void run
12391298
})
12401299
return new Response(null, { status: 101, webSocket: client })
12411300
}
12421301
12431302
if (request.method === 'GET') {
1244-
return Response.json({ status: 'ok', transports: ['http', 'websocket'] })
1303+
return Response.json({
1304+
status: 'ok',
1305+
plan: config.paid ? 'paid' : 'free',
1306+
transports: config.allowWebSocket ? ['http', 'websocket'] : ['http'],
1307+
})
12451308
}
12461309
if (request.method !== 'POST') return new Response('method not allowed', { status: 405 })
12471310
if (request.headers.get('x-relay-token') !== env.RELAY_TOKEN) {
@@ -1250,12 +1313,18 @@ export default {
12501313
12511314
try {
12521315
const payload = await request.json()
1253-
const result = await handleRelayPayload(env, payload)
1316+
const result = await handleRelayPayload(env, payload, config)
12541317
if (result.error) return Response.json({ error: result.error }, { status: result.status })
12551318
12561319
if (result.stateWrite) ctx.waitUntil(result.stateWrite)
12571320
12581321
const upstream = result.upstream
1322+
await logUpstreamError(env, ctx, upstream, {
1323+
transport: 'http',
1324+
mode: payload.mode,
1325+
affinity: payload.affinity,
1326+
bodyBytes: result.bodyBytes,
1327+
})
12591328
return new Response(upstream.body, {
12601329
status: upstream.status,
12611330
statusText: upstream.statusText,

packages/opencode/src/tests/relay-worker-miniflare.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ async function startWorker() {
4848
modules: true,
4949
compatibilityDate: '2026-04-28',
5050
kvNamespaces: ['RELAY_STATE'],
51-
bindings: { RELAY_TOKEN },
51+
bindings: { RELAY_TOKEN, RELAY_PLAN: 'paid' },
5252
port: 0,
5353
log: new NoOpLog(),
5454
})

0 commit comments

Comments
 (0)