victor HF Staff commited on
Commit
9b4ba04
·
unverified ·
1 Parent(s): 4f84dfb

Org billing (#1995)

Browse files

* 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.

.env CHANGED
@@ -34,7 +34,7 @@ COUPLE_SESSION_WITH_COOKIE_NAME=
34
  # when OPEN_ID is configured, users are required to login after the welcome modal
35
  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/
36
  OPENID_CLIENT_SECRET=
37
- OPENID_SCOPES="openid profile inference-api read-mcp"
38
  USE_USER_TOKEN=
39
  AUTOMATIC_LOGIN=# if true authentication is required on all routes
40
 
 
34
  # when OPEN_ID is configured, users are required to login after the welcome modal
35
  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/
36
  OPENID_CLIENT_SECRET=
37
+ OPENID_SCOPES="openid profile inference-api read-mcp read-billing"
38
  USE_USER_TOKEN=
39
  AUTOMATIC_LOGIN=# if true authentication is required on all routes
40
 
chart/env/dev.yaml CHANGED
@@ -38,7 +38,7 @@ ingressInternal:
38
  envVars:
39
  TEST: "test"
40
  COUPLE_SESSION_WITH_COOKIE_NAME: "token"
41
- OPENID_SCOPES: "openid profile inference-api read-mcp"
42
  USE_USER_TOKEN: "true"
43
  MCP_FORWARD_HF_USER_TOKEN: "true"
44
  AUTOMATIC_LOGIN: "false"
 
38
  envVars:
39
  TEST: "test"
40
  COUPLE_SESSION_WITH_COOKIE_NAME: "token"
41
+ OPENID_SCOPES: "openid profile inference-api read-mcp read-billing"
42
  USE_USER_TOKEN: "true"
43
  MCP_FORWARD_HF_USER_TOKEN: "true"
44
  AUTOMATIC_LOGIN: "false"
chart/env/prod.yaml CHANGED
@@ -48,7 +48,7 @@ ingressInternal:
48
 
49
  envVars:
50
  COUPLE_SESSION_WITH_COOKIE_NAME: "token"
51
- OPENID_SCOPES: "openid profile inference-api read-mcp"
52
  USE_USER_TOKEN: "true"
53
  MCP_FORWARD_HF_USER_TOKEN: "true"
54
  AUTOMATIC_LOGIN: "false"
 
48
 
49
  envVars:
50
  COUPLE_SESSION_WITH_COOKIE_NAME: "token"
51
+ OPENID_SCOPES: "openid profile inference-api read-mcp read-billing"
52
  USE_USER_TOKEN: "true"
53
  MCP_FORWARD_HF_USER_TOKEN: "true"
54
  AUTOMATIC_LOGIN: "false"
src/app.d.ts CHANGED
@@ -13,6 +13,8 @@ declare global {
13
  user?: User;
14
  isAdmin: boolean;
15
  token?: string;
 
 
16
  }
17
 
18
  interface Error {
 
13
  user?: User;
14
  isAdmin: boolean;
15
  token?: string;
16
+ /** Organization to bill inference requests to (from settings) */
17
+ billingOrganization?: string;
18
  }
19
 
20
  interface Error {
src/lib/server/api/authPlugin.ts CHANGED
@@ -22,6 +22,7 @@ export const authPlugin = new Elysia({ name: "auth" }).derive(
22
  user: auth?.user,
23
  sessionId: auth?.sessionId,
24
  isAdmin: auth?.isAdmin,
 
25
  },
26
  };
27
  }
 
22
  user: auth?.user,
23
  sessionId: auth?.sessionId,
24
  isAdmin: auth?.isAdmin,
25
+ token: auth?.token,
26
  },
27
  };
28
  }
src/lib/server/api/routes/groups/user.ts CHANGED
@@ -6,6 +6,8 @@ import { authCondition } from "$lib/server/auth";
6
  import { models, validateModel } from "$lib/server/models";
7
  import { DEFAULT_SETTINGS, type SettingsEditable } from "$lib/types/Settings";
8
  import { z } from "zod";
 
 
9
 
10
  export const userGroup = new Elysia()
11
  .use(authPlugin)
@@ -72,6 +74,7 @@ export const userGroup = new Elysia()
72
  customPrompts: settings?.customPrompts ?? {},
73
  multimodalOverrides: settings?.multimodalOverrides ?? {},
