feat: connect cloud api code agent billing (#1139)

This commit is contained in:
Lyon
2026-06-13 20:19:07 +08:00
committed by GitHub
parent 032857361f
commit 3f8fc6b80c
6 changed files with 525 additions and 7 deletions
+143 -2
View File
@@ -93,7 +93,9 @@ export async function handleCodeAgentChatHttp(request, response, options) {
if (manualSession?.blocked) return;
const workspaceClaim = await claimWorkbenchWorkspaceTurn({ params: chatParams, options, traceId, response });
if (workspaceClaim?.blocked) return;
const nativeSessionChatParams = stripSyntheticConversationContext(chatParams, traceId, options);
const billingPreflight = await preflightCodeAgentBilling({ params: chatParams, options, traceId, response });
if (billingPreflight?.blocked) return;
const nativeSessionChatParams = stripSyntheticConversationContext({ ...chatParams, userBillingReservation: billingPreflight?.reservation ?? null }, traceId, options);
if (codeAgentChatShortConnectionRequested(request, params, options)) {
submitCodeAgentChatTurn({
@@ -126,6 +128,7 @@ export async function handleCodeAgentChatHttp(request, response, options) {
}
const payload = await runCodeAgentChat(nativeSessionChatParams, options);
await recordCodeAgentBillingUsage({ payload, params: nativeSessionChatParams, options });
await recordCodeAgentSessionOwner({ payload, params: nativeSessionChatParams, options, status: payload.status === "completed" ? "active" : payload.status });
const responsePayload = annotateOwner(payload, nativeSessionChatParams);
@@ -534,7 +537,7 @@ async function codeAgentAuthEnv(options = {}, env = process.env, params = {}) {
HWLAB_RUNTIME_ENDPOINT_SOURCE: codeAgentAgentRunAdapterEnabled(env) ? "runtime-namespace" : "runtime-env",
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1",
HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1",
...(key?.displaySecret ? { HWLAB_API_KEY: key.displaySecret } : {})
...(options.actorApiKeySecret ? { HWLAB_API_KEY: options.actorApiKeySecret } : key?.displaySecret ? { HWLAB_API_KEY: key.displaySecret } : {})
};
}
@@ -700,6 +703,7 @@ function submitCodeAgentChatTurn({ params, options, traceId }) {
executionOptions = { ...options, ...(await codeAgentChatExecutionOptions(options, params)) };
const payload = await submitAgentRunChatTurn({ params, options: executionOptions, traceId, traceStore, results });
if (isCodeAgentResultCanceled(results.get(traceId))) return;
await recordCodeAgentBillingUsage({ payload, params, options: executionOptions });
const owned = annotateOwner(payload, params);
await recordCodeAgentSessionOwner({ payload: owned, params, options: executionOptions, status: "running" });
results.set(traceId, owned);
@@ -725,6 +729,7 @@ function submitCodeAgentChatTurn({ params, options, traceId }) {
updatedAt: new Date().toISOString()
}, params);
recordCodeAgentConversationFact(payload, executionOptions);
await recordCodeAgentBillingUsage({ payload, params, options: executionOptions });
results.set(traceId, payload);
traceStore.append(traceId, {
type: "result",
@@ -767,6 +772,7 @@ function submitCodeAgentChatTurn({ params, options, traceId }) {
try {
const payload = await runCodeAgentChat(params, options);
if (isCodeAgentResultCanceled(results.get(traceId))) return;
await recordCodeAgentBillingUsage({ payload, params, options });
await recordCodeAgentSessionOwner({ payload, params, options, status: payload.status === "completed" ? "active" : payload.status });
results.set(traceId, annotateOwner(payload, params));
traceStore.append(traceId, {
@@ -796,6 +802,7 @@ function submitCodeAgentChatTurn({ params, options, traceId }) {
},
updatedAt: new Date().toISOString()
};
await recordCodeAgentBillingUsage({ payload, params, options });
results.set(traceId, payload);
traceStore.append(traceId, {
type: "result",
@@ -823,6 +830,140 @@ function codeAgentChatShortConnectionRequested(request, params, options = {}) {
truthyFlag(envValue);
}
async function preflightCodeAgentBilling({ params = {}, options = {}, traceId, response } = {}) {
const client = options.userBillingClient;
if (!codeAgentBillingEnabled(options) || !client?.configured || !options.userBillingAuth?.active) return null;
const estimatedCredits = parsePositiveInteger(options.env?.HWLAB_USER_BILLING_CODE_AGENT_ESTIMATED_CREDITS, 1);
const body = {
...(options.actorApiKeySecret ? { apiKey: options.actorApiKeySecret } : { userId: options.actor?.id }),
serviceId: "hwlab-code-agent",
estimatedCredits,
idempotencyKey: `code-agent:${traceId}:preflight`,
metadata: codeAgentBillingMetadata(params, traceId, { stage: "preflight" })
};
const result = await client.billingPreflight(body);
if (result.ok && result.body?.allowed !== false) return { reservation: sanitizeBillingReservation(result.body) };
const status = result.status === 402 ? 402 : 503;
sendJson(response, status, {
ok: false,
accepted: false,
status: status === 402 ? "payment_required" : "billing_unavailable",
traceId,
error: {
code: result.error?.code ?? (status === 402 ? "insufficient_credits" : "user_billing_preflight_failed"),
layer: "billing",
retryable: status !== 402,
message: result.error?.message ?? "Code Agent billing preflight failed",
route: "/v1/agent/chat"
},
blocker: {
code: result.error?.code ?? (status === 402 ? "insufficient_credits" : "user_billing_preflight_failed"),
layer: "billing",
retryable: status !== 402,
summary: result.error?.message ?? "Code Agent billing preflight failed"
},
valuesRedacted: true
});
return { blocked: true };
}
async function recordCodeAgentBillingUsage({ payload = {}, params = {}, options = {} } = {}) {
const reservation = params.userBillingReservation;
const reservationId = typeof reservation?.reservationId === "string" ? reservation.reservationId : "";
const client = options.userBillingClient;
if (!reservationId || !client?.configured) return null;
const traceId = safeTraceId(payload.traceId ?? params.traceId);
const usedTokens = codeAgentUsedTokens(payload);
const usedCredits = parsePositiveInteger(options.env?.HWLAB_USER_BILLING_CODE_AGENT_USED_CREDITS, 1);
const result = await client.billingRecord({
reservationId,
serviceId: "hwlab-code-agent",
usedCredits,
usedTokens,
idempotencyKey: `code-agent:${traceId}:record`,
metadata: codeAgentBillingMetadata(params, traceId, { stage: "record", status: payload.status ?? "unknown" })
});
const billing = result.ok ? sanitizeBillingRecord(result.body, reservation) : {
recorded: false,
reservationId,
errorCode: result.error?.code ?? "user_billing_record_failed",
valuesRedacted: true
};
payload.billing = billing;
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
if (traceId) {
traceStore.append(traceId, {
type: "billing",
status: result.ok ? "recorded" : "degraded",
label: result.ok ? "billing:recorded" : "billing:record_failed",
reservationId,
recordId: result.ok ? result.body?.recordId ?? null : null,
errorCode: result.ok ? null : billing.errorCode,
valuesPrinted: false
});
}
return billing;
}
function codeAgentBillingEnabled(options = {}) {
const env = options.env ?? process.env;
if (String(env.HWLAB_USER_BILLING_CODE_AGENT_ENABLED ?? "").trim() === "0") return false;
return Boolean(options.userBillingClient?.configured);
}
function codeAgentBillingMetadata(params = {}, traceId, extra = {}) {
return {
traceId,
conversationId: safeConversationId(params.conversationId) || null,
sessionId: safeSessionId(params.sessionId) || null,
projectId: textValue(params.projectId) || null,
route: "/v1/agent/chat",
resource: "code-agent-turn",
...extra,
valuesRedacted: true
};
}
function sanitizeBillingReservation(value = {}) {
return {
allowed: value.allowed === true,
reservationId: textValue(value.reservationId) || null,
estimatedCredits: Number.isFinite(Number(value.estimatedCredits)) ? Number(value.estimatedCredits) : null,
estimatedTokens: Number.isFinite(Number(value.estimatedTokens)) ? Number(value.estimatedTokens) : null,
expiresAt: textValue(value.expiresAt) || null,
valuesRedacted: true
};
}
function sanitizeBillingRecord(value = {}, reservation = {}) {
return {
recorded: true,
reservationId: textValue(reservation.reservationId) || null,
recordId: textValue(value.recordId) || null,
credits: Number.isFinite(Number(value.credits)) ? Number(value.credits) : null,
balance: Number.isFinite(Number(value.balance)) ? Number(value.balance) : null,
valuesRedacted: true
};
}
function codeAgentUsedTokens(payload = {}) {
const usage = payload.usage && typeof payload.usage === "object" ? payload.usage : null;
const traceSummary = payload.traceSummary && typeof payload.traceSummary === "object" ? payload.traceSummary : null;
const candidates = [
usage?.totalTokens,
usage?.total_tokens,
usage?.tokens,
Number(usage?.inputTokens ?? usage?.input_tokens ?? 0) + Number(usage?.outputTokens ?? usage?.output_tokens ?? 0),
traceSummary?.tokenCount,
traceSummary?.tokens
];
for (const value of candidates) {
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed > 0) return Math.ceil(parsed);
}
return 0;
}
export async function handleCodeAgentChatResultHttp(request, response, url, options) {
const parts = url.pathname.split("/").filter(Boolean);
const traceId = decodeURIComponent(parts[4] ?? "");