feat: Preliminary support for kepler-ai-sdk
This commit is contained in:
parent
f8f6748bec
commit
071e1016b1
16 changed files with 2233 additions and 557 deletions
|
|
@ -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=
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -25,3 +25,4 @@ vite.config.ts.timestamp-*
|
|||
.aider*
|
||||
|
||||
src/lib/backend/convex/_generated
|
||||
tmp/
|
||||
|
|
|
|||
40
README.md
40
README.md
|
|
@ -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/)
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
120
src/lib/services/model-manager.ts
Normal file
120
src/lib/services/model-manager.ts
Normal 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();
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>> => {
|
||||
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}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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(
|
||||
(async () => {
|
||||
const res = await fetch('https://openrouter.ai/api/v1/key', {
|
||||
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('Failed to get API key');
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||
}
|
||||
|
||||
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;
|
||||
})(),
|
||||
(e) => `Failed to get API key ${e}`
|
||||
);
|
||||
},
|
||||
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}`
|
||||
);
|
||||
}
|
||||
|
|
@ -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]}
|
||||
{@const meta = PROVIDER_META[provider]}
|
||||
<ProviderCard {provider} {meta} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -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}
|
||||
{#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(
|
||||
${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"
|
||||
|
|
|
|||
|
|
@ -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,39 +73,60 @@ 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(
|
||||
// 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}`
|
||||
),
|
||||
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()) {
|
||||
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.
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,10 +223,34 @@ async function generateAIResponse({
|
|||
return;
|
||||
}
|
||||
|
||||
const [modelResult, keyResult, messagesQueryResult, rulesResult, userSettingsResult] =
|
||||
await Promise.all([
|
||||
modelResultPromise,
|
||||
keyResultPromise,
|
||||
// Get model and provider
|
||||
const model = await modelManager.getModel(modelId);
|
||||
if (!model) {
|
||||
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(
|
||||
client.query(api.messages.getAllFromConversation, {
|
||||
conversation_id: conversationId as Id<'conversations'>,
|
||||
|
|
@ -216,34 +259,8 @@ async function generateAIResponse({
|
|||
(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;
|
||||
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()) {
|
||||
handleGenerationError({
|
||||
error: `messages query failed: ${messagesQueryResult.error}`,
|
||||
|
|
@ -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,
|
||||
provider.generateCompletion({
|
||||
model: finalModelId,
|
||||
messages: messagesToSend,
|
||||
temperature: 0.7,
|
||||
stream: true,
|
||||
reasoning_effort: reasoningEffort,
|
||||
},
|
||||
{
|
||||
signal: abortSignal,
|
||||
}
|
||||
),
|
||||
(e) => `OpenAI API call failed: ${e}`
|
||||
...(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,6 +429,8 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
|||
const annotations: Annotation[] = [];
|
||||
|
||||
try {
|
||||
// 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);
|
||||
|
|
@ -507,15 +439,19 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
|||
|
||||
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 ?? []));
|
||||
// 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 = chunk.id || generationId;
|
||||
|
||||
const updateResult = await ResultAsync.fromPromise(
|
||||
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(
|
||||
`Background stream processing completed. Processed ${chunkCount} chunks, final content length: ${content.length}`,
|
||||
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
1
tmp/kepler-ai-sdk
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 73461f942496d91e098d2d3d61c769571a13cb11
|
||||
Loading…
Add table
Reference in a new issue