api keys (#3)

Co-authored-by: Aidan Bleser <aidanbleser35@gmail.com>
This commit is contained in:
Thomas G. Lopes 2025-06-14 12:40:01 +01:00 committed by GitHub
parent 4304e435c9
commit f63c0b0ba0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 366 additions and 143 deletions

View file

@ -1,7 +1,7 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [

View file

@ -28,8 +28,9 @@ IDK, calm down
## TODO
- [-] Login & Auth
- [x] Login & Auth
- [ ] Convex schemas for chats
- [ ] Actual fucking UI for chat
- [ ] Providers (BYOK)
- [ ] Openrouter
- [ ] HuggingFace

View file

@ -18,9 +18,9 @@ export default ts.config(
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
globals: { ...globals.browser, ...globals.node },
},
rules: { 'no-undef': 'off' }
rules: { 'no-undef': 'off' },
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
@ -29,8 +29,8 @@ export default ts.config(
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
svelteConfig,
},
},
}
);

View file

@ -3,7 +3,7 @@ import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: {
command: 'npm run build && npm run preview',
port: 4173
port: 4173,
},
testDir: 'e2e'
testDir: 'e2e',
});

3
src/app.d.ts vendored
View file

@ -1,4 +1,7 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
import type { Session, User } from 'better-auth';
// for information about these interfaces
declare global {
namespace App {

View file

@ -63,7 +63,7 @@ export function active(node: HTMLAnchorElement, opts: Omit<Options, 'url'> = {})
*/
export function attachActive(opts: Omit<Options, 'url'> = {}) {
return {
[createAttachmentKey()]: (node: HTMLAnchorElement) => active(node, opts)
[createAttachmentKey()]: (node: HTMLAnchorElement) => active(node, opts),
};
}

View file

@ -11,8 +11,8 @@ export const auth = betterAuth({
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!
}
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
plugins: []
plugins: [],
});

View file

@ -7,29 +7,29 @@ A query function that takes two arguments looks like:
```ts
// functions.js
import { query } from "./_generated/server";
import { v } from "convex/values";
import { query } from './_generated/server';
import { v } from 'convex/values';
export const myQueryFunction = query({
// Validators for arguments.
args: {
first: v.number(),
second: v.string(),
},
// Validators for arguments.
args: {
first: v.number(),
second: v.string(),
},
// Function implementation.
handler: async (ctx, args) => {
// Read the database as many times as you need here.
// See https://docs.convex.dev/database/reading-data.
const documents = await ctx.db.query("tablename").collect();
// Function implementation.
handler: async (ctx, args) => {
// Read the database as many times as you need here.
// See https://docs.convex.dev/database/reading-data.
const documents = await ctx.db.query('tablename').collect();
// Arguments passed from the client are properties of the args object.
console.log(args.first, args.second);
// Arguments passed from the client are properties of the args object.
console.log(args.first, args.second);
// Write arbitrary JavaScript here: filter, aggregate, build derived data,
// remove non-public properties, or create new objects.
return documents;
},
// Write arbitrary JavaScript here: filter, aggregate, build derived data,
// remove non-public properties, or create new objects.
return documents;
},
});
```
@ -37,8 +37,8 @@ Using this query function in a React component looks like:
```ts
const data = useQuery(api.functions.myQueryFunction, {
first: 10,
second: "hello",
first: 10,
second: 'hello',
});
```
@ -46,27 +46,27 @@ A mutation function looks like:
```ts
// functions.js
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { mutation } from './_generated/server';
import { v } from 'convex/values';
export const myMutationFunction = mutation({
// Validators for arguments.
args: {
first: v.string(),
second: v.string(),
},
// Validators for arguments.
args: {
first: v.string(),
second: v.string(),
},
// Function implementation.
handler: async (ctx, args) => {
// Insert or modify documents in the database here.
// Mutations can also read from the database like queries.
// See https://docs.convex.dev/database/writing-data.
const message = { body: args.first, author: args.second };
const id = await ctx.db.insert("messages", message);
// Function implementation.
handler: async (ctx, args) => {
// Insert or modify documents in the database here.
// Mutations can also read from the database like queries.
// See https://docs.convex.dev/database/writing-data.
const message = { body: args.first, author: args.second };
const id = await ctx.db.insert('messages', message);
// Optionally, return a value from your mutation.
return await ctx.db.get(id);
},
// Optionally, return a value from your mutation.
return await ctx.db.get(id);
},
});
```
@ -75,13 +75,11 @@ Using this mutation function in a React component looks like:
```ts
const mutation = useMutation(api.functions.myMutationFunction);
function handleButtonPress() {
// fire and forget, the most common way to use mutations
mutation({ first: "Hello!", second: "me" });
// OR
// use the result once the mutation has completed
mutation({ first: "Hello!", second: "me" }).then((result) =>
console.log(result),
);
// fire and forget, the most common way to use mutations
mutation({ first: 'Hello!', second: 'me' });
// OR
// use the result once the mutation has completed
mutation({ first: 'Hello!', second: 'me' }).then((result) => console.log(result));
}
```

View file

@ -6,7 +6,7 @@ const { betterAuth, query, insert, update, delete_, count, getSession } = Convex
action,
internalQuery,
internalMutation,
internal
internal,
}) as ConvexReturnType;
export { betterAuth, query, insert, update, delete_, count, getSession };

