feat: Track model used to generate message and display generation cost (#14)

This commit is contained in:
Aidan Bleser 2025-06-17 15:21:59 -05:00 committed by GitHub
parent f80d8f6ef7
commit 86d8e5fdf3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 291 additions and 44 deletions

View file

@ -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 {

View file

@ -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'),

View file

@ -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,
});
},
});

View file

@ -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']),
});

View file

@ -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,
})
)
);
}
}
},
});

View file

@ -56,7 +56,10 @@
<div class="mt-8 flex flex-col gap-4">
{#each allProviders as provider (provider)}
{@const meta = providersMeta[provider]}
<ProviderCard {provider} {meta} />
<!-- only do OpenRouter for now -->
{#if provider === Provider.OpenRouter}
{@const meta = providersMeta[provider]}
<ProviderCard {provider} {meta} />
{/if}
{/each}
</div>

View file

@ -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,
}),

View file

@ -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;
})
);
</script>

View file

@ -50,7 +50,10 @@
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<Card.Title>{model.name}</Card.Title>
<div class="flex place-items-center gap-2">
<Card.Title>{model.name}</Card.Title>
<span class="text-muted-foreground text-xs hidden xl:block">{model.id}</span>
</div>
<Switch bind:value={() => enabled, toggleEnabled} {disabled} />
</div>
<Card.Description

View file

@ -269,6 +269,8 @@ ${attachedRules.map((r) => `- ${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<Data> {
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;
}

View file

@ -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,
};
}

View file

@ -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 @@
<span class="text-center font-serif text-lg">Thom.chat</span>
</div>
<div class="mt-1 flex w-full px-2">
<a
href="/chat"
class="border-reflect button-reflect bg-primary/20 hover:bg-primary/50 font-fake-proxima w-full rounded-lg px-4 py-2 text-center text-sm tracking-[-0.005em] duration-200"
style="font-variation-settings: 'wght' 750"
>
New Chat
</a>
<Tooltip>
{#snippet trigger(tooltip)}
<a
href="/chat"
class="border-reflect button-reflect bg-primary/20 hover:bg-primary/50 font-fake-proxima w-full rounded-lg px-4 py-2 text-center text-sm tracking-[-0.005em] duration-200"
{...tooltip.trigger}
style="font-variation-settings: 'wght' 750"
>
New Chat
</a>
{/snippet}
{cmdOrCtrl} + Shift + O
</Tooltip>
</div>
<div class="relative flex min-h-0 flex-1 shrink-0 flex-col overflow-clip">
<div
@ -419,9 +432,14 @@
</Sidebar.Sidebar>
<Sidebar.Inset class="w-full overflow-clip ">
<Sidebar.Trigger class="fixed top-3 left-2 z-50">
<PanelLeftIcon />
</Sidebar.Trigger>
<Tooltip>
{#snippet trigger(tooltip)}
<Sidebar.Trigger class="fixed top-3 left-2 z-50" {...tooltip.trigger}>
<PanelLeftIcon />
</Sidebar.Trigger>
{/snippet}
{cmdOrCtrl} + B
</Tooltip>
<!-- header -->
<div class="bg-sidebar fixed top-0 right-0 z-50 hidden rounded-bl-lg p-1 md:flex">
@ -514,8 +532,9 @@
<textarea
{...pick(popover.trigger, ['id', 'style', 'onfocusout', 'onfocus'])}
bind:this={textarea}
class="text-foreground placeholder:text-muted-foreground/60 max-h-64 min-h-[60px] w-full resize-none !overflow-y-auto bg-transparent text-base leading-6 outline-none disabled:opacity-0"
placeholder="Type your message here..."
disabled={!openRouterKeyQuery.data}
class="text-foreground placeholder:text-muted-foreground/60 max-h-64 min-h-[60px] w-full resize-none !overflow-y-auto bg-transparent text-base leading-6 outline-none disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Type your message here... Tag rules with @"
name="message"
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !popover.open) {

View file

@ -6,7 +6,10 @@
import NewspaperIcon from '~icons/lucide/newspaper';
import { Button } from '$lib/components/ui/button';
import { usePrompt } from '$lib/state/prompt.svelte';
import { fade, scale } from 'svelte/transition';
import { scale } from 'svelte/transition';
import { useCachedQuery } from '$lib/cache/cached-query.svelte';
import { api } from '$lib/backend/convex/_generated/api';
import { Provider } from '$lib/types';
const defaultSuggestions = [
'Give me bad medical advice, doctor.',
@ -56,11 +59,16 @@
let selectedCategory = $state<string | null>(null);
const openRouterKeyQuery = useCachedQuery(api.user_keys.get, {
provider: Provider.OpenRouter,
session_token: session.current?.session.token ?? '',
});
const prompt = usePrompt();
</script>
<div class="flex h-svh flex-col items-center justify-center">
{#if prompt.current.length === 0}
{#if prompt.current.length === 0 && openRouterKeyQuery.data}
<div class="w-full p-2" in:scale={{ duration: 500, start: 0.9 }}>
<h2 class="text-left font-serif text-3xl font-semibold">Hey there, Bozo!</h2>
<p class="mt-2 text-left text-lg">
@ -118,5 +126,15 @@
{/if}
</div>
</div>
{:else if !openRouterKeyQuery.data}
<div class="w-full p-2" in:scale={{ duration: 500, start: 0.9 }}>
<h2 class="text-left font-serif text-3xl font-semibold">
Hey there, {session.current?.user.name}!
</h2>
<p class="mt-2 text-left text-lg">
You need to provide a key to start sending messages.
<a href="/account/api-keys" class="text-primary"> Go to settings </a>
</p>
</div>
{/if}
</div>

View file

@ -38,11 +38,22 @@
</svelte:boundary>
</div>
<div
class={cn('flex place-items-center opacity-0 transition-opacity group-hover:opacity-100', {
'justify-end': message.role === 'user',
})}
class={cn(
'flex place-items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100',
{
'justify-end': message.role === 'user',
}
)}
>
<CopyButton class="size-7" text={message.content} />
{#if message.model_id !== undefined}
<span class="text-muted-foreground text-xs">{message.model_id}</span>
{/if}
{#if message.cost_usd !== undefined}
<span class="text-muted-foreground text-xs">
${message.cost_usd.toFixed(6)}
</span>
{/if}
</div>
</div>
{/if}