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 +80 -0
- src/lib/components/Toast.svelte +2 -2
- src/lib/components/chat/ChatMessage.svelte +1 -1
- src/lib/components/chat/ChatWindow.svelte +13 -15
- src/lib/server/api/routes/groups/conversations.ts +6 -1
- src/lib/server/router/arch.ts +40 -4
- src/lib/server/router/endpoint.ts +110 -10
- src/lib/server/router/types.ts +8 -0
- src/lib/types/MessageUpdate.ts +1 -0
- src/routes/conversation/[id]/+page.svelte +12 -1
- src/routes/conversation/[id]/+server.ts +6 -0
|
@@ -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>
|
|
@@ -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>
|
|
@@ -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
|
| 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)}
|
|
@@ -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 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 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
|
|
@@ -191,7 +191,12 @@ export const conversationGroup = new Elysia().use(authPlugin).group("/conversati
|
|
| 191 |
}
|
| 192 |
);
|
| 193 |
|
| 194 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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<
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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";
|
|
@@ -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
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}
|
|
@@ -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 |
}
|