View file

@ -1,8 +1,15 @@
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
import { Provider } from '../../../lib/types';
export const providerValidator = v.union(...Object.values(Provider).map((p) => v.literal(p)));
export default defineSchema({
user_keys: defineTable({
openRouter: v.string()
provider: providerValidator,
user_id: v.id('users'),
key: v.string(),
})
.index('by_user', ['user_id'])
.index('by_provider_user', ['provider', 'user_id']),
});

View file

@ -1,2 +0,0 @@
import { mutation } from './_generated/server';
import { v } from 'convex/values';

View file

@ -1,25 +1,25 @@
{
/* This TypeScript project config describes the environment that
* Convex functions run in and is used to typecheck them.
* You can modify it, but some settings are required to use Convex.
*/
"compilerOptions": {
/* These settings are not required by Convex and can be modified. */
"allowJs": true,
"strict": true,
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
/* This TypeScript project config describes the environment that
* Convex functions run in and is used to typecheck them.
* You can modify it, but some settings are required to use Convex.
*/
"compilerOptions": {
/* These settings are not required by Convex and can be modified. */
"allowJs": true,
"strict": true,
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
/* These compiler options are required by Convex */
"target": "ESNext",
"lib": ["ES2021", "dom"],
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"isolatedModules": true,
"noEmit": true
},
"include": ["./**/*"],
"exclude": ["./_generated"]
/* These compiler options are required by Convex */
"target": "ESNext",
"lib": ["ES2021", "dom"],
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"isolatedModules": true,
"noEmit": true
},
"include": ["./**/*"],
"exclude": ["./_generated"]
}

View file

@ -0,0 +1,46 @@
import { v } from 'convex/values';
import { Provider } from '../../types';
import { mutation, query } from './_generated/server';
import { providerValidator } from './schema';
export const get = query({
args: {
user_id: v.id('users'),
},
handler: async (ctx, args) => {
const allKeys = await ctx.db
.query('user_keys')
.withIndex('by_user', (q) => q.eq('user_id', args.user_id))
.collect();
return Object.values(Provider).reduce(
(acc, key) => {
acc[key] = allKeys.find((item) => item.provider === key)?.key;
return acc;
},
{} as Record<Provider, string | undefined>
);
},
});
export const set = mutation({
args: {
provider: providerValidator,
user_id: v.id('users'),
key: v.string(),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query('user_keys')
.withIndex('by_provider_user', (q) =>
q.eq('provider', args.provider).eq('user_id', args.user_id)
)
.first();
if (existing) {
await ctx.db.replace(existing._id, args);
} else {
await ctx.db.insert('user_keys', args);
}
},
});

View file

