+
+
+
+
+
+ Registered Providers
+
+ Providers available to SCRAPE_TARGETS. A provider is "configured" when its required credentials are present in
+ the environment.
+
+
+
+
- Your prompts are evaluated against multiple types of AI models to track how your brand appears across different
- types of AI search.
+ Your prompts are evaluated against multiple AI models to track how your brand appears across different types of AI
+ search. Toggle off any model you don't want included in runs for this brand.
-
- {MODEL_GROUPS.map((group) => (
-
-
-
-
-
{group.icon}
-
-
-
-
-
-
-
- Provider
-
- {group.provider}
-
-
-
- Model
-
-
-
-
-
- Exact model version used for this group.
-
-
-
- {group.currentModel}
-
-
-
- Tracked
-
-
-
-
-
- Whether this model group is included in your visibility runs.
-
-
-
-
-
- {group.description}
-
-
- ))}
+ {activeTargets.length === 0 && (
+
+
+ No models are configured for this deployment. Ask an admin to set{" "}
+ SCRAPE_TARGETS.
+
+
+ )}
+
+ {orphaned.length > 0 && (
+
+
+
+ The following saved models are no longer in this deployment's{" "}
+ SCRAPE_TARGETS and will cause the worker to throw on next dispatch:{" "}
+ {orphaned.join(", ")}. Click Save to drop them.
+
+
+ )}
+
+ {nothingSelected && activeTargets.length > 0 && (
+
+
+
+ No models are selected. If you save now, prompts will stop running for this brand until a model is re-enabled.
+
+
+ )}
+
+ {activeTargets.length > 0 && (
+
+ Version
+
+
+
+
+
+ Exact version slug passed to the provider when this model runs.
+
+
+
+ {config.version ?? "—"}
+
+
+
+ Web search
+
+
+
+
+
+ {config.webSearch
+ ? "Responses include real-time information from the web."
+ : "Responses are based on the model's training data only."}
+
+
+
+ {config.webSearch ? "Enabled" : "Disabled"}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/server/admin.ts b/apps/web/src/server/admin.ts
index bbbdf898..afe7561c 100644
--- a/apps/web/src/server/admin.ts
+++ b/apps/web/src/server/admin.ts
@@ -17,7 +17,13 @@ import { analyzeWebsite, getCompetitors, generateCandidatePromptsForReports } fr
import { DEFAULT_DELAY_HOURS } from "@workspace/lib/constants";
import { sendImmediatePromptJob } from "@/lib/job-scheduler";
import { Client } from "pg";
-import { parseScrapeTargets } from "@workspace/lib/providers";
+import {
+ parseScrapeTargets,
+ getAllProviders,
+ getProvider,
+ type ModelConfig,
+ type TestResult,
+} from "@workspace/lib/providers";
// ============================================================================
// Admin guard helper
@@ -840,3 +846,74 @@ export const getJobLogsFn = createServerFn({ method: "GET" })
return { jobId: data.jobId, logs, count: logs.length };
});
+
+// ============================================================================
+// Admin Providers - SCRAPE_TARGETS status + per-target smoke test
+// ============================================================================
+
+export interface ProviderStatus {
+ activeTargets: ModelConfig[];
+ providers: { id: string; name: string; configured: boolean }[];
+ deploymentMode: string;
+}
+
+/**
+ * Snapshot of what the worker will dispatch: the parsed SCRAPE_TARGETS plus
+ * which registered providers have credentials configured. Used by
+ * /admin/providers to render the current configuration.
+ */
+export const getProviderStatusFn = createServerFn({ method: "GET" }).handler(
+ async (): Promise => {
+ await requireAdmin();
+
+ const activeTargets = parseScrapeTargets(process.env.SCRAPE_TARGETS);
+ const providers = getAllProviders().map((p) => ({
+ id: p.id,
+ name: p.name,
+ configured: p.isConfigured(),
+ }));
+
+ return {
+ activeTargets,
+ providers,
+ deploymentMode: process.env.DEPLOYMENT_MODE ?? "local",
+ };
+ },
+);
+
+/**
+ * Run a single SCRAPE_TARGETS entry through its provider with a canned prompt.
+ * Surfaces credential and connectivity problems before prompts start failing.
+ */
+export const testProviderFn = createServerFn({ method: "POST" })
+ .inputValidator(z.object({ target: z.string() }))
+ .handler(async ({ data }): Promise => {
+ await requireAdmin();
+
+ const configs = parseScrapeTargets(data.target);
+ if (configs.length !== 1) {
+ throw new Error(
+ `testProviderFn expects a single target; got ${configs.length} from "${data.target}"`,
+ );
+ }
+ const cfg = configs[0];
+ const provider = getProvider(cfg.provider);
+
+ const start = Date.now();
+ try {
+ const result = await provider.run(cfg.model, "What is 2+2?", {
+ webSearch: cfg.webSearch,
+ version: cfg.version,
+ });
+ const latencyMs = Date.now() - start;
+ const sample = result.textContent.trim().slice(0, 240);
+ return { success: true, latencyMs, sampleOutput: sample };
+ } catch (err) {
+ const latencyMs = Date.now() - start;
+ return {
+ success: false,
+ latencyMs,
+ error: err instanceof Error ? err.message : String(err),
+ };
+ }
+ });
diff --git a/apps/web/src/server/brands.ts b/apps/web/src/server/brands.ts
index 12dea70c..c4c483b7 100644
--- a/apps/web/src/server/brands.ts
+++ b/apps/web/src/server/brands.ts
@@ -10,6 +10,7 @@ import { brands, prompts, competitors, type BrandWithPrompts, type Brand } from
import { eq, and, count, sql } from "drizzle-orm";
import { MAX_COMPETITORS } from "@workspace/lib/constants";
import { cleanAndValidateDomain } from "@/lib/domain-categories";
+import { parseScrapeTargets } from "@workspace/lib/providers";
function getDefaultBrandDomains(): string[] {
const raw = process.env.DEFAULT_BRAND_DOMAINS;
@@ -218,6 +219,50 @@ export const updateBrandFn = createServerFn({ method: "POST" })
return result[0];
});
+/**
+ * Update the set of models that should run for this brand.
+ *
+ * Semantics (must match `selectTargetsForBrand` in packages/lib):
+ * - `null` clears the override; every configured SCRAPE_TARGETS model runs.
+ * - `[]` is an explicit opt-out; no models run for this brand.
+ * - `[...]` is an opt-in list; every entry must be a model currently in
+ * SCRAPE_TARGETS, else the worker throws on the next dispatch. Validated
+ * here so the save surfaces the error instead of a silent future break.
+ */
+export const updateBrandEnabledModelsFn = createServerFn({ method: "POST" })
+ .inputValidator(
+ z.object({
+ brandId: z.string(),
+ enabledModels: z.array(z.string()).nullable(),
+ }),
+ )
+ .handler(async ({ data }) => {
+ const session = await requireAuthSession();
+ await requireOrgAccess(session.user.id, data.brandId);
+
+ if (data.enabledModels && data.enabledModels.length > 0) {
+ const activeModels = new Set(
+ parseScrapeTargets(process.env.SCRAPE_TARGETS).map((c) => c.model),
+ );
+ const unknown = data.enabledModels.filter((m) => !activeModels.has(m));
+ if (unknown.length > 0) {
+ throw new Error(
+ `Cannot enable model(s) not in SCRAPE_TARGETS: ${unknown.join(", ")}. ` +
+ `Available models: ${[...activeModels].join(", ") || "(none)"}.`,
+ );
+ }
+ }
+
+ const [result] = await db
+ .update(brands)
+ .set({ enabledModels: data.enabledModels, updatedAt: new Date() })
+ .where(eq(brands.id, data.brandId))
+ .returning();
+
+ if (!result) throw new Error("Brand not found");
+ return result;
+ });
+
/**
* Get competitors for a brand
*/
diff --git a/apps/worker/src/report-worker.ts b/apps/worker/src/report-worker.ts
index 4a11a956..e6d58039 100644
--- a/apps/worker/src/report-worker.ts
+++ b/apps/worker/src/report-worker.ts
@@ -2,7 +2,12 @@ import { db } from "@workspace/lib/db/db";
import { reports, type Brand, brands } from "@workspace/lib/db/schema";
import { eq } from "drizzle-orm";
import { RUNS_PER_PROMPT } from "@workspace/lib/constants";
-import { getProvider, parseScrapeTargets, type ModelConfig } from "@workspace/lib/providers";
+import {
+ getProvider,
+ parseScrapeTargets,
+ WHITELABEL_REPORT_RUNS_PER_MODEL,
+ type ModelConfig,
+} from "@workspace/lib/providers";
import {
analyzeWebsite,
getCompetitors,
@@ -22,17 +27,6 @@ const TARGET_PROMPTS_COUNT = 70;
const MIN_BRAND_MENTIONS = 14;
const MAX_BRAND_MENTIONS = 28;
-// Whitelabel deployments preserve the legacy asymmetric per-candidate sample
-// counts used before SCRAPE_TARGETS drove dispatch. Any model outside this map
-// on a whitelabel deployment is a configuration error (the legacy report flow
-// only knew how to sample these three). Other deployment modes use
-// RUNS_PER_PROMPT (same frequency as day-to-day prompt tracking).
-const WHITELABEL_REPORT_RUNS_PER_MODEL: Record = {
- chatgpt: 2,
- claude: 1,
- "google-ai-mode": 1,
-};
-
function getReportRunsForModel(model: string): number {
if (process.env.DEPLOYMENT_MODE === "whitelabel") {
const count = WHITELABEL_REPORT_RUNS_PER_MODEL[model];
diff --git a/packages/lib/src/providers/index.ts b/packages/lib/src/providers/index.ts
index 79388063..fe7f9192 100644
--- a/packages/lib/src/providers/index.ts
+++ b/packages/lib/src/providers/index.ts
@@ -11,6 +11,7 @@ export { KNOWN_MODELS, getModelMeta } from "./models";
export type { ModelMeta } from "./models";
export { parseScrapeTargets, validateScrapeTargets } from "./config";
export { selectTargetsForBrand } from "./runner";
+export { WHITELABEL_REPORT_RUNS_PER_MODEL } from "./report-runs";
const providerMap: Record = {
olostep,
diff --git a/packages/lib/src/providers/report-runs.ts b/packages/lib/src/providers/report-runs.ts
new file mode 100644
index 00000000..b3054403
--- /dev/null
+++ b/packages/lib/src/providers/report-runs.ts
@@ -0,0 +1,14 @@
+/**
+ * Whitelabel deployments preserve the legacy asymmetric per-candidate sample
+ * counts used before SCRAPE_TARGETS drove dispatch. Any model outside this
+ * map on a whitelabel deployment is a configuration error — the legacy report
+ * flow only knew how to sample these three.
+ *
+ * Exported so the web app can warn admins when a SCRAPE_TARGETS entry would
+ * crash report generation (see `/admin/providers`).
+ */
+export const WHITELABEL_REPORT_RUNS_PER_MODEL: Record = {
+ chatgpt: 2,
+ claude: 1,
+ "google-ai-mode": 1,
+};