diff --git a/bun.lock b/bun.lock
index 9d5cf8a5..eb29a379 100644
--- a/bun.lock
+++ b/bun.lock
@@ -6,6 +6,7 @@
"name": "smshub",
"dependencies": {
"@profullstack/affiliates": "^0.3.0",
+ "@profullstack/ai-providers": "0.1.2",
"@profullstack/coinpay-onboard": "0.4.0",
"@profullstack/footer": "latest",
"@profullstack/profile": "0.1.8",
@@ -295,6 +296,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.2", "", { "bin": { "ai-providers": "src/cli.js" } }, "sha512-nOcKaOhfcDM+RNruA4+znva5xXxI4HF77iqUCgieuou2w8yY8LIF2p5ajSVD4kzFtfIUjC7GhGV8eyOrliHg1w=="],
+
"@profullstack/coinpay-onboard": ["@profullstack/coinpay-onboard@0.4.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "^1.32.2", "zod": "^4.6.7" }, "bin": { "coinpay-onboard": "bin/coinpay-onboard" } }, "sha512-L2P0fxNswZnXt1tvo6t19L/RrJN204vrWnIEY65QI+zriZN7JJPY4Ap6Nv8MLkyA5wDvY88s0+Dgjf9Myw/tUA=="],
"@profullstack/emailer": ["@profullstack/emailer@1.0.3", "", {}, "sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw=="],
diff --git a/package.json b/package.json
index d75ba203..e6faa22c 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
},
"dependencies": {
"@profullstack/affiliates": "^0.3.0",
+ "@profullstack/ai-providers": "0.1.2",
"@profullstack/coinpay-onboard": "0.4.0",
"@profullstack/footer": "latest",
"@profullstack/profile": "0.1.8",
diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx
index 134fd173..f648b7d1 100644
--- a/src/app/privacy/page.tsx
+++ b/src/app/privacy/page.tsx
@@ -33,7 +33,7 @@ export default async function PrivacyPage() {
- Send and receive SMS messages on your behalf through connected providers
- Display your conversations, contacts, and message history
- - Provide AI-powered reply suggestions (when enabled, using OpenAI)
+ - Provide AI-powered reply suggestions (when enabled, using c0mpute, with OpenAI as a fallback)
- Send push notifications for new messages
- Improve the platform and fix bugs
- Communicate with you about your account and service updates
@@ -60,7 +60,8 @@ export default async function PrivacyPage() {
We do not sell, rent, or share your personal data or message content with third parties,
- except as necessary to provide the service (e.g., sending messages through Twilio/Telnyx).
+ except as necessary to provide the service (e.g., sending messages through Twilio/Telnyx,
+ or generating an AI reply suggestion you asked for).
No mobile information will be shared with third parties or affiliates for marketing or
@@ -73,7 +74,8 @@ export default async function PrivacyPage() {
{name} integrates with the following third-party services:
- Twilio / Telnyx / phonenumbers.bot: SMS sending and receiving
- - OpenAI: AI reply suggestions (opt-in, per conversation)
+ - c0mpute: AI reply suggestions (opt-in, per conversation). The recent messages in that conversation are processed by nodes on a peer-to-peer inference network, which may be run by independent operators.
+ - OpenAI: Fallback for AI reply suggestions when c0mpute is unavailable
- Supabase: Database, authentication, and real-time messaging
- Sentry: Error tracking and performance monitoring
- Expo: Mobile app push notifications
diff --git a/src/lib/ai/__tests__/auto-reply.test.ts b/src/lib/ai/__tests__/auto-reply.test.ts
index eb892988..267049e1 100644
--- a/src/lib/ai/__tests__/auto-reply.test.ts
+++ b/src/lib/ai/__tests__/auto-reply.test.ts
@@ -1,11 +1,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { suggestReply } from "../auto-reply";
describe("suggestReply", () => {
const originalEnv = process.env;
+ let suggestReply: typeof import("../auto-reply").suggestReply;
- beforeEach(() => {
- process.env = { ...originalEnv, OPENAI_API_KEY: "test-key" };
+ beforeEach(async () => {
+ process.env = { ...originalEnv, OPENAI_API_KEY: "test-key", C0MPUTE_API_KEY: "" };
+ // A fresh router each test: a failure parks its provider.
+ vi.resetModules();
+ ({ suggestReply } = await import("../auto-reply"));
});
afterEach(() => {
@@ -80,4 +83,45 @@ describe("suggestReply", () => {
})
).rejects.toThrow("No suggestion generated");
});
+
+ describe("with a c0mpute key", () => {
+ const message = [{ direction: "inbound" as const, body: "Hello", created_at: "2024-01-01T00:00:00Z" }];
+
+ beforeEach(() => {
+ process.env.C0MPUTE_API_KEY = "ik_live_test";
+ });
+
+ it("asks c0mpute first and never sends the phone number", async () => {
+ const fetchMock = vi.spyOn(global, "fetch").mockImplementation((async (url: string) =>
+ url.endsWith("/api/chat")
+ ? Response.json({ jobId: "j", source: "p2p", model: "m" })
+ : new Response(`event: done\ndata: ${JSON.stringify({ text: "hmOn my way." })}\n\n`)) as typeof fetch);
+
+ const result = await suggestReply({ messages: message, contactPhone: "+15551234567" });
+
+ expect(result.suggestion).toBe("On my way.");
+ const calls = fetchMock.mock.calls.map(([url]) => String(url));
+ expect(calls.every((url) => url.includes("infernetprotocol.com"))).toBe(true);
+ expect(String(fetchMock.mock.calls[0][1]?.body)).not.toContain("5551234567");
+ });
+
+ it("works on c0mpute alone", async () => {
+ delete process.env.OPENAI_API_KEY;
+ vi.spyOn(global, "fetch").mockImplementation((async (url: string) =>
+ url.endsWith("/api/chat")
+ ? Response.json({ jobId: "j", source: "p2p", model: "m" })
+ : new Response(`event: done\ndata: ${JSON.stringify({ text: "Sure." })}\n\n`)) as typeof fetch);
+
+ expect((await suggestReply({ messages: message })).suggestion).toBe("Sure.");
+ });
+
+ it("falls back to OpenAI, and a dead OpenAI still throws", async () => {
+ vi.spyOn(global, "fetch").mockImplementation((async (url: string) =>
+ url.includes("openai.com")
+ ? new Response("Incorrect API key", { status: 401 })
+ : new Response("{}", { status: 503 })) as typeof fetch);
+
+ await expect(suggestReply({ messages: message })).rejects.toThrow("OpenAI API error: 401");
+ });
+ });
});
diff --git a/src/lib/ai/auto-reply.ts b/src/lib/ai/auto-reply.ts
index e7435890..087173e2 100644
--- a/src/lib/ai/auto-reply.ts
+++ b/src/lib/ai/auto-reply.ts
@@ -1,6 +1,10 @@
/**
- * AI Auto-Reply using OpenAI API (gpt-4o-mini)
+ * AI Auto-Reply: c0mpute first, OpenAI (gpt-4o-mini) behind it.
*/
+import { createAiProviders, type AiRouter } from "@profullstack/ai-providers";
+
+// c0mpute takes 30 s or more, and someone is waiting on the suggestion.
+const C0MPUTE_TIMEOUT_MS = 100_000;
export interface Message {
direction: "inbound" | "outbound";
@@ -18,31 +22,26 @@ export interface SuggestReplyResult {
suggestion: string;
}
-/**
- * Generate a reply suggestion using OpenAI's gpt-4o-mini model.
- */
-export async function suggestReply(
- options: SuggestReplyOptions
-): Promise {
- const apiKey = process.env.OPENAI_API_KEY;
- if (!apiKey) {
- throw new Error("OPENAI_API_KEY is not configured");
- }
-
- const { messages, contactName, contactPhone } = options;
+const c0mputeOn = (): boolean =>
+ Boolean(process.env.C0MPUTE_API_KEY) && !/^(1|true|yes|on)$/i.test(process.env.C0MPUTE_DISABLED ?? "");
- const conversationContext = messages
- .slice(-20) // Last 20 messages for context
- .map(
- (m) =>
- `${m.direction === "inbound" ? (contactName || contactPhone || "Contact") : "You"}: ${m.body}`
- )
- .join("\n");
+let shared: { keys: string; router: AiRouter } | undefined;
- const systemPrompt = `You are an SMS reply assistant. Generate a brief, natural reply suggestion for an SMS conversation. Keep it concise (1-2 sentences max) and appropriate for text messaging. Do not use emojis unless the conversation style uses them. Reply with ONLY the suggested message text, nothing else.`;
-
- const userPrompt = `Here is the recent SMS conversation with ${contactName || contactPhone || "a contact"}:\n\n${conversationContext}\n\nSuggest a reply:`;
+/** One router per set of keys, so a parked provider stays parked across calls. */
+function router(): AiRouter {
+ const keys = `${c0mputeOn()}|${process.env.OPENAI_API_KEY ?? ""}`;
+ if (shared?.keys !== keys) {
+ shared = {
+ keys,
+ router: createAiProviders({
+ env: { ...process.env, C0MPUTE_DISABLED: c0mputeOn() ? "" : "1" },
+ }),
+ };
+ }
+ return shared.router;
+}
+async function openaiReply(apiKey: string, systemPrompt: string, userPrompt: string): Promise {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
@@ -66,11 +65,51 @@ export async function suggestReply(
}
const data = await response.json();
- const suggestion = data.choices?.[0]?.message?.content?.trim();
+ return data.choices?.[0]?.message?.content ?? "";
+}
- if (!suggestion) {
- throw new Error("No suggestion generated");
+/**
+ * Generate a reply suggestion, c0mpute first with OpenAI as the fallback.
+ */
+export async function suggestReply(
+ options: SuggestReplyOptions
+): Promise {
+ const apiKey = process.env.OPENAI_API_KEY;
+ if (!apiKey && !c0mputeOn()) {
+ throw new Error("OPENAI_API_KEY is not configured");
}
+ const { messages, contactName } = options;
+ // Never the phone number: the model has no use for it, and c0mpute nodes are
+ // run by strangers.
+ const contact = contactName || "Contact";
+
+ const conversationContext = messages
+ .slice(-20) // Last 20 messages for context
+ .map((m) => `${m.direction === "inbound" ? contact : "You"}: ${m.body}`)
+ .join("\n");
+
+ const systemPrompt = `You are an SMS reply assistant. Generate a brief, natural reply suggestion for an SMS conversation. Keep it concise (1-2 sentences max) and appropriate for text messaging. Do not use emojis unless the conversation style uses them. Reply with ONLY the suggested message text, nothing else.`;
+
+ const userPrompt = `Here is the recent SMS conversation with ${contactName || "a contact"}:\n\n${conversationContext}\n\nSuggest a reply:`;
+
+ const { value: suggestion } = await router().run(async (provider) => {
+ const text =
+ provider.name === "openai"
+ ? await openaiReply(apiKey as string, systemPrompt, userPrompt)
+ : (
+ await provider.generate({
+ system: systemPrompt,
+ user: userPrompt,
+ maxTokens: 150,
+ temperature: 0.7,
+ timeoutMs: C0MPUTE_TIMEOUT_MS,
+ })
+ ).text;
+ const reply = text.replace(/[\s\S]*?<\/think>/gi, "").trim();
+ if (!reply) throw new Error("No suggestion generated");
+ return reply;
+ });
+
return { suggestion };
}