Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a6562cb
wallet admin dashboard mvp
Sep 11, 2025
3ec9d16
fixes
Sep 11, 2025
b2e5234
owner
Sep 11, 2025
8facb01
lint
Sep 11, 2025
573eb9f
stripe link
Sep 11, 2025
a25286f
revert test secrets
Sep 11, 2025
e53b1a5
Update worker/src/lib/durable-objects/Wallet.ts
chitalian Sep 11, 2025
8b4b6b4
Update valhalla/jawn/src/controllers/private/adminController.ts
chitalian Sep 11, 2025
480b288
Update valhalla/jawn/src/controllers/private/adminController.ts
chitalian Sep 11, 2025
4753909
Update valhalla/jawn/src/managers/admin/walletSyncManager.ts
chitalian Sep 11, 2025
fe4ea3c
Update worker/src/routers/api/apiRouter.ts
chitalian Sep 11, 2025
94c8558
more admin shtuff
Sep 11, 2025
4d178c1
remove
Sep 11, 2025
924ee68
format
Sep 12, 2025
d6e363e
more claude perms
chitalian Sep 26, 2025
5375540
Address PR review comments
chitalian Sep 26, 2025
4add2ca
Merge branch 'main' into justin/credit-admin-page
chitalian Sep 27, 2025
fff6632
fix
chitalian Sep 27, 2025
564701f
admin page credits
chitalian Sep 27, 2025
d58996c
Merge branch 'main' into justin/credit-admin-page
chitalian Sep 27, 2025
b8559cd
added things
chitalian Sep 28, 2025
047af78
Merge branch 'main' into justin/credit-admin-page
chitalian Sep 28, 2025
4fff619
made improvements
chitalian Sep 28, 2025
2626ba6
Update worker/src/routers/api/apiRouter.ts
chitalian Sep 28, 2025
0b02c97
Update valhalla/jawn/src/controllers/private/adminController.ts
chitalian Sep 28, 2025
3c7b408
made improvements
chitalian Sep 28, 2025
35ded80
Merge commit '3c7b4080b' into justin/credit-admin-page
chitalian Sep 28, 2025
2fe57c4
revert back admin controller
chitalian Sep 28, 2025
605545d
fix build
chitalian Sep 28, 2025
e9ddb1d
Merge branch 'main' into justin/credit-admin-page
chitalian Sep 28, 2025
1ebeea8
rm console logs
chitalian Sep 28, 2025
913faee
added correct database url
chitalian Sep 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,13 @@
"Bash(gh run watch:*)",
"Bash(curl:*)",
"Bash(yarn lint:*)",
"mcp__linear-server__list_comments"
"mcp__linear-server__list_comments",
"mcp__playwright__browser_console_messages",
"mcp__playwright__browser_network_requests",
"mcp__playwright__browser_evaluate",
"Bash(yarn build)",
"Bash(gh api:*)"
],
"deny": []
}
}
}
56 changes: 55 additions & 1 deletion bifrost/lib/clients/jawnTypes/private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,9 @@ export interface paths {
"/v1/alert-banner": {
get: operations["GetAlertBanners"];
};
"/v1/admin/gateway/dashboard_data": {
post: operations["GetGatewayDashboardData"];
};
"/v1/admin/has-feature-flag": {
post: operations["HasFeatureFlag"];
};
Expand Down Expand Up @@ -519,6 +522,12 @@ export interface paths {
/** @description Backfill costs in Clickhouse with updated cost package data. */
post: operations["BackfillCosts"];
};
"/v1/admin/wallet/{orgId}": {
post: operations["GetWalletDetails"];
};
"/v1/admin/wallet/{orgId}/tables/{tableName}": {
post: operations["GetWalletTableData"];
};
"/v1/admin/helix-thread/{sessionId}": {
get: operations["GetHelixThread"];
};
Expand Down Expand Up @@ -3165,7 +3174,7 @@ Json: JsonObject;
};
Setting: components["schemas"]["KafkaSettings"] | components["schemas"]["AzureExperiment"] | components["schemas"]["ApiKey"];
/** @enum {string} */
SettingName: "kafka:dlq" | "kafka:log" | "kafka:score" | "kafka:dlq:score" | "kafka:dlq:eu" | "kafka:log:eu" | "kafka:orgs-to-dlq" | "azure:experiment" | "openai:apiKey" | "anthropic:apiKey" | "openrouter:apiKey" | "togetherai:apiKey" | "sqs:request-response-logs" | "sqs:helicone-scores" | "sqs:request-response-logs-dlq" | "sqs:helicone-scores-dlq" | "stripe:products";
SettingName: "stripe:products" | "kafka:dlq" | "kafka:log" | "kafka:score" | "kafka:dlq:score" | "kafka:dlq:eu" | "kafka:log:eu" | "kafka:orgs-to-dlq" | "azure:experiment" | "openai:apiKey" | "anthropic:apiKey" | "openrouter:apiKey" | "togetherai:apiKey" | "sqs:request-response-logs" | "sqs:helicone-scores" | "sqs:request-response-logs-dlq" | "sqs:helicone-scores-dlq";
/**
* @description The **`URL`** interface is used to parse, construct, normalize, and encode URL.
*
Expand Down Expand Up @@ -18580,6 +18589,16 @@ export interface operations {
};
};
};
GetGatewayDashboardData: {
responses: {
/** @description Ok */
200: {
content: {
"application/json": components["schemas"]["ResultError_unknown_"] | components["schemas"]["ResultSuccess_unknown_"];
};
};
};
};
HasFeatureFlag: {
requestBody: {
content: {
Expand Down Expand Up @@ -19090,6 +19109,41 @@ export interface operations {
};
};
};
GetWalletDetails: {
parameters: {
path: {
orgId: string;
};
};
responses: {
/** @description Ok */
200: {
content: {
"application/json": components["schemas"]["ResultError_unknown_"] | components["schemas"]["ResultSuccess_any_"];
};
};
};
};
GetWalletTableData: {
parameters: {
query?: {
page?: number;
pageSize?: number;
};
path: {
orgId: string;
tableName: string;
};
};
responses: {
/** @description Ok */
200: {
content: {
"application/json": components["schemas"]["ResultError_unknown_"] | components["schemas"]["ResultSuccess_any_"];
};
};
};
};
GetHelixThread: {
parameters: {
path: {
Expand Down
247 changes: 246 additions & 1 deletion valhalla/jawn/src/controllers/private/adminController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { clickhouseDb } from "../../lib/db/ClickhouseWrapper";
import { prepareRequestAzure } from "../../lib/experiment/requestPrep/azure";
import { dbExecute } from "../../lib/shared/db/dbExecute";
import type { JawnAuthenticatedRequest } from "../../types/request";
import { Setting } from "../../utils/settings";
import { Setting, SettingsManager } from "../../utils/settings";
import type { SettingName } from "../../utils/settings";
import Stripe from "stripe";
import { AdminManager } from "../../managers/admin/AdminManager";
Expand All @@ -28,6 +28,8 @@ import {
} from "@helicone-package/cost";

import { err, ok, Result } from "../../packages/common/result";
import { COST_PRECISION_MULTIPLIER } from "@helicone-package/cost/costCalc";
import { ENVIRONMENT } from "../../lib/clients/constant";
import { InAppThread } from "../../managers/InAppThreadsManager";

export const authCheckThrow = async (userId: string | undefined) => {
Expand Down Expand Up @@ -57,6 +59,134 @@ export const authCheckThrow = async (userId: string | undefined) => {
@Tags("Admin")
@Security("api_key")
export class AdminController extends Controller {
@Post("/gateway/dashboard_data")
public async getGatewayDashboardData(
@Request() request: JawnAuthenticatedRequest
) {
await authCheckThrow(request.authParams.userId);
const settingsManager = new SettingsManager();
const stripeProductSettings =
await settingsManager.getSetting("stripe:products");
if (
!stripeProductSettings ||
!stripeProductSettings.cloudGatewayTokenUsageProduct
) {
return err("stripe:products setting is not configured");
}
const tokenUsageProductId =
stripeProductSettings.cloudGatewayTokenUsageProduct;

// Get organizations with payments
const paymentsResult = await dbExecute<{
org_id: string;
org_name: string;
stripe_customer_id: string;
tier: string;
total_amount_received: number;
last_payment_date: string;
owner_email: string;
}>(
`
SELECT
organization.id as org_id,
organization.name as org_name,
organization.stripe_customer_id,
organization.tier,
SUM(stripe.payment_intents.amount_received) as total_amount_received,
MAX(stripe.payment_intents.created) as last_payment_date,
auth.users.email as owner_email
FROM stripe.payment_intents
LEFT JOIN organization ON organization.stripe_customer_id = stripe.payment_intents.customer
LEFT JOIN auth.users ON organization.owner = auth.users.id
WHERE stripe.payment_intents.metadata->>'productId' = $1
AND stripe.payment_intents.status = 'succeeded'
AND organization.id IS NOT NULL
GROUP BY organization.id, organization.name, organization.stripe_customer_id, organization.tier, auth.users.email
ORDER BY total_amount_received DESC
LIMIT 1000
`,
[tokenUsageProductId]
);

if (paymentsResult.error) {
return err(paymentsResult.error);
}

if (!paymentsResult.data || paymentsResult.data.length === 0) {
return ok({
organizations: [],
summary: {
totalOrgsWithCredits: 0,
totalCreditsIssued: 0,
totalCreditsSpent: 0,
},
});
}

// Get ClickHouse spending for these organizations
const orgIds = paymentsResult.data.map((org) => org.org_id);

const clickhouseSpendResult = await clickhouseDb.dbQuery<{
organization_id: string;
total_cost: number;
}>(
`
SELECT
organization_id,
SUM(cost) as total_cost
FROM request_response_rmt
WHERE organization_id IN (${orgIds.map(() => "?").join(",")})
GROUP BY organization_id
`,
orgIds
);

const clickhouseSpendMap = new Map<string, number>();
if (!clickhouseSpendResult.error && clickhouseSpendResult.data) {
clickhouseSpendResult.data.forEach((row) => {
// Divide by precision multiplier to get dollars
clickhouseSpendMap.set(
row.organization_id,
Number(row.total_cost) / COST_PRECISION_MULTIPLIER
);
});
}

// Combine the data
const organizations = paymentsResult.data.map((org) => ({
orgId: org.org_id,
orgName: org.org_name || "Unknown",
stripeCustomerId: org.stripe_customer_id,
totalPayments: org.total_amount_received / 100, // Convert cents to dollars
clickhouseTotalSpend: clickhouseSpendMap.get(org.org_id) || 0,
lastPaymentDate: org.last_payment_date
? Number(org.last_payment_date) * 1000
: null, // Convert seconds to milliseconds
tier: org.tier || "free",
ownerEmail: org.owner_email || "Unknown",
}));

// Calculate summary
const totalCreditsIssued = organizations.reduce(
(sum, org) => sum + org.totalPayments,
0
);
const totalCreditsSpent = organizations.reduce(
(sum, org) => sum + org.clickhouseTotalSpend,
0
);

return ok({
organizations,
summary: {
totalOrgsWithCredits: organizations.length,
totalCreditsIssued,
totalCreditsSpent,
},
isProduction: ENVIRONMENT === "production",
});
}

@Post("/has-feature-flag")
public async hasFeatureFlag(
@Request() request: JawnAuthenticatedRequest,
Expand Down Expand Up @@ -1496,6 +1626,121 @@ export class AdminController extends Controller {
return { query };
}

@Post("/wallet/{orgId}")
public async getWalletDetails(
@Request() request: JawnAuthenticatedRequest,
@Path() orgId: string
) {
await authCheckThrow(request.authParams.userId);

// Get the wallet state from the worker API using admin credentials
const workerApiUrl =
process.env.HELICONE_WORKER_API ||
process.env.WORKER_API_URL ||
"https://api.helicone.ai";
const adminAccessKey = process.env.HELICONE_MANUAL_ACCESS_KEY;

if (!adminAccessKey) {
return err("Admin access key not configured");
}

try {
// Use the admin endpoint that can query any org's wallet
const response = await fetch(
`${workerApiUrl}/admin/wallet/${orgId}/state`,
{
method: "GET",
headers: {
Authorization: `Bearer ${adminAccessKey}`,
},
}
);

if (!response.ok) {
const errorText = await response.text();
return err(`Failed to fetch wallet state: ${errorText}`);
}

const walletState = await response.json();
return ok(walletState);
} catch (error) {
console.error("Error fetching wallet state:", error);
return err(`Error fetching wallet state: ${error}`);
}
}

@Post("/wallet/{orgId}/tables/{tableName}")
public async getWalletTableData(
@Request() request: JawnAuthenticatedRequest,
@Path() orgId: string,
@Path() tableName: string,
@Query() page?: number,
@Query() pageSize?: number
) {
// Validate pagination parameters
const validatedPage = Math.max(0, page ?? 0);
const validatedPageSize = Math.min(Math.max(1, pageSize ?? 50), 100);
Comment on lines +1680 to +1682
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: Incorrect validation order creates duplicate URLSearchParams instance and doesn't use validated parameters

Prompt To Fix With AI
This is a comment left during a code review.
Path: valhalla/jawn/src/controllers/private/adminController.ts
Line: 1680:1682

Comment:
logic: Incorrect validation order creates duplicate URLSearchParams instance and doesn't use validated parameters

How can I resolve this? If you propose a fix, please make it concise.


const params = new URLSearchParams();
if (validatedPage > 0) params.set("page", validatedPage.toString());
params.set("pageSize", validatedPageSize.toString());

await authCheckThrow(request.authParams.userId);

// Validate table name to prevent injection
const allowedTables = [
"credit_purchases",
"aggregated_debits",
"escrows",
"disallow_list",
"processed_webhook_events",
];

if (!allowedTables.includes(tableName)) {
return err(`Invalid table name: ${tableName}`);
}

// Get table data from the worker API using admin credentials
const workerApiUrl =
process.env.HELICONE_WORKER_API ||
process.env.WORKER_API_URL ||
"https://api.helicone.ai";
const adminAccessKey = process.env.HELICONE_MANUAL_ACCESS_KEY;

if (!adminAccessKey) {
return err("Admin access key not configured");
}

try {
// Build query params for pagination
const params = new URLSearchParams();
if (page !== undefined) params.set("page", page.toString());
if (pageSize !== undefined) params.set("pageSize", pageSize.toString());
Comment on lines +1715 to +1718
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: URLSearchParams created twice - first validated params are unused in favor of potentially invalid raw params

Prompt To Fix With AI
This is a comment left during a code review.
Path: valhalla/jawn/src/controllers/private/adminController.ts
Line: 1715:1718

Comment:
logic: URLSearchParams created twice - first validated params are unused in favor of potentially invalid raw params

How can I resolve this? If you propose a fix, please make it concise.


// Use the admin endpoint that can query any org's table data
const response = await fetch(
`${workerApiUrl}/admin/wallet/${orgId}/tables/${tableName}?${params.toString()}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${adminAccessKey}`,
},
}
);

if (!response.ok) {
const errorText = await response.text();
return err(`Failed to fetch table data: ${errorText}`);
}

const tableData = await response.json();
return ok(tableData);
} catch (error) {
console.error("Error fetching table data:", error);
return err(`Error fetching table data: ${error}`);
}
}

@Get("/helix-thread/{sessionId}")
public async getHelixThread(
@Request() request: JawnAuthenticatedRequest,
Expand Down
2 changes: 1 addition & 1 deletion valhalla/jawn/src/managers/creditsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,6 @@ export async function getAiGatewaySpend(org_id: string): Promise<

const res = await dbQueryClickhouse<{ spend_cents: string }>(query, argsAcc);
return resultMap(res, (d) => ({
spend_cents: d && d.length > 0 ? +d[0].spend_cents : 0
spend_cents: +(d?.[0]?.spend_cents ?? 0),
}));
}
Loading
Loading