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