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_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=

3
.gitignore vendored
View file

@ -24,4 +24,5 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.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**
- **400+ AI Models** via OpenRouter integration
- **Free Tier** with 10 messages using premium models
- **Unlimited Free Models** (models ending in `:free`)
- **Bring Your Own Key** for unlimited access
- **Multiple AI Providers** - OpenAI, Anthropic, Google Gemini, Mistral, Cohere, OpenRouter
- **600+ AI Models** across all providers
- **Bring Your Own API Keys** - Users must provide their own API keys
- **No Usage Limits** - Use any model without restrictions when you have the API key
### 💬 **Chat Experience**
@ -79,7 +79,7 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
- 🔧 Convex Database
- 🔐 BetterAuth
- 🤖 OpenRouter API
- 🤖 Kepler AI SDK (Multi-provider support)
- 🦾 Blood, sweat, and tears
</td>
@ -92,7 +92,7 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
- Node.js 18+
- pnpm (recommended)
- OpenRouter API key (optional for free tier)
- At least one AI provider API key (OpenAI, Anthropic, Gemini, etc.)
### Installation
@ -129,16 +129,28 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
## 🎮 Usage
### Free Tier
### Getting Started
- Sign up and get **10 free messages** with premium models
- Use **unlimited free models** (ending in `:free`)
- No credit card required
1. **Sign up** for a free account
2. **Add API Keys** - Go to Settings and add API keys for the providers you want to use:
- **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
- Access to all 400+ models
| Provider | Models | Streaming | Tools | Vision | Embeddings |
|----------|---------|-----------|-------|--------|------------|
| 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
@ -158,7 +170,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
- Inspired by [T3 Chat](https://t3.chat/)
- 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/)
---

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",
"mode-watcher": "^1.0.8",
"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",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
@ -83,6 +87,7 @@
"@fontsource-variable/nunito-sans": "^5.2.6",
"@fontsource-variable/open-sans": "^5.2.6",
"@fontsource/instrument-serif": "^5.2.6",
"@keplersystems/kepler-ai-sdk": "^1.0.5",
"better-auth": "^1.2.9",
"convex-helpers": "^0.1.94",
"hastscript": "^9.0.1",

View file

