Add OpenRouter API key setup gating for SAM AI features (#356)
This commit is contained in:
parent
36bd1cd7de
commit
a3e7bffd5b
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 (
|
||||
<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) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
|
||||
43
src/client/features/sam/SamSetupGate.tsx
Normal file
43
src/client/features/sam/SamSetupGate.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
src/client/features/sam/useSamAccess.ts
Normal file
10
src/client/features/sam/useSamAccess.ts
Normal 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.",
|
||||
});
|
||||
}
|
||||
@ -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(
|
||||
|
||||
106
src/routes/_app/help/openrouter-api-key.tsx
Normal file
106
src/routes/_app/help/openrouter-api-key.tsx
Normal 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> ->{" "}
|
||||
<code>Workers & Pages</code>
|
||||
and open your OpenSEO Worker.
|
||||
</li>
|
||||
<li>
|
||||
Open <code>Settings</code>.
|
||||
</li>
|
||||
<li>
|
||||
Go to <code>Variables & 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>
|
||||
);
|
||||
}
|
||||
35
src/serverFunctions/samAccess.ts
Normal file
35
src/serverFunctions/samAccess.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user