From a3e7bffd5b4e5f71289cc582cc825a65806138c2 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:39:26 -0400 Subject: [PATCH] Add OpenRouter API key setup gating for SAM AI features (#356) --- .env.example | 4 + README.md | 4 + src/client/features/sam/SamChat.tsx | 25 +++++ src/client/features/sam/SamSetupGate.tsx | 43 ++++++++ src/client/features/sam/useSamAccess.ts | 10 ++ src/routeTree.gen.ts | 21 ++++ src/routes/_app/help/openrouter-api-key.tsx | 106 ++++++++++++++++++++ src/serverFunctions/samAccess.ts | 35 +++++++ 8 files changed, 248 insertions(+) create mode 100644 src/client/features/sam/SamSetupGate.tsx create mode 100644 src/client/features/sam/useSamAccess.ts create mode 100644 src/routes/_app/help/openrouter-api-key.tsx create mode 100644 src/serverFunctions/samAccess.ts diff --git a/.env.example b/.env.example index 671bff3..ab00267 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,10 @@ # LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID=replace-with-your-loops-verify-template-id # LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID=replace-with-your-loops-reset-template-id +# Optional in self-hosted modes. Required if you want AI features like SAM, +# the in-app SEO agent. Create a key at https://openrouter.ai/settings/keys. +# OPENROUTER_API_KEY=replace-with-your-openrouter-api-key + # Optional in self-hosted modes. Required if you want Google Search Console # integration and MCP tools. BETTER_AUTH_SECRET is also required for GSC (it # encrypts the stored OAuth tokens). See docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md. diff --git a/README.md b/README.md index e314a3b..da8058d 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,10 @@ Search Console is optional and works in self-hosted deployments using your own Google OAuth client. It takes ~10 minutes of one-time setup — see [`docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md`](./docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md). +## AI Features (SAM) + +AI features like SAM, the in-app SEO agent, are optional — set the `OPENROUTER_API_KEY` environment variable to enable them (create a key at [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys)). + ## Self-hosting OpenSEO supports two self-hosting paths: diff --git a/src/client/features/sam/SamChat.tsx b/src/client/features/sam/SamChat.tsx index ddc9c15..e996292 100644 --- a/src/client/features/sam/SamChat.tsx +++ b/src/client/features/sam/SamChat.tsx @@ -7,6 +7,9 @@ import { invalidateSamSessions, samSessionsQueryOptions, } from "@/client/features/sam/samQueries"; +import { AccessGateLoadingState } from "@/client/features/access-gate/AccessGate"; +import { useSamAccess } from "./useSamAccess"; +import { SamSetupGate } from "./SamSetupGate"; import { SamConversation } from "./SamConversation"; /** @@ -23,6 +26,7 @@ export function SamChat({ activeSessionId: string | undefined; }) { const navigate = useNavigate(); + const access = useSamAccess(projectId); const sessionsQuery = useQuery(samSessionsQueryOptions(projectId)); const sessions = sessionsQuery.data ?? []; @@ -53,6 +57,27 @@ export function SamChat({ goToSession(firstSessionId); }, [activeSessionId, firstSessionId, goToSession]); + // Gate the whole page until OPENROUTER_API_KEY is configured — SAM cannot + // answer a single turn without it, so surface setup instructions instead of + // letting a chat fail mid-stream. + if (access.isLoading || !access.enabled) { + return ( +
OPENROUTER_API_KEY{" "}
+ environment variable, restart OpenSEO, then confirm here.
+ >
+ }
+ helperText={
+ <>
+ Step-by-step instructions for every deployment are in the{" "}
+
+ OpenRouter API key setup guide
+
+ .
+ >
+ }
+ buttonLabel="Confirm API Key"
+ externalUrl="https://openrouter.ai/settings/keys"
+ externalLabel="Open OpenRouter Keys"
+ errorMessage={errorMessage}
+ isRefetching={isRefetching}
+ onRetry={onRetry}
+ />
+ );
+}
diff --git a/src/client/features/sam/useSamAccess.ts b/src/client/features/sam/useSamAccess.ts
new file mode 100644
index 0000000..0bfee49
--- /dev/null
+++ b/src/client/features/sam/useSamAccess.ts
@@ -0,0 +1,10 @@
+import { useAccessGate } from "@/client/features/access-gate/useAccessGate";
+import { getSamAccessSetupStatus } from "@/serverFunctions/samAccess";
+
+export function useSamAccess(projectId: string) {
+ return useAccessGate({
+ queryKey: ["samAccessStatus", projectId],
+ queryFn: () => getSamAccessSetupStatus({ data: { projectId } }),
+ statusErrorFallback: "Could not load AI agent setup status.",
+ });
+}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index c82a18b..3c07882 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -31,6 +31,7 @@ import { Route as AuthenticatedOnboardingIndexRouteImport } from './routes/_auth
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as AuthenticatedOnboardingChatRouteImport } from './routes/_authenticated.onboarding.chat'
+import { Route as AppHelpOpenrouterApiKeyRouteImport } from './routes/_app/help/openrouter-api-key'
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
@@ -161,6 +162,11 @@ const AuthenticatedOnboardingChatRoute =
path: '/onboarding/chat',
getParentRoute: () => AuthenticatedRoute,
} as any)
+const AppHelpOpenrouterApiKeyRoute = AppHelpOpenrouterApiKeyRouteImport.update({
+ id: '/help/openrouter-api-key',
+ path: '/help/openrouter-api-key',
+ getParentRoute: () => AppRouteRoute,
+} as any)
const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({
id: '/help/dataforseo-api-key',
path: '/help/dataforseo-api-key',
@@ -285,6 +291,7 @@ export interface FileRoutesByFullPath {
'/subscribe': typeof AuthenticatedSubscribeRoute
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
+ '/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute
@@ -323,6 +330,7 @@ export interface FileRoutesByTo {
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/subscribe': typeof AuthenticatedSubscribeRoute
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
+ '/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute
@@ -365,6 +373,7 @@ export interface FileRoutesById {
'/_app/': typeof AppIndexRoute
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
+ '/_app/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
'/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute
@@ -406,6 +415,7 @@ export interface FileRouteTypes {
| '/subscribe'
| '/p/$projectId'
| '/help/dataforseo-api-key'
+ | '/help/openrouter-api-key'
| '/onboarding/chat'
| '/api/auth/$'
| '/api/autumn/$'
@@ -444,6 +454,7 @@ export interface FileRouteTypes {
| '/oauth-consent'
| '/subscribe'
| '/help/dataforseo-api-key'
+ | '/help/openrouter-api-key'
| '/onboarding/chat'
| '/api/auth/$'
| '/api/autumn/$'
@@ -485,6 +496,7 @@ export interface FileRouteTypes {
| '/_app/'
| '/_project/p/$projectId'
| '/_app/help/dataforseo-api-key'
+ | '/_app/help/openrouter-api-key'
| '/_authenticated/onboarding/chat'
| '/api/auth/$'
| '/api/autumn/$'
@@ -678,6 +690,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedOnboardingChatRouteImport
parentRoute: typeof AuthenticatedRoute
}
+ '/_app/help/openrouter-api-key': {
+ id: '/_app/help/openrouter-api-key'
+ path: '/help/openrouter-api-key'
+ fullPath: '/help/openrouter-api-key'
+ preLoaderRoute: typeof AppHelpOpenrouterApiKeyRouteImport
+ parentRoute: typeof AppRouteRoute
+ }
'/_app/help/dataforseo-api-key': {
id: '/_app/help/dataforseo-api-key'
path: '/help/dataforseo-api-key'
@@ -822,6 +841,7 @@ interface AppRouteRouteChildren {
AppSupportRoute: typeof AppSupportRoute
AppIndexRoute: typeof AppIndexRoute
AppHelpDataforseoApiKeyRoute: typeof AppHelpDataforseoApiKeyRoute
+ AppHelpOpenrouterApiKeyRoute: typeof AppHelpOpenrouterApiKeyRoute
}
const AppRouteRouteChildren: AppRouteRouteChildren = {
@@ -832,6 +852,7 @@ const AppRouteRouteChildren: AppRouteRouteChildren = {
AppSupportRoute: AppSupportRoute,
AppIndexRoute: AppIndexRoute,
AppHelpDataforseoApiKeyRoute: AppHelpDataforseoApiKeyRoute,
+ AppHelpOpenrouterApiKeyRoute: AppHelpOpenrouterApiKeyRoute,
}
const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren(
diff --git a/src/routes/_app/help/openrouter-api-key.tsx b/src/routes/_app/help/openrouter-api-key.tsx
new file mode 100644
index 0000000..3f56edb
--- /dev/null
+++ b/src/routes/_app/help/openrouter-api-key.tsx
@@ -0,0 +1,106 @@
+import { createFileRoute } from "@tanstack/react-router";
+
+const OPENROUTER_KEYS_URL = "https://openrouter.ai/settings/keys";
+
+export const Route = createFileRoute("/_app/help/openrouter-api-key")({
+ component: OpenrouterApiKeyHelpPage,
+});
+
+function OpenrouterApiKeyHelpPage() {
+ return (
+
+ OpenSEO needs the OPENROUTER_API_KEY secret before AI
+ features like SAM, the in-app SEO agent, can run. It is optional —
+ everything else in OpenSEO works without it.
+
OPENROUTER_API_KEY secret in
+ your environment:
+ .env
+ .env.local
+ Compute ->{" "}
+ Workers & Pages
+ and open your OpenSEO Worker.
+ Settings.
+ Variables & Secrets and add a new secret
+ named
+ OPENROUTER_API_KEY.
+ Or set the same secret from your terminal with:
+
+ npx wrangler secret put OPENROUTER_API_KEY
+
+ Paste your OpenRouter API key when prompted.
+