Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -140,7 +141,7 @@ export function useHistoryPagination({
refetchOnMount: true,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
retry: shouldRetryDaemonError,
});

// Flatten pages into a single chronological array.
Expand Down
192 changes: 113 additions & 79 deletions apps/web/src/domains/settings/ai/web-search-card.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -12,6 +13,13 @@ 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 { useIsOrgReady } from "@/hooks/use-is-org-ready";
import {
getLocalSetting,
removeLocalSetting,
Expand All @@ -25,49 +33,113 @@ import { getWebSearchProviderKeyStorage, reconcileFromDaemonConfig } from "@/dom
import { ServiceCard, SaveButton, ResetButton } from "@/domains/settings/ai/ai-shared-ui";
import { useDaemonConfigQuery, useDaemonConfigMutation, useProvisionProviderKey } 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,
config: daemonConfig,
} = useDaemonConfigQuery();
const configMutation = useDaemonConfigMutation();
const provisionProviderKey = useProvisionProviderKey();
const queryClient = useQueryClient();

// --- Form state (local, unsaved) ---
const [saving, setSaving] = useState(false);
const [webSearchMode, setWebSearchMode] = useState<ServiceMode>(
() => getLocalSetting(LS_WEB_SEARCH_MODE, "your-own") as ServiceMode,
);
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 [saving, setSaving] = 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 ---
// 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],
);
// 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.
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 isOrgReady = useIsOrgReady();
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 && isOrgReady,
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;
Expand All @@ -76,7 +148,7 @@ export function WebSearchCard() {
effectiveProvider !== savedWebSearchProvider;
const needsKeyBeforeSave =
webSearchMode === "your-own" &&
needsApiKey &&
requiresProviderCredential &&
!webSearchHasStoredKey &&
!hasNewApiKey;
const saveDisabled =
Expand All @@ -86,60 +158,14 @@ 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();
const providerToSave =
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;
try {
if (hasUserKey) {
await provisionProviderKey(providerToSave, trimmed);
Expand All @@ -153,6 +179,9 @@ export function WebSearchCard() {
captureError(error, { context: "patch_daemon_config" });
throw error;
});
// Optimistically mark these values as "saved" so configChanged stays
// false while the async config refetch is in flight.
setSavedOverride({ mode: webSearchMode, provider: providerToSave });
} catch {
setSaving(false);
return;
Expand All @@ -162,14 +191,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.");
Expand All @@ -178,9 +210,12 @@ export function WebSearchCard() {
toast.error("Saved, but local preferences could not be written.");
}
}, [
needsApiKey,
assistantId,
requiresProviderCredential,
configMutation,
provisionProviderKey,
queryClient,
credentialQueryKey,
webSearchApiKey,
webSearchMode,
webSearchProvider,
Expand All @@ -191,7 +226,6 @@ export function WebSearchCard() {
if (storageKey) {
removeLocalSetting(storageKey);
}
setWebSearchHasStoredKey(false);
setWebSearchApiKey("");
setWebSearchProvider("inference-provider-native");
setLocalSetting(LS_WEB_SEARCH_PROVIDER, "inference-provider-native");
Expand Down Expand Up @@ -230,7 +264,7 @@ export function WebSearchCard() {
/>
</div>

{needsApiKey && (
{requiresProviderCredential && (
<Input
label="API Key"
type="password"
Expand All @@ -244,7 +278,7 @@ export function WebSearchCard() {
<div className="flex items-center gap-2">
<SaveButton onClick={handleSave} disabled={saveDisabled} />
{saving && <Loader2 className="h-4 w-4 animate-spin text-[var(--content-disabled)]" />}
{needsApiKey && (
{requiresProviderCredential && (
<ResetButton onClick={handleReset} filled />
)}
</div>
Expand Down
36 changes: 5 additions & 31 deletions apps/web/src/lib/sentry/capture-error.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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.
Expand Down
Loading