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 ( +
+
+ {access.isLoading ? ( + + ) : ( + + )} +
+
+ ); + } + if (activeSessionId) { return (
diff --git a/src/client/features/sam/SamSetupGate.tsx b/src/client/features/sam/SamSetupGate.tsx new file mode 100644 index 0000000..588973f --- /dev/null +++ b/src/client/features/sam/SamSetupGate.tsx @@ -0,0 +1,43 @@ +import { Link } from "@tanstack/react-router"; +import { AccessGate } from "@/client/features/access-gate/AccessGate"; + +export function SamSetupGate({ + errorMessage, + isRefetching, + onRetry, +}: { + errorMessage: string | null; + isRefetching: boolean; + onRetry: () => void; +}) { + return ( + + SAM, OpenSEO's in-app AI agent, needs an OpenRouter API key. Create a + key on OpenRouter, set it as the 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 ( +
+
+
+
+

+ Set up your OpenRouter API key +

+

+ 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. +

+
+
+ +
+
+

Steps

+
    +
  1. + Create an account at{" "} + + openrouter.ai + {" "} + and add credits (pay-as-you-go, like DataForSEO). +
  2. +
  3. + Go to{" "} + + OpenRouter API Keys + {" "} + and click "Create API Key". +
  4. +
  5. + Save the key as the OPENROUTER_API_KEY secret in + your environment: +
      +
    • + Docker self-hosting: .env +
    • +
    • Cloudflare: set it in the Workers UI (see below)
    • +
    • + Local development: .env.local +
    • +
    +
  6. +
  7. Restart OpenSEO.
  8. +
+
+
+ +
+
+

+ Cloudflare Workers (Dashboard UI) +

+
    +
  1. + In Cloudflare, go to Compute ->{" "} + Workers & Pages + and open your OpenSEO Worker. +
  2. +
  3. + Open Settings. +
  4. +
  5. + Go to Variables & Secrets and add a new secret + named + OPENROUTER_API_KEY. +
  6. +
  7. Paste your OpenRouter API key and save.
  8. +
+ +
+ +

Or set the same secret from your terminal with:

+
+              npx wrangler secret put OPENROUTER_API_KEY
+            
+

Paste your OpenRouter API key when prompted.

+
+
+
+
+ ); +} diff --git a/src/serverFunctions/samAccess.ts b/src/serverFunctions/samAccess.ts new file mode 100644 index 0000000..b8b1d91 --- /dev/null +++ b/src/serverFunctions/samAccess.ts @@ -0,0 +1,35 @@ +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { + getOptionalEnvValue, + isHostedServerAuthMode, +} from "@/server/lib/runtime-env"; +import { requireProjectContext } from "@/serverFunctions/middleware"; + +const OPENROUTER_KEY_MISSING_MESSAGE = + "OPENROUTER_API_KEY is not set for this deployment yet. Add it to your environment, restart OpenSEO, then confirm here."; + +const projectScopedSchema = z.object({ projectId: z.string().min(1) }); + +type SamAccessStatus = { + enabled: boolean; + errorMessage: string | null; +}; + +// Gates the in-app AI agent (SAM) on an OpenRouter key being configured, the +// same way backlinks/AI-search gate on their DataForSEO subscriptions. Hosted +// deployments always have the key provisioned, so only self-hosted is checked. +export const getSamAccessSetupStatus = createServerFn({ method: "GET" }) + .middleware(requireProjectContext) + .inputValidator((data: unknown) => projectScopedSchema.parse(data)) + .handler(async (): Promise => { + if (await isHostedServerAuthMode()) { + return { enabled: true, errorMessage: null }; + } + + const enabled = Boolean(await getOptionalEnvValue("OPENROUTER_API_KEY")); + return { + enabled, + errorMessage: enabled ? null : OPENROUTER_KEY_MISSING_MESSAGE, + }; + });