From f2ae048d5f7970efc825d4ac4b299ea60ca0904a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 7 Nov 2026 10:53:00 +0000 Subject: [PATCH] c0mpute first in the edge functions' AI provider chains draft-email-broadcast, fetch-metadata and generate-news-post try the c0mpute peer network first (npm:@profullstack/ai-providers@0.2.0 in Deno, via supabase/functions/_shared/c0mpute.ts), then their existing OpenAI and Gemini calls. Timeouts stay under the self-hosted edge runtime's ~60 s worker limit. Banner images stay on OpenAI. Co-Authored-By: Claude Opus 5.5 --- .env.example | 6 +- supabase/functions/_shared/c0mpute.ts | 55 +++++++++++ .../functions/draft-email-broadcast/index.ts | 22 ++++- supabase/functions/fetch-metadata/index.ts | 16 +++- .../functions/generate-news-post/index.ts | 91 +++++++++++-------- 5 files changed, 147 insertions(+), 43 deletions(-) create mode 100644 supabase/functions/_shared/c0mpute.ts diff --git a/.env.example b/.env.example index 8f6a6f9..9b10ea4 100644 --- a/.env.example +++ b/.env.example @@ -31,7 +31,11 @@ SUPABASE_EDGE_ANON_JWT=your-legacy-anon-jwt-here # Site URL (for generating links in emails) SITE_URL=http://localhost:3000 -# OpenAI API Key (for AI-powered features) +# c0mpute (Infernet account key): text AI in the edge functions runs here first; +# without a key, the free tier answers. Set it in the functions container env too. +C0MPUTE_API_KEY= + +# OpenAI API Key (banner images; paid fallback for text) OPENAI_API_KEY=sk-proj-your-openai-api-key-here # CoinPay (paid listing tiers: hosted checkout sessions, any coin or PayPal). diff --git a/supabase/functions/_shared/c0mpute.ts b/supabase/functions/_shared/c0mpute.ts new file mode 100644 index 0000000..91a89d7 --- /dev/null +++ b/supabase/functions/_shared/c0mpute.ts @@ -0,0 +1,55 @@ +// c0mpute (https://c0mpute.com): the peer network that goes first in every AI +// provider chain in these functions, through @profullstack/ai-providers. The +// existing vendor calls (OpenAI, Gemini) stay behind it as the paid tier. +// +// C0MPUTE_API_KEY is an Infernet account key; without one the network's small +// free tier answers. C0MPUTE_DISABLED=1 skips it. +import { createAiProviders, extractJson } from 'npm:@profullstack/ai-providers@0.2.0' + +/** + * The self-hosted edge runtime stops a worker after about 60 s of wall clock, + * so c0mpute gets most of that and leaves room for one vendor attempt. Peer + * nodes write about two tokens a second, so long outputs may not finish here. + */ +export const C0MPUTE_EDGE_TIMEOUT_MS = Number(Deno.env.get('C0MPUTE_TIMEOUT_MS')) || 40_000 + +export interface C0mputeAsk { + system?: string + user: string + maxTokens?: number + temperature?: number + timeoutMs?: number + feature?: string + fetch?: typeof fetch +} + +/** True unless C0MPUTE_DISABLED is set, so key gates can count it. */ +export function c0mputeEnabled(): boolean { + return !/^(1|true|yes|on)$/i.test(Deno.env.get('C0MPUTE_DISABLED') ?? '') +} + +/** Ask c0mpute for a JSON answer; returns the raw text. Throws on any failure. */ +export async function askC0mpute(req: C0mputeAsk): Promise { + const [provider] = createAiProviders({ + env: Deno.env.toObject(), + paidFallback: false, + ...(req.fetch ? { c0mpute: { fetch: req.fetch } } : {}), + }).primary + if (!provider) throw new Error('c0mpute is disabled') + const { text } = await provider.generate({ + system: req.system, + user: req.user, + maxTokens: req.maxTokens ?? 1024, + temperature: req.temperature, + timeoutMs: req.timeoutMs ?? C0MPUTE_EDGE_TIMEOUT_MS, + json: true, + feature: req.feature, + }) + if (!text.trim()) throw new Error('c0mpute returned no text') + return text +} + +/** The JSON object in a model's answer, fenced or not, re-serialized. */ +export function jsonText(raw: string): string { + return JSON.stringify(extractJson(raw)) +} diff --git a/supabase/functions/draft-email-broadcast/index.ts b/supabase/functions/draft-email-broadcast/index.ts index 52000c1..dc5e947 100644 --- a/supabase/functions/draft-email-broadcast/index.ts +++ b/supabase/functions/draft-email-broadcast/index.ts @@ -1,8 +1,8 @@ // Drafts an email broadcast (subject + markdown body) from the admin's description. // Admin-only; nothing is sent or stored, the page drops the draft into its form. // -// Providers are tried in order, like fetch-metadata: OpenAI, then Gemini. A provider -// with no key is skipped. +// Providers are tried in order, like fetch-metadata: c0mpute, then OpenAI, then +// Gemini. A provider with no key is skipped (c0mpute needs none). import { createClient } from 'npm:@supabase/supabase-js@2.76.1' import { requireAdmin } from '../_shared/adminAuth.ts' import { @@ -13,6 +13,7 @@ import { type BroadcastAudience, type BroadcastDraft, } from '../_shared/broadcastDraft.ts' +import { askC0mpute, c0mputeEnabled, jsonText } from '../_shared/c0mpute.ts' const corsHeaders = { 'Access-Control-Allow-Origin': '*', @@ -84,11 +85,26 @@ Deno.serve(async (req: Request) => { const user = buildDraftUserPrompt(description, audience, { subject, content }) const providers: Array<[string, () => Promise]> = [] + if (c0mputeEnabled()) { + providers.push([ + 'c0mpute', + async () => + jsonText( + await askC0mpute({ + system: DRAFT_SYSTEM_PROMPT, + user, + temperature: 0.7, + maxTokens: 1200, + feature: 'saasrow.draft-email-broadcast', + }), + ), + ]) + } const openaiKey = Deno.env.get('OPENAI_API_KEY') const geminiKey = Deno.env.get('GEMINI_API_KEY') if (openaiKey) providers.push(['openai', () => askOpenAI(user, openaiKey)]) if (geminiKey) providers.push(['gemini', () => askGemini(user, geminiKey)]) - if (providers.length === 0) return json({ error: 'No AI provider configured (OPENAI_API_KEY or GEMINI_API_KEY)' }, 500) + if (providers.length === 0) return json({ error: 'No AI provider configured (c0mpute disabled, no OPENAI_API_KEY or GEMINI_API_KEY)' }, 500) let lastError = '' for (const [name, ask] of providers) { diff --git a/supabase/functions/fetch-metadata/index.ts b/supabase/functions/fetch-metadata/index.ts index cb22e1f..c27142b 100644 --- a/supabase/functions/fetch-metadata/index.ts +++ b/supabase/functions/fetch-metadata/index.ts @@ -1,5 +1,6 @@ import { createClient } from 'npm:@supabase/supabase-js@2.76.1' import { OpenAI } from 'npm:openai@4.47.1' +import { askC0mpute, c0mputeEnabled, jsonText } from '../_shared/c0mpute.ts' const corsHeaders = { 'Access-Control-Allow-Origin': '*', @@ -213,8 +214,8 @@ async function askGemini(prompt: string, apiKey: string): Promise { /** * Title, description, category and tags from a model. Providers are tried - * in order and the first one that answers wins: OpenAI when it has credits, - * otherwise Gemini. A provider with no key is skipped; if all fail we fall + * in order and the first one that answers wins: c0mpute (peer network, no + * vendor key), then OpenAI when it has credits, otherwise Gemini. A provider with no key is skipped; if all fail we fall * back to the page's own title and meta description rather than blocking * the submission. */ @@ -222,11 +223,20 @@ async function generateWithAI(url: string, metadata: MetaData): Promise Promise]> = [] + // Shorter than the other functions: a listing submission waits on this, + // and the page's own metadata is an acceptable answer. + if (c0mputeEnabled()) { + providers.push([ + 'c0mpute', + async () => + jsonText(await askC0mpute({ user: prompt, temperature: 0.7, maxTokens: 600, timeoutMs: 30_000, feature: 'saasrow.fetch-metadata' })), + ]) + } const openaiKey = Deno.env.get('OPENAI_API_KEY') const geminiKey = Deno.env.get('GEMINI_API_KEY') if (openaiKey) providers.push(['openai', () => askOpenAI(prompt, openaiKey)]) if (geminiKey) providers.push(['gemini', () => askGemini(prompt, geminiKey)]) - if (providers.length === 0) console.warn('[AI] No OPENAI_API_KEY or GEMINI_API_KEY configured, falling back to metadata only') + if (providers.length === 0) console.warn('[AI] c0mpute disabled and no OPENAI_API_KEY or GEMINI_API_KEY configured, falling back to metadata only') for (const [name, ask] of providers) { try { diff --git a/supabase/functions/generate-news-post/index.ts b/supabase/functions/generate-news-post/index.ts index 8720e39..f69facf 100644 --- a/supabase/functions/generate-news-post/index.ts +++ b/supabase/functions/generate-news-post/index.ts @@ -1,5 +1,6 @@ import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import { createClient } from "npm:@supabase/supabase-js@2.76.1"; +import { askC0mpute, c0mputeEnabled, jsonText } from "../_shared/c0mpute.ts"; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -52,9 +53,9 @@ Deno.serve(async (req: Request) => { const openAIKey = Deno.env.get('OPENAI_API_KEY'); console.log('OpenAI key present:', !!openAIKey); - if (!openAIKey) { + if (!openAIKey && !c0mputeEnabled()) { return new Response( - JSON.stringify({ error: 'OpenAI API key not configured' }), + JSON.stringify({ error: 'No AI provider configured (c0mpute disabled and no OPENAI_API_KEY)' }), { status: 500, headers: { @@ -109,35 +110,57 @@ Return a JSON object with: - excerpt: A hook that makes people want to read more (120-160 characters) - content: Full HTML with

