Add OpenRouter API key setup gating for SAM AI features (#356)

This commit is contained in:
Ben Senescu 2026-07-05 18:39:26 -04:00 committed by GitHub
parent 36bd1cd7de
commit a3e7bffd5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 248 additions and 0 deletions

View File

@ -38,6 +38,10 @@
# LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID=replace-with-your-loops-verify-template-id # LOOPS_TRANSACTIONAL_VERIFY_EMAIL_ID=replace-with-your-loops-verify-template-id
# LOOPS_TRANSACTIONAL_RESET_PASSWORD_ID=replace-with-your-loops-reset-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 # 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 # 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. # encrypts the stored OAuth tokens). See docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md.

View File

@ -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 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). [`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 ## Self-hosting
OpenSEO supports two self-hosting paths: OpenSEO supports two self-hosting paths:

View File

@ -7,6 +7,9 @@ import {
invalidateSamSessions, invalidateSamSessions,
samSessionsQueryOptions, samSessionsQueryOptions,
} from "@/client/features/sam/samQueries"; } 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"; import { SamConversation } from "./SamConversation";
/** /**
@ -23,6 +26,7 @@ export function SamChat({
activeSessionId: string | undefined; activeSessionId: string | undefined;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const access = useSamAccess(projectId);
const sessionsQuery = useQuery(samSessionsQueryOptions(projectId)); const sessionsQuery = useQuery(samSessionsQueryOptions(projectId));
const sessions = sessionsQuery.data ?? []; const sessions = sessionsQuery.data ?? [];
@ -53,6 +57,27 @@ export function SamChat({
goToSession(firstSessionId); goToSession(firstSessionId);
}, [activeSessionId, firstSessionId, goToSession]); }, [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 (
<div className="overflow-auto px-4 py-4 md:px-6 md:py-6">
<div className="mx-auto max-w-3xl">
{access.isLoading ? (
<AccessGateLoadingState />
) : (
<SamSetupGate
errorMessage={access.errorMessage ?? access.statusErrorMessage}
isRefetching={access.isRefetching}
onRetry={access.onRetry}
/>
)}
</div>
</div>
);
}
if (activeSessionId) { if (activeSessionId) {
return ( return (
<div className="flex h-full min-h-0"> <div className="flex h-full min-h-0">

View File

@ -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 (
<AccessGate
title="Enable AI Features"
bodyText={
<>
SAM, OpenSEO's in-app AI agent, needs an OpenRouter API key. Create a
key on OpenRouter, set it as the <code>OPENROUTER_API_KEY</code>{" "}
environment variable, restart OpenSEO, then confirm here.
</>
}
helperText={
<>
Step-by-step instructions for every deployment are in the{" "}
<Link
className="underline underline-offset-2 hover:text-base-content/70"
to="/help/openrouter-api-key"
>
OpenRouter API key setup guide
</Link>
.
</>
}
buttonLabel="Confirm API Key"
externalUrl="https://openrouter.ai/settings/keys"
externalLabel="Open OpenRouter Keys"
errorMessage={errorMessage}
isRefetching={isRefetching}
onRetry={onRetry}
/>
);
}

View File

@ -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.",
});
}

View File

@ -31,6 +31,7 @@ import { Route as AuthenticatedOnboardingIndexRouteImport } from './routes/_auth
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$' import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as AuthenticatedOnboardingChatRouteImport } from './routes/_authenticated.onboarding.chat' 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 AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route' import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index' import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
@ -161,6 +162,11 @@ const AuthenticatedOnboardingChatRoute =
path: '/onboarding/chat', path: '/onboarding/chat',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AppHelpOpenrouterApiKeyRoute = AppHelpOpenrouterApiKeyRouteImport.update({
id: '/help/openrouter-api-key',
path: '/help/openrouter-api-key',
getParentRoute: () => AppRouteRoute,
} as any)
const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({ const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({
id: '/help/dataforseo-api-key', id: '/help/dataforseo-api-key',
path: '/help/dataforseo-api-key', path: '/help/dataforseo-api-key',
@ -285,6 +291,7 @@ export interface FileRoutesByFullPath {
'/subscribe': typeof AuthenticatedSubscribeRoute '/subscribe': typeof AuthenticatedSubscribeRoute
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute '/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
@ -323,6 +330,7 @@ export interface FileRoutesByTo {
'/oauth-consent': typeof AuthenticatedOauthConsentRoute '/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/subscribe': typeof AuthenticatedSubscribeRoute '/subscribe': typeof AuthenticatedSubscribeRoute
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute '/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
@ -365,6 +373,7 @@ export interface FileRoutesById {
'/_app/': typeof AppIndexRoute '/_app/': typeof AppIndexRoute
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/_app/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
'/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute '/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
@ -406,6 +415,7 @@ export interface FileRouteTypes {
| '/subscribe' | '/subscribe'
| '/p/$projectId' | '/p/$projectId'
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/help/openrouter-api-key'
| '/onboarding/chat' | '/onboarding/chat'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
@ -444,6 +454,7 @@ export interface FileRouteTypes {
| '/oauth-consent' | '/oauth-consent'
| '/subscribe' | '/subscribe'
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/help/openrouter-api-key'
| '/onboarding/chat' | '/onboarding/chat'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
@ -485,6 +496,7 @@ export interface FileRouteTypes {
| '/_app/' | '/_app/'
| '/_project/p/$projectId' | '/_project/p/$projectId'
| '/_app/help/dataforseo-api-key' | '/_app/help/dataforseo-api-key'
| '/_app/help/openrouter-api-key'
| '/_authenticated/onboarding/chat' | '/_authenticated/onboarding/chat'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
@ -678,6 +690,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedOnboardingChatRouteImport preLoaderRoute: typeof AuthenticatedOnboardingChatRouteImport
parentRoute: typeof AuthenticatedRoute 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': { '/_app/help/dataforseo-api-key': {
id: '/_app/help/dataforseo-api-key' id: '/_app/help/dataforseo-api-key'
path: '/help/dataforseo-api-key' path: '/help/dataforseo-api-key'
@ -822,6 +841,7 @@ interface AppRouteRouteChildren {
AppSupportRoute: typeof AppSupportRoute AppSupportRoute: typeof AppSupportRoute
AppIndexRoute: typeof AppIndexRoute AppIndexRoute: typeof AppIndexRoute
AppHelpDataforseoApiKeyRoute: typeof AppHelpDataforseoApiKeyRoute AppHelpDataforseoApiKeyRoute: typeof AppHelpDataforseoApiKeyRoute
AppHelpOpenrouterApiKeyRoute: typeof AppHelpOpenrouterApiKeyRoute
} }
const AppRouteRouteChildren: AppRouteRouteChildren = { const AppRouteRouteChildren: AppRouteRouteChildren = {
@ -832,6 +852,7 @@ const AppRouteRouteChildren: AppRouteRouteChildren = {
AppSupportRoute: AppSupportRoute, AppSupportRoute: AppSupportRoute,
AppIndexRoute: AppIndexRoute, AppIndexRoute: AppIndexRoute,
AppHelpDataforseoApiKeyRoute: AppHelpDataforseoApiKeyRoute, AppHelpDataforseoApiKeyRoute: AppHelpDataforseoApiKeyRoute,
AppHelpOpenrouterApiKeyRoute: AppHelpOpenrouterApiKeyRoute,
} }
const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren( const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren(

View File

@ -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 (
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
<div className="mx-auto max-w-3xl space-y-4">
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3">
<h1 className="text-2xl font-semibold">
Set up your OpenRouter API key
</h1>
<p className="text-sm text-base-content/70">
OpenSEO needs the <code>OPENROUTER_API_KEY</code> secret before AI
features like SAM, the in-app SEO agent, can run. It is optional
everything else in OpenSEO works without it.
</p>
</div>
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
<h2 className="card-title text-base">Steps</h2>
<ol className="list-decimal pl-5 text-sm space-y-3 text-base-content/80">
<li>
Create an account at{" "}
<a
className="link link-primary"
href="https://openrouter.ai"
target="_blank"
rel="noreferrer"
>
openrouter.ai
</a>{" "}
and add credits (pay-as-you-go, like DataForSEO).
</li>
<li>
Go to{" "}
<a
className="link link-primary"
href={OPENROUTER_KEYS_URL}
target="_blank"
rel="noreferrer"
>
OpenRouter API Keys
</a>{" "}
and click "Create API Key".
</li>
<li>
Save the key as the <code>OPENROUTER_API_KEY</code> secret in
your environment:
<ul className="list-disc pl-5 mt-2 space-y-1">
<li>
Docker self-hosting: <code>.env</code>
</li>
<li>Cloudflare: set it in the Workers UI (see below)</li>
<li>
Local development: <code>.env.local</code>
</li>
</ul>
</li>
<li>Restart OpenSEO.</li>
</ol>
</div>
</div>
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-2 text-sm text-base-content/75">
<h2 className="card-title text-base">
Cloudflare Workers (Dashboard UI)
</h2>
<ol className="list-decimal pl-5 space-y-2 text-sm text-base-content/80">
<li>
In Cloudflare, go to <code>Compute</code> -&gt;{" "}
<code>Workers &amp; Pages</code>
and open your OpenSEO Worker.
</li>
<li>
Open <code>Settings</code>.
</li>
<li>
Go to <code>Variables &amp; Secrets</code> and add a new secret
named
<code className="mx-1">OPENROUTER_API_KEY</code>.
</li>
<li>Paste your OpenRouter API key and save.</li>
</ol>
<div className="divider my-1" />
<p>Or set the same secret from your terminal with:</p>
<pre className="p-3 rounded bg-base-200 border border-base-300 overflow-x-auto text-xs">
<code>npx wrangler secret put OPENROUTER_API_KEY</code>
</pre>
<p>Paste your OpenRouter API key when prompted.</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -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<SamAccessStatus> => {
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,
};
});