feat: Preliminary support for kepler-ai-sdk

This commit is contained in:
Aunali321 2025-08-30 20:52:03 +05:30
parent f8f6748bec
commit 071e1016b1
16 changed files with 2233 additions and 557 deletions

View file

@ -13,4 +13,11 @@ GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
OPENROUTER_FREE_KEY= # Optional: Development API keys for testing (not required for production)
# Users will provide their own API keys through the settings interface
DEV_OPENAI_API_KEY=
DEV_ANTHROPIC_API_KEY=
DEV_GEMINI_API_KEY=
DEV_MISTRAL_API_KEY=
DEV_COHERE_API_KEY=
DEV_OPENROUTER_API_KEY=

1
.gitignore vendored
View file

@ -25,3 +25,4 @@ vite.config.ts.timestamp-*
.aider* .aider*
src/lib/backend/convex/_generated src/lib/backend/convex/_generated
tmp/

View file

@ -32,10 +32,10 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
### 🤖 **AI & Models** ### 🤖 **AI & Models**
- **400+ AI Models** via OpenRouter integration - **Multiple AI Providers** - OpenAI, Anthropic, Google Gemini, Mistral, Cohere, OpenRouter
- **Free Tier** with 10 messages using premium models - **600+ AI Models** across all providers
- **Unlimited Free Models** (models ending in `:free`) - **Bring Your Own API Keys** - Users must provide their own API keys
- **Bring Your Own Key** for unlimited access - **No Usage Limits** - Use any model without restrictions when you have the API key
### 💬 **Chat Experience** ### 💬 **Chat Experience**
@ -79,7 +79,7 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
- 🔧 Convex Database - 🔧 Convex Database
- 🔐 BetterAuth - 🔐 BetterAuth
- 🤖 OpenRouter API - 🤖 Kepler AI SDK (Multi-provider support)
- 🦾 Blood, sweat, and tears - 🦾 Blood, sweat, and tears
</td> </td>
@ -92,7 +92,7 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
- Node.js 18+ - Node.js 18+
- pnpm (recommended) - pnpm (recommended)
- OpenRouter API key (optional for free tier) - At least one AI provider API key (OpenAI, Anthropic, Gemini, etc.)
### Installation ### Installation
@ -129,16 +129,28 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
## 🎮 Usage ## 🎮 Usage
### Free Tier ### Getting Started
- Sign up and get **10 free messages** with premium models 1. **Sign up** for a free account
- Use **unlimited free models** (ending in `:free`) 2. **Add API Keys** - Go to Settings and add API keys for the providers you want to use:
- No credit card required - **OpenAI** - GPT models, DALL-E, Whisper
- **Anthropic** - Claude models
- **Google Gemini** - Gemini models and vision
- **Mistral** - Mistral models and embeddings
- **Cohere** - Command models and embeddings
- **OpenRouter** - Access to 300+ models
3. **Start Chatting** - Select any model from your enabled providers
### Premium Features ### Supported Providers
- Add your own OpenRouter API key for unlimited access | Provider | Models | Streaming | Tools | Vision | Embeddings |
- Access to all 400+ models |----------|---------|-----------|-------|--------|------------|
| OpenAI | GPT-4, o3-mini, DALL-E, TTS | ✅ | ✅ | ✅ | ✅ |
| Anthropic | Claude 4, Claude 3.5 Sonnet | ✅ | ✅ | ✅ | ❌ |
| Google Gemini | Gemini 2.5 Pro, Imagen | ✅ | ✅ | ✅ | ✅ |
| Mistral | Mistral Large, Mistral Embed | ✅ | ✅ | ❌ | ✅ |
| Cohere | Command A, Command R+ | ✅ | ✅ | ❌ | ✅ |
| OpenRouter | 300+ models | ✅ | ✅ | ✅ | ❌ |
## 🤝 Contributing ## 🤝 Contributing
@ -158,7 +170,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- Inspired by [T3 Chat](https://t3.chat/) - Inspired by [T3 Chat](https://t3.chat/)
- Built with [SvelteKit](https://kit.svelte.dev/) - Built with [SvelteKit](https://kit.svelte.dev/)
- Powered by [OpenRouter](https://openrouter.ai/) - Powered by [Kepler AI SDK](https://deepwiki.com/keplersystems/kepler-ai-sdk)
- Database by [Convex](https://convex.dev/) - Database by [Convex](https://convex.dev/)
--- ---

1496
bun.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -48,6 +48,10 @@
"melt": "^0.38.0", "melt": "^0.38.0",
"mode-watcher": "^1.0.8", "mode-watcher": "^1.0.8",
"neverthrow": "^8.2.0", "neverthrow": "^8.2.0",
"@anthropic-ai/sdk": "^0.29.0",
"@google/generative-ai": "^0.21.0",
"@mistralai/mistralai": "^1.1.0",
"cohere-ai": "^7.14.0",
"openai": "^5.5.1", "openai": "^5.5.1",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
@ -83,6 +87,7 @@
"@fontsource-variable/nunito-sans": "^5.2.6", "@fontsource-variable/nunito-sans": "^5.2.6",
"@fontsource-variable/open-sans": "^5.2.6", "@fontsource-variable/open-sans": "^5.2.6",
"@fontsource/instrument-serif": "^5.2.6", "@fontsource/instrument-serif": "^5.2.6",
"@keplersystems/kepler-ai-sdk": "^1.0.5",
"better-auth": "^1.2.9", "better-auth": "^1.2.9",
"convex-helpers": "^0.1.94", "convex-helpers": "^0.1.94",
"hastscript": "^9.0.1", "hastscript": "^9.0.1",

View file

@ -22,7 +22,6 @@ 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

@ -89,38 +89,6 @@ export const set = mutation({
await ctx.db.replace(existing._id, userKey); await ctx.db.replace(existing._id, userKey);
} else { } else {
await ctx.db.insert('user_keys', userKey); 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(async (model) => {
const existing = await ctx.db
.query('user_enabled_models')
.withIndex('by_model_provider_user', (q) =>
q
.eq('model_id', model)
.eq('provider', Provider.OpenRouter)
.eq('user_id', session.userId)
)
.first();
if (existing) return;
await ctx.db.insert('user_enabled_models', {
user_id: session.userId,
provider: Provider.OpenRouter,
model_id: model,
pinned: true,
});
})
);
}
} }
}, },
}); });

View file