74
  toolsOverrides: settings?.toolsOverrides ?? {},
 
75
  };
76
  })
77
  .post("/settings", async ({ locals, request }) => {
@@ -90,6 +93,7 @@ export const userGroup = new Elysia()
90
  disableStream: z.boolean().default(false),
91
  directPaste: z.boolean().default(false),
92
  hidePromptExamples: z.record(z.boolean()).default({}),
 
93
  })
94
  .parse(body) satisfies SettingsEditable;
95
 
@@ -123,5 +127,79 @@ export const userGroup = new Elysia()
123
  })
124
  .toArray();
125
  return reports;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  });
127
  });
 
6
  import { models, validateModel } from "$lib/server/models";
7
  import { DEFAULT_SETTINGS, type SettingsEditable } from "$lib/types/Settings";
8
  import { z } from "zod";
9
+ import { config } from "$lib/server/config";
10
+ import { logger } from "$lib/server/logger";
11
 
12
  export const userGroup = new Elysia()
13
  .use(authPlugin)
 
74
  customPrompts: settings?.customPrompts ?? {},
75
  multimodalOverrides: settings?.multimodalOverrides ?? {},
76
  toolsOverrides: settings?.toolsOverrides ?? {},
77
+ billingOrganization: settings?.billingOrganization ?? undefined,
78
  };
79
  })
80
  .post("/settings", async ({ locals, request }) => {
 
93
  disableStream: z.boolean().default(false),
94
  directPaste: z.boolean().default(false),
95
  hidePromptExamples: z.record(z.boolean()).default({}),
96
+ billingOrganization: z.string().optional(),
97
  })
98
  .parse(body) satisfies SettingsEditable;
99
 
 
127
  })
128
  .toArray();
129
  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
+ }
204
  });
205
  });
src/lib/server/endpoints/openai/endpointOai.ts CHANGED
@@ -148,6 +148,10 @@ export async function endpointOai(
148
  "ChatUI-Conversation-ID": conversationId?.toString() ?? "",
149
  "X-use-cache": "false",
150
  ...(locals?.token ? { Authorization: `Bearer ${locals.token}` } : {}),
 
 
 
 
151
  },
152
  signal: abortSignal,
153
  });
@@ -218,6 +222,10 @@ export async function endpointOai(
218
  "ChatUI-Conversation-ID": conversationId?.toString() ?? "",
219
  "X-use-cache": "false",
220
  ...(locals?.token ? { Authorization: `Bearer ${locals.token}` } : {}),
 
 
 
 
221
  },
222
  signal: abortSignal,
223
  }
@@ -232,6 +240,10 @@ export async function endpointOai(
232
  "ChatUI-Conversation-ID": conversationId?.toString() ?? "",
233
  "X-use-cache": "false",
234
  ...(locals?.token ? { Authorization: `Bearer ${locals.token}` } : {}),
 
 
 
 
235
  },
236
  signal: abortSignal,
237
  }
 
148
  "ChatUI-Conversation-ID": conversationId?.toString() ?? "",
149
  "X-use-cache": "false",
150
  ...(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
+ : {}),
155
  },
156
  signal: abortSignal,
157
  });
 
222
  "ChatUI-Conversation-ID": conversationId?.toString() ?? "",
223
  "X-use-cache": "false",
224
  ...(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
+ : {}),
229
  },
230
  signal: abortSignal,
231
  }
 
240
  "ChatUI-Conversation-ID": conversationId?.toString() ?? "",
241
  "X-use-cache": "false",
242
  ...(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
+ : {}),
247
  },
248
  signal: abortSignal,
249
  }
src/lib/server/router/arch.ts CHANGED
@@ -152,6 +152,10 @@ export async function archSelectRoute(
152
  const headers: HeadersInit = {
153
  Authorization: `Bearer ${getApiToken(locals)}`,
154
  "Content-Type": "application/json",
 
 
 
 
155
  };
156
  const body = {
157
  model: archModel,
 
152
  const headers: HeadersInit = {
153
  Authorization: `Bearer ${getApiToken(locals)}`,
154
  "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
+ : {}),
159
  };
160
  const body = {
161
  model: archModel,
src/lib/server/textGeneration/mcp/runMcpFlow.ts CHANGED
@@ -264,6 +264,12 @@ export async function* runMcpFlow({
264
  apiKey: config.OPENAI_API_KEY || config.HF_TOKEN || "sk-",
265
  baseURL: config.OPENAI_BASE_URL,
266
  fetch: captureProviderFetch,
 
 
 
 
 
 
267
  });
268
 
269
  const mmEnabled = (forceMultimodal ?? false) || targetModel.multimodal;
 
264
  apiKey: config.OPENAI_API_KEY || config.HF_TOKEN || "sk-",
265
  baseURL: config.OPENAI_BASE_URL,
266
  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
+ },
273
  });
