Skip to content

Commit 321397f

Browse files
authored
Org billing (#1995)
* Add billing organization selection Introduces support for selecting a billing organization for inference requests in HuggingChat. Adds a new billing section in application settings, updates user settings and API to store and validate the selected organization, and ensures requests are billed to the chosen organization by sending the X-HF-Bill-To header. Also updates environment and chart files to include the new OpenID scope required for billing. * Update +page.svelte * Remove billingOrganization from default settings * Fix user settings creation and update test for findUser The test for updateUser now passes a URL to findUser to match the updated function signature. Also, the updateUser function now inserts the full DEFAULT_SETTINGS object when creating default user settings, ensuring billingOrganization is included.
1 parent ce1ffc4 commit 321397f

File tree

15 files changed

+221
-23
lines changed

15 files changed

+221
-23
lines changed

.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ COUPLE_SESSION_WITH_COOKIE_NAME=
3434
# when OPEN_ID is configured, users are required to login after the welcome modal
3535
OPENID_CLIENT_ID="" # You can set to "__CIMD__" for automatic oauth app creation when deployed, see https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
3636
OPENID_CLIENT_SECRET=
37-
OPENID_SCOPES="openid profile inference-api read-mcp"
37+
OPENID_SCOPES="openid profile inference-api read-mcp read-billing"
3838
USE_USER_TOKEN=
3939
AUTOMATIC_LOGIN=# if true authentication is required on all routes
4040

chart/env/dev.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ ingressInternal:
3838
envVars:
3939
TEST: "test"
4040
COUPLE_SESSION_WITH_COOKIE_NAME: "token"
41-
OPENID_SCOPES: "openid profile inference-api read-mcp"
41+
OPENID_SCOPES: "openid profile inference-api read-mcp read-billing"
4242
USE_USER_TOKEN: "true"
4343
MCP_FORWARD_HF_USER_TOKEN: "true"
4444
AUTOMATIC_LOGIN: "false"

chart/env/prod.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ ingressInternal:
4848

4949
envVars:
5050
COUPLE_SESSION_WITH_COOKIE_NAME: "token"
51-
OPENID_SCOPES: "openid profile inference-api read-mcp"
51+
OPENID_SCOPES: "openid profile inference-api read-mcp read-billing"
5252
USE_USER_TOKEN: "true"
5353
MCP_FORWARD_HF_USER_TOKEN: "true"
5454
AUTOMATIC_LOGIN: "false"

src/app.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ declare global {
1313
user?: User;
1414
isAdmin: boolean;
1515
token?: string;
16+
/** Organization to bill inference requests to (from settings) */
17+
billingOrganization?: string;
1618
}
1719

1820
interface Error {

src/lib/server/api/authPlugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const authPlugin = new Elysia({ name: "auth" }).derive(
2222
user: auth?.user,
2323
sessionId: auth?.sessionId,
2424
isAdmin: auth?.isAdmin,
25+
token: auth?.token,
2526
},
2627
};
2728
}

src/lib/server/api/routes/groups/user.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { authCondition } from "$lib/server/auth";
66
import { models, validateModel } from "$lib/server/models";
77
import { DEFAULT_SETTINGS, type SettingsEditable } from "$lib/types/Settings";
88
import { z } from "zod";
9+
import { config } from "$lib/server/config";
10+
import { logger } from "$lib/server/logger";
911

1012
export const userGroup = new Elysia()
1113
.use(authPlugin)
@@ -72,6 +74,7 @@ export const userGroup = new Elysia()
7274
customPrompts: settings?.customPrompts ?? {},
7375
multimodalOverrides: settings?.multimodalOverrides ?? {},
7476
toolsOverrides: settings?.toolsOverrides ?? {},
77+
billingOrganization: settings?.billingOrganization ?? undefined,
7578
};
7679
})
7780
.post("/settings", async ({ locals, request }) => {
@@ -90,6 +93,7 @@ export const userGroup = new Elysia()
9093
disableStream: z.boolean().default(false),
9194
directPaste: z.boolean().default(false),
9295
hidePromptExamples: z.record(z.boolean()).default({}),
96+
billingOrganization: z.string().optional(),
9397
})
9498
.parse(body) satisfies SettingsEditable;
9599