@ -26,41 +26,6 @@ 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(),
@ -86,7 +51,6 @@ 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, {
@ -105,7 +69,6 @@ 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

@ -0,0 +1,120 @@
import {
ModelManager,
OpenAIProvider,
AnthropicProvider,
GeminiProvider,
MistralProvider,
CohereProvider,
OpenRouterProvider,
type ProviderAdapter,
type ModelInfo,
} from '@keplersystems/kepler-ai-sdk';
import type { Provider } from '$lib/types';
export interface ProviderConfig {
apiKey: string;
baseURL?: string;
}
export interface UserApiKeys {
openai?: string;
anthropic?: string;
gemini?: string;
mistral?: string;
cohere?: string;
openrouter?: string;
}
export class ChatModelManager {
private modelManager: ModelManager;
private enabledProviders: Map<Provider, ProviderAdapter> = new Map();
constructor() {
this.modelManager = new ModelManager();
}
initializeProviders(userApiKeys: UserApiKeys): void {
this.enabledProviders.clear();
if (userApiKeys.openai) {
const provider = new OpenAIProvider({
apiKey: userApiKeys.openai,
});
this.modelManager.addProvider(provider);
this.enabledProviders.set('openai', provider);
}
if (userApiKeys.anthropic) {
const provider = new AnthropicProvider({
apiKey: userApiKeys.anthropic,
});
this.modelManager.addProvider(provider);
this.enabledProviders.set('anthropic', provider);
}
if (userApiKeys.gemini) {
const provider = new GeminiProvider({
apiKey: userApiKeys.gemini,
});
this.modelManager.addProvider(provider);
this.enabledProviders.set('gemini', provider);
}
if (userApiKeys.mistral) {
const provider = new MistralProvider({
apiKey: userApiKeys.mistral,
});
this.modelManager.addProvider(provider);
this.enabledProviders.set('mistral', provider);
}
if (userApiKeys.cohere) {
const provider = new CohereProvider({
apiKey: userApiKeys.cohere,
});
this.modelManager.addProvider(provider);
this.enabledProviders.set('cohere', provider);
}
if (userApiKeys.openrouter) {
const provider = new OpenRouterProvider({
apiKey: userApiKeys.openrouter,
});
this.modelManager.addProvider(provider);
this.enabledProviders.set('openrouter', provider);
}
}
async getModel(modelId: string): Promise<ModelInfo | null> {
return await this.modelManager.getModel(modelId);
}
getProvider(providerName: string): ProviderAdapter | undefined {
return this.modelManager.getProvider(providerName);
}
async listAvailableModels(): Promise<ModelInfo[]> {
return await this.modelManager.listModels();
}
async getModelsByCapability(capability: string): Promise<ModelInfo[]> {
const allModels = await this.listAvailableModels();
return allModels.filter(model => model.capabilities[capability as keyof typeof model.capabilities]);
}
hasProviderEnabled(provider: Provider): boolean {
return this.enabledProviders.has(provider);
}
getEnabledProviders(): Provider[] {
return Array.from(this.enabledProviders.keys());
}
isModelAvailable(modelId: string): Promise<boolean> {
return this.getModel(modelId).then(model => model !== null);
}
}
export const createModelManager = (): ChatModelManager => {
return new ChatModelManager();
};

View file

@ -1,10 +1,12 @@
import { z } from 'zod'; import { z } from 'zod';
export const Provider = { export const Provider = {
OpenRouter: 'openrouter',
HuggingFace: 'huggingface',
OpenAI: 'openai', OpenAI: 'openai',
Anthropic: 'anthropic', Anthropic: 'anthropic',
Gemini: 'gemini',
Mistral: 'mistral',
Cohere: 'cohere',
OpenRouter: 'openrouter',
} as const; } as const;
export type Provider = (typeof Provider)[keyof typeof Provider]; export type Provider = (typeof Provider)[keyof typeof Provider];
@ -13,8 +15,13 @@ export type ProviderMeta = {
title: string; title: string;
link: string; link: string;
description: string; description: string;
models?: string[]; apiKeyName: string;
placeholder?: string; placeholder: string;
docsLink: string;
supportsStreaming: boolean;
supportsTools: boolean;
supportsVision: boolean;
supportsEmbeddings: boolean;
}; };
export const UrlCitationSchema = z.object({ export const UrlCitationSchema = z.object({
@ -34,3 +41,78 @@ export type UrlCitation = z.infer<typeof UrlCitationSchema>;
// export const AnnotationSchema = z.union([UrlCitationSchema, ...]); // export const AnnotationSchema = z.union([UrlCitationSchema, ...]);
export const AnnotationSchema = UrlCitationSchema; export const AnnotationSchema = UrlCitationSchema;
export type Annotation = z.infer<typeof AnnotationSchema>; export type Annotation = z.infer<typeof AnnotationSchema>;
export const PROVIDER_META: Record<Provider, ProviderMeta> = {
[Provider.OpenAI]: {
title: 'OpenAI',
link: 'https://openai.com',
description: 'GPT models, DALL-E, and Whisper from OpenAI',
apiKeyName: 'OpenAI API Key',
placeholder: 'sk-...',
docsLink: 'https://platform.openai.com/docs',
supportsStreaming: true,
supportsTools: true,
supportsVision: true,
supportsEmbeddings: true,
},
[Provider.Anthropic]: {
title: 'Anthropic',
link: 'https://anthropic.com',
description: 'Claude models from Anthropic',
apiKeyName: 'Anthropic API Key',
placeholder: 'sk-ant-...',
docsLink: 'https://docs.anthropic.com',
supportsStreaming: true,
supportsTools: true,
supportsVision: true,
supportsEmbeddings: false,
},
[Provider.Gemini]: {
title: 'Google Gemini',
link: 'https://cloud.google.com/vertex-ai',
description: 'Gemini models from Google',
apiKeyName: 'Google AI API Key',
placeholder: 'AIza...',
docsLink: 'https://ai.google.dev/docs',
supportsStreaming: true,
supportsTools: true,
supportsVision: true,
supportsEmbeddings: true,
},
[Provider.Mistral]: {
title: 'Mistral',
link: 'https://mistral.ai',
description: 'Mistral models and embeddings',
apiKeyName: 'Mistral API Key',
placeholder: 'mistral-...',
docsLink: 'https://docs.mistral.ai',
supportsStreaming: true,
supportsTools: true,
supportsVision: false,
supportsEmbeddings: true,
},
[Provider.Cohere]: {
title: 'Cohere',
link: 'https://cohere.com',
description: 'Command models and embeddings from Cohere',
apiKeyName: 'Cohere API Key',
placeholder: 'co_...',
docsLink: 'https://docs.cohere.com',
supportsStreaming: true,
supportsTools: true,
supportsVision: false,
supportsEmbeddings: true,
},
[Provider.OpenRouter]: {
title: 'OpenRouter',
link: 'https://openrouter.ai',
description: 'Access to 300+ models through OpenRouter',
apiKeyName: 'OpenRouter API Key',
placeholder: 'sk-or-...',
docsLink: 'https://openrouter.ai/docs',
supportsStreaming: true,
supportsTools: true,
supportsVision: true,
supportsEmbeddings: false,
},
};

View file

@ -1,34 +1,212 @@
import { Result, ResultAsync } from 'neverthrow'; import { Result, ResultAsync } from 'neverthrow';
import { Provider, PROVIDER_META } from '$lib/types';
export type OpenRouterApiKeyData = { export type ProviderApiKeyData = {
label: string; label: string;
usage: number; usage?: number;
is_free_tier: boolean; is_free_tier?: boolean;
is_provisioning_key: boolean; is_provisioning_key?: boolean;
limit: number; limit?: number;
limit_remaining: number; limit_remaining?: number;
valid: boolean;
}; };
export const OpenRouter = { export const ProviderUtils = {
getApiKey: async (key: string): Promise<Result<OpenRouterApiKeyData, string>> => { /**
* Validate an API key for a specific provider
*/
validateApiKey: async (provider: Provider, key: string): Promise<Result<ProviderApiKeyData, string>> => {
switch (provider) {
case Provider.OpenRouter:
return await validateOpenRouterKey(key);
case Provider.OpenAI:
return await validateOpenAIKey(key);
case Provider.Anthropic:
return await validateAnthropicKey(key);
case Provider.Gemini:
return await validateGeminiKey(key);
case Provider.Mistral:
return await validateMistralKey(key);
case Provider.Cohere:
return await validateCohereKey(key);
default:
return Result.err(`Validation not implemented for provider: ${provider}`);
}
},
/**
* Get provider metadata
*/
getProviderMeta: (provider: Provider) => {
return PROVIDER_META[provider];
},
/**
* Check if a provider is supported
*/
isProviderSupported: (provider: string): provider is Provider => {
return Object.values(Provider).includes(provider as Provider);
},
/**
* Get all supported providers
*/
getSupportedProviders: () => {
return Object.values(Provider);
},
};
// Provider-specific validation functions
async function validateOpenRouterKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise( return await ResultAsync.fromPromise(
(async () => { (async () => {
const res = await fetch('https://openrouter.ai/api/v1/key', { const res = await fetch('https://openrouter.ai/api/v1/auth/key', {
headers: { headers: {
Authorization: `Bearer ${key}`, Authorization: `Bearer ${key}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
if (!res.ok) throw new Error('Failed to get API key'); if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const { data } = await res.json(); const { data } = await res.json();
if (!data) throw new Error('No info returned for api key'); if (!data) {
throw new Error('No key information returned');
}
return data as OpenRouterApiKeyData; return {
label: data.label || 'OpenRouter API Key',
usage: data.usage,
is_free_tier: data.is_free_tier,
is_provisioning_key: data.is_provisioning_key,
limit: data.limit,
limit_remaining: data.limit_remaining,
valid: true,
};
})(), })(),
(e) => `Failed to get API key ${e}` (e) => `Failed to validate OpenRouter API key: ${e}`
); );
}
async function validateOpenAIKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch('https://api.openai.com/v1/models', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
}, },
}; });
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return {
label: 'OpenAI API Key',
valid: true,
};
})(),
(e) => `Failed to validate OpenAI API key: ${e}`
);
}
async function validateAnthropicKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
// Anthropic doesn't have a simple key validation endpoint
// We'll try a minimal request to test the key
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': key,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'claude-3-haiku-20240307',
max_tokens: 1,
messages: [{ role: 'user', content: 'test' }],
}),
});
// Even a 400 error means the key is valid (just bad request format)
if (res.status === 401 || res.status === 403) {
throw new Error('Invalid API key');
}
return {
label: 'Anthropic API Key',
valid: true,
};
})(),
(e) => `Failed to validate Anthropic API key: ${e}`
);
}
async function validateGeminiKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${key}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return {
label: 'Google Gemini API Key',
valid: true,
};
})(),
(e) => `Failed to validate Gemini API key: ${e}`
);
}
async function validateMistralKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch('https://api.mistral.ai/v1/models', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return {
label: 'Mistral API Key',
valid: true,
};
})(),
(e) => `Failed to validate Mistral API key: ${e}`
);
}
async function validateCohereKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch('https://api.cohere.ai/v1/models', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return {
label: 'Cohere API Key',
valid: true,
};
})(),
(e) => `Failed to validate Cohere API key: ${e}`
);
}

View file

@ -1,45 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Provider, type ProviderMeta } from '$lib/types'; import { Provider, PROVIDER_META } from '$lib/types';
import ProviderCard from './provider-card.svelte'; import ProviderCard from './provider-card.svelte';
const allProviders = Object.values(Provider); const allProviders = Object.values(Provider);
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-...',
},
};
</script> </script>
<svelte:head> <svelte:head>
@ -49,17 +12,13 @@
<div> <div>
<h1 class="text-2xl font-bold">API Keys</h1> <h1 class="text-2xl font-bold">API Keys</h1>
<h2 class="text-muted-foreground mt-2 text-sm"> <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 Add your API keys to access models from different AI providers. You need at least one API key to use the chat.
towards your monthly limits.
</h2> </h2>
</div> </div>
<div class="mt-8 flex flex-col gap-4"> <div class="mt-8 flex flex-col gap-4">
{#each allProviders as provider (provider)} {#each allProviders as provider (provider)}
<!-- only do OpenRouter for now --> {@const meta = PROVIDER_META[provider]}
{#if provider === Provider.OpenRouter}
{@const meta = providersMeta[provider]}
<ProviderCard {provider} {meta} /> <ProviderCard {provider} {meta} />
{/if}
{/each} {/each}
</div> </div>

View file

@ -12,7 +12,7 @@
import { useConvexClient } from 'convex-svelte'; import { useConvexClient } from 'convex-svelte';
import { ResultAsync } from 'neverthrow'; import { ResultAsync } from 'neverthrow';
import { resource } from 'runed'; import { resource } from 'runed';
import * as providers from '$lib/utils/providers'; import { ProviderUtils } from '$lib/utils/providers';
type Props = { type Props = {
provider: Provider; provider: Provider;
@ -65,11 +65,8 @@
async (key) => { async (key) => {
if (!key) return null; if (!key) return null;
if (provider === Provider.OpenRouter) { const result = await ProviderUtils.validateApiKey(provider, key);
return (await providers.OpenRouter.getApiKey(key)).unwrapOr(null); return result.unwrapOr(null);
}
return null;
} }
); );
</script> </script>
@ -99,11 +96,17 @@
{#if apiKeyInfoResource.loading} {#if apiKeyInfoResource.loading}
<div class="bg-input h-6 w-[200px] animate-pulse rounded-md"></div> <div class="bg-input h-6 w-[200px] animate-pulse rounded-md"></div>
{:else if apiKeyInfoResource.current} {:else if apiKeyInfoResource.current}
{#if apiKeyInfoResource.current.usage !== undefined && apiKeyInfoResource.current.limit_remaining !== undefined}
<span class="text-muted-foreground flex h-6 place-items-center text-xs"> <span class="text-muted-foreground flex h-6 place-items-center text-xs">
${apiKeyInfoResource.current?.usage.toFixed(3)} used ・ ${apiKeyInfoResource.current?.limit_remaining.toFixed( ${apiKeyInfoResource.current.usage.toFixed(3)} used ・ ${apiKeyInfoResource.current.limit_remaining.toFixed(
3 3
)} remaining )} remaining
</span> </span>
{:else}
<span class="text-muted-foreground flex h-6 place-items-center text-xs">
✅ API key is valid
</span>
{/if}
{:else} {:else}
<span <span
class="flex h-6 w-fit place-items-center rounded-lg bg-red-500/50 px-2 text-xs text-red-500" class="flex h-6 w-fit place-items-center rounded-lg bg-red-500/50 px-2 text-xs text-red-500"

View file

@ -1,15 +1,12 @@
import { error, json, type RequestHandler } from '@sveltejs/kit'; import { error, json, type RequestHandler } from '@sveltejs/kit';
import { ResultAsync } from 'neverthrow'; import { ResultAsync } from 'neverthrow';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
import { OPENROUTER_FREE_KEY } from '$env/static/private';
import { OpenAI } from 'openai';
import { ConvexHttpClient } from 'convex/browser'; import { ConvexHttpClient } from 'convex/browser';
import { PUBLIC_CONVEX_URL } from '$env/static/public'; import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { api } from '$lib/backend/convex/_generated/api'; import { api } from '$lib/backend/convex/_generated/api';
import { parseMessageForRules } from '$lib/utils/rules'; import { parseMessageForRules } from '$lib/utils/rules';
import { Provider } from '$lib/types'; import { createModelManager } from '$lib/services/model-manager';
import type { UserApiKeys } from '$lib/services/model-manager';
const FREE_MODEL = 'google/gemma-3-27b-it';
const reqBodySchema = z.object({ const reqBodySchema = z.object({
prompt: z.string(), prompt: z.string(),
@ -31,6 +28,29 @@ function response({ enhanced_prompt }: { enhanced_prompt: string }) {
}); });
} }
async function getUserApiKeys(sessionToken: string): Promise<UserApiKeys | null> {
const keysResult = await ResultAsync.fromPromise(
client.query(api.user_keys.all, {
session_token: sessionToken,
}),
(e) => `Failed to get user API keys: ${e}`
);
if (keysResult.isErr()) {
return null;
}
const keys = keysResult.value;
return {
openai: keys.openai,
anthropic: keys.anthropic,
gemini: keys.gemini,
mistral: keys.mistral,
cohere: keys.cohere,
openrouter: keys.openrouter,
};
}
export const POST: RequestHandler = async ({ request, locals }) => { export const POST: RequestHandler = async ({ request, locals }) => {
const bodyResult = await ResultAsync.fromPromise( const bodyResult = await ResultAsync.fromPromise(
request.json(), request.json(),
@ -53,39 +73,60 @@ export const POST: RequestHandler = async ({ request, locals }) => {
return error(401, 'You must be logged in to enhance a prompt'); return error(401, 'You must be logged in to enhance a prompt');
} }
const [rulesResult, keyResult] = await Promise.all([ // Get user API keys
ResultAsync.fromPromise( const userApiKeys = await getUserApiKeys(session.session.token);
if (!userApiKeys) {
return error(500, 'Failed to get user API keys');
}
const hasAnyKey = Object.values(userApiKeys).some((key) => key);
if (!hasAnyKey) {
return error(
400,
'No API keys configured. Please add at least one provider API key in settings to enhance prompts.'
);
}
// Get user rules for context
const rulesResult = await ResultAsync.fromPromise(
client.query(api.user_rules.all, { client.query(api.user_rules.all, {
session_token: session.session.token, session_token: session.session.token,
}), }),
(e) => `Failed to get rules: ${e}` (e) => `Failed to get rules: ${e}`
), );
ResultAsync.fromPromise(
client.query(api.user_keys.get, {
provider: Provider.OpenRouter,
session_token: session.session.token,
}),
(e) => `Failed to get API key: ${e}`
),
]);
if (rulesResult.isErr()) { if (rulesResult.isErr()) {
return error(500, 'Failed to get rules'); return error(500, 'Failed to get rules');
} }
if (keyResult.isErr()) {
return error(500, 'Failed to get key');
}
const mentionedRules = parseMessageForRules( const mentionedRules = parseMessageForRules(
args.prompt, args.prompt,
rulesResult.value.filter((r) => r.attach === 'manual') rulesResult.value.filter((r) => r.attach === 'manual')
); );
const openai = new OpenAI({ // Initialize model manager with user's API keys
baseURL: 'https://openrouter.ai/api/v1', const modelManager = createModelManager();
apiKey: keyResult.value ?? OPENROUTER_FREE_KEY, modelManager.initializeProviders(userApiKeys);
});
// Try to find a fast, cheap model for prompt enhancement
const availableModels = await modelManager.listAvailableModels();
const enhanceModel =
availableModels.find(
(model) =>
model.id.includes('kimi-k2') ||
model.id.includes('gemini-2.5-flash-lite') ||
model.id.includes('gpt-5-mini') ||
model.id.includes('mistral-small')
) || availableModels[0];
if (!enhanceModel) {
return error(500, 'No suitable models available for prompt enhancement');
}
const provider = modelManager.getProvider(enhanceModel.provider);
if (!provider) {
return error(500, `Provider ${enhanceModel.provider} not available`);
}
const enhancePrompt = ` const enhancePrompt = `
Enhance prompt below (wrapped in <prompt> tags) so that it can be better understood by LLMs You job is not to answer the prompt but simply prepare it to be answered by another LLM. Enhance prompt below (wrapped in <prompt> tags) so that it can be better understood by LLMs You job is not to answer the prompt but simply prepare it to be answered by another LLM.
@ -107,23 +148,24 @@ ${args.prompt}
`; `;
const enhancedResult = await ResultAsync.fromPromise( const enhancedResult = await ResultAsync.fromPromise(
openai.chat.completions.create({ provider.generateCompletion({
model: FREE_MODEL, model: enhanceModel.id,
messages: [{ role: 'user', content: enhancePrompt }], messages: [{ role: 'user', content: enhancePrompt }],
temperature: 0.5, temperature: 0.5,
maxTokens: 1000,
}), }),
(e) => `Enhance prompt API call failed: ${e}` (e) => `Enhance prompt API call failed: ${e}`
); );
if (enhancedResult.isErr()) { if (enhancedResult.isErr()) {
return error(500, 'error enhancing the prompt'); return error(500, 'Error enhancing the prompt');
} }
const enhancedResponse = enhancedResult.value; const enhancedResponse = enhancedResult.value;
const enhanced = enhancedResponse.choices[0]?.message?.content; const enhanced = enhancedResponse.content?.trim();
if (!enhanced) { if (!enhanced) {
return error(500, 'error enhancing the prompt'); return error(500, 'Error enhancing the prompt');
} }
return response({ return response({

View file

@ -1,5 +1,4 @@
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, type Annotation } from '$lib/types'; import { Provider, type Annotation } from '$lib/types';
@ -8,12 +7,13 @@ import { waitUntil } from '@vercel/functions';
import { getSessionCookie } from 'better-auth/cookies'; import { getSessionCookie } from 'better-auth/cookies';
import { ConvexHttpClient } from 'convex/browser'; import { ConvexHttpClient } from 'convex/browser';
import { err, ok, Result, ResultAsync } from 'neverthrow'; import { err, ok, Result, ResultAsync } from 'neverthrow';
import OpenAI from 'openai';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
import { generationAbortControllers } from './cache.js'; import { generationAbortControllers } from './cache.js';
import { md } from '$lib/utils/markdown-it.js'; import { md } from '$lib/utils/markdown-it.js';
import * as array from '$lib/utils/array'; import * as array from '$lib/utils/array';
import { parseMessageForRules } from '$lib/utils/rules.js'; import { parseMessageForRules } from '$lib/utils/rules.js';
import { createModelManager, type ChatModelManager } from '$lib/services/model-manager.js';
import type { UserApiKeys } from '$lib/services/model-manager.js';
// Set to true to enable debug logging // Set to true to enable debug logging
const ENABLE_LOGGING = true; const ENABLE_LOGGING = true;
@ -22,7 +22,6 @@ const reqBodySchema = z
.object({ .object({
message: z.string().optional(), message: z.string().optional(),
model_id: z.string(), model_id: z.string(),
session_token: z.string(), session_token: z.string(),
conversation_id: z.string().optional(), conversation_id: z.string().optional(),
web_search_enabled: z.boolean().optional(), web_search_enabled: z.boolean().optional(),
@ -40,7 +39,6 @@ const reqBodySchema = z
.refine( .refine(
(data) => { (data) => {
if (data.conversation_id === undefined && data.message === undefined) return false; if (data.conversation_id === undefined && data.message === undefined) return false;
return true; return true;
}, },
{ {
@ -67,34 +65,45 @@ function log(message: string, startTime: number): void {
const client = new ConvexHttpClient(PUBLIC_CONVEX_URL); const client = new ConvexHttpClient(PUBLIC_CONVEX_URL);
async function getUserApiKeys(sessionToken: string): Promise<Result<UserApiKeys, string>> {
const keysResult = await ResultAsync.fromPromise(
client.query(api.user_keys.all, {
session_token: sessionToken,
}),
(e) => `Failed to get user API keys: ${e}`
);
if (keysResult.isErr()) {
return err(keysResult.error);
}
const keys = keysResult.value;
return ok({
openai: keys.openai,
anthropic: keys.anthropic,
gemini: keys.gemini,
mistral: keys.mistral,
cohere: keys.cohere,
openrouter: keys.openrouter,
});
}
async function generateConversationTitle({ async function generateConversationTitle({
conversationId, conversationId,
sessionToken, sessionToken,
startTime, startTime,
keyResultPromise,
userMessage, userMessage,
modelManager,
}: { }: {
conversationId: string; conversationId: string;
sessionToken: string; sessionToken: string;
startTime: number; startTime: number;
keyResultPromise: ResultAsync<string | null, string>;
userMessage: string; userMessage: string;
modelManager: ChatModelManager;
}) { }) {
log('Starting conversation title generation', startTime); log('Starting conversation title generation', startTime);
const keyResult = await keyResultPromise; // Check if conversation currently has default title
if (keyResult.isErr()) {
log(`Title generation: API key error: ${keyResult.error}`, startTime);
return;
}
const userKey = keyResult.value;
const actualKey = userKey || OPENROUTER_FREE_KEY;
log(`Title generation: Using ${userKey ? 'user' : 'free tier'} API key`, startTime);
// Only generate title if conversation currently has default title
const conversationResult = await ResultAsync.fromPromise( const conversationResult = await ResultAsync.fromPromise(
client.query(api.conversations.get, { client.query(api.conversations.get, {
session_token: sessionToken, session_token: sessionToken,
@ -115,12 +124,25 @@ async function generateConversationTitle({
return; return;
} }
const openai = new OpenAI({ // Try to find a fast, cheap model for title generation
baseURL: 'https://openrouter.ai/api/v1', const availableModels = await modelManager.listAvailableModels();
apiKey: actualKey, const titleModel =
}); availableModels.find((model) => model.id.includes('kimi-k2')) ||
availableModels.find((model) => model.id.includes('gemini-2.5-flash-lite')) ||
availableModels.find((model) => model.id.includes('gpt-5-mini')) ||
availableModels[0];
if (!titleModel) {
log('Title generation: No suitable model available', startTime);
return;
}
const provider = modelManager.getProvider(titleModel.provider);
if (!provider) {
log(`Title generation: Provider ${titleModel.provider} not found`, startTime);
return;
}
// Create a prompt for title generation using only the first user message
const titlePrompt = `Based on this message: const titlePrompt = `Based on this message:
"""${userMessage}""" """${userMessage}"""
@ -129,26 +151,25 @@ Generate only the title based on the message, nothing else. Don't name the title
Also, do not interact with the message directly or answer it. Just generate the title based on the message. Also, do not interact with the message directly or answer it. Just generate the title based on the message.
If its a simple hi, just name it "Greeting" or something like that. If its a simple hi, just name it "Greeting" or something like that.`;
`;
const titleResult = await ResultAsync.fromPromise( const titleResult = await ResultAsync.fromPromise(
openai.chat.completions.create({ provider.generateCompletion({
model: 'mistralai/ministral-8b', model: titleModel.id,
messages: [{ role: 'user', content: titlePrompt }], messages: [{ role: 'user', content: titlePrompt }],
max_tokens: 20, maxTokens: 1024,
temperature: 0.5, temperature: 0.5,
}), }),
(e) => `Title generation API call failed: ${e}` (e) => `Title generation API call failed: ${e}`
); );
if (titleResult.isErr()) { if (titleResult.isErr()) {
log(`Title generation: OpenAI call failed: ${titleResult.error}`, startTime); log(`Title generation: API call failed: ${titleResult.error}`, startTime);
return; return;
} }
const titleResponse = titleResult.value; const titleResponse = titleResult.value;
const rawTitle = titleResponse.choices[0]?.message?.content?.trim(); const rawTitle = titleResponse.content?.trim();
if (!rawTitle) { if (!rawTitle) {
log('Title generation: No title generated', startTime); log('Title generation: No title generated', startTime);
@ -180,20 +201,18 @@ async function generateAIResponse({
conversationId, conversationId,
sessionToken, sessionToken,
startTime, startTime,
modelResultPromise, modelId,
keyResultPromise, modelManager,
rulesResultPromise, rulesResultPromise,
userSettingsPromise,
abortSignal, abortSignal,
reasoningEffort, reasoningEffort,
}: { }: {
conversationId: string; conversationId: string;
sessionToken: string; sessionToken: string;
startTime: number; startTime: number;
keyResultPromise: ResultAsync<string | null, string>; modelId: string;
modelResultPromise: ResultAsync<Doc<'user_enabled_models'> | null, string>; modelManager: ChatModelManager;
rulesResultPromise: ResultAsync<Doc<'user_rules'>[], string>; rulesResultPromise: ResultAsync<Doc<'user_rules'>[], string>;
userSettingsPromise: ResultAsync<Doc<'user_settings'> | null, string>;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
reasoningEffort?: 'low' | 'medium' | 'high'; reasoningEffort?: 'low' | 'medium' | 'high';
}) { }) {
@ -204,10 +223,34 @@ async function generateAIResponse({
return; return;
} }
const [modelResult, keyResult, messagesQueryResult, rulesResult, userSettingsResult] = // Get model and provider
await Promise.all([ const model = await modelManager.getModel(modelId);
modelResultPromise, if (!model) {
keyResultPromise, handleGenerationError({
error: `Model ${modelId} not found or not available`,
conversationId,
messageId: undefined,
sessionToken,
startTime,
});
return;
}
const provider = modelManager.getProvider(model.provider);
if (!provider) {
handleGenerationError({
error: `Provider ${model.provider} not available`,
conversationId,
messageId: undefined,
sessionToken,
startTime,
});
return;
}
log(`Background: Using model ${modelId} with provider ${model.provider}`, startTime);
const [messagesQueryResult, rulesResult] = await Promise.all([
ResultAsync.fromPromise( ResultAsync.fromPromise(
client.query(api.messages.getAllFromConversation, { client.query(api.messages.getAllFromConversation, {
conversation_id: conversationId as Id<'conversations'>, conversation_id: conversationId as Id<'conversations'>,
@ -216,34 +259,8 @@ async function generateAIResponse({
(e) => `Failed to get messages: ${e}` (e) => `Failed to get messages: ${e}`
), ),
rulesResultPromise, rulesResultPromise,
userSettingsPromise,
]); ]);
if (modelResult.isErr()) {
handleGenerationError({
error: modelResult.error,
conversationId,
messageId: undefined,
sessionToken,
startTime,
});
return;
}
const model = modelResult.value;
if (!model) {
handleGenerationError({
error: 'Model not found or not enabled',
conversationId,
messageId: undefined,
sessionToken,
startTime,
});
return;
}
log('Background: Model found and enabled', startTime);
if (messagesQueryResult.isErr()) { if (messagesQueryResult.isErr()) {
handleGenerationError({ handleGenerationError({
error: `messages query failed: ${messagesQueryResult.error}`, error: `messages query failed: ${messagesQueryResult.error}`,
@ -262,14 +279,14 @@ async function generateAIResponse({
const lastUserMessage = messages.filter((m) => m.role === 'user').pop(); const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
const webSearchEnabled = lastUserMessage?.web_search_enabled ?? false; const webSearchEnabled = lastUserMessage?.web_search_enabled ?? false;
const modelId = webSearchEnabled ? `${model.model_id}:online` : model.model_id; const finalModelId = webSearchEnabled ? `${modelId}:online` : modelId;
// Create assistant message // Create assistant message
const messageCreationResult = await ResultAsync.fromPromise( const messageCreationResult = await ResultAsync.fromPromise(
client.mutation(api.messages.create, { client.mutation(api.messages.create, {
conversation_id: conversationId, conversation_id: conversationId,
model_id: model.model_id, model_id: modelId,
provider: Provider.OpenRouter, provider: model.provider as Provider,
content: '', content: '',
role: 'assistant', role: 'assistant',
session_token: sessionToken, session_token: sessionToken,
@ -292,84 +309,6 @@ async function generateAIResponse({
const mid = messageCreationResult.value; const mid = messageCreationResult.value;
log('Background: Assistant message created', startTime); log('Background: Assistant message created', startTime);
if (keyResult.isErr()) {
handleGenerationError({
error: `API key query failed: ${keyResult.error}`,
conversationId,
messageId: mid,
sessionToken,
startTime,
});
return;
}
if (userSettingsResult.isErr()) {
handleGenerationError({
error: `User settings query failed: ${userSettingsResult.error}`,
conversationId,
messageId: mid,
sessionToken,
startTime,
});
return;
}
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 if using a free model
const isFreeModel = model.model_id.endsWith(':free');
if (!isFreeModel) {
// For non-free models, check the 10 message 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, or use a free model ending in ":free".',
conversationId,
messageId: mid,
sessionToken,
startTime,
});
return;
}
// Increment free message count before generating (only for non-free models)
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;
}
log(`Background: Using free tier (${freeMessagesUsed + 1}/10 messages)`, startTime);
} else {
log(`Background: Using free model (${model.model_id}) - no message count`, startTime);
}
// Use environment OpenRouter key
actualKey = OPENROUTER_FREE_KEY;
}
if (rulesResult.isErr()) { if (rulesResult.isErr()) {
handleGenerationError({ handleGenerationError({
error: `rules query failed: ${rulesResult.error}`, error: `rules query failed: ${rulesResult.error}`,
@ -405,7 +344,7 @@ async function generateAIResponse({
attachedRules.push(...parsedRules); attachedRules.push(...parsedRules);
} }
// remove duplicates // Remove duplicates
attachedRules = array.fromMap( attachedRules = array.fromMap(
array.toMap(attachedRules, (r) => [r._id, r]), array.toMap(attachedRules, (r) => [r._id, r]),
(_k, v) => v (_k, v) => v
@ -413,19 +352,14 @@ async function generateAIResponse({
log(`Background: ${attachedRules.length} rules attached`, startTime); log(`Background: ${attachedRules.length} rules attached`, startTime);
const openai = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: actualKey,
});
const formattedMessages = messages.map((m) => { const formattedMessages = messages.map((m) => {
if (m.images && m.images.length > 0 && m.role === 'user') { if (m.images && m.images.length > 0 && m.role === 'user') {
return { return {
role: 'user' as const, role: 'user' as const,
content: [ content: [
{ type: 'text' as const, text: m.content }, { type: 'text', text: m.content },
...m.images.map((img) => ({ ...m.images.map((img) => ({
type: 'image_url' as const, type: 'image_url',
image_url: { url: img.url }, image_url: { url: img.url },
})), })),
], ],
@ -462,20 +396,16 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
return; return;
} }
// Generate completion with streaming
const streamResult = await ResultAsync.fromPromise( const streamResult = await ResultAsync.fromPromise(
openai.chat.completions.create( provider.generateCompletion({
{ model: finalModelId,
model: modelId,
messages: messagesToSend, messages: messagesToSend,
temperature: 0.7, temperature: 0.7,
stream: true, stream: true,
reasoning_effort: reasoningEffort, ...(reasoningEffort && { reasoning_effort: reasoningEffort }),
}, }),
{ (e) => `API call failed: ${e}`
signal: abortSignal,
}
),
(e) => `OpenAI API call failed: ${e}`
); );
if (streamResult.isErr()) { if (streamResult.isErr()) {
@ -490,7 +420,7 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
} }
const stream = streamResult.value; const stream = streamResult.value;
log('Background: OpenAI stream created successfully', startTime); log('Background: Stream created successfully', startTime);
let content = ''; let content = '';
let reasoning = ''; let reasoning = '';
@ -499,6 +429,8 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
const annotations: Annotation[] = []; const annotations: Annotation[] = [];
try { try {
// Handle streaming response
if (stream && typeof stream[Symbol.asyncIterator] === 'function') {
for await (const chunk of stream) { for await (const chunk of stream) {
if (abortSignal?.aborted) { if (abortSignal?.aborted) {
log('AI response generation aborted during streaming', startTime); log('AI response generation aborted during streaming', startTime);
@ -507,15 +439,19 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
chunkCount++; chunkCount++;
// @ts-expect-error you're wrong // Extract content from chunk based on the stream format
reasoning += chunk.choices[0]?.delta?.reasoning || ''; if (chunk && typeof chunk === 'object') {
content += chunk.choices[0]?.delta?.content || ''; const chunkContent = chunk.content || chunk.text || '';
// @ts-expect-error you're wrong const chunkReasoning = chunk.reasoning || '';
annotations.push(...(chunk.choices[0]?.delta?.annotations ?? [])); const chunkAnnotations = chunk.annotations || [];
reasoning += chunkReasoning;
content += chunkContent;
annotations.push(...chunkAnnotations);
if (!content && !reasoning) continue; if (!content && !reasoning) continue;
generationId = chunk.id; generationId = chunk.id || generationId;
const updateResult = await ResultAsync.fromPromise( const updateResult = await ResultAsync.fromPromise(
client.mutation(api.messages.updateContent, { client.mutation(api.messages.updateContent, {
@ -537,56 +473,55 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
); );
} }
} }
}
} else {
// Handle non-streaming response
const response = stream as any;
content = response.content || response.text || '';
reasoning = response.reasoning || '';
generationId = response.id;
if (response.annotations) {
annotations.push(...response.annotations);
}
const updateResult = await ResultAsync.fromPromise(
client.mutation(api.messages.updateContent, {
message_id: mid,
content,
reasoning: reasoning.length > 0 ? reasoning : undefined,
session_token: sessionToken,
generation_id: generationId,
annotations,
reasoning_effort: reasoningEffort,
}),
(e) => `Failed to update message content: ${e}`
);
if (updateResult.isErr()) {
log(`Background message update failed: ${updateResult.error}`, startTime);
}
}
log( log(
`Background stream processing completed. Processed ${chunkCount} chunks, final content length: ${content.length}`, `Background stream processing completed. Processed ${chunkCount} chunks, final content length: ${content.length}`,
startTime startTime
); );
if (!generationId) { // Final message update with completion stats
log('Background: No generation id found', startTime);
return;
}
const contentHtmlResultPromise = ResultAsync.fromPromise( const contentHtmlResultPromise = ResultAsync.fromPromise(
md.renderAsync(content), md.renderAsync(content),
(e) => `Failed to render HTML: ${e}` (e) => `Failed to render HTML: ${e}`
); );
const generationStatsResult = await retryResult(
() => getGenerationStats(generationId!, actualKey),
{
delay: 500,
retries: 2,
startTime,
fnName: 'getGenerationStats',
}
);
if (generationStatsResult.isErr()) {
log(`Background: Failed to get generation stats: ${generationStatsResult.error}`, startTime);
}
// just default so we don't blow up
const generationStats = generationStatsResult.unwrapOr({
tokens_completion: undefined,
total_cost: undefined,
});
log('Background: Got generation stats', startTime);
const contentHtmlResult = await contentHtmlResultPromise; const contentHtmlResult = await contentHtmlResultPromise;
if (contentHtmlResult.isErr()) { const [updateMessageResult, updateGeneratingResult] = await Promise.all([
log(`Background: Failed to render HTML: ${contentHtmlResult.error}`, startTime);
}
const [updateMessageResult, updateGeneratingResult, updateCostUsdResult] = await Promise.all([
ResultAsync.fromPromise( ResultAsync.fromPromise(
client.mutation(api.messages.updateMessage, { client.mutation(api.messages.updateMessage, {
message_id: mid, message_id: mid,
token_count: generationStats.tokens_completion, token_count: undefined, // Will be calculated by provider if available
cost_usd: generationStats.total_cost, cost_usd: undefined, // Will be calculated by provider if available
generation_id: generationId, generation_id: generationId,
session_token: sessionToken, session_token: sessionToken,
content_html: contentHtmlResult.unwrapOr(undefined), content_html: contentHtmlResult.unwrapOr(undefined),
@ -601,14 +536,6 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
}), }),
(e) => `Failed to update generating status: ${e}` (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 ?? 0,
session_token: sessionToken,
}),
(e) => `Failed to update cost usd: ${e}`
),
]); ]);
if (updateGeneratingResult.isErr()) { if (updateGeneratingResult.isErr()) {
@ -624,13 +551,6 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
} }
log('Background: Message updated', startTime); 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) { } catch (error) {
handleGenerationError({ handleGenerationError({
error: `Stream processing error: ${error}`, error: `Stream processing error: ${error}`,
@ -672,7 +592,6 @@ export const POST: RequestHandler = async ({ request }) => {
log('Schema validation passed', startTime); log('Schema validation passed', startTime);
const cookie = getSessionCookie(request.headers); const cookie = getSessionCookie(request.headers);
const sessionToken = cookie?.split('.')[0] ?? null; const sessionToken = cookie?.split('.')[0] ?? null;
if (!sessionToken) { if (!sessionToken) {
@ -680,29 +599,37 @@ export const POST: RequestHandler = async ({ request }) => {
return error(401, 'Unauthorized'); return error(401, 'Unauthorized');
} }
const modelResultPromise = ResultAsync.fromPromise( // Get user API keys
client.query(api.user_enabled_models.get, { const userApiKeysResult = await getUserApiKeys(sessionToken);
provider: Provider.OpenRouter, if (userApiKeysResult.isErr()) {
model_id: args.model_id, log(`Failed to get user API keys: ${userApiKeysResult.error}`, startTime);
session_token: sessionToken, return error(500, 'Failed to get user API keys');
}), }
(e) => `Failed to get model: ${e}`
);
const keyResultPromise = ResultAsync.fromPromise( const userApiKeys = userApiKeysResult.value;
client.query(api.user_keys.get, { const hasAnyKey = Object.values(userApiKeys).some((key) => key);
provider: Provider.OpenRouter,
session_token: sessionToken,
}),
(e) => `Failed to get API key: ${e}`
);
const userSettingsPromise = ResultAsync.fromPromise( if (!hasAnyKey) {
client.query(api.user_settings.get, { log('User has no API keys configured', startTime);
session_token: sessionToken, return error(
}), 400,
(e) => `Failed to get user settings: ${e}` 'No API keys configured. Please add at least one provider API key in settings.'
); );
}
// Initialize model manager with user's API keys
const modelManager = createModelManager();
modelManager.initializeProviders(userApiKeys);
// Check if the requested model is available
const modelAvailable = await modelManager.isModelAvailable(args.model_id);
if (!modelAvailable) {
log(`Requested model ${args.model_id} not available`, startTime);
return error(
400,
`Model ${args.model_id} is not available. Please check your API keys and try a different model.`
);
}
const rulesResultPromise = ResultAsync.fromPromise( const rulesResultPromise = ResultAsync.fromPromise(
client.query(api.user_rules.all, { client.query(api.user_rules.all, {
@ -715,7 +642,7 @@ export const POST: RequestHandler = async ({ request }) => {
let conversationId = args.conversation_id; let conversationId = args.conversation_id;
if (!conversationId) { if (!conversationId) {
// technically zod should catch this but just in case // Create new conversation
if (args.message === undefined) { if (args.message === undefined) {
return error(400, 'You must provide a message when creating a new conversation'); return error(400, 'You must provide a message when creating a new conversation');
} }
@ -746,8 +673,8 @@ export const POST: RequestHandler = async ({ request }) => {
conversationId, conversationId,
sessionToken, sessionToken,
startTime, startTime,
keyResultPromise,
userMessage: args.message, userMessage: args.message,
modelManager,
}).catch((error) => { }).catch((error) => {
log(`Background title generation error: ${error}`, startTime); log(`Background title generation error: ${error}`, startTime);
}) })
@ -798,16 +725,15 @@ export const POST: RequestHandler = async ({ request }) => {
const abortController = new AbortController(); const abortController = new AbortController();
generationAbortControllers.set(conversationId, abortController); generationAbortControllers.set(conversationId, abortController);
// Start AI response generation in background - don't await // Start AI response generation in background
waitUntil( waitUntil(
generateAIResponse({ generateAIResponse({
conversationId, conversationId,
sessionToken, sessionToken,
startTime, startTime,
modelResultPromise, modelId: args.model_id,
keyResultPromise, modelManager,
rulesResultPromise, rulesResultPromise,
userSettingsPromise,
abortSignal: abortController.signal, abortSignal: abortController.signal,
reasoningEffort: args.reasoning_effort, reasoningEffort: args.reasoning_effort,
}) })
@ -834,57 +760,6 @@ export const POST: RequestHandler = async ({ request }) => {
return response({ ok: true, conversation_id: conversationId }); return response({ ok: true, conversation_id: conversationId });
}; };
async function getGenerationStats(
generationId: string,
token: string
): Promise<Result<Data, string>> {
try {
const generation = await fetch(`https://openrouter.ai/api/v1/generation?id=${generationId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const { data } = await generation.json();
if (!data) {
return err('No data returned from OpenRouter');
}
return ok(data);
} catch {
return err('Failed to get generation stats');
}
}
async function retryResult<T, E>(
fn: () => Promise<Result<T, E>>,
{
retries,
delay,
startTime,
fnName,
}: { retries: number; delay: number; startTime: number; fnName: string }
): Promise<Result<T, E>> {
let attempts = 0;
let lastResult: Result<T, E> | null = null;
while (attempts <= retries) {
lastResult = await fn();
if (lastResult.isOk()) return lastResult;
log(`Retrying ${fnName} ${attempts} failed: ${lastResult.error}`, startTime);
await new Promise((resolve) => setTimeout(resolve, delay));
attempts++;
}
if (!lastResult) throw new Error('This should never happen');
return lastResult;
}
async function handleGenerationError({ async function handleGenerationError({
error, error,
conversationId, conversationId,
@ -917,38 +792,3 @@ async function handleGenerationError({
log('Error updated', startTime); log('Error updated', startTime);
} }
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;
}

1
tmp/kepler-ai-sdk Submodule

@ -0,0 +1 @@
Subproject commit 73461f942496d91e098d2d3d61c769571a13cb11