From e4bb379d7791642688c7fd549a97821aa49e960b Mon Sep 17 00:00:00 2001 From: Aidan Bleser Date: Tue, 17 Jun 2025 13:00:02 -0500 Subject: [PATCH 1/6] use unverified session token to pass to mutations --- src/routes/api/generate-message/+server.ts | 51 +++++++++------------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/src/routes/api/generate-message/+server.ts b/src/routes/api/generate-message/+server.ts index b6d6949..30bec40 100644 --- a/src/routes/api/generate-message/+server.ts +++ b/src/routes/api/generate-message/+server.ts @@ -1,7 +1,6 @@ import { PUBLIC_CONVEX_URL } from '$env/static/public'; import { api } from '$lib/backend/convex/_generated/api'; import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel'; -import type { SessionObj } from '$lib/backend/convex/betterAuth'; import { Provider } from '$lib/types'; import { error, json, type RequestHandler } from '@sveltejs/kit'; import { ConvexHttpClient } from 'convex/browser'; @@ -11,6 +10,7 @@ import { waitUntil } from '@vercel/functions'; import { z } from 'zod/v4'; import type { ChatCompletionSystemMessageParam } from 'openai/resources'; +import { getSessionCookie } from 'better-auth/cookies'; // Set to true to enable debug logging const ENABLE_LOGGING = true; @@ -44,13 +44,13 @@ const client = new ConvexHttpClient(PUBLIC_CONVEX_URL); async function generateConversationTitle({ conversationId, - session, + sessionToken, startTime, keyResultPromise, userMessage, }: { conversationId: string; - session: SessionObj; + sessionToken: string; startTime: number; keyResultPromise: ResultAsync; userMessage: string; @@ -73,7 +73,7 @@ async function generateConversationTitle({ // Only generate title if conversation currently has default title const conversationResult = await ResultAsync.fromPromise( client.query(api.conversations.get, { - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to get conversations: ${e}` ); @@ -134,7 +134,7 @@ Generate only the title based on what the user is asking for, nothing else:`; client.mutation(api.conversations.updateTitle, { conversation_id: conversationId as Id<'conversations'>, title: generatedTitle, - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to update conversation title: ${e}` ); @@ -149,14 +149,14 @@ Generate only the title based on what the user is asking for, nothing else:`; async function generateAIResponse({ conversationId, - session, + sessionToken, startTime, modelResultPromise, keyResultPromise, rulesResultPromise, }: { conversationId: string; - session: SessionObj; + sessionToken: string; startTime: number; keyResultPromise: ResultAsync; modelResultPromise: ResultAsync | null, string>; @@ -170,7 +170,7 @@ async function generateAIResponse({ ResultAsync.fromPromise( client.query(api.messages.getAllFromConversation, { conversation_id: conversationId as Id<'conversations'>, - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to get messages: ${e}` ), @@ -271,7 +271,7 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`, conversation_id: conversationId, content: '', role: 'assistant', - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to create assistant message: ${e}` ); @@ -297,7 +297,7 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`, client.mutation(api.messages.updateContent, { message_id: mid, content, - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to update message content: ${e}` ); @@ -319,7 +319,7 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`, client.mutation(api.conversations.updateGenerating, { conversation_id: conversationId as Id<'conversations'>, generating: false, - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to update generating status: ${e}` ); @@ -360,21 +360,12 @@ export const POST: RequestHandler = async ({ request }) => { log('Schema validation passed', startTime); - const sessionResult = await ResultAsync.fromPromise( - client.query(api.betterAuth.publicGetSession, { - session_token: args.session_token, - }), - (e) => `Failed to get session: ${e}` - ); + const cookie = getSessionCookie(request.headers); - if (sessionResult.isErr()) { - log(`Session query failed: ${sessionResult.error}`, startTime); - return error(401, 'Failed to authenticate'); - } + const sessionToken = cookie?.split('.')[0] ?? null; - const session = sessionResult.value; - if (!session) { - log('No session found - unauthorized', startTime); + if (!sessionToken) { + log(`No session token found`, startTime); return error(401, 'Unauthorized'); } @@ -382,7 +373,7 @@ export const POST: RequestHandler = async ({ request }) => { client.query(api.user_enabled_models.get, { provider: Provider.OpenRouter, model_id: args.model_id, - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to get model: ${e}` ); @@ -390,14 +381,14 @@ export const POST: RequestHandler = async ({ request }) => { const keyResultPromise = ResultAsync.fromPromise( client.query(api.user_keys.get, { provider: Provider.OpenRouter, - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to get API key: ${e}` ); const rulesResultPromise = ResultAsync.fromPromise( client.query(api.user_rules.all, { - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to get rules: ${e}` ); @@ -410,7 +401,7 @@ export const POST: RequestHandler = async ({ request }) => { client.mutation(api.conversations.createAndAddMessage, { content: args.message, role: 'user', - session_token: session.token, + session_token: sessionToken, }), (e) => `Failed to create conversation: ${e}` ); @@ -427,7 +418,7 @@ export const POST: RequestHandler = async ({ request }) => { waitUntil( generateConversationTitle({ conversationId, - session, + sessionToken, startTime, keyResultPromise, userMessage: args.message, @@ -460,7 +451,7 @@ export const POST: RequestHandler = async ({ request }) => { waitUntil( generateAIResponse({ conversationId, - session, + sessionToken, startTime, modelResultPromise, keyResultPromise, From f80d8f6ef746da1b441390fd9e1d6d0fd1bc234f Mon Sep 17 00:00:00 2001 From: Aidan Bleser Date: Tue, 17 Jun 2025 13:16:03 -0500 Subject: [PATCH 2/6] new chat shortcut --- src/routes/+layout.svelte | 6 ++++++ src/routes/account/+layout.svelte | 31 +++++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index ee0dbaa..e4df54b 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -5,6 +5,8 @@ import { PUBLIC_CONVEX_URL } from '$env/static/public'; import { models } from '$lib/state/models.svelte'; import GlobalModal from '$lib/components/ui/modal/global-modal.svelte'; + import { shortcut } from '$lib/actions/shortcut.svelte'; + import { goto } from '$app/navigation'; let { children } = $props(); @@ -12,6 +14,10 @@ models.init(); + goto('/chat') }} +/> + {@render children()} diff --git a/src/routes/account/+layout.svelte b/src/routes/account/+layout.svelte index d937661..10eb590 100644 --- a/src/routes/account/+layout.svelte +++ b/src/routes/account/+layout.svelte @@ -30,6 +30,22 @@ }, ]; + type Shortcut = { + name: string; + keys: string[]; + }; + + const shortcuts: Shortcut[] = [ + { + name: 'Toggle Sidebar', + keys: [cmdOrCtrl, 'B'], + }, + { + name: 'New Chat', + keys: [cmdOrCtrl, 'Shift', 'O'], + }, + ]; + async function signOut() { await authClient.signOut(); @@ -69,14 +85,17 @@
Keyboard Shortcuts
-
- Toggle Sidebar + {#each shortcuts as { name, keys } (name)} +
+ {name} -
- {cmdOrCtrl} - B +
+ {#each keys as key (key)} + {key} + {/each} +
-
+ {/each}
From 86d8e5fdf3ff43142774e7212a39e366f32a56e3 Mon Sep 17 00:00:00 2001 From: Aidan Bleser <117548273+ieedan@users.noreply.github.com> Date: Tue, 17 Jun 2025 15:21:59 -0500 Subject: [PATCH 3/6] feat: Track model used to generate message and display generation cost (#14) --- src/app.css | 18 ++- src/lib/backend/convex/conversations.ts | 27 +++++ src/lib/backend/convex/messages.ts | 31 +++++ src/lib/backend/convex/schema.ts | 3 + src/lib/backend/convex/user_keys.ts | 29 ++++- src/routes/account/api-keys/+page.svelte | 7 +- .../account/api-keys/provider-card.svelte | 1 - src/routes/account/models/+page.svelte | 10 +- src/routes/account/models/model-card.svelte | 5 +- src/routes/api/generate-message/+server.ts | 111 ++++++++++++++++-- src/routes/chat/+layout.server.ts | 11 ++ src/routes/chat/+layout.svelte | 43 +++++-- src/routes/chat/+page.svelte | 22 +++- src/routes/chat/[id]/message.svelte | 17 ++- 14 files changed, 291 insertions(+), 44 deletions(-) create mode 100644 src/routes/chat/+layout.server.ts diff --git a/src/app.css b/src/app.css index 40442ae..b7d4706 100644 --- a/src/app.css +++ b/src/app.css @@ -233,16 +233,14 @@ display: none; /* Chrome, Safari, and Opera */ } -@layer utilities { - .animation-delay-0 { - animation-delay: 0s; - } - .animation-delay-100 { - animation-delay: 0.1s; - } - .animation-delay-200 { - animation-delay: 0.2s; - } +.animation-delay-0 { + animation-delay: 0s; +} +.animation-delay-100 { + animation-delay: 0.1s; +} +.animation-delay-200 { + animation-delay: 0.2s; } @layer components { diff --git a/src/lib/backend/convex/conversations.ts b/src/lib/backend/convex/conversations.ts index 4e5d60b..2cc6913 100644 --- a/src/lib/backend/convex/conversations.ts +++ b/src/lib/backend/convex/conversations.ts @@ -183,6 +183,33 @@ export const updateGenerating = mutation({ }, }); +export const updateCostUsd = mutation({ + args: { + conversation_id: v.id('conversations'), + cost_usd: v.number(), + session_token: v.string(), + }, + handler: async (ctx, args) => { + const session = await ctx.runQuery(api.betterAuth.publicGetSession, { + session_token: args.session_token, + }); + + if (!session) { + throw new Error('Unauthorized'); + } + + // Verify the conversation belongs to the user + const conversation = await ctx.db.get(args.conversation_id); + if (!conversation || conversation.user_id !== session.userId) { + throw new Error('Conversation not found or unauthorized'); + } + + await ctx.db.patch(args.conversation_id, { + cost_usd: (conversation.cost_usd ?? 0) + args.cost_usd, + }); + }, +}); + export const togglePin = mutation({ args: { conversation_id: v.id('conversations'), diff --git a/src/lib/backend/convex/messages.ts b/src/lib/backend/convex/messages.ts index 0c44403..da6708e 100644 --- a/src/lib/backend/convex/messages.ts +++ b/src/lib/backend/convex/messages.ts @@ -109,3 +109,34 @@ export const updateContent = mutation({ }); }, }); + +export const updateMessage = mutation({ + args: { + session_token: v.string(), + message_id: v.string(), + token_count: v.optional(v.number()), + cost_usd: v.optional(v.number()), + generation_id: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const session = await ctx.runQuery(api.betterAuth.publicGetSession, { + session_token: args.session_token, + }); + + if (!session) { + throw new Error('Unauthorized'); + } + + const message = await ctx.db.get(args.message_id as Id<'messages'>); + + if (!message) { + throw new Error('Message not found'); + } + + await ctx.db.patch(message._id, { + token_count: args.token_count, + cost_usd: args.cost_usd, + generation_id: args.generation_id, + }); + }, +}); diff --git a/src/lib/backend/convex/schema.ts b/src/lib/backend/convex/schema.ts index 618e629..64fc342 100644 --- a/src/lib/backend/convex/schema.ts +++ b/src/lib/backend/convex/schema.ts @@ -47,6 +47,7 @@ export default defineSchema({ updated_at: v.optional(v.number()), pinned: v.optional(v.boolean()), generating: v.boolean(), + cost_usd: v.optional(v.number()), }).index('by_user', ['user_id']), messages: defineTable({ conversation_id: v.string(), @@ -56,5 +57,7 @@ export default defineSchema({ model_id: v.optional(v.string()), provider: v.optional(providerValidator), token_count: v.optional(v.number()), + cost_usd: v.optional(v.number()), + generation_id: v.optional(v.string()), }).index('by_conversation', ['conversation_id']), }); diff --git a/src/lib/backend/convex/user_keys.ts b/src/lib/backend/convex/user_keys.ts index ebcdada..a91a1c9 100644 --- a/src/lib/backend/convex/user_keys.ts +++ b/src/lib/backend/convex/user_keys.ts @@ -64,7 +64,6 @@ export const get = query({ export const set = mutation({ args: { provider: providerValidator, - user_id: v.string(), key: v.string(), session_token: v.string(), }, @@ -80,14 +79,36 @@ export const set = mutation({ const existing = await ctx.db .query('user_keys') .withIndex('by_provider_user', (q) => - q.eq('provider', args.provider).eq('user_id', args.user_id) + q.eq('provider', args.provider).eq('user_id', session.userId) ) .first(); + const userKey = { ...args, session_token: undefined, user_id: session.userId }; + if (existing) { - await ctx.db.replace(existing._id, args); + await ctx.db.replace(existing._id, userKey); } else { - await ctx.db.insert('user_keys', args); + await ctx.db.insert('user_keys', userKey); + + if (args.provider === Provider.OpenRouter) { + const defaultModels = [ + 'google/gemini-2.5-flash', + 'anthropic/claude-sonnet-4', + 'openai/o3-mini', + 'deepseek/deepseek-chat-v3-0324:free', + ]; + + await Promise.all( + defaultModels.map((model) => + ctx.db.insert('user_enabled_models', { + user_id: session.userId, + provider: Provider.OpenRouter, + model_id: model, + pinned: null, + }) + ) + ); + } } }, }); diff --git a/src/routes/account/api-keys/+page.svelte b/src/routes/account/api-keys/+page.svelte index 037f394..fc32ccf 100644 --- a/src/routes/account/api-keys/+page.svelte +++ b/src/routes/account/api-keys/+page.svelte @@ -56,7 +56,10 @@
{#each allProviders as provider (provider)} - {@const meta = providersMeta[provider]} - + + {#if provider === Provider.OpenRouter} + {@const meta = providersMeta[provider]} + + {/if} {/each}
diff --git a/src/routes/account/api-keys/provider-card.svelte b/src/routes/account/api-keys/provider-card.svelte index aaf412e..a6242a9 100644 --- a/src/routes/account/api-keys/provider-card.svelte +++ b/src/routes/account/api-keys/provider-card.svelte @@ -42,7 +42,6 @@ const res = await ResultAsync.fromPromise( client.mutation(api.user_keys.set, { provider, - user_id: session.current?.user.id ?? '', key: `${key}`, session_token: session.current?.session.token, }), diff --git a/src/routes/account/models/+page.svelte b/src/routes/account/models/+page.svelte index 003c1ec..768e399 100644 --- a/src/routes/account/models/+page.svelte +++ b/src/routes/account/models/+page.svelte @@ -31,7 +31,15 @@ }); const openRouterModels = $derived( - fuzzysearch({ haystack: models.from(Provider.OpenRouter), needle: search, property: 'name' }) + fuzzysearch({ + haystack: models.from(Provider.OpenRouter), + needle: search, + property: 'name', + }).sort((a, b) => { + if (a.enabled && !b.enabled) return -1; + if (!a.enabled && b.enabled) return 1; + return 0; + }) ); diff --git a/src/routes/account/models/model-card.svelte b/src/routes/account/models/model-card.svelte index 7b995c4..41adea5 100644 --- a/src/routes/account/models/model-card.svelte +++ b/src/routes/account/models/model-card.svelte @@ -50,7 +50,10 @@
- {model.name} +
+ {model.name} + +
enabled, toggleEnabled} {disabled} />
`- ${r.name}: ${r.rule}`).join('\n')}`, const messageCreationResult = await ResultAsync.fromPromise( client.mutation(api.messages.create, { conversation_id: conversationId, + model_id: model.model_id, + provider: Provider.OpenRouter, content: '', role: 'assistant', session_token: sessionToken, @@ -286,6 +288,7 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`, let content = ''; let chunkCount = 0; + let generationId: string | null = null; try { for await (const chunk of stream) { @@ -293,6 +296,8 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`, content += chunk.choices[0]?.delta?.content || ''; if (!content) continue; + generationId = chunk.id; + const updateResult = await ResultAsync.fromPromise( client.mutation(api.messages.updateContent, { message_id: mid, @@ -315,14 +320,43 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`, startTime ); - const updateGeneratingResult = await ResultAsync.fromPromise( - client.mutation(api.conversations.updateGenerating, { - conversation_id: conversationId as Id<'conversations'>, - generating: false, - session_token: sessionToken, - }), - (e) => `Failed to update generating status: ${e}` - ); + if (!generationId) { + log('Background: No generation id found', startTime); + return; + } + + const generationStats = await getGenerationStats(generationId, key); + + log('Background: Got generation stats', startTime); + + const [updateMessageResult, updateGeneratingResult, updateCostUsdResult] = await Promise.all([ + ResultAsync.fromPromise( + client.mutation(api.messages.updateMessage, { + message_id: mid, + token_count: generationStats.tokens_completion, + cost_usd: generationStats.total_cost, + generation_id: generationId, + session_token: sessionToken, + }), + (e) => `Failed to update message: ${e}` + ), + ResultAsync.fromPromise( + client.mutation(api.conversations.updateGenerating, { + conversation_id: conversationId as Id<'conversations'>, + generating: false, + session_token: sessionToken, + }), + (e) => `Failed to update generating status: ${e}` + ), + ResultAsync.fromPromise( + client.mutation(api.conversations.updateCostUsd, { + conversation_id: conversationId as Id<'conversations'>, + cost_usd: generationStats.total_cost, + session_token: sessionToken, + }), + (e) => `Failed to update cost usd: ${e}` + ), + ]); if (updateGeneratingResult.isErr()) { log(`Background generating status update failed: ${updateGeneratingResult.error}`, startTime); @@ -330,6 +364,20 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`, } log('Background: Generating status updated to false', startTime); + + if (updateMessageResult.isErr()) { + log(`Background message update failed: ${updateMessageResult.error}`, startTime); + return; + } + + log('Background: Message updated', startTime); + + if (updateCostUsdResult.isErr()) { + log(`Background cost usd update failed: ${updateCostUsdResult.error}`, startTime); + return; + } + + log('Background: Cost usd updated', startTime); } catch (error) { log(`Background stream processing error: ${error}`, startTime); } @@ -477,3 +525,50 @@ function parseMessageForRules(message: string, rules: Doc<'user_rules'>[]): Doc< return matchedRules; } + +async function getGenerationStats(generationId: string, token: string): Promise { + const generation = await fetch(`https://openrouter.ai/api/v1/generation?id=${generationId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const { data } = await generation.json(); + + return data; +} + +export interface ApiResponse { + data: Data; +} + +export interface Data { + created_at: string; + model: string; + app_id: string | null; + external_user: string | null; + streamed: boolean; + cancelled: boolean; + latency: number; + moderation_latency: number | null; + generation_time: number; + tokens_prompt: number; + tokens_completion: number; + native_tokens_prompt: number; + native_tokens_completion: number; + native_tokens_reasoning: number; + native_tokens_cached: number; + num_media_prompt: number | null; + num_media_completion: number | null; + num_search_results: number | null; + origin: string; + is_byok: boolean; + finish_reason: string; + native_finish_reason: string; + usage: number; + id: string; + upstream_id: string; + total_cost: number; + cache_discount: number | null; + provider_name: string; +} diff --git a/src/routes/chat/+layout.server.ts b/src/routes/chat/+layout.server.ts new file mode 100644 index 0000000..f43adcc --- /dev/null +++ b/src/routes/chat/+layout.server.ts @@ -0,0 +1,11 @@ +import { redirectToLogin } from '$lib/backend/auth/redirect'; + +export async function load({ locals, url }) { + const session = await locals.auth(); + + if (!session) redirectToLogin(url); + + return { + session, + }; +} diff --git a/src/routes/chat/+layout.svelte b/src/routes/chat/+layout.svelte index 907b6e4..e1e0576 100644 --- a/src/routes/chat/+layout.svelte +++ b/src/routes/chat/+layout.svelte @@ -31,6 +31,8 @@ import XIcon from '~icons/lucide/x'; import { callGenerateMessage } from '../api/generate-message/call.js'; import ModelPicker from './model-picker.svelte'; + import { cmdOrCtrl } from '$lib/hooks/is-mac.svelte.js'; + import { Provider } from '$lib/types.js'; const client = useConvexClient(); @@ -65,6 +67,11 @@ session_token: session.current?.session.token ?? '', }); + const openRouterKeyQuery = useCachedQuery(api.user_keys.get, { + provider: Provider.OpenRouter, + session_token: session.current?.session.token ?? '', + }); + const rulesQuery = useCachedQuery(api.user_rules.all, { session_token: session.current?.session.token ?? '', }); @@ -292,13 +299,19 @@ Thom.chat
- - New Chat - + + {#snippet trigger(tooltip)} + + New Chat + + {/snippet} + {cmdOrCtrl} + Shift + O +
- - - + + {#snippet trigger(tooltip)} + + + + {/snippet} + {cmdOrCtrl} + B +