From 701816bd42e69822f8c4f26770e095b73c1ce754 Mon Sep 17 00:00:00 2001 From: "ashlee@vellum.ai" Date: Wed, 3 Jun 2026 00:38:17 +0000 Subject: [PATCH 1/3] fix(web): root-cause fix for daemon query retries + TQ conversion (WEB-7, WEB-2H) Closes LUM-2199 - Extract shouldRetryDaemonError utility to utils/daemon-errors.ts - Move isExpectedDaemonTransientError from lib/sentry/ to utils/ (re-export for compat) - Replace retry: false with shouldRetryDaemonError in use-history-pagination.ts (WEB-7) - Convert imperative credential read to TQ useQuery in web-search-card.tsx (WEB-2H) - Derive saved state from daemon config via useMemo, eliminating redundant useState - Keep bestEffort: true as defense-in-depth in error handlers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../chat/transcript/use-history-pagination.ts | 3 +- .../domains/settings/ai/web-search-card.tsx | 172 ++++++++++-------- apps/web/src/lib/sentry/capture-error.ts | 36 +--- apps/web/src/utils/daemon-errors.test.ts | 107 +++++++++++ apps/web/src/utils/daemon-errors.ts | 69 +++++++ 5 files changed, 277 insertions(+), 110 deletions(-) create mode 100644 apps/web/src/utils/daemon-errors.test.ts create mode 100644 apps/web/src/utils/daemon-errors.ts diff --git a/apps/web/src/domains/chat/transcript/use-history-pagination.ts b/apps/web/src/domains/chat/transcript/use-history-pagination.ts index b3a37b4b97c..5a3cf37dd13 100644 --- a/apps/web/src/domains/chat/transcript/use-history-pagination.ts +++ b/apps/web/src/domains/chat/transcript/use-history-pagination.ts @@ -24,6 +24,7 @@ import { fetchLatestHistoryPage, fetchOlderHistoryPage, } from "@/domains/chat/api/history"; +import { shouldRetryDaemonError } from "@/utils/daemon-errors"; import type { PaginatedHistoryResult } from "@/domains/chat/transcript/types"; import { mergeAdjacentAssistantMessages } from "@/domains/chat/utils/message-merge"; import type { DisplayMessage } from "@/domains/chat/utils/reconcile"; @@ -140,7 +141,7 @@ export function useHistoryPagination({ refetchOnMount: true, refetchOnWindowFocus: false, refetchOnReconnect: false, - retry: false, + retry: shouldRetryDaemonError, }); // Flatten pages into a single chronological array. diff --git a/apps/web/src/domains/settings/ai/web-search-card.tsx b/apps/web/src/domains/settings/ai/web-search-card.tsx index f2767a7104c..f43822ba812 100644 --- a/apps/web/src/domains/settings/ai/web-search-card.tsx +++ b/apps/web/src/domains/settings/ai/web-search-card.tsx @@ -1,6 +1,7 @@ import { Loader2 } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Dropdown } from "@vellum/design-library/components/dropdown"; import { Input } from "@vellum/design-library/components/input"; import { toast } from "@vellum/design-library/components/toast"; @@ -12,6 +13,12 @@ import { } from "@/assistant/generated/web-search-provider-catalog.gen"; import { captureError } from "@/lib/sentry/capture-error"; import { secretsReadPost } from "@/generated/daemon/sdk.gen"; +import { + ApiError, + assertHasResponse, + extractErrorMessage, +} from "@/utils/api-errors"; +import { shouldRetryDaemonError } from "@/utils/daemon-errors"; import { getLocalSetting, removeLocalSetting, @@ -25,6 +32,19 @@ import { getWebSearchProviderKeyStorage, reconcileFromDaemonConfig } from "@/dom import { ServiceCard, SaveButton, ResetButton } from "@/domains/settings/ai/ai-shared-ui"; import { useDaemonConfig } from "@/domains/settings/ai/use-daemon-config"; +// --------------------------------------------------------------------------- +// Query key for the stored-credential presence check +// --------------------------------------------------------------------------- + +const WEB_SEARCH_CREDENTIAL_QK = "web-search-credential" as const; + +function webSearchCredentialQueryKey( + assistantId: string | null | undefined, + provider: string, +) { + return [WEB_SEARCH_CREDENTIAL_QK, assistantId ?? "", provider] as const; +} + export function WebSearchCard() { const { assistantId, @@ -33,7 +53,9 @@ export function WebSearchCard() { provisionProviderKey, patchDaemonConfig, } = useDaemonConfig(); + const queryClient = useQueryClient(); + // --- Form state (local, unsaved) --- const [saving, setSaving] = useState(false); const [webSearchMode, setWebSearchMode] = useState( () => getLocalSetting(LS_WEB_SEARCH_MODE, "your-own") as ServiceMode, @@ -41,35 +63,70 @@ export function WebSearchCard() { const [webSearchProvider, setWebSearchProvider] = useState(() => getLocalSetting(LS_WEB_SEARCH_PROVIDER, "inference-provider-native"), ); - const [savedWebSearchMode, setSavedWebSearchMode] = useState(webSearchMode); - const [savedWebSearchProvider, setSavedWebSearchProvider] = useState(webSearchProvider); const [webSearchApiKey, setWebSearchApiKey] = useState(""); - const [webSearchHasStoredKey, setWebSearchHasStoredKey] = useState(false); - const [secretReadRevision, setSecretReadRevision] = useState(0); - const secretScopeRef = useRef<{ - assistantId: string | null; - provider: string | null; - }>({ assistantId: null, provider: null }); - // Hydrate from daemon config on first load + // --- Saved state derived from daemon config --- + const reconciled = useMemo( + () => (daemonConfig ? reconcileFromDaemonConfig(daemonConfig) : null), + [daemonConfig], + ); + const savedWebSearchMode = reconciled?.webSearchMode ?? webSearchMode; + const savedWebSearchProvider = reconciled?.webSearchProvider ?? webSearchProvider; + + // Seed form state from daemon config on first load. Subsequent config + // refetches (after save) do not overwrite in-progress edits. const initialized = useRef(false); useEffect(() => { if (!daemonConfig || initialized.current) return; initialized.current = true; - const reconciled = reconcileFromDaemonConfig(daemonConfig); - if (reconciled.webSearchMode) { - setWebSearchMode(reconciled.webSearchMode); - setSavedWebSearchMode(reconciled.webSearchMode); - } - if (reconciled.webSearchProvider) { - setWebSearchProvider(reconciled.webSearchProvider); - setSavedWebSearchProvider(reconciled.webSearchProvider); - } + const r = reconcileFromDaemonConfig(daemonConfig); + if (r.webSearchMode) setWebSearchMode(r.webSearchMode); + if (r.webSearchProvider) setWebSearchProvider(r.webSearchProvider); }, [daemonConfig]); - // Derived state - const needsApiKey = - WEB_SEARCH_BYOK_PROVIDER_IDS.has(webSearchProvider); + // --- Secret presence query (TanStack Query) --- + const requiresProviderCredential = WEB_SEARCH_BYOK_PROVIDER_IDS.has(webSearchProvider); + + const credentialQueryKey = useMemo( + () => webSearchCredentialQueryKey(assistantId, webSearchProvider), + [assistantId, webSearchProvider], + ); + + const credentialQuery = useQuery({ + queryKey: credentialQueryKey, + queryFn: async () => { + const { data, error, response } = await secretsReadPost({ + path: { assistant_id: assistantId! }, + body: { type: "api_key", name: webSearchProvider }, + throwOnError: false, + }); + assertHasResponse(response, error, "Failed to check stored key"); + if (!response.ok) { + throw new ApiError( + response.status, + extractErrorMessage(error, response, `Failed to check stored key (HTTP ${response.status})`), + ); + } + return data!.found; + }, + enabled: !!assistantId && requiresProviderCredential, + retry: shouldRetryDaemonError, + staleTime: 30_000, + }); + + // Defense-in-depth: if retries exhaust on an expected transient error, + // suppress the Sentry report rather than creating noise. + useEffect(() => { + if (!credentialQuery.error) return; + captureError(credentialQuery.error, { + context: "settings-ai-web-search-read-credential", + bestEffort: true, + }); + }, [credentialQuery.error]); + + const webSearchHasStoredKey = credentialQuery.data ?? false; + + // --- Derived state --- const hasNewApiKey = webSearchApiKey.trim().length > 0; const effectiveProvider = webSearchMode === "managed" ? "inference-provider-native" : webSearchProvider; @@ -78,7 +135,7 @@ export function WebSearchCard() { effectiveProvider !== savedWebSearchProvider; const needsKeyBeforeSave = webSearchMode === "your-own" && - needsApiKey && + requiresProviderCredential && !webSearchHasStoredKey && !hasNewApiKey; const saveDisabled = @@ -88,52 +145,6 @@ export function WebSearchCard() { webSearchHasStoredKey, ); - // Check if a stored key exists for the current provider - useEffect(() => { - let cancelled = false; - const previousScope = secretScopeRef.current; - const currentScope = { - assistantId: assistantId ?? null, - provider: webSearchProvider, - }; - const scopeChanged = - previousScope.assistantId !== currentScope.assistantId || - previousScope.provider !== currentScope.provider; - secretScopeRef.current = currentScope; - - void (async () => { - await Promise.resolve(); - if (cancelled) return; - - if (!assistantId || !needsApiKey) { - setWebSearchHasStoredKey(false); - return; - } - - if (scopeChanged) { - setWebSearchHasStoredKey(false); - } - - try { - const { data: result } = await secretsReadPost({ - path: { assistant_id: assistantId }, - body: { type: "api_key", name: webSearchProvider }, - throwOnError: true, - }); - if (cancelled) return; - setWebSearchHasStoredKey(result.found); - } catch (error) { - if (cancelled) return; - setWebSearchHasStoredKey(false); - captureError(error, { context: "settings-ai-web-search-read-secret" }); - } - })(); - - return () => { - cancelled = true; - }; - }, [assistantId, needsApiKey, webSearchProvider, secretReadRevision]); - const handleSave = useCallback(async () => { setSaving(true); const trimmed = webSearchApiKey.trim(); @@ -141,7 +152,7 @@ export function WebSearchCard() { webSearchMode === "managed" ? "inference-provider-native" : webSearchProvider; const storageKey = getWebSearchProviderKeyStorage(providerToSave); const hasUserKey = - webSearchMode === "your-own" && needsApiKey && trimmed.length > 0; + webSearchMode === "your-own" && requiresProviderCredential && trimmed.length > 0; let remoteSaved = false; try { if (hasUserKey) { @@ -165,14 +176,17 @@ export function WebSearchCard() { setLocalSetting(LS_WEB_SEARCH_MODE, webSearchMode); setLocalSetting(LS_WEB_SEARCH_PROVIDER, providerToSave); setWebSearchProvider(providerToSave); - setSavedWebSearchMode(webSearchMode); - setSavedWebSearchProvider(providerToSave); if (hasUserKey) { if (storageKey) { setLocalSetting(storageKey, trimmed); } - setWebSearchHasStoredKey(true); - setSecretReadRevision((r) => r + 1); + // Optimistic update: mark key as stored immediately, then + // background-refetch confirms server state. + queryClient.setQueryData( + webSearchCredentialQueryKey(assistantId, providerToSave), + true, + ); + void queryClient.invalidateQueries({ queryKey: credentialQueryKey }); setWebSearchApiKey(""); } toast.success("Web search settings saved."); @@ -183,10 +197,13 @@ export function WebSearchCard() { setSaving(false); } }, [ + assistantId, invalidateConfig, - needsApiKey, + requiresProviderCredential, patchDaemonConfig, provisionProviderKey, + queryClient, + credentialQueryKey, webSearchApiKey, webSearchMode, webSearchProvider, @@ -197,7 +214,6 @@ export function WebSearchCard() { if (storageKey) { removeLocalSetting(storageKey); } - setWebSearchHasStoredKey(false); setWebSearchApiKey(""); setWebSearchProvider("inference-provider-native"); setLocalSetting(LS_WEB_SEARCH_PROVIDER, "inference-provider-native"); @@ -236,7 +252,7 @@ export function WebSearchCard() { /> - {needsApiKey && ( + {requiresProviderCredential && ( {saving && } - {needsApiKey && ( + {requiresProviderCredential && ( )} diff --git a/apps/web/src/lib/sentry/capture-error.ts b/apps/web/src/lib/sentry/capture-error.ts index dbdf24c44fe..ac7d03af0ec 100644 --- a/apps/web/src/lib/sentry/capture-error.ts +++ b/apps/web/src/lib/sentry/capture-error.ts @@ -1,6 +1,6 @@ import * as Sentry from "@sentry/react"; -import { ApiError } from "@/utils/api-errors"; +import { isExpectedDaemonTransientError } from "@/utils/daemon-errors"; import { isTransientNetworkError } from "@/utils/is-transient-network-error"; /** @@ -46,36 +46,10 @@ export function normalizeToError(value: unknown): Error { return new Error(String(value)); } -/** - * Detects expected transient HTTP errors from daemon API calls that - * occur during normal startup sequences and auth-session hydration. - * - * These are valid HTTP responses (not browser-level network failures) - * that indicate the daemon or its infrastructure is not yet ready: - * - * - **503** — Daemon still starting up ("Your assistant is still starting up") - * - **502** — Reverse proxy cannot reach the daemon pod yet - * - **401** — Auth session not yet established (race during login) - * - **400 with org-header message** — Org store has not hydrated yet; - * the `Vellum-Organization-Id` header interceptor read `null` - * - * Only `ApiError` instances are matched. Other error types (TypeError, - * generic Error, plain objects) pass through — they represent network - * failures (handled by `isTransientNetworkError`) or application bugs. - */ -export function isExpectedDaemonTransientError(error: unknown): boolean { - if (!(error instanceof ApiError)) return false; - if (error.status === 503) return true; - if (error.status === 502) return true; - if (error.status === 401) return true; - if ( - error.status === 400 && - error.message.includes("Organization-Id header") - ) { - return true; - } - return false; -} +// Re-export so existing `import { isExpectedDaemonTransientError } from +// "@/lib/sentry/capture-error"` call sites keep working during migration. +// Canonical home is `@/utils/daemon-errors`. +export { isExpectedDaemonTransientError } from "@/utils/daemon-errors"; /** * Captures a non-transient error to Sentry with structured tags. diff --git a/apps/web/src/utils/daemon-errors.test.ts b/apps/web/src/utils/daemon-errors.test.ts new file mode 100644 index 00000000000..8480fd466a5 --- /dev/null +++ b/apps/web/src/utils/daemon-errors.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; + +const { isExpectedDaemonTransientError, shouldRetryDaemonError } = + await import("@/utils/daemon-errors"); +const { ApiError } = await import("@/utils/api-errors"); + +describe("isExpectedDaemonTransientError", () => { + test("returns true for 503 daemon starting up", () => { + expect( + isExpectedDaemonTransientError( + new ApiError(503, "Your assistant is still starting up."), + ), + ).toBe(true); + }); + + test("returns true for 502 bad gateway", () => { + expect( + isExpectedDaemonTransientError(new ApiError(502, "Bad gateway")), + ).toBe(true); + }); + + test("returns true for 401 auth race", () => { + expect( + isExpectedDaemonTransientError( + new ApiError(401, "Authentication credentials were not provided."), + ), + ).toBe(true); + }); + + test("returns true for 400 org-header missing", () => { + expect( + isExpectedDaemonTransientError( + new ApiError(400, "Vellum-Organization-Id header is required."), + ), + ).toBe(true); + }); + + test("returns false for 400 without org-header message", () => { + expect( + isExpectedDaemonTransientError( + new ApiError(400, "Invalid request body."), + ), + ).toBe(false); + }); + + test("returns false for 500 internal server error", () => { + expect( + isExpectedDaemonTransientError( + new ApiError(500, "Internal Server Error"), + ), + ).toBe(false); + }); + + test("returns false for non-ApiError instances", () => { + expect(isExpectedDaemonTransientError(new Error("random error"))).toBe( + false, + ); + expect( + isExpectedDaemonTransientError(new TypeError("Failed to fetch")), + ).toBe(false); + expect(isExpectedDaemonTransientError("string error")).toBe(false); + expect(isExpectedDaemonTransientError(null)).toBe(false); + }); +}); + +describe("shouldRetryDaemonError", () => { + test("retries transient daemon errors within budget", () => { + const err = new ApiError(503, "Your assistant is still starting up."); + expect(shouldRetryDaemonError(0, err)).toBe(true); + expect(shouldRetryDaemonError(1, err)).toBe(true); + expect(shouldRetryDaemonError(2, err)).toBe(true); + }); + + test("stops retrying after 3 failures", () => { + const err = new ApiError(503, "Your assistant is still starting up."); + expect(shouldRetryDaemonError(3, err)).toBe(false); + expect(shouldRetryDaemonError(4, err)).toBe(false); + }); + + test("does not retry non-transient errors", () => { + expect(shouldRetryDaemonError(0, new ApiError(500, "Internal Server Error"))).toBe(false); + expect(shouldRetryDaemonError(0, new Error("random error"))).toBe(false); + expect(shouldRetryDaemonError(0, new TypeError("Failed to fetch"))).toBe(false); + }); + + test("retries 502 bad gateway", () => { + expect(shouldRetryDaemonError(0, new ApiError(502, "Bad gateway"))).toBe(true); + }); + + test("retries 401 auth race", () => { + expect( + shouldRetryDaemonError( + 0, + new ApiError(401, "Authentication credentials were not provided."), + ), + ).toBe(true); + }); + + test("retries 400 org-header missing", () => { + expect( + shouldRetryDaemonError( + 0, + new ApiError(400, "Vellum-Organization-Id header is required."), + ), + ).toBe(true); + }); +}); diff --git a/apps/web/src/utils/daemon-errors.ts b/apps/web/src/utils/daemon-errors.ts new file mode 100644 index 00000000000..abcb7bd30d0 --- /dev/null +++ b/apps/web/src/utils/daemon-errors.ts @@ -0,0 +1,69 @@ +/** + * Daemon error classification and retry utilities. + * + * Centralises detection of expected transient HTTP errors from daemon + * API calls — startup races, auth-session hydration, org-header + * propagation. Used by both Sentry error reporting (`captureError`) and + * TanStack Query retry predicates. + * + * References: + * - https://tanstack.com/query/latest/docs/framework/react/guides/query-retries + * - https://heyapi.dev/openapi-ts/clients/fetch#throwing-errors + */ + +import { ApiError } from "@/utils/api-errors"; + +const MAX_DAEMON_RETRIES = 3; + +/** + * Detects expected transient HTTP errors from daemon API calls that + * occur during normal startup sequences and auth-session hydration. + * + * - **503** — Daemon still starting up + * - **502** — Reverse proxy cannot reach the daemon pod yet + * - **401** — Auth session not yet established (race during login) + * - **400 with org-header message** — Org store has not hydrated yet + * + * Only `ApiError` instances are matched. Other error types (TypeError, + * generic Error, plain objects) pass through — they represent network + * failures (handled by `isTransientNetworkError`) or application bugs. + */ +export function isExpectedDaemonTransientError(error: unknown): boolean { + if (!(error instanceof ApiError)) return false; + if (error.status === 503) return true; + if (error.status === 502) return true; + if (error.status === 401) return true; + if ( + error.status === 400 && + error.message.includes("Organization-Id header") + ) { + return true; + } + return false; +} + +/** + * TanStack Query retry predicate for daemon queries. + * + * Retries expected transient errors (503 startup, 502 bad-gateway, + * 401 auth-race, 400 org-header) up to {@link MAX_DAEMON_RETRIES} + * times with TQ's built-in exponential backoff. Fails fast on + * unexpected errors (500, data integrity, programming errors). + * + * ```ts + * useQuery({ + * queryKey: [...], + * queryFn: ..., + * retry: shouldRetryDaemonError, + * }); + * ``` + * + * Reference: https://tanstack.com/query/latest/docs/framework/react/guides/query-retries + */ +export function shouldRetryDaemonError( + failureCount: number, + error: Error, +): boolean { + if (failureCount >= MAX_DAEMON_RETRIES) return false; + return isExpectedDaemonTransientError(error); +} From e2c6e1d83aca1fdddea3787564a2318b27f67944 Mon Sep 17 00:00:00 2001 From: "ashlee@vellum.ai" Date: Wed, 3 Jun 2026 00:48:19 +0000 Subject: [PATCH 2/3] fix(web): add optimistic savedOverride to prevent save button race after config patch The savedWebSearchMode/savedWebSearchProvider derivation from daemon config lags behind during the async refetch window after patchDaemonConfig. This briefly re-enables the save button. Fix: set a synchronous override in handleSave, cleared when the daemon config refetch completes. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../domains/settings/ai/web-search-card.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/web/src/domains/settings/ai/web-search-card.tsx b/apps/web/src/domains/settings/ai/web-search-card.tsx index f43822ba812..8fae8b51378 100644 --- a/apps/web/src/domains/settings/ai/web-search-card.tsx +++ b/apps/web/src/domains/settings/ai/web-search-card.tsx @@ -66,12 +66,24 @@ export function WebSearchCard() { const [webSearchApiKey, setWebSearchApiKey] = useState(""); // --- Saved state derived from daemon config --- + // Optimistic override bridges the gap between a successful save and the + // async daemon config refetch. Without it, savedWebSearch* would reflect + // stale config during the refetch window, making configChanged=true and + // briefly re-enabling the save button. + const [savedOverride, setSavedOverride] = useState<{ + mode: ServiceMode; + provider: string; + } | null>(null); const reconciled = useMemo( () => (daemonConfig ? reconcileFromDaemonConfig(daemonConfig) : null), [daemonConfig], ); - const savedWebSearchMode = reconciled?.webSearchMode ?? webSearchMode; - const savedWebSearchProvider = reconciled?.webSearchProvider ?? webSearchProvider; + // Clear override once daemon config catches up. + useEffect(() => { + if (reconciled) setSavedOverride(null); + }, [reconciled]); + const savedWebSearchMode = savedOverride?.mode ?? reconciled?.webSearchMode ?? webSearchMode; + const savedWebSearchProvider = savedOverride?.provider ?? reconciled?.webSearchProvider ?? webSearchProvider; // Seed form state from daemon config on first load. Subsequent config // refetches (after save) do not overwrite in-progress edits. @@ -165,6 +177,9 @@ export function WebSearchCard() { }); remoteSaved = true; invalidateConfig(); + // Optimistically mark these values as "saved" so configChanged stays + // false while the async config refetch is in flight. + setSavedOverride({ mode: webSearchMode, provider: providerToSave }); } catch { // Errors already surfaced via toast + captureError inside the callees. } From ca4f9da9e6bc7056080afb65b9ec2d351feb791e Mon Sep 17 00:00:00 2001 From: "ashlee@vellum.ai" Date: Wed, 3 Jun 2026 00:59:17 +0000 Subject: [PATCH 3/3] fix(web): gate credential query on org readiness to prevent 400 before hydration The secretsReadPost daemon call needs the Vellum-Organization-Id header, which the org store provides after async hydration. Without the isOrgReady gate, the query could fire before hydration completes, exhaust retries on 400 'Organization-Id header' errors, and leave webSearchHasStoredKey as false permanently. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/src/domains/settings/ai/web-search-card.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/domains/settings/ai/web-search-card.tsx b/apps/web/src/domains/settings/ai/web-search-card.tsx index 02b5b8da79e..7c9b7f53dea 100644 --- a/apps/web/src/domains/settings/ai/web-search-card.tsx +++ b/apps/web/src/domains/settings/ai/web-search-card.tsx @@ -19,6 +19,7 @@ import { extractErrorMessage, } from "@/utils/api-errors"; import { shouldRetryDaemonError } from "@/utils/daemon-errors"; +import { useIsOrgReady } from "@/hooks/use-is-org-ready"; import { getLocalSetting, removeLocalSetting, @@ -96,6 +97,7 @@ export function WebSearchCard() { }, [daemonConfig]); // --- Secret presence query (TanStack Query) --- + const isOrgReady = useIsOrgReady(); const requiresProviderCredential = WEB_SEARCH_BYOK_PROVIDER_IDS.has(webSearchProvider); const credentialQueryKey = useMemo( @@ -120,7 +122,7 @@ export function WebSearchCard() { } return data!.found; }, - enabled: !!assistantId && requiresProviderCredential, + enabled: !!assistantId && requiresProviderCredential && isOrgReady, retry: shouldRetryDaemonError, staleTime: 30_000, });