274
 
275
  const mmEnabled = (forceMultimodal ?? false) || targetModel.multimodal;
src/lib/stores/settings.ts CHANGED
@@ -17,6 +17,7 @@ type SettingsStore = {
17
  disableStream: boolean;
18
  directPaste: boolean;
19
  hidePromptExamples: Record<string, boolean>;
 
20
  };
21
 
22
  type SettingsStoreWritable = Writable<SettingsStore> & {
 
17
  disableStream: boolean;
18
  directPaste: boolean;
19
  hidePromptExamples: Record<string, boolean>;
20
+ billingOrganization?: string;
21
  };
22
 
23
  type SettingsStoreWritable = Writable<SettingsStore> & {
src/lib/types/Settings.ts CHANGED
@@ -35,6 +35,12 @@ export interface Settings extends Timestamps {
35
 
36
  disableStream: boolean;
37
  directPaste: boolean;
 
 
 
 
 
 
38
  }
39
 
40
  export type SettingsEditable = Omit<Settings, "welcomeModalSeenAt" | "createdAt" | "updatedAt">;
 
35
 
36
  disableStream: boolean;
37
  directPaste: boolean;
38
+
39
+ /**
40
+ * Organization to bill inference requests to (HuggingChat only).
41
+ * Stores the org's preferred_username. If empty/undefined, bills to personal account.
42
+ */
43
+ billingOrganization?: string;
44
  }
45
 
46
  export type SettingsEditable = Omit<Settings, "welcomeModalSeenAt" | "createdAt" | "updatedAt">;