@@ -123,5 +127,79 @@ export const userGroup = new Elysia()
123127
})
124128
.toArray();
125129
return reports;
130+
})
131+
.get("/billing-orgs", async ({ locals, set }) => {
132+
// Only available for HuggingChat
133+
if (!config.isHuggingChat) {
134+
set.status = 404;
135+
return { error: "Not available" };
136+
}
137+
138+
// Requires authenticated user with OAuth token
139+
if (!locals.user) {
140+
set.status = 401;
141+
return { error: "Login required" };
142+
}
143+
144+
if (!locals.token) {
145+
set.status = 401;
146+
return { error: "OAuth token not available. Please log out and log back in." };
147+
}
148+
149+
try {
150+
// Fetch billing info from HuggingFace OAuth userinfo
151+
const response = await fetch("https://huggingface.co/oauth/userinfo", {
152+
headers: { Authorization: `Bearer ${locals.token}` },
153+
});
154+
155+
if (!response.ok) {
156+
logger.error(`Failed to fetch billing orgs: ${response.status}`);
157+
set.status = 502;
158+
return { error: "Failed to fetch billing information" };
159+
}
160+
161+
const data = await response.json();
162+
163+
// Get user's current billingOrganization setting
164+
const settings = await collections.settings.findOne(authCondition(locals));
165+
const currentBillingOrg = settings?.billingOrganization;
166+
167+
// Filter orgs to only those with canPay: true
168+
const billingOrgs = (data.orgs ?? [])
169+
.filter((org: { canPay?: boolean }) => org.canPay === true)
170+
.map((org: { sub: string; name: string; preferred_username: string }) => ({
171+
sub: org.sub,
172+
name: org.name,
173+
preferred_username: org.preferred_username,
174+
}));
175+
176+
// Check if current billing org is still valid
177+
const isCurrentOrgValid =
178+
!currentBillingOrg ||
179+
billingOrgs.some(
180+
(org: { preferred_username: string }) => org.preferred_username === currentBillingOrg
181+
);
182+
183+
// If current billing org is no longer valid, clear it
184+
if (!isCurrentOrgValid && currentBillingOrg) {
185+
logger.info(
186+
`Clearing invalid billingOrganization '${currentBillingOrg}' for user ${locals.user._id}`
187+
);
188+
await collections.settings.updateOne(authCondition(locals), {
189+
$unset: { billingOrganization: "" },
190+
$set: { updatedAt: new Date() },
191+
});
192+
}
193+
194+
return {
195+
userCanPay: data.canPay ?? false,
196+
organizations: billingOrgs,
197+
currentBillingOrg: isCurrentOrgValid ? currentBillingOrg : undefined,
198+
};
199+
} catch (err) {
200+
logger.error("Error fetching billing orgs:", err);
201+
set.status = 500;
202+
return { error: "Internal server error" };
203+
}
126204
});
127205
});

src/lib/server/endpoints/openai/endpointOai.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ export async function endpointOai(
148148
"ChatUI-Conversation-ID": conversationId?.toString() ?? "",
149149
"X-use-cache": "false",
150150
...(locals?.token ? { Authorization: `Bearer ${locals.token}` } : {}),
151+
// Bill to organization if configured (HuggingChat only)
152+
...(config.isHuggingChat && locals?.billingOrganization
153+
? { "X-HF-Bill-To": locals.billingOrganization }
154+
: {}),
151155
},
152156
signal: abortSignal,
153157
});
@@ -218,6 +222,10 @@ export async function endpointOai(
218222
"ChatUI-Conversation-ID": conversationId?.toString() ?? "",
219223
"X-use-cache": "false",
220224
...(locals?.token ? { Authorization: `Bearer ${locals.token}` } : {}),
225+
// Bill to organization if configured (HuggingChat only)
226+
...(config.isHuggingChat && locals?.billingOrganization
227+
? { "X-HF-Bill-To": locals.billingOrganization }
228+
: {}),
221229
},
222230
signal: abortSignal,
223231
}
@@ -232,6 +240,10 @@ export async function endpointOai(
232240
"ChatUI-Conversation-ID": conversationId?.toString() ?? "",
233241
"X-use-cache": "false",
234242
...(locals?.token ? { Authorization: `Bearer ${locals.token}` } : {}),
243+
// Bill to organization if configured (HuggingChat only)
244+
...(config.isHuggingChat && locals?.billingOrganization
245+
? { "X-HF-Bill-To": locals.billingOrganization }
246+
: {}),
235247
},
236248
signal: abortSignal,
237249
}

src/lib/server/router/arch.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ export async function archSelectRoute(
152152
const headers: HeadersInit = {
153153
Authorization: `Bearer ${getApiToken(locals)}`,
154154
"Content-Type": "application/json",
155+
// Bill to organization if configured (HuggingChat only)
156+
...(config.isHuggingChat && locals?.billingOrganization
157+
? { "X-HF-Bill-To": locals.billingOrganization }
158+
: {}),
155159
};
156160
const body = {
157161
model: archModel,

src/lib/server/textGeneration/mcp/runMcpFlow.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,12 @@ export async function* runMcpFlow({
264264
apiKey: config.OPENAI_API_KEY || config.HF_TOKEN || "sk-",
265265
baseURL: config.OPENAI_BASE_URL,
266266
fetch: captureProviderFetch,
267+
defaultHeaders: {
268+
// Bill to organization if configured (HuggingChat only)
269+
...(config.isHuggingChat && locals?.billingOrganization
270+
? { "X-HF-Bill-To": locals.billingOrganization }
271+
: {}),
272+
},
267273
});
268274

269275
const mmEnabled = (forceMultimodal ?? false) || targetModel.multimodal;

src/lib/stores/settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type SettingsStore = {
1717
disableStream: boolean;
1818
directPaste: boolean;
1919
hidePromptExamples: Record<string, boolean>;
20+
billingOrganization?: string;
2021
};
2122

2223
type SettingsStoreWritable = Writable<SettingsStore> & {

0 commit comments

Comments
 (0)