,

,

    ,
  • tags. Write like a human, not a content template.`; - console.log('Calling OpenAI API...'); - const openAIResponse = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${openAIKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: 'gpt-4o-mini', - messages: [ - { - role: 'system', - content: 'You are an experienced tech blogger with a casual, authentic writing voice. Write like a real person sharing genuine insights, not like an AI following a template. Your writing should have personality and feel conversational. Always respond with valid JSON only.' - }, - { - role: 'user', - content: prompt - } - ], - temperature: 0.9, - response_format: { type: 'json_object' } - }), - }); - - console.log('OpenAI response status:', openAIResponse.status); - - if (!openAIResponse.ok) { - const errorText = await openAIResponse.text(); - console.error('OpenAI API error:', openAIResponse.status, errorText); + const SYSTEM = 'You are an experienced tech blogger with a casual, authentic writing voice. Write like a real person sharing genuine insights, not like an AI following a template. Your writing should have personality and feel conversational. Always respond with valid JSON only.'; + + // c0mpute first, then OpenAI as the paid fallback. + let generatedContent: { title?: string; excerpt?: string; content?: string } | null = null; + if (c0mputeEnabled()) { + try { + console.log('Calling c0mpute...'); + const parsed = JSON.parse(jsonText(await askC0mpute({ + system: SYSTEM, + user: prompt, + temperature: 0.9, + maxTokens: 1500, + feature: 'saasrow.generate-news-post', + }))); + if (typeof parsed?.title === 'string' && typeof parsed?.content === 'string') generatedContent = parsed; + else console.error('c0mpute answer is missing title or content'); + } catch (err) { + console.error('c0mpute failed:', err instanceof Error ? err.message : String(err)); + } + } + + if (!generatedContent && openAIKey) { + console.log('Calling OpenAI API...'); + const openAIResponse = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${openAIKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: SYSTEM }, + { role: 'user', content: prompt }, + ], + temperature: 0.9, + response_format: { type: 'json_object' } + }), + }); + + console.log('OpenAI response status:', openAIResponse.status); + if (openAIResponse.ok) { + const openAIData = await openAIResponse.json(); + generatedContent = JSON.parse(openAIData.choices[0].message.content); + } else { + const errorText = await openAIResponse.text(); + console.error('OpenAI API error:', openAIResponse.status, errorText); + } + } + + if (!generatedContent?.title) { return new Response( JSON.stringify({ error: 'Failed to generate content' }), { @@ -150,10 +173,6 @@ Return a JSON object with: ); } - console.log('Parsing OpenAI response...'); - const openAIData = await openAIResponse.json(); - console.log('OpenAI data received, parsing content...'); - const generatedContent = JSON.parse(openAIData.choices[0].message.content); console.log('Generated content:', generatedContent.title); const slug = generatedContent.title @@ -203,7 +222,7 @@ Return a JSON object with: } ); } catch (error) { - console.error('Unexpected error:', error, error.message, error.stack); + console.error('Unexpected error:', error, error instanceof Error ? error.stack : ''); return new Response( JSON.stringify({ error: 'Internal server error' }), {