Skip to content
Closed
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
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.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 5 additions & 3 deletions src/app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default async function PrivacyPage() {
<ul className="list-disc list-inside space-y-1 text-gray-300">
<li>Send and receive SMS messages on your behalf through connected providers</li>
<li>Display your conversations, contacts, and message history</li>
<li>Provide AI-powered reply suggestions (when enabled, using OpenAI)</li>
<li>Provide AI-powered reply suggestions (when enabled, using c0mpute, with OpenAI as a fallback)</li>
<li>Send push notifications for new messages</li>
<li>Improve the platform and fix bugs</li>
<li>Communicate with you about your account and service updates</li>
Expand All @@ -60,7 +60,8 @@ export default async function PrivacyPage() {
</p>
<p>
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).
</p>
<p>
No mobile information will be shared with third parties or affiliates for marketing or
Expand All @@ -73,7 +74,8 @@ export default async function PrivacyPage() {
<p>{name} integrates with the following third-party services:</p>
<ul className="list-disc list-inside space-y-1 text-gray-300">
<li><strong>Twilio / Telnyx / phonenumbers.bot:</strong> SMS sending and receiving</li>
<li><strong>OpenAI:</strong> AI reply suggestions (opt-in, per conversation)</li>
<li><strong>c0mpute:</strong> 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.</li>
<li><strong>OpenAI:</strong> Fallback for AI reply suggestions when c0mpute is unavailable</li>
<li><strong>Supabase:</strong> Database, authentication, and real-time messaging</li>
<li><strong>Sentry:</strong> Error tracking and performance monitoring</li>
<li><strong>Expo:</strong> Mobile app push notifications</li>
Expand Down
50 changes: 47 additions & 3 deletions src/lib/ai/__tests__/auto-reply.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -80,4 +83,45 @@
})
).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: "<think>hm</think>On 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");
});
});
});
91 changes: 65 additions & 26 deletions src/lib/ai/auto-reply.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<SuggestReplyResult> {
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<string> {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Expand All @@ -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<SuggestReplyResult> {
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(/<think>[\s\S]*?<\/think>/gi, "").trim();
if (!reply) throw new Error("No suggestion generated");
return reply;
});

return { suggestion };
}
Loading