src/routes/conversation/[id]/+server.ts CHANGED
@@ -517,6 +517,12 @@ export async function POST({ request, locals, params, getClientAddress }) {
517
  const initialMessageContent = messageToWriteTo.content;
518
 
519
  try {
 
 
 
 
 
 
520
  const ctx: TextGenerationContext = {
521
  model,
522
  endpoint: await model.getEndpoint(),
@@ -527,15 +533,9 @@ export async function POST({ request, locals, params, getClientAddress }) {
527
  ip: getClientAddress(),
528
  username: locals.user?.username,
529
  // Force-enable multimodal if user settings say so for this model
530
- forceMultimodal: Boolean(
531
- (await collections.settings.findOne(authCondition(locals)))?.multimodalOverrides?.[
532
- model.id
533
- ]
534
- ),
535
  // Force-enable tools if user settings say so for this model
536
- forceTools: Boolean(
537
- (await collections.settings.findOne(authCondition(locals)))?.toolsOverrides?.[model.id]
538
- ),
539
  locals,
540
  abortController: ctrl,
541
  };
 
517
  const initialMessageContent = messageToWriteTo.content;
518
 
519
  try {
520
+ // Fetch user settings once for all overrides and billing org
521
+ const userSettings = await collections.settings.findOne(authCondition(locals));
522
+
523
+ // Add billing organization to locals for the endpoint to use
524
+ locals.billingOrganization = userSettings?.billingOrganization;
525
+
526
  const ctx: TextGenerationContext = {
527
  model,
528
  endpoint: await model.getEndpoint(),
 
533
  ip: getClientAddress(),
534
  username: locals.user?.username,
535
  // Force-enable multimodal if user settings say so for this model
536
+ forceMultimodal: Boolean(userSettings?.multimodalOverrides?.[model.id]),
 
 
 
 
537
  // Force-enable tools if user settings say so for this model
538
+ forceTools: Boolean(userSettings?.toolsOverrides?.[model.id]),
 
 
539
  locals,
540
  abortController: ctrl,
541
  };
src/routes/login/callback/updateUser.spec.ts CHANGED
@@ -99,7 +99,7 @@ describe("login", () => {
99
  await updateUser({ userData, locals, cookies: cookiesMock, token });
100
 
101
  // updateUser creates a new sessionId, so we need to use the updated value
102
- const user = (await findUser(locals.sessionId)).user;
103
 
104
  assert.exists(user);
105
 
 
99
  await updateUser({ userData, locals, cookies: cookiesMock, token });
100
 
101
  // updateUser creates a new sessionId, so we need to use the updated value
102
+ const user = (await findUser(locals.sessionId, undefined, new URL("http://localhost"))).user;
103
 
104
  assert.exists(user);
105
 
src/routes/settings/(nav)/+server.ts CHANGED
@@ -19,6 +19,7 @@ export async function POST({ request, locals }) {
19
  disableStream: z.boolean().default(false),
20
  directPaste: z.boolean().default(false),
21
  hidePromptExamples: z.record(z.boolean()).default({}),
 
22
  })
23
  .parse(body) satisfies SettingsEditable;
24
 
 
19
  disableStream: z.boolean().default(false),
20
  directPaste: z.boolean().default(false),
21
  hidePromptExamples: z.record(z.boolean()).default({}),
22
+ billingOrganization: z.string().optional(),
23
  })
24
  .parse(body) satisfies SettingsEditable;
25
 
src/routes/settings/(nav)/application/+page.svelte CHANGED
@@ -41,21 +41,57 @@
41
 
42
  const client = useAPIClient();
43
 
44
- let OPENAI_BASE_URL: string | null = $state(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  onMount(async () => {
 
46
  try {
47
  const cfg = await client.debug.config.get().then(handleResponse);
48
  OPENAI_BASE_URL = (cfg as { OPENAI_BASE_URL?: string }).OPENAI_BASE_URL || null;
49
  } catch (e) {
50
  // ignore if debug endpoint is unavailable
51
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  });
53
 
54
- let themePref: ThemePreference = $state(browser ? getThemePreference() : "system");
55
 
56
  // Admin: model refresh UI state
57
- let refreshing: boolean = $state(false);
58
- let refreshMessage: string | null = $state(null);
59
  </script>
60
 
61
  <div class="flex w-full flex-col gap-4">
@@ -222,7 +258,64 @@
222
  </div>
223
  </div>
224
 
225
- <div class="mt-6 flex flex-col gap-2 text-[13px]">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  {#if publicConfig.isHuggingChat}
227
  <a
228
  href="https://github.com/huggingface/chat-ui"
@@ -230,12 +323,6 @@
230
  class="flex items-center underline decoration-gray-300 underline-offset-2 hover:decoration-gray-700 dark:decoration-gray-700 dark:hover:decoration-gray-400"
231
  ><CarbonLogoGithub class="mr-1.5 shrink-0 text-sm " /> Github repository</a
232
  >
233
- <a
234
- href="https://huggingface.co/settings/inference-providers/settings"
235
- target="_blank"
236
- class="flex items-center underline decoration-gray-300 underline-offset-2 hover:decoration-gray-700 dark:decoration-gray-700 dark:hover:decoration-gray-400"
237
- ><CarbonArrowUpRight class="mr-1.5 shrink-0 text-sm " /> Providers settings</a
238
- >
239
  <a
240
  href="https://huggingface.co/spaces/huggingchat/chat-ui/discussions/764"
241
  target="_blank"
 
41
 
42
  const client = useAPIClient();
43
 
44
+ let OPENAI_BASE_URL = $state<string | null>(null);
45
+
46
+ // Billing organization state
47
+ type BillingOrg = { sub: string; name: string; preferred_username: string };
48
+ let billingOrgs = $state<BillingOrg[]>([]);
49
+ let billingOrgsLoading = $state(false);
50
+ let billingOrgsError = $state<string | null>(null);
51
+
52
+ function getBillingOrganization() {
53
+ return $settings.billingOrganization ?? "";
54
+ }
55
+ function setBillingOrganization(v: string) {
56
+ settings.update((s) => ({ ...s, billingOrganization: v || undefined }));
57
+ }
58
+
59
  onMount(async () => {
60
+ // Fetch debug config
61
  try {
62
  const cfg = await client.debug.config.get().then(handleResponse);
63
  OPENAI_BASE_URL = (cfg as { OPENAI_BASE_URL?: string }).OPENAI_BASE_URL || null;
64
  } catch (e) {
65
  // ignore if debug endpoint is unavailable
66
  }
67
+
68
+ // Fetch billing organizations (only for HuggingChat + logged in users)
69
+ if (publicConfig.isHuggingChat && page.data.user) {
70
+ billingOrgsLoading = true;
71
+ try {
72
+ const data = (await client.user["billing-orgs"].get().then(handleResponse)) as {
73
+ userCanPay: boolean;
74
+ organizations: BillingOrg[];
75
+ currentBillingOrg?: string;
76
+ };
77
+ billingOrgs = data.organizations ?? [];
78
+ // Update settings if current billing org was cleared by server
79
+ if (data.currentBillingOrg !== getBillingOrganization()) {
80
+ setBillingOrganization(data.currentBillingOrg ?? "");
81
+ }
82
+ } catch {
83
+ billingOrgsError = "Failed to load billing options";
84
+ } finally {
85
+ billingOrgsLoading = false;
86
+ }
87
+ }
88
  });
89
 
90
+ let themePref = $state<ThemePreference>(browser ? getThemePreference() : "system");
91
 
92
  // Admin: model refresh UI state
93
+ let refreshing = $state(false);
94
+ let refreshMessage = $state<string | null>(null);
95
  </script>
96
 
97
  <div class="flex w-full flex-col gap-4">
 
258
  </div>
259
  </div>
260
 
261
+ <!-- Billing section (HuggingChat only) -->
262
+ {#if publicConfig.isHuggingChat && page.data.user}
263
+ <div
264
+ class="rounded-xl border border-gray-200 bg-white px-3 shadow-sm dark:border-gray-700 dark:bg-gray-800"
265
+ >
266
+ <div class="divide-y divide-gray-200 dark:divide-gray-700">
267
+ <!-- Bill usage to -->
268
+ <div class="flex items-start justify-between py-3">
269
+ <div>
270
+ <div class="text-[13px] font-medium text-gray-800 dark:text-gray-200">Billing</div>
271
+ <p class="text-[12px] text-gray-500 dark:text-gray-400">
272
+ Select between personal or organization billing (for eligible organizations).
273
+ </p>
274
+ </div>
275
+ <div class="flex items-center">
276
+ {#if billingOrgsLoading}
277
+ <span class="text-xs text-gray-500 dark:text-gray-400">Loading...</span>
278
+ {:else if billingOrgsError}
279
+ <span class="text-xs text-red-500">{billingOrgsError}</span>
280
+ {:else}
281
+ <select
282
+ class="rounded-md border border-gray-300 bg-white px-1 py-1 text-xs text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
283
+ value={getBillingOrganization()}
284
+ onchange={(e) => setBillingOrganization(e.currentTarget.value)}
285
+ >
286
+ <option value="">Personal</option>
287
+ {#each billingOrgs as org}
288
+ <option value={org.preferred_username}>{org.name}</option>
289
+ {/each}
290
+ </select>
291
+ {/if}
292
+ </div>
293
+ </div>
294
+ <!-- Providers Usage -->
295
+ <div class="flex items-start justify-between py-3">
296
+ <div>
297
+ <div class="text-[13px] font-medium text-gray-800 dark:text-gray-200">
298
+ Providers Usage
299
+ </div>
300
+ <p class="text-[12px] text-gray-500 dark:text-gray-400">
301
+ See which providers you use and choose your preferred ones.
302
+ </p>
303
+ </div>
304
+ <a
305
+ href={getBillingOrganization()
306
+ ? `https://huggingface.co/organizations/${getBillingOrganization()}/settings/inference-providers/overview`
307
+ : "https://huggingface.co/settings/inference-providers/settings"}
308
+ target="_blank"
309
+ class="whitespace-nowrap rounded-md border border-gray-300 bg-white px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
310
+ >
311
+ View Usage
312
+ </a>
313
+ </div>
314
+ </div>
315
+ </div>
316
+ {/if}
317
+
318
+ <div class="mt-6 flex flex-col gap-2 self-start text-[13px]">
319
  {#if publicConfig.isHuggingChat}
320
  <a
321
  href="https://github.com/huggingface/chat-ui"
 
323
  class="flex items-center underline decoration-gray-300 underline-offset-2 hover:decoration-gray-700 dark:decoration-gray-700 dark:hover:decoration-gray-400"
324
  ><CarbonLogoGithub class="mr-1.5 shrink-0 text-sm " /> Github repository</a
325
  >
 
 
 
 
 
 
326
  <a
327
  href="https://huggingface.co/spaces/huggingchat/chat-ui/discussions/764"
328
  target="_blank"