diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index be4b6e72..fd14cb0e 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -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 diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 608613f4..ef7770d9 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -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; diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index e592d222..3b391093 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -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] ?? ""); diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 959d8de4..f5396455 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -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) { diff --git a/internal/cloud/user-billing-client.ts b/internal/cloud/user-billing-client.ts new file mode 100644 index 00000000..a746be18 --- /dev/null +++ b/internal/cloud/user-billing-client.ts @@ -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; + } +} diff --git a/internal/cloud/user-billing-integration.test.ts b/internal/cloud/user-billing-integration.test.ts new file mode 100644 index 00000000..e794f3c4 --- /dev/null +++ b/internal/cloud/user-billing-integration.test.ts @@ -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)}`); +}