import { epochToWindowLabel, isoToEpochMs, jsonBody, makeLimit, unknownReport } from "../driver.js"; import { defineUsageFeed } from "../registry.js"; import type { UsageCredential, UsageLimit, UsageReportBody, UsageRequest, UsageResponse, UsageState, UsageStep, } from "../types.js"; /** Teams endpoint for Charm Hyper account and quota resolution */ export const HYPER_TEAMS_URL = "https://hyper.charm.land/v1/teams"; function asRecord(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; } function asNumber(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim()) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : undefined; } return undefined; } /** * Parses Charm Hyper team billing payload. * `plan.hypercredits` is the total recurring allocation. * `balances.plan.hypercredits` is the remaining quota. * `hypercredit_refresh.next_at` is the ISO reset timestamp. * `balances.total.usd` is the dollar balance. */ export function parseHyperBilling( payload: unknown, nowMs: number, ): { planType?: string; limits: UsageLimit[]; balance?: string } { const root = asRecord(payload); const plan = asRecord(root?.plan); const balances = asRecord(root?.balances); const planBalances = asRecord(balances?.plan); const totalBalances = asRecord(balances?.total); const refresh = asRecord(root?.hypercredit_refresh); const planType = typeof plan?.name === "string" && plan.name.trim() ? plan.name.toLowerCase() : typeof plan?.type === "string" && plan.type.trim() ? plan.type.toLowerCase() : undefined; // Unit converted FROM: hypercredits remaining count (balances.plan.hypercredits) and plan allocation (plan.hypercredits) const limit = asNumber(plan?.hypercredits); const remaining = asNumber(planBalances?.hypercredits); let usedPercent: number | undefined; if (limit !== undefined && limit > 0 && remaining !== undefined) { const used = Math.max(0, limit - remaining); // Stored as percent (0..100) rounded to 1 decimal place usedPercent = Math.max(0, Math.min(100, Math.round((used / limit) * 1000) / 10)); } else if (remaining !== undefined && remaining <= 0) { usedPercent = 100; } // Unit converted FROM: ISO-8601 string (hypercredit_refresh.next_at) to epoch milliseconds const resetsAt = isoToEpochMs(refresh?.next_at); // Unit converted FROM: ISO-8601 string (hypercredit_refresh.last_at) to epoch milliseconds const lastAt = isoToEpochMs(refresh?.last_at); let windowLabel: string | undefined; if (resetsAt !== undefined && lastAt !== undefined) { // Unit converted FROM: difference in milliseconds between next_at and last_at to seconds const diffSec = Math.round((resetsAt - lastAt) / 1000); if (diffSec >= 86_400) { const days = Math.round(diffSec / 86_400); windowLabel = days === 1 ? "24h window" : `${days}d window`; } else if (diffSec > 0) { windowLabel = `${Math.max(1, Math.round(diffSec / 3600))}h window`; } } else { windowLabel = epochToWindowLabel(resetsAt, nowMs); } const status = root?.status !== "active" ? "unknown" : usedPercent === undefined ? "unknown" : usedPercent >= 100 ? "exhausted" : usedPercent >= 90 ? "warning" : "ok"; const detail = remaining !== undefined && limit !== undefined ? `${remaining} of ${limit} credits` : undefined; const limits: UsageLimit[] = [ makeLimit("Primary", usedPercent, { windowLabel, resetsAt, status, detail, }), ]; // Unit converted FROM: USD dollar amount string (balances.total.usd) const balance = typeof totalBalances?.usd === "string" && totalBalances.usd.trim() ? totalBalances.usd.trim() : typeof totalBalances?.usd === "number" ? totalBalances.usd.toFixed(2) : undefined; return { planType, limits, balance }; } function httpStatusError(response: UsageResponse | undefined): string { if (!response) return "HTTP no response"; const payload = jsonBody>(response); const error = payload?.error; if (typeof error === "string" && error.trim()) { return `HTTP ${response.status}: ${error.trim()}`; } if (error && typeof error === "object" && !Array.isArray(error)) { const message = (error as Record).message; if (typeof message === "string" && message.trim()) { return `HTTP ${response.status}: ${message.trim()}`; } } return `HTTP ${response.status}`; } function resolveToken(credential: UsageCredential): string | undefined { if (credential.kind === "oauth") return credential.access; if (credential.kind === "apiKey") return credential.apiKey; return undefined; } /** Per-credential session state across rounds in a runUsageFeed execution */ const sessionCache = new WeakMap(); export const hyperUsageFeed = defineUsageFeed({ provider: "hyper", providerName: "Charm Hyper", credentialKinds: ["oauth", "apiKey"], advance(state: UsageState, responses): UsageStep { const credential = state.credential; const token = resolveToken(credential); if (!token) { return { report: unknownReport("hyper needs an OAuth or apiKey credential") }; } const headers: Record = { Authorization: `Bearer ${token}`, }; // Round 0: Ask for teams if (responses.length === 0) { const request: UsageRequest = { kind: "http", method: "GET", url: HYPER_TEAMS_URL, headers }; return { requests: [request] }; } // Round 1 response: check if it's the teams response const firstResponse = responses[0]; if (!firstResponse) { return { report: unknownReport("HTTP no response") }; } if (firstResponse.status !== 200) { return { report: unknownReport(httpStatusError(firstResponse)) }; } const firstPayload = jsonBody>(firstResponse); if (!firstPayload) { return { report: unknownReport("usage body is not JSON") }; } // Detect if responses are already billing (e.g. direct test call or 2nd round) const isBillingResponse = Boolean(firstPayload.plan || firstPayload.balances); if (!isBillingResponse && responses.length === 1) { // This is the teams response const items = Array.isArray(firstPayload.items) ? firstPayload.items : []; if (items.length === 0) { return { report: unknownReport("no teams found for hyper account") }; } // Match credential.accountId if provided, else use first team const team = (credential.accountId ? items.find((item) => asRecord(item)?.id === credential.accountId) : undefined) ?? items[0]; const teamRec = asRecord(team); const teamId = typeof teamRec?.id === "string" ? teamRec.id : undefined; const teamName = typeof teamRec?.name === "string" ? teamRec.name : undefined; if (!teamId) { return { report: unknownReport("invalid team in hyper response") }; } sessionCache.set(credential, { teamId, teamName }); // Return 2 requests: billing (required) and members (optional email discovery) return { requests: [ { kind: "http", method: "GET", url: `${HYPER_TEAMS_URL}/${teamId}/billing`, headers }, { kind: "http", method: "GET", url: `${HYPER_TEAMS_URL}/${teamId}/members`, headers }, ], }; } // Billing response handling (Round 2 or direct test invocation) const { planType, limits, balance } = parseHyperBilling(firstPayload, state.context.nowMs); // Second response, if present, is team members let memberEmail: string | undefined; if (responses.length >= 2) { const membersResponse = responses[1]; if (membersResponse && membersResponse.status === 200) { const membersPayload = jsonBody>(membersResponse); const memberItems = Array.isArray(membersPayload?.items) ? membersPayload.items : []; const firstMember = asRecord(memberItems[0]); if (typeof firstMember?.email === "string" && firstMember.email.trim()) { memberEmail = firstMember.email.trim(); } } } const cached = sessionCache.get(credential); const accountId = credential.accountId ?? cached?.teamId; const email = memberEmail ?? credential.email; const notes = cached?.teamName ? [`Team: ${cached.teamName}`] : undefined; const body: UsageReportBody = { planType, accountId, email, limits, ...(balance ? { balance } : {}), ...(notes ? { notes } : {}), error: null, }; return { report: body }; }, });