Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/actions/surveys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { env } from "@/lib/env";
import { missingOrgSchema } from "@/lib/orgs";
import { z } from "zod/v4";
import { aiClients } from "@/lib/outreach/pipeline";
import { generateStructuredOutput } from "@/lib/lx/backendAi";
import { generateStructuredOutput, c0mputeEnabled } from "@/lib/lx/backendAi";
import {
sendOutreachEmail,
sendOutreachSms,
Expand Down Expand Up @@ -104,7 +104,7 @@ export async function generateSurveyDraft(input: {
: { data: [] };

const { anthropic, openai } = aiClients();
if (!anthropic && !openai) return { ok: false, error: "AI drafting is temporarily unavailable." };
if (!anthropic && !openai && !c0mputeEnabled()) return { ok: false, error: "AI drafting is temporarily unavailable." };

try {
const { output } = await generateStructuredOutput({
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions lib/ai/spend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ const RATES: Record<string, Rate> = {
"gpt-5.6-sol": { input: 4_000_000, output: 20_000_000 },
"gpt-5.6-terra": { input: 2_500_000, output: 15_000_000 },
"gpt-5.6-luna": { input: 1_000_000, output: 6_000_000 },
// c0mpute: the peer network reports no token counts or price, so tokens are
// chars/4 and the rate is bot.seo's placeholder until the market sets one.
"c0mpute/": { input: 150_000, output: 600_000 },
};

/**
Expand Down
13 changes: 6 additions & 7 deletions lib/lx/articleGen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1238,13 +1238,12 @@ export async function generateArticle(
});
queryEmbedding = emb.data[0].embedding as number[];
} catch (err) {
await failKeyword(
supabase,
keyword.id,
`embedding failed: ${err instanceof Error ? err.message : String(err)}`,
// Embeddings are OpenAI-only (c0mpute serves none). Without credit there
// the article still gets written, just without the vector-matched
// internal links; the other candidate sources below still apply.
console.warn(
`[lx] embedding failed, writing without vector links: ${err instanceof Error ? err.message : String(err)}`,
);
await refundArticleGenerationCharge(supabase, charge);
return { ok: false, error: "embedding failed" };
}

// Build the link-exchange candidate pool from three sources, ordered
Expand All @@ -1261,7 +1260,7 @@ export async function generateArticle(
);
const sitePageSlots = Math.min(desiredInternalLinks, MAX_LINK_CANDIDATES);
let candidates: LabeledCandidate[] = [];
if (sitePageSlots > 0) {
if (sitePageSlots > 0 && queryEmbedding.length > 0) {
const [{ data: pillarRows, error: pillarErr }, { data: blogRows, error: blogErr }] =
await Promise.all([
supabase.rpc("lx_find_internal_links", {
Expand Down
127 changes: 103 additions & 24 deletions lib/lx/backendAi.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import OpenAI from "openai";
import {
AiRouter,
C0mputeProvider,
generateObjectOn,
} from "@profullstack/ai-providers";
import { z } from "zod/v4";
import { recordAiSpend } from "@/lib/ai/spend";
import { env } from "../env";

export type BackendAiProvider = "anthropic" | "openai";
export type BackendAiProvider = "c0mpute" | "anthropic" | "openai";
type BackendAiPreference = BackendAiProvider | "auto";

type StructuredOutputArgs<T> = {
Expand Down Expand Up @@ -51,16 +56,61 @@ export function orderedBackendAiProviders(input: {
];
}

/**
* c0mpute is the default for every text call: the peer network through
* Infernet, no paid key needed. OpenAI and Anthropic are fallbacks only,
* alternating call by call so neither bill takes all the overflow, and a key
* that answers "no credits" is parked for an hour instead of retried.
* C0MPUTE_DISABLED=1 turns c0mpute off.
*/
export function c0mputeEnabled(): boolean {
return !/^(1|true|yes|on)$/i.test(process.env.C0MPUTE_DISABLED ?? "");
}

let router: AiRouter | null = null;
function backendRouter(): AiRouter {
if (router) return router;
const models = (process.env.C0MPUTE_MODELS ?? process.env.C0MPUTE_MODEL ?? "")
.split(",")
.map((m) => m.trim())
.filter(Boolean);
const num = (v?: string) => (v && Number.isFinite(Number(v)) ? Number(v) : undefined);
// The paid tier is crawlproof's own SDK code (structured outputs, prompt
// caching, spend rows); the router only needs their names to order them.
const named = (name: string) =>
({ name, generate: () => Promise.reject(new Error("not used")) }) as never;
router = new AiRouter({
primary: c0mputeEnabled()
? [
new C0mputeProvider({
apiKey: process.env.C0MPUTE_API_KEY,
baseUrl: process.env.C0MPUTE_URL,
...(models.length ? { models } : {}),
concurrency: num(process.env.C0MPUTE_CONCURRENCY),
stallMs: num(process.env.C0MPUTE_STALL_MS),
// Articles run to thousands of tokens on CPU nodes.
timeoutMs: num(process.env.C0MPUTE_TIMEOUT_MS) ?? 900_000,
}),
]
: [],
fallback: [named("openai"), named("anthropic")],
});
return router;
}

export function backendAiStatus() {
return backendRouter().status();
}

export function hasBackendAiTextProvider(input: {
anthropicApiKey?: string;
openaiApiKey?: string;
}): boolean {
return !!input.anthropicApiKey || !!input.openaiApiKey;
return c0mputeEnabled() || !!input.anthropicApiKey || !!input.openaiApiKey;
}

export function backendAiTextProviderLabel(): string {
const pref = normalizeBackendAiPreference();
return pref === "auto" ? "Anthropic or OpenAI" : pref === "openai" ? "OpenAI" : "Anthropic";
return c0mputeEnabled() ? "c0mpute" : "Anthropic or OpenAI";
}

/**
Expand All @@ -85,32 +135,61 @@ export function anthropicThinkingFor(
export async function generateStructuredOutput<T>(
args: StructuredOutputArgs<T>,
): Promise<{ provider: BackendAiProvider; output: T }> {
const providers = orderedBackendAiProviders({
preference: args.preference,
hasAnthropic: !!args.anthropic,
hasOpenAI: !!args.openai,
});
if (providers.length === 0) {
const only: string[] = [];
if (c0mputeEnabled()) only.push("c0mpute");
if (args.openai) only.push("openai");
if (args.anthropic) only.push("anthropic");
if (only.length === 0) {
throw new Error(
"No backend AI provider configured. Set OPENAI_API_KEY or ANTHROPIC_API_KEY.",
"No backend AI provider configured. c0mpute is disabled (C0MPUTE_DISABLED) and neither OPENAI_API_KEY nor ANTHROPIC_API_KEY is set.",
);
}

const errors: string[] = [];
for (const provider of providers) {
try {
if (provider === "anthropic") {
return { provider, output: await generateWithAnthropic(args) };
}
return { provider, output: await generateWithOpenAI(args) };
} catch (err) {
errors.push(
`${provider}: ${err instanceof Error ? err.message : String(err)}`,
);
}
try {
const { provider, value } = await backendRouter().run(
(p: { name: string }) => {
if (p.name === "anthropic") return generateWithAnthropic(args);
if (p.name === "openai") return generateWithOpenAI(args);
return generateWithC0mpute(args, p as C0mputeProvider);
},
{ feature: args.name, only },
);
return { provider: provider as BackendAiProvider, output: value };
} catch (err) {
// Same prefix as before, so log searches and alerts keep matching.
throw new Error(
`Backend AI generation failed (${
err instanceof Error ? err.message.replace(/^AI generation failed \(|\)$/g, "") : String(err)
})`,
);
}
}

throw new Error(`Backend AI generation failed (${errors.join("; ")})`);
async function generateWithC0mpute<T>(
args: StructuredOutputArgs<T>,
provider: C0mputeProvider,
): Promise<T> {
const jsonSchema =
args.openaiJsonSchema ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(z.toJSONSchema(args.schema as any) as Record<string, unknown>);
const out = await generateObjectOn<T>(provider, {
name: args.name,
schema: args.schema,
jsonSchema,
system: args.system,
user: args.user,
maxTokens: args.maxTokens,
temperature: 0.4,
});
void recordAiSpend({
provider: "c0mpute",
model: `c0mpute/${out.model}`,
feature: args.name,
inputTokens: out.usage.inputTokens,
outputTokens: out.usage.outputTokens,
});
return out.object;
}

async function generateWithAnthropic<T>(
Expand Down
5 changes: 3 additions & 2 deletions lib/mcp/promote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
validateFeedUrl,
} from "@/lib/promote/sources";
import { fanOutToSubscribers, ingestFeedNow } from "@/lib/promote/ingest";
import { c0mputeEnabled } from "@/lib/lx/backendAi";

type Account = { id: string; platform: string; handle: string; status: string };

Expand Down Expand Up @@ -100,7 +101,7 @@ export function registerPromoteTools(server: McpServer): void {
},
async (args, _extra) => {
const { anthropic, openai } = aiClients();
if (!anthropic && !openai)
if (!anthropic && !openai && !c0mputeEnabled())
return errorResult("No AI provider configured on the server.");
const title = await fetchLinkTitle(args.url).catch(() => null);
const pitch = await generatePitch({
Expand Down Expand Up @@ -172,7 +173,7 @@ export function registerPromoteTools(server: McpServer): void {
"No matching active accounts. Connect some at https://crawlproof.com/dashboard/promote/accounts",
);
const { anthropic, openai } = aiClients();
if (!anthropic && !openai) return errorResult("No AI provider configured on the server.");
if (!anthropic && !openai && !c0mputeEnabled()) return errorResult("No AI provider configured on the server.");
const title = await fetchLinkTitle(args.url).catch(() => null);
const sb = serviceClient();
const lines: string[] = [];
Expand Down
4 changes: 2 additions & 2 deletions lib/outreach/generatePitch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import { z } from "zod/v4";
import { env } from "@/lib/env";
import { aiClients } from "./pipeline";
import { generateStructuredOutput } from "@/lib/lx/backendAi";
import { generateStructuredOutput, c0mputeEnabled } from "@/lib/lx/backendAi";
import { loadRecipientContext } from "./recipientContext";

const PitchSchema = z.object({
Expand Down Expand Up @@ -67,7 +67,7 @@ export async function generatePitch(input: {
}

const { anthropic, openai } = aiClients();
if (!anthropic && !openai) {
if (!anthropic && !openai && !c0mputeEnabled()) {
return { ok: false, error: "No AI provider is configured." };
}

Expand Down
4 changes: 2 additions & 2 deletions lib/outreach/intentRelevance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

import { z } from "zod/v4";
import { env } from "@/lib/env";
import { generateStructuredOutput } from "@/lib/lx/backendAi";
import { generateStructuredOutput, c0mputeEnabled } from "@/lib/lx/backendAi";
import { aiClients } from "./pipeline";

const RelevanceSchema = z.object({
Expand Down Expand Up @@ -73,7 +73,7 @@ export async function judgeRelevance(input: {
if (!sells || !text) return null;

const { anthropic, openai } = aiClients();
if (!anthropic && !openai) return null;
if (!anthropic && !openai && !c0mputeEnabled()) return null;

try {
const res = await generateStructuredOutput({
Expand Down
6 changes: 3 additions & 3 deletions lib/outreach/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import OpenAI from "openai";
import { z } from "zod/v4";
import { env } from "@/lib/env";
import { serviceClient } from "@/lib/supabase/service";
import { generateStructuredOutput } from "@/lib/lx/backendAi";
import { generateStructuredOutput, c0mputeEnabled } from "@/lib/lx/backendAi";
import { isAllowedTargetUrl, checkPerTargetLimit } from "@/lib/rateLimit";
import { DEFAULT_PROJECT_ENGINES } from "@/lib/credits";
import { newShareToken } from "@/lib/shareToken";
Expand Down Expand Up @@ -697,7 +697,7 @@ export async function draftEmail(input: {
return { ok: false, problems: ["no findings and no score on file — nothing truthful to say"] };
}
const { anthropic, openai } = aiClients();
if (!anthropic && !openai) {
if (!anthropic && !openai && !c0mputeEnabled()) {
return { ok: false, problems: ["no AI provider configured (ANTHROPIC_API_KEY / OPENAI_API_KEY)"] };
}

Expand Down Expand Up @@ -796,7 +796,7 @@ async function draftCustomEmail(input: {
}

const { anthropic, openai } = aiClients();
if (!anthropic && !openai) {
if (!anthropic && !openai && !c0mputeEnabled()) {
return { ok: false, problems: ["no AI provider configured (ANTHROPIC_API_KEY / OPENAI_API_KEY)"] };
}

Expand Down
4 changes: 2 additions & 2 deletions lib/outreach/redditPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import { env } from "@/lib/env";
import { serviceClient } from "@/lib/supabase/service";
import { generateStructuredOutput } from "@/lib/lx/backendAi";
import { generateStructuredOutput, c0mputeEnabled } from "@/lib/lx/backendAi";
import { z } from "zod/v4";
import {
buildSearchQueries,
Expand Down Expand Up @@ -233,7 +233,7 @@ export async function draftRedditReply(input: {
}

const { anthropic, openai } = aiClients();
if (!anthropic && !openai) {
if (!anthropic && !openai && !c0mputeEnabled()) {
return { ok: false, error: "No AI provider configured (ANTHROPIC_API_KEY / OPENAI_API_KEY)." };
}

Expand Down
4 changes: 2 additions & 2 deletions lib/promote/generatePitch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
import { z } from "zod/v4";
import { generateStructuredOutput } from "@/lib/lx/backendAi";
import { generateStructuredOutput, c0mputeEnabled } from "@/lib/lx/backendAi";
import { env } from "@/lib/env";

const RENDER_MODEL = "claude-haiku-4-5-20251001";
Expand Down Expand Up @@ -167,7 +167,7 @@ export async function generatePitch(
openai,
} = args;

if (!anthropic && !openai) {
if (!anthropic && !openai && !c0mputeEnabled()) {
throw new Error(
"No AI provider configured (ANTHROPIC_API_KEY / OPENAI_API_KEY).",
);
Expand Down
4 changes: 2 additions & 2 deletions lib/sp/renderPost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
// SDK zod helpers require v4 (z.toJSONSchema). See lib/lx/detectBlog.ts.
import { z } from "zod/v4";
import { generateStructuredOutput } from "@/lib/lx/backendAi";
import { generateStructuredOutput, c0mputeEnabled } from "@/lib/lx/backendAi";
import { env } from "@/lib/env";

const RENDER_MODEL = "claude-haiku-4-5-20251001";
Expand Down Expand Up @@ -146,7 +146,7 @@ export async function renderPostForPlatform(args: {
// "ships in a later phase" error gracefully.
console.warn(`[sp] no platform profile for "${platform}", using generic fallback`);
}
if (!anthropic && !openai) {
if (!anthropic && !openai && !c0mputeEnabled()) {
throw new Error(
"No AI provider configured (ANTHROPIC_API_KEY / OPENAI_API_KEY).",
);
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@libsql/client": "^0.18.0",
"@modelcontextprotocol/sdk": "^1.32.2",
"@profullstack/affiliates": "^0.3.0",
"@profullstack/ai-providers": "0.1.0",
"@profullstack/autoblog": "github:profullstack/autoblog#75e54af",
"@profullstack/coinpay": "^0.25.0",
"@profullstack/coinpay-onboard": "0.4.0",
Expand Down
Loading