free tier

This commit is contained in:
Thomas G. Lopes 2025-06-19 13:26:07 +01:00
parent bbc2739832
commit ee2d8bcb6c
4 changed files with 108 additions and 14 deletions

View file

@ -17,6 +17,7 @@ export default defineSchema({
user_settings: defineTable({ user_settings: defineTable({
user_id: v.string(), user_id: v.string(),
privacy_mode: v.boolean(), privacy_mode: v.boolean(),
free_messages_used: v.optional(v.number()),
}).index('by_user', ['user_id']), }).index('by_user', ['user_id']),
user_keys: defineTable({ user_keys: defineTable({
user_id: v.string(), user_id: v.string(),

View file

@ -26,6 +26,41 @@ export const get = query({
}, },
}); });
export const incrementFreeMessageCount = mutation({
args: {
session_token: v.string(),
},
handler: async (ctx, args) => {
const session = await ctx.runQuery(internal.betterAuth.getSession, {
sessionToken: args.session_token,
});
if (!session) {
throw new Error('Invalid session token');
}
const s = session as SessionObj;
const existing = await ctx.db
.query('user_settings')
.withIndex('by_user', (q) => q.eq('user_id', s.userId))
.first();
if (!existing) {
await ctx.db.insert('user_settings', {
user_id: s.userId,
privacy_mode: false,
free_messages_used: 1,
});
} else {
const currentCount = existing.free_messages_used || 0;
await ctx.db.patch(existing._id, {
free_messages_used: currentCount + 1,
});
}
},
});
export const set = mutation({ export const set = mutation({
args: { args: {
privacy_mode: v.boolean(), privacy_mode: v.boolean(),
@ -51,6 +86,7 @@ export const set = mutation({
await ctx.db.insert('user_settings', { await ctx.db.insert('user_settings', {
user_id: s.userId, user_id: s.userId,
privacy_mode: args.privacy_mode, privacy_mode: args.privacy_mode,
free_messages_used: 0,
}); });
} else { } else {
await ctx.db.patch(existing._id, { await ctx.db.patch(existing._id, {
@ -69,6 +105,7 @@ export const create = mutation({
await ctx.db.insert('user_settings', { await ctx.db.insert('user_settings', {
user_id: args.user_id, user_id: args.user_id,
privacy_mode: false, privacy_mode: false,
free_messages_used: 0,
}); });
}, },
}); });

View file

@ -1,4 +1,5 @@
import { PUBLIC_CONVEX_URL } from '$env/static/public'; import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { OPENROUTER_FREE_KEY } from '$env/static/private';
import { api } from '$lib/backend/convex/_generated/api'; import { api } from '$lib/backend/convex/_generated/api';
import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel'; import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel';
import { Provider } from '$lib/types'; import { Provider } from '$lib/types';
@ -74,11 +75,10 @@ async function generateConversationTitle({
return; return;
} }
const key = keyResult.value; const userKey = keyResult.value;
if (!key) { const actualKey = userKey || OPENROUTER_FREE_KEY;
log('Title generation: No API key found', startTime);
return; log(`Title generation: Using ${userKey ? 'user' : 'free tier'} API key`, startTime);
}
// Only generate title if conversation currently has default title // Only generate title if conversation currently has default title
const conversationResult = await ResultAsync.fromPromise( const conversationResult = await ResultAsync.fromPromise(
@ -103,7 +103,7 @@ async function generateConversationTitle({
const openai = new OpenAI({ const openai = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1', baseURL: 'https://openrouter.ai/api/v1',
apiKey: key, apiKey: actualKey,
}); });
// Create a prompt for title generation using only the first user message // Create a prompt for title generation using only the first user message
@ -164,6 +164,7 @@ async function generateAIResponse({
modelResultPromise, modelResultPromise,
keyResultPromise, keyResultPromise,
rulesResultPromise, rulesResultPromise,
userSettingsPromise,
abortSignal, abortSignal,
}: { }: {
conversationId: string; conversationId: string;
@ -172,6 +173,7 @@ async function generateAIResponse({
keyResultPromise: ResultAsync<string | null, string>; keyResultPromise: ResultAsync<string | null, string>;
modelResultPromise: ResultAsync<Doc<'user_enabled_models'> | null, string>; modelResultPromise: ResultAsync<Doc<'user_enabled_models'> | null, string>;
rulesResultPromise: ResultAsync<Doc<'user_rules'>[], string>; rulesResultPromise: ResultAsync<Doc<'user_rules'>[], string>;
userSettingsPromise: ResultAsync<Doc<'user_settings'> | null, string>;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
}) { }) {
log('Starting AI response generation in background', startTime); log('Starting AI response generation in background', startTime);
@ -181,7 +183,7 @@ async function generateAIResponse({
return; return;
} }
const [modelResult, keyResult, messagesQueryResult, rulesResult] = await Promise.all([ const [modelResult, keyResult, messagesQueryResult, rulesResult, userSettingsResult] = await Promise.all([
modelResultPromise, modelResultPromise,
keyResultPromise, keyResultPromise,
ResultAsync.fromPromise( ResultAsync.fromPromise(
@ -192,6 +194,7 @@ async function generateAIResponse({
(e) => `Failed to get messages: ${e}` (e) => `Failed to get messages: ${e}`
), ),
rulesResultPromise, rulesResultPromise,
userSettingsPromise,
]); ]);
if (modelResult.isErr()) { if (modelResult.isErr()) {
@ -278,10 +281,9 @@ async function generateAIResponse({
return; return;
} }
const key = keyResult.value; if (userSettingsResult.isErr()) {
if (!key) {
handleGenerationError({ handleGenerationError({
error: 'No API key found', error: `User settings query failed: ${userSettingsResult.error}`,
conversationId, conversationId,
messageId: mid, messageId: mid,
sessionToken, sessionToken,
@ -290,7 +292,52 @@ async function generateAIResponse({
return; return;
} }
log('Background: API key retrieved successfully', startTime); const userKey = keyResult.value;
const userSettings = userSettingsResult.value;
let actualKey: string;
if (userKey) {
// User has their own API key
actualKey = userKey;
log('Background: Using user API key', startTime);
} else {
// User doesn't have API key, check free tier limit
const freeMessagesUsed = userSettings?.free_messages_used || 0;
if (freeMessagesUsed >= 10) {
handleGenerationError({
error: 'Free message limit reached (10/10). Please add your own OpenRouter API key to continue chatting.',
conversationId,
messageId: mid,
sessionToken,
startTime,
});
return;
}
// Increment free message count before generating
const incrementResult = await ResultAsync.fromPromise(
client.mutation(api.user_settings.incrementFreeMessageCount, {
session_token: sessionToken,
}),
(e) => `Failed to increment free message count: ${e}`
);
if (incrementResult.isErr()) {
handleGenerationError({
error: `Failed to track free message usage: ${incrementResult.error}`,
conversationId,
messageId: mid,
sessionToken,
startTime,
});
return;
}
// Use environment OpenRouter key
actualKey = OPENROUTER_FREE_KEY;
log(`Background: Using free tier (${freeMessagesUsed + 1}/10 messages)`, startTime);
}
if (rulesResult.isErr()) { if (rulesResult.isErr()) {
handleGenerationError({ handleGenerationError({
@ -328,7 +375,7 @@ async function generateAIResponse({
const openai = new OpenAI({ const openai = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1', baseURL: 'https://openrouter.ai/api/v1',
apiKey: key, apiKey: actualKey,
}); });
const formattedMessages = messages.map((m) => { const formattedMessages = messages.map((m) => {
@ -453,7 +500,7 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
(e) => `Failed to render HTML: ${e}` (e) => `Failed to render HTML: ${e}`
); );
const generationStatsResult = await retryResult(() => getGenerationStats(generationId!, key), { const generationStatsResult = await retryResult(() => getGenerationStats(generationId!, actualKey), {
delay: 500, delay: 500,
retries: 2, retries: 2,
startTime, startTime,
@ -594,6 +641,13 @@ export const POST: RequestHandler = async ({ request }) => {
(e) => `Failed to get API key: ${e}` (e) => `Failed to get API key: ${e}`
); );
const userSettingsPromise = ResultAsync.fromPromise(
client.query(api.user_settings.get, {
session_token: sessionToken,
}),
(e) => `Failed to get user settings: ${e}`
);
const rulesResultPromise = ResultAsync.fromPromise( const rulesResultPromise = ResultAsync.fromPromise(
client.query(api.user_rules.all, { client.query(api.user_rules.all, {
session_token: sessionToken, session_token: sessionToken,
@ -688,6 +742,7 @@ export const POST: RequestHandler = async ({ request }) => {
modelResultPromise, modelResultPromise,
keyResultPromise, keyResultPromise,
rulesResultPromise, rulesResultPromise,
userSettingsPromise,
abortSignal: abortController.signal, abortSignal: abortController.signal,
}) })
.catch(async (error) => { .catch(async (error) => {

View file

@ -135,7 +135,8 @@
>! >!
</h2> </h2>
<p class="mt-2 text-left text-lg"> <p class="mt-2 text-left text-lg">
You need to provide a key to start sending messages. You can send some free messages, or provide a key for limitless access.
<br />
<a href="/account/api-keys" class="text-primary"> Go to settings </a> <a href="/account/api-keys" class="text-primary"> Go to settings </a>
</p> </p>
</div> </div>