feat: connect cloud api code agent billing (#1139)
This commit is contained in:
@@ -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] ?? "");
|
||||
|
||||
Reference in New Issue
Block a user