From 64ae51e7afaffd71d7d8837ac301e22a64dd57f0 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 2 Nov 2026 12:52:13 +0000 Subject: [PATCH 1/2] feat(ai): c0mpute first for all backend text generation OpenAI and Anthropic are out of credit, so every autoblog, outreach, promote, ad copy and survey call failed. generateStructuredOutput now routes through @profullstack/ai-providers: c0mpute (Infernet peer nodes, p2p_only, trusted client key) first, then OpenAI and Anthropic alternating call by call, with a provider that answers 'no credits' parked for an hour instead of retried. - the existing Anthropic/OpenAI SDK paths stay as the paid tier, unchanged - the eight 'no paid key' gates count c0mpute - article embeddings are OpenAI-only: a failure now writes the article without vector-matched internal links instead of failing and refunding it - c0mpute spend recorded at a placeholder rate (chars/4 tokens) Co-Authored-By: Claude Opus 5.5 --- app/actions/surveys.ts | 4 +- lib/ai/spend.ts | 3 + lib/lx/articleGen.ts | 13 ++-- lib/lx/backendAi.ts | 127 +++++++++++++++++++++++++------ lib/mcp/promote.ts | 5 +- lib/outreach/generatePitch.ts | 4 +- lib/outreach/intentRelevance.ts | 4 +- lib/outreach/pipeline.ts | 6 +- lib/outreach/redditPipeline.ts | 4 +- lib/promote/generatePitch.ts | 4 +- lib/sp/renderPost.ts | 4 +- tests/backend-ai-c0mpute.test.ts | 50 ++++++++++++ 12 files changed, 180 insertions(+), 48 deletions(-) create mode 100644 tests/backend-ai-c0mpute.test.ts diff --git a/app/actions/surveys.ts b/app/actions/surveys.ts index b433576a..0dd310bf 100644 --- a/app/actions/surveys.ts +++ b/app/actions/surveys.ts @@ -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, @@ -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({ diff --git a/lib/ai/spend.ts b/lib/ai/spend.ts index 3882fa5a..7d037c84 100644 --- a/lib/ai/spend.ts +++ b/lib/ai/spend.ts @@ -34,6 +34,9 @@ const RATES: Record = { "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 }, }; /** diff --git a/lib/lx/articleGen.ts b/lib/lx/articleGen.ts index 1b289596..267c7a11 100644 --- a/lib/lx/articleGen.ts +++ b/lib/lx/articleGen.ts @@ -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 @@ -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", { diff --git a/lib/lx/backendAi.ts b/lib/lx/backendAi.ts index b78f4369..3b5b429e 100644 --- a/lib/lx/backendAi.ts +++ b/lib/lx/backendAi.ts @@ -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 = { @@ -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"; } /** @@ -85,32 +135,61 @@ export function anthropicThinkingFor( export async function generateStructuredOutput( args: StructuredOutputArgs, ): 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( + args: StructuredOutputArgs, + provider: C0mputeProvider, +): Promise { + const jsonSchema = + args.openaiJsonSchema ?? + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (z.toJSONSchema(args.schema as any) as Record); + const out = await generateObjectOn(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( diff --git a/lib/mcp/promote.ts b/lib/mcp/promote.ts index 6bddb579..b7a5c593 100644 --- a/lib/mcp/promote.ts +++ b/lib/mcp/promote.ts @@ -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 }; @@ -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({ @@ -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[] = []; diff --git a/lib/outreach/generatePitch.ts b/lib/outreach/generatePitch.ts index c0c98c0d..1219a8fd 100644 --- a/lib/outreach/generatePitch.ts +++ b/lib/outreach/generatePitch.ts @@ -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({ @@ -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." }; } diff --git a/lib/outreach/intentRelevance.ts b/lib/outreach/intentRelevance.ts index e6f21e1a..1169b133 100644 --- a/lib/outreach/intentRelevance.ts +++ b/lib/outreach/intentRelevance.ts @@ -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({ @@ -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({ diff --git a/lib/outreach/pipeline.ts b/lib/outreach/pipeline.ts index 988f8403..6e5d9c12 100644 --- a/lib/outreach/pipeline.ts +++ b/lib/outreach/pipeline.ts @@ -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"; @@ -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)"] }; } @@ -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)"] }; } diff --git a/lib/outreach/redditPipeline.ts b/lib/outreach/redditPipeline.ts index 29699301..dd90fd6f 100644 --- a/lib/outreach/redditPipeline.ts +++ b/lib/outreach/redditPipeline.ts @@ -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, @@ -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)." }; } diff --git a/lib/promote/generatePitch.ts b/lib/promote/generatePitch.ts index 7eeb773b..dbfaf405 100644 --- a/lib/promote/generatePitch.ts +++ b/lib/promote/generatePitch.ts @@ -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"; @@ -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).", ); diff --git a/lib/sp/renderPost.ts b/lib/sp/renderPost.ts index 1a38ee1d..8fa4a7da 100644 --- a/lib/sp/renderPost.ts +++ b/lib/sp/renderPost.ts @@ -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"; @@ -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).", ); diff --git a/tests/backend-ai-c0mpute.test.ts b/tests/backend-ai-c0mpute.test.ts new file mode 100644 index 00000000..41cd78ed --- /dev/null +++ b/tests/backend-ai-c0mpute.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod/v4"; + +// The router is a lazy singleton that captures fetch when first built, so the +// stub goes in before the module is imported. +const calls: string[] = []; +vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => { + calls.push(String(url)); + if (String(url).endsWith("/api/chat")) { + const body = JSON.parse(String(init?.body)); + expect(body.p2p_only).toBe(true); + return Response.json({ jobId: "j1", source: "p2p", model: body.modelName }); + } + const sse = + 'event: meta\ndata: {"model":"qwen-test"}\n\n' + + 'event: token\ndata: {"text":"```json\\n{\\"headline\\":\\"Hi\\"}\\n```"}\n\n' + + "event: done\ndata: {}\n\n"; + return new Response(sse, { headers: { "content-type": "text/event-stream" } }); +}); + +const { generateStructuredOutput, hasBackendAiTextProvider } = await import("@/lib/lx/backendAi"); + +describe("backend AI routes through c0mpute first", () => { + it("counts c0mpute as a provider with no paid key", () => { + expect(hasBackendAiTextProvider({})).toBe(true); + }); + + it("answers from c0mpute and never touches the paid clients", async () => { + const paid = { + responses: { create: vi.fn() }, + messages: { stream: vi.fn() }, + }; + const res = await generateStructuredOutput({ + name: "ad", + schema: z.object({ headline: z.string() }), + system: "s", + user: "u", + maxTokens: 100, + anthropicModel: "claude-opus-5", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + openai: paid as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + anthropic: paid as any, + }); + expect(res).toEqual({ provider: "c0mpute", output: { headline: "Hi" } }); + expect(paid.responses.create).not.toHaveBeenCalled(); + expect(paid.messages.stream).not.toHaveBeenCalled(); + expect(calls.some((u) => u.includes("/api/chat/stream/j1"))).toBe(true); + }); +}); From 750d604cd2c8f7ac75b96b23bb40dfd6a028d28e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 2 Nov 2026 12:53:41 +0000 Subject: [PATCH 2/2] deps: @profullstack/ai-providers 0.1.0 Co-Authored-By: Claude Opus 5.5 --- bun.lock | 3 +++ package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/bun.lock b/bun.lock index 224b9c8c..2e13e2f1 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,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", @@ -316,6 +317,8 @@ "@profullstack/affiliates": ["@profullstack/affiliates@0.3.0", "", { "dependencies": { "@profullstack/referrals": "^0.1.0" }, "bin": { "affiliates": "dist/cli.js" } }, "sha512-bp0iREciWetDWrQYOsa6SJRTzviekKwgJQuATT7vql+4equu7pN76q8tb3SP1oBMczydsv6rYUMvPpS6+QT7yQ=="], + "@profullstack/ai-providers": ["@profullstack/ai-providers@0.1.0", "", { "bin": { "ai-providers": "src/cli.js" } }, "sha512-vc7aFjozyiwIhREWLhB3xWpUPeGggM348ng5W6j4IA+gxYF3S7TNLoGWrF/ax2NttsK40C801CRkD9JSVA4aOg=="], + "@profullstack/autoblog": ["@profullstack/autoblog@github:profullstack/autoblog#75e54af", {}, "profullstack-autoblog-75e54af", "sha512-f/YwwiVTzKz659+6RHgeGueSdUrWAnon5OHT1YfOI5TdLZPDrLFGE88051fzQXAlsBrkDK1jrm216OIrKygnRA=="], "@profullstack/coinpay": ["@profullstack/coinpay@0.25.0", "", { "dependencies": { "@inquirer/prompts": "^8.3.0", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@profullstack/hqtui": "^0.6.3", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0" }, "bin": { "coinpay": "bin/coinpay.js" } }, "sha512-ZoK8COqBCdJXvr0fO5UWw9HPh88CdMBLycc/dU6kXwAyeIrwZ4WkBApisuDgs4leXNN+SMjwex2etgrEvlICTQ=="], diff --git a/package.json b/package.json index dcfe4c49..4c877b30 100644 --- a/package.json +++ b/package.json @@ -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",