@ -9,7 +9,7 @@ import Root, {
type AnchorElementProps,
type ButtonElementProps,
type ButtonPropsWithoutHTML,
buttonVariants
buttonVariants,
} from './button.svelte';
export {
@ -23,5 +23,5 @@ export {
type ButtonVariant,
type AnchorElementProps,
type ButtonElementProps,
type ButtonPropsWithoutHTML
type ButtonPropsWithoutHTML,
};

View file

@ -1,10 +1,17 @@
<script lang="ts">
<script lang="ts" generics="Tag extends keyof HTMLElementTagNameMap">
import { cn } from '$lib/utils/utils';
import type { HTMLAttributes } from 'svelte/elements';
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
type ElementTag = HTMLElementTagNameMap[Tag];
let {
tag = 'div' as Tag,
class: className,
children,
...restProps
}: HTMLAttributes<ElementTag> & { tag?: Tag } = $props();
</script>
<div class={cn('flex flex-col gap-2', className)} {...restProps}>
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
<svelte:element this={tag} class={cn('flex flex-col gap-2', className)} {...restProps as any}>
{@render children?.()}
</div>
</svelte:element>

View file

@ -11,7 +11,7 @@
<div
{...rest}
class={cn('[--sidebar-width:0px] md:grid md:grid-cols-[var(--sidebar-width)_1fr]', {
'[--sidebar-width:250px]': sidebar.showSidebar
'[--sidebar-width:250px]': sidebar.showSidebar,
})}
>
{@render children?.()}

View file

@ -0,0 +1,8 @@
import { page } from '$app/state';
import type { Session, User } from 'better-auth';
export const session = {
get current() {
return page.data.session as { session: Session; user: User } | null;
},
};

8
src/lib/types.ts Normal file
View file

@ -0,0 +1,8 @@
export const Provider = {
OpenRouter: 'openrouter',
HuggingFace: 'huggingface',
OpenAI: 'openai',
Anthropic: 'anthropic',
} as const;
export type Provider = (typeof Provider)[keyof typeof Provider];

4
src/lib/utils/object.ts Normal file
View file

@ -0,0 +1,4 @@
// typed object.keys
export function keys<T extends object>(obj: T): Array<keyof T> {
return Object.keys(obj) as Array<keyof T>;
}

View file

@ -1,7 +1,9 @@
export async function load({locals}) {
const session = await locals.auth();
import type { LayoutServerLoad } from './$types';
return {
session
}
}
export const load: LayoutServerLoad = async ({ locals }) => {
const session = await locals.auth();
return {
session,
};
};

View file

@ -1,10 +1,12 @@
<script lang="ts">
import { setupConvex } from 'convex-svelte';
import '../app.css';
import { ModeWatcher } from 'mode-watcher';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
let { data, children } = $props();
let { children } = $props();
$inspect(data.session);
setupConvex(PUBLIC_CONVEX_URL);
</script>
<ModeWatcher />

View file

@ -6,6 +6,6 @@ export async function load({ locals, url }) {
if (!session) redirectToLogin(url);
return {
session
session,
};
}

View file

@ -12,20 +12,20 @@
const navigation: { title: string; href: string }[] = [
{
title: 'Account',
href: '/account'
href: '/account',
},
{
title: 'Customization',
href: '/account/customization'
href: '/account/customization',
},
{
title: 'Models',
href: '/account/models'
href: '/account/models',
},
{
title: 'API Keys',
href: '/account/api-keys'
}
href: '/account/api-keys',
},
];
async function signOut() {
@ -49,14 +49,19 @@
<div class="px-4 md:grid md:grid-cols-[280px_1fr]">
<div class="hidden md:col-start-1 md:block">
<div class="flex flex-col place-items-center gap-2">
<Avatar src={data.session.user.image}>
<Avatar src={data.session.user.image ?? undefined}>
{#snippet children(avatar)}
<img {...avatar.image} alt="Your avatar" class="size-40 rounded-full" />
<span {...avatar.fallback}>{data.session.user.name}</span>
<span {...avatar.fallback}>
{data.session.user.name
.split(' ')
.map((i) => i[0].toUpperCase())
.join('')}
</span>
{/snippet}
</Avatar>
<div class="flex flex-col gap-1">
<h1 class="text-center text-2xl font-bold">{data.session.user.name}</h1>
<p class="text-center text-2xl font-bold">{data.session.user.name}</p>
<span class="text-muted-foreground text-center text-sm">{data.session.user.email}</span>
</div>
</div>

View file

@ -4,27 +4,73 @@
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Link } from '$lib/components/ui/link';
import { Provider } from '$lib/types';
import { useConvexClient, useQuery } from 'convex-svelte';
import { api } from '$lib/backend/convex/_generated/api';
import { session } from '$lib/state/session.svelte.js';
import ProviderCard from './provider-card.svelte';
const allProviders = Object.values(Provider);
type ProviderMeta = {
title: string;
link: string;
description: string;
models?: string[];
placeholder?: string;
};
const providersMeta: Record<Provider, ProviderMeta> = {
[Provider.OpenRouter]: {
title: 'OpenRouter',
link: 'https://openrouter.ai/settings/keys',
description: 'API Key for OpenRouter.',
models: ['a shit ton'],
placeholder: 'sk-or-...',
},
[Provider.HuggingFace]: {
title: 'HuggingFace',
link: 'https://huggingface.co/settings/tokens',
description: 'API Key for HuggingFace, for open-source models.',
placeholder: 'hf_...',
},
[Provider.OpenAI]: {
title: 'OpenAI',
link: 'https://platform.openai.com/account/api-keys',
description: 'API Key for OpenAI.',
models: ['gpt-3.5-turbo', 'gpt-4'],
placeholder: 'sk-...',
},
[Provider.Anthropic]: {
title: 'Anthropic',
link: 'https://console.anthropic.com/account/api-keys',
description: 'API Key for Anthropic.',
models: [
'Claude 3.5 Sonnet',
'Claude 3.7 Sonnet',
'Claude 3.7 Sonnet (Reasoning)',
'Claude 4 Opus',
'Claude 4 Sonnet',
'Claude 4 Sonnet (Reasoning)',
],
placeholder: 'sk-ant-...',
},
};
const client = useConvexClient();
const keys = useQuery(api.user_keys.get, { user_id: session.current?.user.id ?? '' });
</script>
<Card.Root>
<Card.Header>
<Card.Title>
<KeyIcon class="inline size-4" /> Open Router
</Card.Title>
<Card.Description>API Key for OpenRouter.</Card.Description>
</Card.Header>
<Card.Content>
<div class="flex flex-col gap-1">
<Input type="password" placeholder="sk-or-..." />
<span class="text-muted-foreground text-xs">
Get your API key from
<Link href="https://openrouter.ai/settings/keys" target="_blank" class="text-blue-500">
OpenRouter
</Link>
</span>
</div>
<div class="flex justify-end">
<Button type="submit">Save</Button>
</div>
</Card.Content>
</Card.Root>
<h1 class="text-2xl font-bold">API Keys</h1>
<h2 class="text-muted-foreground mt-2 text-sm">
Bring your own API keys for select models. Messages sent using your API keys will not count
towards your monthly limits.
</h2>
<div class="mt-8 flex flex-col gap-8">
{#each allProviders as provider (provider)}
{@const meta = providersMeta[provider]}
<ProviderCard {provider} {meta} key={(async () => await keys.data?.[provider])()} />
{/each}
</div>

View file

@ -0,0 +1,88 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { KeyIcon } from '@lucide/svelte';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { Link } from '$lib/components/ui/link';
import { useConvexClient } from 'convex-svelte';
import { api } from '$lib/backend/convex/_generated/api';
import { session } from '$lib/state/session.svelte.js';
import type { Provider } from '$lib/types';
type ProviderMeta = {
title: string;
link: string;
description: string;
models?: string[];
placeholder?: string;
};
type Props = {
provider: Provider;
meta: ProviderMeta;
key: Promise<string>;
};
let { provider, meta, key: keyPromise }: Props = $props();
const client = useConvexClient();
let loading = $state(false);
async function submit(e: SubmitEvent) {
loading = true;
e.preventDefault();
try {
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
const key = formData.get('key');
if (key === null || !session.current?.user.id) return;
const res = await client.mutation(api.user_keys.set, {
provider,
user_id: session.current?.user.id ?? '',
key: `${key}`,
});
// TODO: Setup toast notifications
} catch {
} finally {
loading = false;
}
}
</script>
<Card.Root>
<Card.Header>
<Card.Title>
<KeyIcon class="inline size-4" />
{meta.title}
</Card.Title>
<Card.Description>{meta.description}</Card.Description>
</Card.Header>
<Card.Content tag="form" onsubmit={submit}>
<div class="flex flex-col gap-1">
{#await keyPromise}
<div class="bg-input h-9 animate-pulse rounded-md"></div>
{:then key}
<Input
type="password"
placeholder={meta.placeholder ?? ''}
autocomplete="off"
name="key"
value={key}
/>
{/await}
<span class="text-muted-foreground text-xs">
Get your API key from
<Link href={meta.link} target="_blank" class="text-blue-500">
{meta.title}
</Link>
</span>
</div>
<div class="flex justify-end">
<Button {loading} type="submit">Save</Button>
</div>
</Card.Content>
</Card.Root>

View file

@ -6,12 +6,12 @@
<div class="flex size-full place-items-center justify-center">
<div class="flex w-full max-w-lg flex-col place-items-center gap-1">
<form class="relative w-full h-18">
<form class="relative h-18 w-full">
<textarea
class="border-input bg-background h-full ring-ring ring-offset-background w-full resize-none rounded-lg border p-2 text-sm ring-offset-2 outline-none focus-visible:ring-2"
class="border-input bg-background ring-ring ring-offset-background h-full w-full resize-none rounded-lg border p-2 text-sm ring-offset-2 outline-none focus-visible:ring-2"
placeholder="Ask me anything..."
></textarea>
<Button type="submit" size="icon" class="absolute bottom-1 right-1 size-8">
<Button type="submit" size="icon" class="absolute right-1 bottom-1 size-8">
<SendIcon />
</Button>
</form>

View file

@ -3,7 +3,7 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = {
preprocess: vitePreprocess(),
kit: { adapter: adapter() }
kit: { adapter: adapter() },
};
export default config;

View file

@ -16,8 +16,8 @@ export default defineConfig({
clearMocks: true,
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**'],
setupFiles: ['./vitest-setup-client.ts']
}
setupFiles: ['./vitest-setup-client.ts'],
},
},
{
extends: './vite.config.ts',
@ -25,9 +25,9 @@ export default defineConfig({
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
}
}
]
}
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'],
},
},
],
},
});

View file

@ -11,8 +11,8 @@ Object.defineProperty(window, 'matchMedia', {
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
dispatchEvent: vi.fn(),
})),
});
// add more mocks here if you need them