feat: Track model used to generate message and display generation cost (#14)
This commit is contained in:
parent
f80d8f6ef7
commit
86d8e5fdf3
14 changed files with 291 additions and 44 deletions
18
src/app.css
18
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 {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
11
src/routes/chat/+layout.server.ts
Normal file
11
src/routes/chat/+layout.server.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue