victor HF Staff commited on
Commit
4566da9
·
unverified ·
1 Parent(s): 47b8403

Get pro (#1930)

Browse files

* BREAKING? Remove overflow-hidden from message info container

The overflow-hidden class was removed from the message info container div to allow content to overflow as needed. This may improve the display of message metadata or related elements.

* Add subscribe modal and improve error handling

Introduces a SubscribeModal component to prompt users to upgrade when hitting message limits, shown on 402 errors in conversation pages. Enhances error propagation and handling in router and endpoint logic, preserving HTTP status codes and error messages, and updates types to support status codes in message updates. Also includes minor UI improvements to Toast and refines error extraction and logging throughout the routing flow.

* Sanitize and improve error handling in router endpoints

Introduces functions to sanitize error messages before sending them to clients, preventing leakage of provider details. Differentiates between policy errors (e.g., authorization, payment) and transient errors, surfacing only policy errors to users while allowing fallback for transient errors. Also updates conversation update logic for compatibility with different MongoDB driver versions.

* Remove public error message sanitization

Eliminated the publicErrorMessage function and now forward raw error messages to clients. This simplifies error handling and avoids altering upstream error messages before sending them to the client.

* Hide router UI elements on error and refactor RetryBtn

Router examples and follow-ups are now hidden when the last message is an error. Also refactored the RetryBtn rendering logic to simplify the condition and only show it when not loading and lastIsError is true.

* Improve error handling and type safety in server code

Refactored error extraction logic in endpoint.ts for better type safety and more robust handling of OpenAI and HTTP errors. Updated conversation group and conversation server routes to use stricter type checks for status codes. Removed unused import and variable from SubscribeModal.svelte.

src/lib/components/SubscribeModal.svelte ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import Modal from "$lib/components/Modal.svelte";
3
+
4
+ interface Props {
5
+ close: () => void;
6
+ }
7
+
8
+ let { close }: Props = $props();
9
+ </script>
10
+
11
+ <Modal closeOnBackdrop={false} onclose={close} width="!max-w-[420px] !m-4">
12
+ <div
13
+ class="flex w-full flex-col gap-8 bg-white bg-gradient-to-b to-transparent px-6 pb-7 dark:bg-black dark:from-white/10 dark:to-white/5"
14
+ >
15
+ <div
16
+ class="-mx-6 grid h-48 select-none place-items-center bg-gradient-to-t from-black/5 dark:from-white/10"
17
+ >
18
+ <div class="flex flex-col items-center justify-center gap-2.5 px-8 text-center">
19
+ <div
20
+ class="flex size-14 items-center justify-center rounded-full bg-gradient-to-br from-pink-500/15 from-15% via-green-500/15 to-yellow-500/15 text-3xl"
21
+ >
22
+ <svg
23
+ width="1em"
24
+ height="1em"
25
+ viewBox="0 0 12 12"
26
+ fill="none"
27
+ xmlns="http://www.w3.org/2000/svg"
28
+ >
29
+ <path
30
+ d="M6.48 1.26001C6.48 2.81001 7.15 3.84001 7.98 4.50001C8.84 5.18001 9.88 5.50001 10.56 5.57001V6.43001C9.6233 6.5513 8.73602 6.92071 7.99 7.50001C7.50131 7.88332 7.10989 8.37647 6.84753 8.93943C6.58516 9.50238 6.45925 10.1193 6.48 10.74H5.52C5.52 9.19001 4.85 8.16001 4.02 7.50001C3.27114 6.91907 2.3802 6.54958 1.44 6.43001V5.57001C2.37671 5.44872 3.26398 5.07931 4.01 4.50001C4.4987 4.1167 4.89011 3.62355 5.15248 3.06059C5.41484 2.49764 5.54076 1.88075 5.52 1.26001H6.48Z"
31
+ fill="url(#paint0_linear_141_2)"
32
+ />
33
+ <defs>
34
+ <linearGradient
35
+ id="paint0_linear_141_2"
36
+ x1="3.37"
37
+ y1="3.43001"
38
+ x2="8.14"
39
+ y2="8.90001"
40
+ gradientUnits="userSpaceOnUse"
41
+ >
42
+ <stop stop-color="#FF0789" />
43
+ <stop offset="0.63" stop-color="#21DE75" />
44
+ <stop offset="1" stop-color="#FF8D00" />
45
+ </linearGradient>
46
+ </defs>
47
+ </svg>
48
+ </div>
49
+ <h2 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Upgrade Required</h2>
50
+ </div>
51
+ </div>
52
+
53
+ <div class="text-gray-700 dark:text-gray-200">
54
+ <p class="text-[15px] leading-relaxed">
55
+ You've reached your message limit. Upgrade to Hugging Face PRO to continue using
56
+ HuggingChat.
57
+ </p>
58
+ <p class="mt-3 text-[15px] italic leading-relaxed opacity-75">
59
+ It's also possible to use your PRO credits in your favorite AI tools.
60
+ </p>
61
+ </div>
62
+
63
+ <div class="flex flex-col gap-2.5">
64
+ <a
65
+ href="https://huggingface.co/subscribe/pro?from=HuggingChat"
66
+ target="_blank"
67
+ rel="noopener noreferrer"
68
+ class="w-full rounded-xl bg-black px-5 py-2.5 text-center text-base font-medium text-white hover:bg-gray-800 dark:bg-white dark:text-black dark:hover:bg-gray-200"
69
+ >
70
+ Upgrade to Pro
71
+ </a>
72
+ <button
73
+ class="w-full rounded-xl bg-gray-200 px-5 py-2.5 text-base font-medium text-gray-700 hover:bg-gray-300/80 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10"
74
+ onclick={close}
75
+ >
76
+ Maybe later
77
+ </button>
78
+ </div>
79
+ </div>
80
+ </Modal>
src/lib/components/Toast.svelte CHANGED
@@ -13,12 +13,12 @@
13
  <Portal>
14
  <div
15
  transition:fade|global={{ duration: 300 }}
16
- class="pointer-events-none fixed right-0 top-12 z-50 bg-gradient-to-bl from-red-500/20 via-red-500/0 to-red-500/0 pb-36 pl-36 pr-2 pt-2 md:top-0 md:pr-8 md:pt-5"
17
  >
18
  <div
19
  class="pointer-events-auto flex items-center rounded-full bg-white/90 px-3 py-1 shadow-sm dark:bg-gray-900/80"
20
  >
21
- <IconDazzled classNames="text-2xl mr-2" />
22
  <h2 class="line-clamp-2 max-w-2xl font-semibold text-gray-800 dark:text-gray-200">
23
  {message}
24
  </h2>
 
13
  <Portal>
14
  <div
15
  transition:fade|global={{ duration: 300 }}
16
+ class="pointer-events-none fixed right-0 top-12 z-50 bg-gradient-to-bl from-red-500/20 via-red-500/0 to-red-500/0 pb-36 pl-36 pr-2 pt-2 max-sm:text-sm md:top-0 md:pr-8 md:pt-5"
17
  >
18
  <div
19
  class="pointer-events-auto flex items-center rounded-full bg-white/90 px-3 py-1 shadow-sm dark:bg-gray-900/80"
20
  >
21
+ <IconDazzled classNames="text-2xl mr-2 flex-none" />
22
  <h2 class="line-clamp-2 max-w-2xl font-semibold text-gray-800 dark:text-gray-200">
23
  {message}
24
  </h2>
src/lib/components/chat/ChatMessage.svelte CHANGED
@@ -196,7 +196,7 @@
196
  <div
197
  class="absolute -bottom-3.5 {message.routerMetadata && messageInfoWidth > messageWidth
198
  ? 'left-1 pl-1 lg:pl-7'
199
- : 'right-1'} flex max-w-[calc(100dvw-40px)] items-center gap-0.5 overflow-hidden"
200
  bind:offsetWidth={messageInfoWidth}
