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
+4
View File
@@ -402,6 +402,10 @@ lanes:
HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH: secretRef:hwlab-v03-bootstrap-admin/password-hash
HWLAB_BOOTSTRAP_ADMIN_API_KEY_ID: key_master_server_admin
HWLAB_BOOTSTRAP_ADMIN_API_KEY: secretRef:hwlab-v03-master-server-admin-api-key/api-key
HWLAB_USER_BILLING_URL: http://hwlab-user-billing.hwlab-v03.svc.cluster.local:6670
HWLAB_USER_BILLING_CODE_AGENT_ENABLED: "1"
HWLAB_USER_BILLING_CODE_AGENT_ESTIMATED_CREDITS: "1"
HWLAB_USER_BILLING_CODE_AGENT_USED_CREDITS: "1"
HWLAB_KEYCLOAK_ISSUER: https://auth.74-48-78-17.nip.io/realms/hwlab
HWLAB_KEYCLOAK_CLIENT_ID: hwlab-cloud-web
HWLAB_KEYCLOAK_CLIENT_SECRET: secretRef:hwlab-cloud-web-client/client-secret
+105 -4
View File
@@ -13,6 +13,7 @@ import {
openFgaObject
} from "./openfga-authorization.ts";
import { getHeader, readBody, sendJson, sendRedirect, truthyFlag } from "./server-http-utils.ts";
import { createUserBillingClient } from "./user-billing-client.ts";
const SESSION_COOKIE = "hwlab_session";
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24;
@@ -35,6 +36,8 @@ const ADMIN_BOOTSTRAP_API_KEY_NAME = "Master server admin API key";
const AUTH_METHOD_API_KEY = "api-key";
const AUTH_METHOD_WEB_SESSION = "web-session";
const AUTH_METHOD_LEGACY_LOCAL_SESSION = "legacy-local-session";
const AUTH_METHOD_USER_BILLING_API_KEY = "user-billing-api-key";
const AUTH_METHOD_USER_BILLING_SESSION = "user-billing-session";
const ACCESS_SCHEMA_STATEMENTS = Object.freeze([
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
@@ -159,7 +162,7 @@ function accessStoreForRuntime(runtimeStore, options = {}) {
}
class AccessController {
constructor({ store, env = process.env, fetchImpl = fetch, traceStore = null, codeAgentChatResults = null, now = () => new Date().toISOString(), required = truthyFlag(env.HWLAB_ACCESS_CONTROL_REQUIRED), openFgaAuthorizer = null } = {}) {
constructor({ store, env = process.env, fetchImpl = fetch, traceStore = null, codeAgentChatResults = null, now = () => new Date().toISOString(), required = truthyFlag(env.HWLAB_ACCESS_CONTROL_REQUIRED), openFgaAuthorizer = null, userBillingClient = null } = {}) {
this.store = store;
this.env = env;
this.fetchImpl = fetchImpl;
@@ -169,6 +172,7 @@ class AccessController {
this.now = now;
this.required = required;
this.openFga = openFgaAuthorizer ?? createOpenFgaAuthorizer({ env, fetchImpl, now, configStore: store });
this.userBilling = userBillingClient ?? createUserBillingClient({ env, fetchImpl });
this.bootstrapAttempted = false;
}
@@ -565,6 +569,11 @@ class AccessController {
async authenticate(request, { required = this.required } = {}) {
await this.ensureBootstrap();
const bearerToken = bearerTokenFromRequest(request);
if (bearerToken && this.userBilling?.configured) {
const delegated = await this.authenticateUserBillingToken(bearerToken);
if (delegated.ok || !isApiKeySecret(bearerToken)) return delegated;
}
const apiKeySecret = apiKeyFromRequest(request);
if (apiKeySecret) return this.authenticateApiKey(apiKeySecret);
const token = sessionCookieFromRequest(request);
@@ -579,6 +588,39 @@ class AccessController {
return { ok: true, actor: session.user, session: publicSession(session), authMethod: AUTH_METHOD_WEB_SESSION };
}
async authenticateUserBillingToken(secret) {
const result = await this.userBilling.introspect(secret);
if (!result.ok) {
return errorPayload(result.error?.code ?? "user_billing_introspect_failed", result.error?.message ?? "user-billing introspection failed", result.status ?? 503);
}
if (result.body?.active !== true || !result.body?.principal?.userId) {
return errorPayload("api_key_invalid", "API key is missing or invalid", 401);
}
const principal = result.body.principal;
const actor = userBillingActor(principal);
const synced = await this.store.upsertExternalUser?.({ ...actor, authProvider: "user-billing", email: principal.email, now: this.now() }) ?? actor;
const authMethod = principal.authType === "session" ? AUTH_METHOD_USER_BILLING_SESSION : AUTH_METHOD_USER_BILLING_API_KEY;
return {
ok: true,
actor: synced,
session: {
id: principal.keyId || `ub_${sha256(`${principal.userId}:${authMethod}`).slice(0, 24)}`,
userId: synced.id,
keyPrefix: authMethod === AUTH_METHOD_USER_BILLING_API_KEY ? redactedTokenPrefix(secret) : null,
name: "user-billing",
createdAt: null,
lastSeenAt: this.now(),
expiresAt: null,
revoked: false,
tokenSource: authMethod,
valuesRedacted: true
},
apiKey: authMethod === AUTH_METHOD_USER_BILLING_API_KEY ? { id: principal.keyId ?? null, keyPrefix: redactedTokenPrefix(secret), name: "user-billing", displaySecret: secret } : null,
authMethod,
userBilling: { active: true, principal: { ...principal, keyId: principal.keyId ?? null }, valuesRedacted: true }
};
}
async authenticateApiKey(secret) {
if (!isApiKeySecret(secret)) return errorPayload("api_key_invalid", "API key is missing or invalid", 401);
const prefix = apiKeyPrefixOf(secret);
@@ -1487,6 +1529,29 @@ class MemoryAccessStore {
this.users.set(user.id, user);
return user;
}
async upsertExternalUser(input = {}) {
const now = input.now ?? this.now();
const id = textOr(input.id, "");
if (!id) return null;
const existing = this.users.get(id) ?? null;
const user = {
id,
username: textOr(input.username, existing?.username ?? id),
displayName: textOr(input.displayName, existing?.displayName ?? input.username ?? id),
role: input.role === "admin" ? "admin" : "user",
status: input.status === "disabled" ? "disabled" : "active",
passwordHash: existing?.passwordHash ?? null,
authProvider: textOr(input.authProvider, existing?.authProvider ?? "external"),
keycloakIssuer: existing?.keycloakIssuer ?? null,
keycloakSub: existing?.keycloakSub ?? null,
email: textOr(input.email, existing?.email ?? "") || null,
lastLoginAt: now,
createdAt: existing?.createdAt ?? now,
updatedAt: now
};
this.users.set(user.id, user);
return user;
}
async createFirstAdmin(input) {
if (this.users.size > 0) return null;
return this.createUser({ ...input, role: "admin", status: "active" });
@@ -1649,6 +1714,23 @@ class PostgresAccessStore extends MemoryAccessStore {
const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (username) DO UPDATE SET display_name = EXCLUDED.display_name, role = EXCLUDED.role, status = EXCLUDED.status, password_hash = EXCLUDED.password_hash, updated_at = EXCLUDED.updated_at RETURNING id, username, display_name, role, status, password_hash, created_at, updated_at", [user.id, user.username, user.displayName, user.role, user.status, user.passwordHash, user.createdAt, user.updatedAt]);
return pgUser(result.rows?.[0]);
}
async upsertExternalUser(input = {}) {
await this.ensureSchema();
const now = input.now ?? this.now();
const id = textOr(input.id, "");
if (!id) return null;
const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, auth_provider, email, last_login_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,NULL,$6,$7,$8,$8,$8) ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, display_name = EXCLUDED.display_name, role = EXCLUDED.role, status = EXCLUDED.status, auth_provider = EXCLUDED.auth_provider, email = EXCLUDED.email, last_login_at = EXCLUDED.last_login_at, updated_at = EXCLUDED.updated_at RETURNING id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at", [
id,
textOr(input.username, id),
textOr(input.displayName, input.username ?? id),
input.role === "admin" ? "admin" : "user",
input.status === "disabled" ? "disabled" : "active",
textOr(input.authProvider, "external"),
textOr(input.email, "") || null,
now
]);
return pgUserWithOIDC(result.rows?.[0]);
}
async createFirstAdmin(input) {
await this.ensureSchema();
const now = input.now ?? this.now();
@@ -1791,10 +1873,13 @@ function safeAgentSessionId(value) { const text = textOr(value, ""); return /^se
function safeTraceIdLocal(value) { const text = textOr(value, ""); return /^trc_[A-Za-z0-9_.:-]+$/u.test(text) ? text : ""; }
function safeWorkspaceId(value) { return /^wsp_[A-Za-z0-9_.:-]+$/u.test(textOr(value, "")); }
function apiKeyFromRequest(request) {
const token = bearerTokenFromRequest(request);
return isApiKeySecret(token) ? token : "";
}
function bearerTokenFromRequest(request) {
const auth = getHeader(request, "authorization");
if (!/^Bearer\s+/iu.test(String(auth ?? ""))) return "";
const token = String(auth).replace(/^Bearer\s+/iu, "").trim();
return isApiKeySecret(token) ? token : "";
return String(auth).replace(/^Bearer\s+/iu, "").trim();
}
function hashPassword(password) { const salt = randomBytes(16).toString("hex"); return `sha256:${salt}:${sha256(`${salt}:${password}`)}`; }
function verifyPassword(stored, password) { const [, salt, digest] = String(stored ?? "").split(":"); return Boolean(salt && digest && sha256(`${salt}:${password}`) === digest); }
@@ -1817,6 +1902,7 @@ function hashApiKey(secret) {
return `sha256:${sha256(`${prefix}:${secret}`)}`;
}
function isApiKeySecret(value) { return textOr(value, "").startsWith(API_KEY_PREFIX); }
function redactedTokenPrefix(value) { const token = textOr(value, ""); return token ? `${token.slice(0, Math.min(token.length, 12))}...` : ""; }
function stableJson(value) { return JSON.stringify(sortJson(value)); }
function sortJson(value) { if (Array.isArray(value)) return value.map(sortJson); if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])])); return value; }
function accessTupleKey(tuple = {}) { return `${tuple.userId}\u0000${tuple.relation}\u0000${tuple.object}`; }
@@ -1825,7 +1911,22 @@ function accessRoutes() { return { session: "/auth/session", login: "/auth/login
function normalizeToolId(value) { return textOr(value, "").replace(/-/gu, "_"); }
function publicActor(user) { return user ? { id: user.id, username: user.username, displayName: user.displayName, role: user.role, status: user.status } : null; }
function publicAuthMethod(method) {
return [AUTH_METHOD_API_KEY, AUTH_METHOD_WEB_SESSION, AUTH_METHOD_LEGACY_LOCAL_SESSION].includes(method) ? method : AUTH_METHOD_WEB_SESSION;
return [AUTH_METHOD_API_KEY, AUTH_METHOD_WEB_SESSION, AUTH_METHOD_LEGACY_LOCAL_SESSION, AUTH_METHOD_USER_BILLING_API_KEY, AUTH_METHOD_USER_BILLING_SESSION].includes(method) ? method : AUTH_METHOD_WEB_SESSION;
}
function userBillingActor(principal = {}) {
const id = textOr(principal.userId, "");
return {
id,
username: localUserBillingUsername(id),
displayName: textOr(principal.displayName, principal.username ?? principal.email ?? principal.userId),
role: principal.role === "admin" ? "admin" : "user",
status: "active"
};
}
function localUserBillingUsername(userId) {
const source = textOr(userId, "external");
const safe = source.replace(/[^A-Za-z0-9_.:-]+/gu, "_").slice(0, 48) || "external";
return `ub_${safe}_${sha256(source).slice(0, 12)}`;
}
function publicApiKey(key, { includeSecret = false } = {}) {
if (!key) return null;
+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] ?? "");
+9 -1
View File
@@ -925,7 +925,15 @@ async function codeAgentOptions(request, response, options, authOptions = {}) {
sendJson(response, auth.status, auth);
return null;
}
return { ...options, actor: auth.actor ?? null, authSession: auth.session ?? null };
return {
...options,
actor: auth.actor ?? null,
authSession: auth.session ?? null,
authApiKey: auth.apiKey ?? null,
actorApiKeySecret: auth.apiKey?.displaySecret ?? null,
userBillingAuth: auth.userBilling ?? null,
userBillingClient: options.userBillingClient ?? options.accessController?.userBilling ?? null
};
}
async function readJsonObject(request, limitBytes) {
+112
View File
@@ -0,0 +1,112 @@
const DEFAULT_TIMEOUT_MS = 5000;
export function createUserBillingClient({ env = process.env, fetchImpl = fetch } = {}) {
const baseUrl = normalizeBaseUrl(firstText(
env.HWLAB_USER_BILLING_URL,
env.HWLAB_USER_BILLING_BASE_URL,
env.HWLAB_USER_BILLING_INTERNAL_URL
));
const internalToken = text(env.HWLAB_USER_BILLING_INTERNAL_TOKEN);
const timeoutMs = positiveInteger(env.HWLAB_USER_BILLING_TIMEOUT_MS, DEFAULT_TIMEOUT_MS);
async function post(path, body = {}) {
if (!baseUrl) {
return { ok: false, status: 503, error: { code: "user_billing_not_configured", message: "HWLAB user-billing URL is not configured" } };
}
const headers = {
accept: "application/json",
"content-type": "application/json"
};
if (internalToken) headers["x-hwlab-internal-token"] = internalToken;
try {
const response = await fetchImpl(`${baseUrl}${path}`, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs)
});
const raw = await response.text();
const payload = parseJson(raw, null);
if (!response.ok) {
return {
ok: false,
status: response.status,
body: payload,
error: normalizeUserBillingError(payload, response.status)
};
}
return { ok: true, status: response.status, body: payload ?? {} };
} catch (error) {
return {
ok: false,
status: 503,
error: {
code: "user_billing_unreachable",
message: error instanceof Error ? error.message : String(error)
}
};
}
}
return {
configured: Boolean(baseUrl),
baseUrl,
async introspect(token) {
return post("/internal/auth/introspect", { token });
},
async billingPreflight(body) {
return post("/internal/billing/preflight", body);
},
async billingRecord(body) {
return post("/internal/billing/record", body);
}
};
}
function normalizeBaseUrl(value) {
const input = text(value);
if (!input) return "";
try {
const url = new URL(input);
url.pathname = url.pathname.replace(/\/+$/u, "");
url.search = "";
url.hash = "";
return url.toString().replace(/\/+$/u, "");
} catch {
return "";
}
}
function normalizeUserBillingError(payload, status) {
const error = payload && typeof payload === "object" && !Array.isArray(payload) ? payload.error : null;
return {
code: text(error?.code) || (status === 402 ? "insufficient_credits" : "user_billing_request_failed"),
message: text(error?.message) || `user-billing request failed with HTTP ${status}`
};
}
function positiveInteger(value, fallback) {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function firstText(...values) {
for (const value of values) {
const current = text(value);
if (current) return current;
}
return "";
}
function text(value) {
return String(value ?? "").trim();
}
function parseJson(value, fallback) {
if (!value) return fallback;
try {
return JSON.parse(String(value));
} catch {
return fallback;
}
}
@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { test } from "bun:test";
import { createCloudApiServer } from "./server.ts";
import { codexStdioChatFixture, codexStdioReadyFixture } from "./server-test-helpers.ts";
test("cloud api accepts user-billing API keys and records Code Agent billing usage", async () => {
const calls = [];
const userBillingClient = {
configured: true,
async introspect(token) {
calls.push({ op: "introspect", tokenPrefix: token.slice(0, 8) });
assert.equal(token, "hwl_user_billing_test_secret");
return {
ok: true,
status: 200,
body: {
active: true,
principal: {
userId: "usr_user_billing_agent",
email: "agent@hwlab.local",
username: "billing-agent",
role: "user",
scopes: ["api"],
authType: "api-key",
keyId: "key_user_billing_agent"
}
}
};
},
async billingPreflight(body) {
calls.push({ op: "preflight", body });
assert.equal(body.apiKey, "hwl_user_billing_test_secret");
assert.equal(body.serviceId, "hwlab-code-agent");
assert.equal(body.estimatedCredits, 1);
assert.equal(body.idempotencyKey, "code-agent:trc_user_billing_agent:preflight");
assert.equal(body.metadata.resource, "code-agent-turn");
return { ok: true, status: 200, body: { allowed: true, reservationId: "res_user_billing_agent", estimatedCredits: 1, expiresAt: "2026-06-13T12:00:00.000Z" } };
},
async billingRecord(body) {
calls.push({ op: "record", body });
assert.equal(body.reservationId, "res_user_billing_agent");
assert.equal(body.serviceId, "hwlab-code-agent");
assert.equal(body.usedCredits, 1);
assert.equal(body.idempotencyKey, "code-agent:trc_user_billing_agent:record");
return { ok: true, status: 200, body: { recordId: "use_user_billing_agent", credits: 1, balance: 99 } };
}
};
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-user-billing-agent-"));
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-user-billing-agent-codex-"));
const server = createCloudApiServer({
env: {
PATH: process.env.PATH,
CODEX_HOME: codexHome,
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
HWLAB_USER_BILLING_CODE_AGENT_ENABLED: "1",
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access",
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
HWLAB_CODE_AGENT_MODEL: "gpt-test",
OPENAI_API_KEY: "test-openai-key-material",
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
},
userBillingClient,
codexStdioManager: {
describe() { return { ...codexStdioReadyFixture({ workspace, codexHome }), sandbox: "danger-full-access" }; },
async probe() { return { ...codexStdioReadyFixture({ workspace, codexHome }), sandbox: "danger-full-access" }; },
async chat(params = {}) {
return {
...codexStdioChatFixture({ workspace, codexHome, params }),
usage: { totalTokens: 1200 },
sandbox: "danger-full-access"
};
},
cancel() {},
reapIdle() {}
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const authHeader = { authorization: "Bearer hwl_user_billing_test_secret" };
const me = await fetch(`http://127.0.0.1:${port}/v1/users/me`, { headers: authHeader });
assert.equal(me.status, 200);
const meBody = await me.json();
assert.equal(meBody.actor.id, "usr_user_billing_agent");
assert.equal(meBody.authMethod, "user-billing-api-key");
const sessionResponse = await fetch(`http://127.0.0.1:${port}/v1/agent/sessions`, {
method: "POST",
headers: {
"content-type": "application/json",
...authHeader
},
body: JSON.stringify({
conversationId: "cnv_user_billing_agent",
sessionId: "ses_user_billing_agent",
providerProfile: "deepseek"
})
});
assert.equal(sessionResponse.status, 201);
const manual = await sessionResponse.json();
assert.equal(manual.ok, true);
assert.equal(manual.session.sessionId, "ses_user_billing_agent");
const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer hwl_user_billing_test_secret",
"x-trace-id": "trc_user_billing_agent",
prefer: "respond-async"
},
body: JSON.stringify({
conversationId: manual.session.conversationId,
sessionId: manual.session.sessionId,
shortConnection: true,
message: "billing integration smoke"
})
});
assert.equal(submit.status, 202);
const result = await pollUserBillingAgentResult(port, "trc_user_billing_agent", authHeader);
assert.equal(result.status, "completed");
assert.equal(result.billing.recorded, true);
assert.equal(result.billing.reservationId, "res_user_billing_agent");
assert.equal(result.billing.recordId, "use_user_billing_agent");
assert.deepEqual(calls.map((call) => call.op), ["introspect", "introspect", "introspect", "preflight", "record", "introspect"]);
assert.equal(JSON.stringify(result).includes("hwl_user_billing_test_secret"), false);
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
await rm(workspace, { recursive: true, force: true });
await rm(codexHome, { recursive: true, force: true });
}
});
async function pollUserBillingAgentResult(port, traceId, headers) {
let last = null;
for (let attempt = 0; attempt < 20; attempt += 1) {
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/result/${encodeURIComponent(traceId)}`, { headers });
last = {
status: response.status,
body: await response.json()
};
if (response.status === 200) return last.body;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Code Agent result did not complete: ${JSON.stringify(last)}`);
}