@ -22,7 +22,6 @@ export default defineSchema({
user_settings: defineTable({
user_id: v.string(),
privacy_mode: v.boolean(),
free_messages_used: v.optional(v.number()),
}).index('by_user', ['user_id']),
user_keys: defineTable({
user_id: v.string(),

View file

@ -89,38 +89,6 @@ export const set = mutation({
await ctx.db.replace(existing._id, userKey);
} else {
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({
args: {
privacy_mode: v.boolean(),
@ -86,7 +51,6 @@ export const set = mutation({
await ctx.db.insert('user_settings', {
user_id: s.userId,
privacy_mode: args.privacy_mode,
free_messages_used: 0,
});
} else {
await ctx.db.patch(existing._id, {
@ -105,7 +69,6 @@ export const create = mutation({
await ctx.db.insert('user_settings', {
user_id: args.user_id,
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';
export const Provider = {
OpenRouter: 'openrouter',
HuggingFace: 'huggingface',
OpenAI: 'openai',
Anthropic: 'anthropic',
Gemini: 'gemini',
Mistral: 'mistral',
Cohere: 'cohere',
OpenRouter: 'openrouter',
} as const;
export type Provider = (typeof Provider)[keyof typeof Provider];
@ -13,8 +15,13 @@ export type ProviderMeta = {
title: string;
link: string;
description: string;
models?: string[];
placeholder?: string;
apiKeyName: string;
placeholder: string;
docsLink: string;
supportsStreaming: boolean;
supportsTools: boolean;
supportsVision: boolean;
supportsEmbeddings: boolean;
};
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 = UrlCitationSchema;
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 { Provider, PROVIDER_META } from '$lib/types';
export type OpenRouterApiKeyData = {
export type ProviderApiKeyData = {
label: string;
usage: number;
is_free_tier: boolean;
is_provisioning_key: boolean;
limit: number;
limit_remaining: number;
usage?: number;
is_free_tier?: boolean;
is_provisioning_key?: boolean;
limit?: number;
limit_remaining?: number;
valid: boolean;
};
export const OpenRouter = {
getApiKey: async (key: string): Promise<Result<OpenRouterApiKeyData, string>> => {
return await ResultAsync.fromPromise(
(async () => {
const res = await fetch('https://openrouter.ai/api/v1/key', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
});
export const ProviderUtils = {
/**
* 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}`);
}
},
if (!res.ok) throw new Error('Failed to get API key');
/**
* Get provider metadata
*/
getProviderMeta: (provider: Provider) => {
return PROVIDER_META[provider];
},
const { data } = await res.json();
/**
* Check if a provider is supported
*/
isProviderSupported: (provider: string): provider is Provider => {
return Object.values(Provider).includes(provider as Provider);
},
if (!data) throw new Error('No info returned for api key');
return data as OpenRouterApiKeyData;
})(),
(e) => `Failed to get API key ${e}`
);
/**
* 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(
(async () => {
const res = await fetch('https://openrouter.ai/api/v1/auth/key', {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const { data } = await res.json();
if (!data) {
throw new Error('No key information returned');
}
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 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">
import { Provider, type ProviderMeta } from '$lib/types';
import { Provider, PROVIDER_META } from '$lib/types';
import ProviderCard from './provider-card.svelte';
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>
<svelte:head>
@ -49,17 +12,13 @@
<div>
<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.
Add your API keys to access models from different AI providers. You need at least one API key to use the chat.
</h2>
</div>
<div class="mt-8 flex flex-col gap-4">
{#each allProviders as provider (provider)}
<!-- only do OpenRouter for now -->
{#if provider === Provider.OpenRouter}
{@const meta = providersMeta[provider]}
<ProviderCard {provider} {meta} />
{/if}
{@const meta = PROVIDER_META[provider]}
<ProviderCard {provider} {meta} />
{/each}
</div>
</div>

View file

@ -12,7 +12,7 @@
import { useConvexClient } from 'convex-svelte';
import { ResultAsync } from 'neverthrow';
import { resource } from 'runed';
import * as providers from '$lib/utils/providers';
import { ProviderUtils } from '$lib/utils/providers';
type Props = {
provider: Provider;
@ -65,11 +65,8 @@
async (key) => {
if (!key) return null;
if (provider === Provider.OpenRouter) {
return (await providers.OpenRouter.getApiKey(key)).unwrapOr(null);
}
return null;
const result = await ProviderUtils.validateApiKey(provider, key);
return result.unwrapOr(null);
}
);
</script>
@ -99,11 +96,17 @@
{#if apiKeyInfoResource.loading}
<div class="bg-input h-6 w-[200px] animate-pulse rounded-md"></div>
{:else if apiKeyInfoResource.current}
<span class="text-muted-foreground flex h-6 place-items-center text-xs">
${apiKeyInfoResource.current?.usage.toFixed(3)} used ・ ${apiKeyInfoResource.current?.limit_remaining.toFixed(
3
)} remaining
</span>
{#if apiKeyInfoResource.current.usage !== undefined && apiKeyInfoResource.current.limit_remaining !== undefined}
<span class="text-muted-foreground flex h-6 place-items-center text-xs">
${apiKeyInfoResource.current.usage.toFixed(3)} used ・ ${apiKeyInfoResource.current.limit_remaining.toFixed(
3
)} remaining
</span>
{:else}
<span class="text-muted-foreground flex h-6 place-items-center text-xs">
✅ API key is valid
</span>
{/if}
{:else}
<span
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 { ResultAsync } from 'neverthrow';
import { z } from 'zod/v4';
import { OPENROUTER_FREE_KEY } from '$env/static/private';
import { OpenAI } from 'openai';
import { ConvexHttpClient } from 'convex/browser';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { api } from '$lib/backend/convex/_generated/api';
import { parseMessageForRules } from '$lib/utils/rules';
import { Provider } from '$lib/types';
const FREE_MODEL = 'google/gemma-3-27b-it';
import { createModelManager } from '$lib/services/model-manager';
import type { UserApiKeys } from '$lib/services/model-manager';
const reqBodySchema = z.object({
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 }) => {
const bodyResult = await ResultAsync.fromPromise(
request.json(),
@ -53,42 +73,63 @@ export const POST: RequestHandler = async ({ request, locals }) => {
return error(401, 'You must be logged in to enhance a prompt');
}
const [rulesResult, keyResult] = await Promise.all([
ResultAsync.fromPromise(
client.query(api.user_rules.all, {
session_token: session.session.token,
}),
(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}`
),
]);
// Get user API keys
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, {
session_token: session.session.token,
}),
(e) => `Failed to get rules: ${e}`
);
if (rulesResult.isErr()) {
return error(500, 'Failed to get rules');
}
if (keyResult.isErr()) {
return error(500, 'Failed to get key');
}
const mentionedRules = parseMessageForRules(
args.prompt,
rulesResult.value.filter((r) => r.attach === 'manual')
);
const openai = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: keyResult.value ?? OPENROUTER_FREE_KEY,
});
// Initialize model manager with user's API keys
const modelManager = createModelManager();
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 = `
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.
You can do this by fixing spelling/grammatical errors, clarifying details, and removing unnecessary wording where possible.
Only return the enhanced prompt, nothing else. Do NOT wrap it in quotes, do NOT use markdown.
Do NOT respond to the prompt only optimize it so that another LLM can understand it better.
@ -107,23 +148,24 @@ ${args.prompt}
`;
const enhancedResult = await ResultAsync.fromPromise(
openai.chat.completions.create({
model: FREE_MODEL,
provider.generateCompletion({
model: enhanceModel.id,
messages: [{ role: 'user', content: enhancePrompt }],
temperature: 0.5,
maxTokens: 1000,
}),
(e) => `Enhance prompt API call failed: ${e}`
);
if (enhancedResult.isErr()) {
return error(500, 'error enhancing the prompt');
return error(500, 'Error enhancing the prompt');
}
const enhancedResponse = enhancedResult.value;
const enhanced = enhancedResponse.choices[0]?.message?.content;
const enhanced = enhancedResponse.content?.trim();
if (!enhanced) {
return error(500, 'error enhancing the prompt');
return error(500, 'Error enhancing the prompt');
}
return response({

View file

@ -1,5 +1,4 @@
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 type { Doc, Id } from '$lib/backend/convex/_generated/dataModel';
import { Provider, type Annotation } from '$lib/types';
@ -8,12 +7,13 @@ import { waitUntil } from '@vercel/functions';
import { getSessionCookie } from 'better-auth/cookies';
import { ConvexHttpClient } from 'convex/browser';
import { err, ok, Result, ResultAsync } from 'neverthrow';
import OpenAI from 'openai';
import { z } from 'zod/v4';
import { generationAbortControllers } from './cache.js';
import { md } from '$lib/utils/markdown-it.js';
import * as array from '$lib/utils/array';
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
const ENABLE_LOGGING = true;
@ -22,7 +22,6 @@ const reqBodySchema = z
.object({
message: z.string().optional(),
model_id: z.string(),
session_token: z.string(),
conversation_id: z.string().optional(),
web_search_enabled: z.boolean().optional(),
@ -40,7 +39,6 @@ const reqBodySchema = z
.refine(
(data) => {
if (data.conversation_id === undefined && data.message === undefined) return false;
return true;
},
{
@ -67,34 +65,45 @@ function log(message: string, startTime: number): void {
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({
conversationId,
sessionToken,
startTime,
keyResultPromise,
userMessage,
modelManager,
}: {
conversationId: string;
sessionToken: string;
startTime: number;
keyResultPromise: ResultAsync<string | null, string>;
userMessage: string;
modelManager: ChatModelManager;
}) {
log('Starting conversation title generation', startTime);
const keyResult = await keyResultPromise;
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
// Check if conversation currently has default title
const conversationResult = await ResultAsync.fromPromise(
client.query(api.conversations.get, {
session_token: sessionToken,
@ -115,12 +124,25 @@ async function generateConversationTitle({
return;
}
const openai = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: actualKey,
});
// Try to find a fast, cheap model for title generation
const availableModels = await modelManager.listAvailableModels();
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:
"""${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.
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(
openai.chat.completions.create({
model: 'mistralai/ministral-8b',
provider.generateCompletion({
model: titleModel.id,
messages: [{ role: 'user', content: titlePrompt }],
max_tokens: 20,
maxTokens: 1024,
temperature: 0.5,
}),
(e) => `Title generation API call failed: ${e}`
);
if (titleResult.isErr()) {
log(`Title generation: OpenAI call failed: ${titleResult.error}`, startTime);
log(`Title generation: API call failed: ${titleResult.error}`, startTime);
return;
}
const titleResponse = titleResult.value;
const rawTitle = titleResponse.choices[0]?.message?.content?.trim();
const rawTitle = titleResponse.content?.trim();
if (!rawTitle) {
log('Title generation: No title generated', startTime);
@ -180,20 +201,18 @@ async function generateAIResponse({
conversationId,
sessionToken,
startTime,
modelResultPromise,
keyResultPromise,
modelId,
modelManager,
rulesResultPromise,
userSettingsPromise,
abortSignal,
reasoningEffort,
}: {
conversationId: string;
sessionToken: string;
startTime: number;
keyResultPromise: ResultAsync<string | null, string>;
modelResultPromise: ResultAsync<Doc<'user_enabled_models'> | null, string>;
modelId: string;
modelManager: ChatModelManager;
rulesResultPromise: ResultAsync<Doc<'user_rules'>[], string>;
userSettingsPromise: ResultAsync<Doc<'user_settings'> | null, string>;
abortSignal?: AbortSignal;
reasoningEffort?: 'low' | 'medium' | 'high';
}) {
@ -204,36 +223,11 @@ async function generateAIResponse({
return;
}
const [modelResult, keyResult, messagesQueryResult, rulesResult, userSettingsResult] =
await Promise.all([
modelResultPromise,
keyResultPromise,
ResultAsync.fromPromise(
client.query(api.messages.getAllFromConversation, {
conversation_id: conversationId as Id<'conversations'>,
session_token: sessionToken,
}),
(e) => `Failed to get messages: ${e}`
),
rulesResultPromise,
userSettingsPromise,
]);
if (modelResult.isErr()) {
handleGenerationError({
error: modelResult.error,
conversationId,
messageId: undefined,
sessionToken,
startTime,
});
return;
}
const model = modelResult.value;
// Get model and provider
const model = await modelManager.getModel(modelId);
if (!model) {
handleGenerationError({
error: 'Model not found or not enabled',
error: `Model ${modelId} not found or not available`,
conversationId,
messageId: undefined,
sessionToken,
@ -242,7 +236,30 @@ async function generateAIResponse({
return;
}
log('Background: Model found and enabled', startTime);
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(
client.query(api.messages.getAllFromConversation, {
conversation_id: conversationId as Id<'conversations'>,
session_token: sessionToken,
}),
(e) => `Failed to get messages: ${e}`
),
rulesResultPromise,
]);
if (messagesQueryResult.isErr()) {
handleGenerationError({
@ -262,14 +279,14 @@ async function generateAIResponse({
const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
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
const messageCreationResult = await ResultAsync.fromPromise(
client.mutation(api.messages.create, {
conversation_id: conversationId,
model_id: model.model_id,
provider: Provider.OpenRouter,
model_id: modelId,
provider: model.provider as Provider,
content: '',
role: 'assistant',
session_token: sessionToken,
@ -292,84 +309,6 @@ async function generateAIResponse({
const mid = messageCreationResult.value;
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()) {
handleGenerationError({
error: `rules query failed: ${rulesResult.error}`,
@ -405,7 +344,7 @@ async function generateAIResponse({
attachedRules.push(...parsedRules);
}
// remove duplicates
// Remove duplicates
attachedRules = array.fromMap(
array.toMap(attachedRules, (r) => [r._id, r]),
(_k, v) => v
@ -413,19 +352,14 @@ async function generateAIResponse({
log(`Background: ${attachedRules.length} rules attached`, startTime);
const openai = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: actualKey,
});
const formattedMessages = messages.map((m) => {
if (m.images && m.images.length > 0 && m.role === 'user') {
return {
role: 'user' as const,
content: [
{ type: 'text' as const, text: m.content },
{ type: 'text', text: m.content },
...m.images.map((img) => ({
type: 'image_url' as const,
type: 'image_url',
image_url: { url: img.url },
})),
],
@ -462,20 +396,16 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
return;
}
// Generate completion with streaming
const streamResult = await ResultAsync.fromPromise(
openai.chat.completions.create(
{
model: modelId,
messages: messagesToSend,
temperature: 0.7,
stream: true,
reasoning_effort: reasoningEffort,
},
{
signal: abortSignal,
}
),
(e) => `OpenAI API call failed: ${e}`
provider.generateCompletion({
model: finalModelId,
messages: messagesToSend,
temperature: 0.7,
stream: true,
...(reasoningEffort && { reasoning_effort: reasoningEffort }),
}),
(e) => `API call failed: ${e}`
);
if (streamResult.isErr()) {
@ -490,7 +420,7 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
}
const stream = streamResult.value;
log('Background: OpenAI stream created successfully', startTime);
log('Background: Stream created successfully', startTime);
let content = '';
let reasoning = '';
@ -499,23 +429,61 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
const annotations: Annotation[] = [];
try {
for await (const chunk of stream) {
if (abortSignal?.aborted) {
log('AI response generation aborted during streaming', startTime);
break;
// Handle streaming response
if (stream && typeof stream[Symbol.asyncIterator] === 'function') {
for await (const chunk of stream) {
if (abortSignal?.aborted) {
log('AI response generation aborted during streaming', startTime);
break;
}
chunkCount++;
// Extract content from chunk based on the stream format
if (chunk && typeof chunk === 'object') {
const chunkContent = chunk.content || chunk.text || '';
const chunkReasoning = chunk.reasoning || '';
const chunkAnnotations = chunk.annotations || [];
reasoning += chunkReasoning;
content += chunkContent;
annotations.push(...chunkAnnotations);
if (!content && !reasoning) continue;
generationId = chunk.id || generationId;
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 on chunk ${chunkCount}: ${updateResult.error}`,
startTime
);
}
}
}
} else {
// Handle non-streaming response
const response = stream as any;
content = response.content || response.text || '';
reasoning = response.reasoning || '';
generationId = response.id;
chunkCount++;
// @ts-expect-error you're wrong
reasoning += chunk.choices[0]?.delta?.reasoning || '';
content += chunk.choices[0]?.delta?.content || '';
// @ts-expect-error you're wrong
annotations.push(...(chunk.choices[0]?.delta?.annotations ?? []));
if (!content && !reasoning) continue;
generationId = chunk.id;
if (response.annotations) {
annotations.push(...response.annotations);
}
const updateResult = await ResultAsync.fromPromise(
client.mutation(api.messages.updateContent, {
@ -531,10 +499,7 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
);
if (updateResult.isErr()) {
log(
`Background message update failed on chunk ${chunkCount}: ${updateResult.error}`,
startTime
);
log(`Background message update failed: ${updateResult.error}`, startTime);
}
}
@ -543,50 +508,20 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
startTime
);
if (!generationId) {
log('Background: No generation id found', startTime);
return;
}
// Final message update with completion stats
const contentHtmlResultPromise = ResultAsync.fromPromise(
md.renderAsync(content),
(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;
if (contentHtmlResult.isErr()) {
log(`Background: Failed to render HTML: ${contentHtmlResult.error}`, startTime);
}
const [updateMessageResult, updateGeneratingResult, updateCostUsdResult] = await Promise.all([
const [updateMessageResult, updateGeneratingResult] = await Promise.all([
ResultAsync.fromPromise(
client.mutation(api.messages.updateMessage, {
message_id: mid,
token_count: generationStats.tokens_completion,
cost_usd: generationStats.total_cost,
token_count: undefined, // Will be calculated by provider if available
cost_usd: undefined, // Will be calculated by provider if available
generation_id: generationId,
session_token: sessionToken,
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}`
),
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()) {
@ -624,13 +551,6 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
}
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) {
handleGenerationError({
error: `Stream processing error: ${error}`,
@ -672,7 +592,6 @@ export const POST: RequestHandler = async ({ request }) => {
log('Schema validation passed', startTime);
const cookie = getSessionCookie(request.headers);
const sessionToken = cookie?.split('.')[0] ?? null;
if (!sessionToken) {
@ -680,29 +599,37 @@ export const POST: RequestHandler = async ({ request }) => {
return error(401, 'Unauthorized');
}
const modelResultPromise = ResultAsync.fromPromise(
client.query(api.user_enabled_models.get, {
provider: Provider.OpenRouter,
model_id: args.model_id,
session_token: sessionToken,
}),
(e) => `Failed to get model: ${e}`
);
// Get user API keys
const userApiKeysResult = await getUserApiKeys(sessionToken);
if (userApiKeysResult.isErr()) {
log(`Failed to get user API keys: ${userApiKeysResult.error}`, startTime);
return error(500, 'Failed to get user API keys');
}
const keyResultPromise = ResultAsync.fromPromise(
client.query(api.user_keys.get, {
provider: Provider.OpenRouter,
session_token: sessionToken,
}),
(e) => `Failed to get API key: ${e}`
);
const userApiKeys = userApiKeysResult.value;
const hasAnyKey = Object.values(userApiKeys).some((key) => key);
const userSettingsPromise = ResultAsync.fromPromise(
client.query(api.user_settings.get, {
session_token: sessionToken,
}),
(e) => `Failed to get user settings: ${e}`
);
if (!hasAnyKey) {
log('User has no API keys configured', startTime);
return error(
400,
'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(
client.query(api.user_rules.all, {
@ -715,7 +642,7 @@ export const POST: RequestHandler = async ({ request }) => {
let conversationId = args.conversation_id;
if (!conversationId) {
// technically zod should catch this but just in case
// Create new conversation
if (args.message === undefined) {
return error(400, 'You must provide a message when creating a new conversation');
}
@ -746,8 +673,8 @@ export const POST: RequestHandler = async ({ request }) => {
conversationId,
sessionToken,
startTime,
keyResultPromise,
userMessage: args.message,
modelManager,
}).catch((error) => {
log(`Background title generation error: ${error}`, startTime);
})
@ -798,16 +725,15 @@ export const POST: RequestHandler = async ({ request }) => {
const abortController = new AbortController();
generationAbortControllers.set(conversationId, abortController);
// Start AI response generation in background - don't await
// Start AI response generation in background
waitUntil(
generateAIResponse({
conversationId,
sessionToken,
startTime,
modelResultPromise,
keyResultPromise,
modelId: args.model_id,
modelManager,
rulesResultPromise,
userSettingsPromise,
abortSignal: abortController.signal,
reasoningEffort: args.reasoning_effort,
})
@ -834,57 +760,6 @@ export const POST: RequestHandler = async ({ request }) => {
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({
error,
conversationId,
@ -917,38 +792,3 @@ async function handleGenerationError({
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