201
  >
202
  {#if message.routerMetadata && (!isLast || !loading)}
 
196
  <div
197
  class="absolute -bottom-3.5 {message.routerMetadata && messageInfoWidth > messageWidth
198
  ? 'left-1 pl-1 lg:pl-7'
199
+ : 'right-1'} flex max-w-[calc(100dvw-40px)] items-center gap-0.5"
200
  bind:offsetWidth={messageInfoWidth}
201
  >
202
  {#if message.routerMetadata && (!isLast || !loading)}
src/lib/components/chat/ChatWindow.svelte CHANGED
@@ -398,7 +398,7 @@
398
  dark:from-gray-900 dark:via-gray-900/100
399
  dark:to-gray-900/0 max-sm:py-0 sm:px-5 md:pb-4 xl:max-w-4xl [&>*]:pointer-events-auto"
400
  >
401
- {#if !message.length && !messages.length && !sources.length && !loading && currentModel.isRouter && routerExamples.length && !hideRouterExamples}
402
  <div
403
  class="no-scrollbar mb-3 flex w-full select-none justify-start gap-2 overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"
404
  >
@@ -410,7 +410,7 @@
410
  {/each}
411
  </div>
412
  {/if}
413
- {#if shouldShowRouterFollowUps}
414
  <div
415
  class="no-scrollbar mb-3 flex w-full select-none justify-start gap-2 overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"
416
  >
@@ -446,19 +446,17 @@
446
 
447
  <div class="w-full">
448
  <div class="flex w-full *:mb-3">
449
- {#if !loading}
450
- {#if lastIsError}
451
- <RetryBtn
452
- classNames="ml-auto"
453
- onClick={() => {
454
- if (lastMessage && lastMessage.ancestors) {
455
- onretry?.({
456
- id: lastMessage.id,
457
- });
458
- }
459
- }}
460
- />
461
- {/if}
462
  {/if}
463
  </div>
464
  <form
 
398
  dark:from-gray-900 dark:via-gray-900/100
399
  dark:to-gray-900/0 max-sm:py-0 sm:px-5 md:pb-4 xl:max-w-4xl [&>*]:pointer-events-auto"
400
  >
401
+ {#if !message.length && !messages.length && !sources.length && !loading && currentModel.isRouter && routerExamples.length && !hideRouterExamples && !lastIsError}
402
  <div
403
  class="no-scrollbar mb-3 flex w-full select-none justify-start gap-2 overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"
404
  >
 
410
  {/each}
411
  </div>
412
  {/if}
413
+ {#if shouldShowRouterFollowUps && !lastIsError}
414
  <div
415
  class="no-scrollbar mb-3 flex w-full select-none justify-start gap-2 overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"
416
  >
 
446
 
447
  <div class="w-full">
448
  <div class="flex w-full *:mb-3">
449
+ {#if !loading && lastIsError}
450
+ <RetryBtn
451
+ classNames="ml-auto"
452
+ onClick={() => {
453
+ if (lastMessage && lastMessage.ancestors) {
454
+ onretry?.({
455
+ id: lastMessage.id,
456
+ });
457
+ }
458
+ }}
459
+ />
 
 
460
  {/if}
461
  </div>
462
  <form
src/lib/server/api/routes/groups/conversations.ts CHANGED
@@ -191,7 +191,12 @@ export const conversationGroup = new Elysia().use(authPlugin).group("/conversati
191
  }
192
  );
193
 
194
- if (res.modifiedCount === 0) {
 
 
 
 
 
195
  throw new Error("Conversation not found");
196
  }
197
 
 
191
  }
192
  );
193
 
194
+ // Use matchedCount if available (newer drivers), fallback to modifiedCount for compatibility
195
+ if (
196
+ typeof res.matchedCount === "number"
197
+ ? res.matchedCount === 0
198
+ : res.modifiedCount === 0
199
+ ) {
200
  throw new Error("Conversation not found");
201
  }
202
 
src/lib/server/router/arch.ts CHANGED
@@ -1,7 +1,7 @@
1
  import { config } from "$lib/server/config";
2
  import { logger } from "$lib/server/logger";
3
  import type { EndpointMessage } from "../endpoints/endpoints";
4
- import type { Route, RouteConfig } from "./types";
5
  import { getRoutes } from "./policy";
6
  import { getApiToken } from "$lib/server/apiToken";
7
 
@@ -71,7 +71,7 @@ export async function archSelectRoute(
71
  messages: EndpointMessage[],
72
  traceId: string | undefined,
73
  locals: App.Locals | undefined
74
- ): Promise<{ routeName: string }> {
75
  const routes = await getRoutes();
76
  const prompt = toRouterPrompt(messages, routes);
77
 
@@ -107,7 +107,35 @@ export async function archSelectRoute(
107
  signal: ctrl.signal,
108
  });
109
  clearTimeout(to);
110
- if (!resp.ok) throw new Error(`arch-router ${resp.status}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  const data: { choices: { message: { content: string } }[] } = await resp.json();
112
  const text = (data?.choices?.[0]?.message?.content ?? "").toString().trim();
113
  const raw = parseRouteName(text);
@@ -118,7 +146,15 @@ export async function archSelectRoute(
118
  return { routeName: exists ? chosen : "casual_conversation" };
119
  } catch (e) {
120
  clearTimeout(to);
 
121
  logger.warn({ err: String(e), traceId }, "arch router selection failed");
122
- return { routeName: "arch_router_failure" };
 
 
 
 
 
 
 
123
  }
124
  }
 
1
  import { config } from "$lib/server/config";
2
  import { logger } from "$lib/server/logger";
3
  import type { EndpointMessage } from "../endpoints/endpoints";
4
+ import type { Route, RouteConfig, RouteSelection } from "./types";
5
  import { getRoutes } from "./policy";
6
  import { getApiToken } from "$lib/server/apiToken";
7
 
 
71
  messages: EndpointMessage[],
72
  traceId: string | undefined,
73
  locals: App.Locals | undefined
74
+ ): Promise<RouteSelection> {
75
  const routes = await getRoutes();
76
  const prompt = toRouterPrompt(messages, routes);
77
 
 
107
  signal: ctrl.signal,
108
  });
109
  clearTimeout(to);
110
+ if (!resp.ok) {
111
+ // Extract error message from response
112
+ let errorMessage = `arch-router ${resp.status}`;
113
+ try {
114
+ const errorData = await resp.json();
115
+ // Try to extract message from OpenAI-style error format
116
+ if (errorData.error?.message) {
117
+ errorMessage = errorData.error.message;
118
+ } else if (errorData.message) {
119
+ errorMessage = errorData.message;
120
+ }
121
+ } catch {
122
+ // If JSON parsing fails, use status text
123
+ errorMessage = resp.statusText || errorMessage;
124
+ }
125
+
126
+ logger.warn(
127
+ { status: resp.status, error: errorMessage, traceId },
128
+ "[arch] router returned error"
129
+ );
130
+
131
+ return {
132
+ routeName: "arch_router_failure",
133
+ error: {
134
+ message: errorMessage,
135
+ statusCode: resp.status,
136
+ },
137
+ };
138
+ }
139
  const data: { choices: { message: { content: string } }[] } = await resp.json();
140
  const text = (data?.choices?.[0]?.message?.content ?? "").toString().trim();
141
  const raw = parseRouteName(text);
 
146
  return { routeName: exists ? chosen : "casual_conversation" };
147
  } catch (e) {
148
  clearTimeout(to);
149
+ const err = e as Error;
150
  logger.warn({ err: String(e), traceId }, "arch router selection failed");
151
+
152
+ // Return error with context but no status code (network/timeout errors)
153
+ return {
154
+ routeName: "arch_router_failure",
155
+ error: {
156
+ message: err.message || String(e),
157
+ },
158
+ };
159
  }
160
  }
src/lib/server/router/endpoint.ts CHANGED
@@ -11,11 +11,75 @@ import { logger } from "$lib/server/logger";
11
  import { archSelectRoute } from "./arch";
12
  import { getRoutes, resolveRouteModels } from "./policy";
13
  import { getApiToken } from "$lib/server/apiToken";
 
14
 
15
  const REASONING_BLOCK_REGEX = /<think>[\s\S]*?(?:<\/think>|$)/g;
16
 
17
  const ROUTER_MULTIMODAL_ROUTE = "multimodal";
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  function stripReasoningBlocks(text: string): string {
20
  const stripped = text.replace(REASONING_BLOCK_REGEX, "");
21
  return stripped === text ? text : stripped.trim();
@@ -124,32 +188,66 @@ export async function makeRouterEndpoint(routerModel: ProcessedModel): Promise<E
124
  const gen = await ep({ ...params });
125
  return metadataThenStream(gen, multimodalCandidate, ROUTER_MULTIMODAL_ROUTE);
126
  } catch (e) {
 
127
  logger.error(
128
- { route: ROUTER_MULTIMODAL_ROUTE, model: multimodalCandidate, err: String(e) },
 
 
 
 
 
129
  "[router] multimodal fallback failed"
130
  );
131
- throw new Error(
132
- "Failed to call the configured multimodal model. Remove the image or try again later."
133
- );
134
  }
135
  }
136
 
137
- const { routeName } = await archSelectRoute(sanitizedMessages, undefined, params.locals);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
  const fallbackModel = config.LLM_ROUTER_FALLBACK_MODEL || routerModel.id;
140
- const { candidates } = resolveRouteModels(routeName, routes, fallbackModel);
141
 
142
  let lastErr: unknown = undefined;
143
  for (const candidate of candidates) {
144
  try {
145
- logger.info({ route: routeName, model: candidate }, "[router] trying candidate");
 
 
 
146
  const ep = await createCandidateEndpoint(candidate);
147
  const gen = await ep({ ...params });
148
- return metadataThenStream(gen, candidate, routeName);
149
  } catch (e) {
150
  lastErr = e;
 
151
  logger.warn(
152
- { route: routeName, model: candidate, err: String(e) },
 
 
 
 
 
153
  "[router] candidate failed"
154
  );
155
  continue;
@@ -157,6 +255,8 @@ export async function makeRouterEndpoint(routerModel: ProcessedModel): Promise<E
157
  }
158
 
159
  // Exhausted all candidates — throw to signal upstream failure
160
- throw new Error(`Routing failed for route=${routeName}: ${String(lastErr)}`);
 
 
161
  };
162
  }
 
11
  import { archSelectRoute } from "./arch";
12
  import { getRoutes, resolveRouteModels } from "./policy";
13
  import { getApiToken } from "$lib/server/apiToken";
14
+ import { ROUTER_FAILURE } from "./types";
15
 
16
  const REASONING_BLOCK_REGEX = /<think>[\s\S]*?(?:<\/think>|$)/g;
17
 
18
  const ROUTER_MULTIMODAL_ROUTE = "multimodal";
19
 
20
+ /**
21
+ * Custom error class that preserves HTTP status codes
22
+ */
23
+ class HTTPError extends Error {
24
+ constructor(
25
+ message: string,
26
+ public statusCode?: number
27
+ ) {
28
+ super(message);
29
+ this.name = "HTTPError";
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Extract the actual error message and status from OpenAI SDK errors or other upstream errors
35
+ */
36
+ function extractUpstreamError(error: unknown): { message: string; statusCode?: number } {
37
+ // Check if it's an OpenAI APIError with structured error info
38
+ if (error && typeof error === "object") {
39
+ const err = error as Record<string, unknown>;
40
+
41
+ // OpenAI SDK error with error.error.message and status
42
+ if (
43
+ err.error &&
44
+ typeof err.error === "object" &&
45
+ "message" in err.error &&
46
+ typeof err.error.message === "string"
47
+ ) {
48
+ return {
49
+ message: err.error.message,
50
+ statusCode: typeof err.status === "number" ? err.status : undefined,
51
+ };
52
+ }
53
+
54
+ // HTTPError or error with statusCode
55
+ if (typeof err.statusCode === "number" && typeof err.message === "string") {
56
+ return { message: err.message, statusCode: err.statusCode };
57
+ }
58
+
59
+ // Error with status field
60
+ if (typeof err.status === "number" && typeof err.message === "string") {
61
+ return { message: err.message, statusCode: err.status };
62
+ }
63
+
64
+ // Direct error message
65
+ if (typeof err.message === "string") {
66
+ return { message: err.message };
67
+ }
68
+ }
69
+
70
+ return { message: String(error) };
71
+ }
72
+
73
+ /**
74
+ * Determines if an error is a policy/entitlement error that should be shown to users immediately
75
+ * (vs transient errors that should trigger fallback)
76
+ */
77
+ function isPolicyError(statusCode?: number): boolean {
78
+ if (!statusCode) return false;
79
+ // 402: Payment Required, 401: Unauthorized, 403: Forbidden
80
+ return statusCode === 402 || statusCode === 401 || statusCode === 403;
81
+ }
82
+
83
  function stripReasoningBlocks(text: string): string {
84
  const stripped = text.replace(REASONING_BLOCK_REGEX, "");
85
  return stripped === text ? text : stripped.trim();
 
188
  const gen = await ep({ ...params });
189
  return metadataThenStream(gen, multimodalCandidate, ROUTER_MULTIMODAL_ROUTE);
190
  } catch (e) {
191
+ const { message, statusCode } = extractUpstreamError(e);
192
  logger.error(
193
+ {
194
+ route: ROUTER_MULTIMODAL_ROUTE,
195
+ model: multimodalCandidate,
196
+ err: message,
197
+ ...(statusCode && { status: statusCode }),
198
+ },
199
  "[router] multimodal fallback failed"
200
  );
201
+ throw statusCode ? new HTTPError(message, statusCode) : new Error(message);
 
 
202
  }
203
  }
204
 
205
+ const routeSelection = await archSelectRoute(sanitizedMessages, undefined, params.locals);
206
+
207
+ // If arch router failed with an error, only hard-fail for policy errors (402/401/403)
208
+ // For transient errors (5xx, timeouts, network), allow fallback to continue
209
+ if (routeSelection.routeName === ROUTER_FAILURE && routeSelection.error) {
210
+ const { message, statusCode } = routeSelection.error;
211
+
212
+ if (isPolicyError(statusCode)) {
213
+ // Policy errors should be surfaced to the user immediately (e.g., subscription required)
214
+ logger.error(
215
+ { err: message, ...(statusCode && { status: statusCode }) },
216
+ "[router] arch router failed with policy error, propagating to client"
217
+ );
218
+ throw statusCode ? new HTTPError(message, statusCode) : new Error(message);
219
+ }
220
+
221
+ // Transient errors: log and continue to fallback
222
+ logger.warn(
223
+ { err: message, ...(statusCode && { status: statusCode }) },
224
+ "[router] arch router failed with transient error, attempting fallback"
225
+ );
226
+ }
227
 
228
  const fallbackModel = config.LLM_ROUTER_FALLBACK_MODEL || routerModel.id;
229
+ const { candidates } = resolveRouteModels(routeSelection.routeName, routes, fallbackModel);
230
 
231
  let lastErr: unknown = undefined;
232
  for (const candidate of candidates) {
233
  try {
234
+ logger.info(
235
+ { route: routeSelection.routeName, model: candidate },
236
+ "[router] trying candidate"
237
+ );
238
  const ep = await createCandidateEndpoint(candidate);
239
  const gen = await ep({ ...params });
240
+ return metadataThenStream(gen, candidate, routeSelection.routeName);
241
  } catch (e) {
242
  lastErr = e;
243
+ const { message: errMsg, statusCode: errStatus } = extractUpstreamError(e);
244
  logger.warn(
245
+ {
246
+ route: routeSelection.routeName,
247
+ model: candidate,
248
+ err: errMsg,
249
+ ...(errStatus && { status: errStatus }),
250
+ },
251
  "[router] candidate failed"
252
  );
253
  continue;
 
255
  }
256
 
257
  // Exhausted all candidates — throw to signal upstream failure
258
+ // Forward the upstream error to the client
259
+ const { message, statusCode } = extractUpstreamError(lastErr);
260
+ throw statusCode ? new HTTPError(message, statusCode) : new Error(message);
261
  };
262
  }
src/lib/server/router/types.ts CHANGED
@@ -10,4 +10,12 @@ export interface RouteConfig {
10
  description: string;
11
  }
12
 
 
 
 
 
 
 
 
 
13
  export const ROUTER_FAILURE = "arch_router_failure";
 
10
  description: string;
11
  }
12
 
13
+ export interface RouteSelection {
14
+ routeName: string;
15
+ error?: {
16
+ message: string;
17
+ statusCode?: number;
18
+ };
19
+ }
20
+
21
  export const ROUTER_FAILURE = "arch_router_failure";
src/lib/types/MessageUpdate.ts CHANGED
@@ -28,6 +28,7 @@ export interface MessageStatusUpdate {
28
  type: MessageUpdateType.Status;
29
  status: MessageUpdateStatus;
30
  message?: string;
 
31
  }
32
 
33
  // Everything else
 
28
  type: MessageUpdateType.Status;
29
  status: MessageUpdateStatus;
30
  message?: string;
31
+ statusCode?: number;
32
  }
33
 
34
  // Everything else
src/routes/conversation/[id]/+page.svelte CHANGED
@@ -30,12 +30,14 @@
30
  import type { TreeNode, TreeId } from "$lib/utils/tree/tree";
31
  import "katex/dist/katex.min.css";
32
  import { updateDebouncer } from "$lib/utils/updates.js";
 
33
 
34
  let { data = $bindable() } = $props();
35
 
36
  let loading = $state(false);
37
  let pending = $state(false);
38
  let initialRun = true;
 
39
 
40
  let files: File[] = $state([]);
41
 
@@ -302,7 +304,12 @@
302
  update.type === MessageUpdateType.Status &&
303
  update.status === MessageUpdateStatus.Error
304
  ) {
305
- $error = update.message ?? "An error has occurred";
 
 
 
 
 
306
  } else if (update.type === MessageUpdateType.Title) {
307
  const convInData = conversations.find(({ id }) => id === page.params.id);
308
  if (convInData) {
@@ -518,3 +525,7 @@
518
  models={data.models}
519
  currentModel={findCurrentModel(data.models, data.oldModels, data.model)}
520
  />
 
 
 
 
 
30
  import type { TreeNode, TreeId } from "$lib/utils/tree/tree";
31
  import "katex/dist/katex.min.css";
32
  import { updateDebouncer } from "$lib/utils/updates.js";
33
+ import SubscribeModal from "$lib/components/SubscribeModal.svelte";
34
 
35
  let { data = $bindable() } = $props();
36
 
37
  let loading = $state(false);
38
  let pending = $state(false);
39
  let initialRun = true;
40
+ let showSubscribeModal = $state(false);
41
 
42
  let files: File[] = $state([]);
43
 
 
304
  update.type === MessageUpdateType.Status &&
305
  update.status === MessageUpdateStatus.Error
306
  ) {
307
+ // Check if this is a 402 payment required error
308
+ if (update.statusCode === 402) {
309
+ showSubscribeModal = true;
310
+ } else {
311
+ $error = update.message ?? "An error has occurred";
312
+ }
313
  } else if (update.type === MessageUpdateType.Title) {
314
  const convInData = conversations.find(({ id }) => id === page.params.id);
315
  if (convInData) {
 
525
  models={data.models}
526
  currentModel={findCurrentModel(data.models, data.oldModels, data.model)}
527
  />
528
+
529
+ {#if showSubscribeModal}
530
+ <SubscribeModal close={() => (showSubscribeModal = false)} />
531
+ {/if}
src/routes/conversation/[id]/+server.ts CHANGED
@@ -494,10 +494,16 @@ export async function POST({ request, locals, params, getClientAddress }) {
494
  }
495
  } else {
496
  hasError = true;
 
 
 
 
 
497
  await update({
498
  type: MessageUpdateType.Status,
499
  status: MessageUpdateStatus.Error,
500
  message: err.message,
 
501
  });
502
  logger.error(err);
503
  }
 
494
  }
495
  } else {
496
  hasError = true;
497
+ // Extract status code if available from HTTPError or APIError
498
+ const errObj = err as unknown as Record<string, unknown>;
499
+ const statusCode =
500
+ (typeof errObj.statusCode === "number" ? errObj.statusCode : undefined) ||
501
+ (typeof errObj.status === "number" ? errObj.status : undefined);
502
  await update({
503
  type: MessageUpdateType.Status,
504
  status: MessageUpdateStatus.Error,
505
  message: err.message,
506
+ ...(statusCode && { statusCode }),
507
  });
508
  logger.error(err);
509
  }