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_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
OPENROUTER_FREE_KEY=
|
# Optional: Development API keys for testing (not required for production)
|
||||||
|
# Users will provide their own API keys through the settings interface
|
||||||
|
DEV_OPENAI_API_KEY=
|
||||||
|
DEV_ANTHROPIC_API_KEY=
|
||||||
|
DEV_GEMINI_API_KEY=
|
||||||
|
DEV_MISTRAL_API_KEY=
|
||||||
|
DEV_COHERE_API_KEY=
|
||||||
|
DEV_OPENROUTER_API_KEY=
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -25,3 +25,4 @@ vite.config.ts.timestamp-*
|
||||||
.aider*
|
.aider*
|
||||||
|
|
||||||
src/lib/backend/convex/_generated
|
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**
|
### 🤖 **AI & Models**
|
||||||
|
|
||||||
- **400+ AI Models** via OpenRouter integration
|
- **Multiple AI Providers** - OpenAI, Anthropic, Google Gemini, Mistral, Cohere, OpenRouter
|
||||||
- **Free Tier** with 10 messages using premium models
|
- **600+ AI Models** across all providers
|
||||||
- **Unlimited Free Models** (models ending in `:free`)
|
- **Bring Your Own API Keys** - Users must provide their own API keys
|
||||||
- **Bring Your Own Key** for unlimited access
|
- **No Usage Limits** - Use any model without restrictions when you have the API key
|
||||||
|
|
||||||
### 💬 **Chat Experience**
|
### 💬 **Chat Experience**
|
||||||
|
|
||||||
|
|
@ -79,7 +79,7 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
|
||||||
|
|
||||||
- 🔧 Convex Database
|
- 🔧 Convex Database
|
||||||
- 🔐 BetterAuth
|
- 🔐 BetterAuth
|
||||||
- 🤖 OpenRouter API
|
- 🤖 Kepler AI SDK (Multi-provider support)
|
||||||
- 🦾 Blood, sweat, and tears
|
- 🦾 Blood, sweat, and tears
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -92,7 +92,7 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
|
||||||
|
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- pnpm (recommended)
|
- pnpm (recommended)
|
||||||
- OpenRouter API key (optional for free tier)
|
- At least one AI provider API key (OpenAI, Anthropic, Gemini, etc.)
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
|
|
@ -129,16 +129,28 @@ While thom.chat is a clone, the featureset is not identical to T3 Chat.
|
||||||
|
|
||||||
## 🎮 Usage
|
## 🎮 Usage
|
||||||
|
|
||||||
### Free Tier
|
### Getting Started
|
||||||
|
|
||||||
- Sign up and get **10 free messages** with premium models
|
1. **Sign up** for a free account
|
||||||
- Use **unlimited free models** (ending in `:free`)
|
2. **Add API Keys** - Go to Settings and add API keys for the providers you want to use:
|
||||||
- No credit card required
|
- **OpenAI** - GPT models, DALL-E, Whisper
|
||||||
|
- **Anthropic** - Claude models
|
||||||
|
- **Google Gemini** - Gemini models and vision
|
||||||
|
- **Mistral** - Mistral models and embeddings
|
||||||
|
- **Cohere** - Command models and embeddings
|
||||||
|
- **OpenRouter** - Access to 300+ models
|
||||||
|
3. **Start Chatting** - Select any model from your enabled providers
|
||||||
|
|
||||||
### Premium Features
|
### Supported Providers
|
||||||
|
|
||||||
- Add your own OpenRouter API key for unlimited access
|
| Provider | Models | Streaming | Tools | Vision | Embeddings |
|
||||||
- Access to all 400+ models
|
|----------|---------|-----------|-------|--------|------------|
|
||||||
|
| OpenAI | GPT-4, o3-mini, DALL-E, TTS | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Anthropic | Claude 4, Claude 3.5 Sonnet | ✅ | ✅ | ✅ | ❌ |
|
||||||
|
| Google Gemini | Gemini 2.5 Pro, Imagen | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Mistral | Mistral Large, Mistral Embed | ✅ | ✅ | ❌ | ✅ |
|
||||||
|
| Cohere | Command A, Command R+ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|
| OpenRouter | 300+ models | ✅ | ✅ | ✅ | ❌ |
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
|
@ -158,7 +170,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
||||||
|
|
||||||
- Inspired by [T3 Chat](https://t3.chat/)
|
- Inspired by [T3 Chat](https://t3.chat/)
|
||||||
- Built with [SvelteKit](https://kit.svelte.dev/)
|
- Built with [SvelteKit](https://kit.svelte.dev/)
|
||||||
- Powered by [OpenRouter](https://openrouter.ai/)
|
- Powered by [Kepler AI SDK](https://deepwiki.com/keplersystems/kepler-ai-sdk)
|
||||||
- Database by [Convex](https://convex.dev/)
|
- Database by [Convex](https://convex.dev/)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,10 @@
|
||||||
"melt": "^0.38.0",
|
"melt": "^0.38.0",
|
||||||
"mode-watcher": "^1.0.8",
|
"mode-watcher": "^1.0.8",
|
||||||
"neverthrow": "^8.2.0",
|
"neverthrow": "^8.2.0",
|
||||||
|
"@anthropic-ai/sdk": "^0.29.0",
|
||||||
|
"@google/generative-ai": "^0.21.0",
|
||||||
|
"@mistralai/mistralai": "^1.1.0",
|
||||||
|
"cohere-ai": "^7.14.0",
|
||||||
"openai": "^5.5.1",
|
"openai": "^5.5.1",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
|
|
@ -83,6 +87,7 @@
|
||||||
"@fontsource-variable/nunito-sans": "^5.2.6",
|
"@fontsource-variable/nunito-sans": "^5.2.6",
|
||||||
"@fontsource-variable/open-sans": "^5.2.6",
|
"@fontsource-variable/open-sans": "^5.2.6",
|
||||||
"@fontsource/instrument-serif": "^5.2.6",
|
"@fontsource/instrument-serif": "^5.2.6",
|
||||||
|
"@keplersystems/kepler-ai-sdk": "^1.0.5",
|
||||||
"better-auth": "^1.2.9",
|
"better-auth": "^1.2.9",
|
||||||
"convex-helpers": "^0.1.94",
|
"convex-helpers": "^0.1.94",
|
||||||
"hastscript": "^9.0.1",
|
"hastscript": "^9.0.1",
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ export default defineSchema({
|
||||||
user_settings: defineTable({
|
user_settings: defineTable({
|
||||||
user_id: v.string(),
|
user_id: v.string(),
|
||||||
privacy_mode: v.boolean(),
|
privacy_mode: v.boolean(),
|
||||||
free_messages_used: v.optional(v.number()),
|
|
||||||
}).index('by_user', ['user_id']),
|
}).index('by_user', ['user_id']),
|
||||||
user_keys: defineTable({
|
user_keys: defineTable({
|
||||||
user_id: v.string(),
|
user_id: v.string(),
|
||||||
|
|
|
||||||
|
|
@ -89,38 +89,6 @@ export const set = mutation({
|
||||||
await ctx.db.replace(existing._id, userKey);
|
await ctx.db.replace(existing._id, userKey);
|
||||||
} else {
|
} else {
|
||||||
await ctx.db.insert('user_keys', userKey);
|
await ctx.db.insert('user_keys', userKey);
|
||||||
|
|
||||||
if (args.provider === Provider.OpenRouter) {
|
|
||||||
const defaultModels = [
|
|
||||||
'google/gemini-2.5-flash',
|
|
||||||
'anthropic/claude-sonnet-4',
|
|
||||||
'openai/o3-mini',
|
|
||||||
'deepseek/deepseek-chat-v3-0324:free',
|
|
||||||
];
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
defaultModels.map(async (model) => {
|
|
||||||
const existing = await ctx.db
|
|
||||||
.query('user_enabled_models')
|
|
||||||
.withIndex('by_model_provider_user', (q) =>
|
|
||||||
q
|
|
||||||
.eq('model_id', model)
|
|
||||||
.eq('provider', Provider.OpenRouter)
|
|
||||||
.eq('user_id', session.userId)
|
|
||||||
)
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (existing) return;
|
|
||||||
|
|
||||||
await ctx.db.insert('user_enabled_models', {
|
|
||||||
user_id: session.userId,
|
|
||||||
provider: Provider.OpenRouter,
|
|
||||||
model_id: model,
|
|
||||||
pinned: true,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -26,41 +26,6 @@ export const get = query({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const incrementFreeMessageCount = mutation({
|
|
||||||
args: {
|
|
||||||
session_token: v.string(),
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
const session = await ctx.runQuery(internal.betterAuth.getSession, {
|
|
||||||
sessionToken: args.session_token,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
throw new Error('Invalid session token');
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = session as SessionObj;
|
|
||||||
|
|
||||||
const existing = await ctx.db
|
|
||||||
.query('user_settings')
|
|
||||||
.withIndex('by_user', (q) => q.eq('user_id', s.userId))
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
await ctx.db.insert('user_settings', {
|
|
||||||
user_id: s.userId,
|
|
||||||
privacy_mode: false,
|
|
||||||
free_messages_used: 1,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const currentCount = existing.free_messages_used || 0;
|
|
||||||
await ctx.db.patch(existing._id, {
|
|
||||||
free_messages_used: currentCount + 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const set = mutation({
|
export const set = mutation({
|
||||||
args: {
|
args: {
|
||||||
privacy_mode: v.boolean(),
|
privacy_mode: v.boolean(),
|
||||||
|
|
@ -86,7 +51,6 @@ export const set = mutation({
|
||||||
await ctx.db.insert('user_settings', {
|
await ctx.db.insert('user_settings', {
|
||||||
user_id: s.userId,
|
user_id: s.userId,
|
||||||
privacy_mode: args.privacy_mode,
|
privacy_mode: args.privacy_mode,
|
||||||
free_messages_used: 0,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await ctx.db.patch(existing._id, {
|
await ctx.db.patch(existing._id, {
|
||||||
|
|
@ -105,7 +69,6 @@ export const create = mutation({
|
||||||
await ctx.db.insert('user_settings', {
|
await ctx.db.insert('user_settings', {
|
||||||
user_id: args.user_id,
|
user_id: args.user_id,
|
||||||
privacy_mode: false,
|
privacy_mode: false,
|
||||||
free_messages_used: 0,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
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';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const Provider = {
|
export const Provider = {
|
||||||
OpenRouter: 'openrouter',
|
|
||||||
HuggingFace: 'huggingface',
|
|
||||||
OpenAI: 'openai',
|
OpenAI: 'openai',
|
||||||
Anthropic: 'anthropic',
|
Anthropic: 'anthropic',
|
||||||
|
Gemini: 'gemini',
|
||||||
|
Mistral: 'mistral',
|
||||||
|
Cohere: 'cohere',
|
||||||
|
OpenRouter: 'openrouter',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Provider = (typeof Provider)[keyof typeof Provider];
|
export type Provider = (typeof Provider)[keyof typeof Provider];
|
||||||
|
|
@ -13,8 +15,13 @@ export type ProviderMeta = {
|
||||||
title: string;
|
title: string;
|
||||||
link: string;
|
link: string;
|
||||||
description: string;
|
description: string;
|
||||||
models?: string[];
|
apiKeyName: string;
|
||||||
placeholder?: string;
|
placeholder: string;
|
||||||
|
docsLink: string;
|
||||||
|
supportsStreaming: boolean;
|
||||||
|
supportsTools: boolean;
|
||||||
|
supportsVision: boolean;
|
||||||
|
supportsEmbeddings: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UrlCitationSchema = z.object({
|
export const UrlCitationSchema = z.object({
|
||||||
|
|
@ -34,3 +41,78 @@ export type UrlCitation = z.infer<typeof UrlCitationSchema>;
|
||||||
// export const AnnotationSchema = z.union([UrlCitationSchema, ...]);
|
// export const AnnotationSchema = z.union([UrlCitationSchema, ...]);
|
||||||
export const AnnotationSchema = UrlCitationSchema;
|
export const AnnotationSchema = UrlCitationSchema;
|
||||||
export type Annotation = z.infer<typeof AnnotationSchema>;
|
export type Annotation = z.infer<typeof AnnotationSchema>;
|
||||||
|
|
||||||
|
export const PROVIDER_META: Record<Provider, ProviderMeta> = {
|
||||||
|
[Provider.OpenAI]: {
|
||||||
|
title: 'OpenAI',
|
||||||
|
link: 'https://openai.com',
|
||||||
|
description: 'GPT models, DALL-E, and Whisper from OpenAI',
|
||||||
|
apiKeyName: 'OpenAI API Key',
|
||||||
|
placeholder: 'sk-...',
|
||||||
|
docsLink: 'https://platform.openai.com/docs',
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsTools: true,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsEmbeddings: true,
|
||||||
|
},
|
||||||
|
[Provider.Anthropic]: {
|
||||||
|
title: 'Anthropic',
|
||||||
|
link: 'https://anthropic.com',
|
||||||
|
description: 'Claude models from Anthropic',
|
||||||
|
apiKeyName: 'Anthropic API Key',
|
||||||
|
placeholder: 'sk-ant-...',
|
||||||
|
docsLink: 'https://docs.anthropic.com',
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsTools: true,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsEmbeddings: false,
|
||||||
|
},
|
||||||
|
[Provider.Gemini]: {
|
||||||
|
title: 'Google Gemini',
|
||||||
|
link: 'https://cloud.google.com/vertex-ai',
|
||||||
|
description: 'Gemini models from Google',
|
||||||
|
apiKeyName: 'Google AI API Key',
|
||||||
|
placeholder: 'AIza...',
|
||||||
|
docsLink: 'https://ai.google.dev/docs',
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsTools: true,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsEmbeddings: true,
|
||||||
|
},
|
||||||
|
[Provider.Mistral]: {
|
||||||
|
title: 'Mistral',
|
||||||
|
link: 'https://mistral.ai',
|
||||||
|
description: 'Mistral models and embeddings',
|
||||||
|
apiKeyName: 'Mistral API Key',
|
||||||
|
placeholder: 'mistral-...',
|
||||||
|
docsLink: 'https://docs.mistral.ai',
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsTools: true,
|
||||||
|
supportsVision: false,
|
||||||
|
supportsEmbeddings: true,
|
||||||
|
},
|
||||||
|
[Provider.Cohere]: {
|
||||||
|
title: 'Cohere',
|
||||||
|
link: 'https://cohere.com',
|
||||||
|
description: 'Command models and embeddings from Cohere',
|
||||||
|
apiKeyName: 'Cohere API Key',
|
||||||
|
placeholder: 'co_...',
|
||||||
|
docsLink: 'https://docs.cohere.com',
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsTools: true,
|
||||||
|
supportsVision: false,
|
||||||
|
supportsEmbeddings: true,
|
||||||
|
},
|
||||||
|
[Provider.OpenRouter]: {
|
||||||
|
title: 'OpenRouter',
|
||||||
|
link: 'https://openrouter.ai',
|
||||||
|
description: 'Access to 300+ models through OpenRouter',
|
||||||
|
apiKeyName: 'OpenRouter API Key',
|
||||||
|
placeholder: 'sk-or-...',
|
||||||
|
docsLink: 'https://openrouter.ai/docs',
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsTools: true,
|
||||||
|
supportsVision: true,
|
||||||
|
supportsEmbeddings: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,212 @@
|
||||||
import { Result, ResultAsync } from 'neverthrow';
|
import { Result, ResultAsync } from 'neverthrow';
|
||||||
|
import { Provider, PROVIDER_META } from '$lib/types';
|
||||||
|
|
||||||
export type OpenRouterApiKeyData = {
|
export type ProviderApiKeyData = {
|
||||||
label: string;
|
label: string;
|
||||||
usage: number;
|
usage?: number;
|
||||||
is_free_tier: boolean;
|
is_free_tier?: boolean;
|
||||||
is_provisioning_key: boolean;
|
is_provisioning_key?: boolean;
|
||||||
limit: number;
|
limit?: number;
|
||||||
limit_remaining: number;
|
limit_remaining?: number;
|
||||||
|
valid: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OpenRouter = {
|
export const ProviderUtils = {
|
||||||
getApiKey: async (key: string): Promise<Result<OpenRouterApiKeyData, string>> => {
|
/**
|
||||||
|
* Validate an API key for a specific provider
|
||||||
|
*/
|
||||||
|
validateApiKey: async (provider: Provider, key: string): Promise<Result<ProviderApiKeyData, string>> => {
|
||||||
|
switch (provider) {
|
||||||
|
case Provider.OpenRouter:
|
||||||
|
return await validateOpenRouterKey(key);
|
||||||
|
case Provider.OpenAI:
|
||||||
|
return await validateOpenAIKey(key);
|
||||||
|
case Provider.Anthropic:
|
||||||
|
return await validateAnthropicKey(key);
|
||||||
|
case Provider.Gemini:
|
||||||
|
return await validateGeminiKey(key);
|
||||||
|
case Provider.Mistral:
|
||||||
|
return await validateMistralKey(key);
|
||||||
|
case Provider.Cohere:
|
||||||
|
return await validateCohereKey(key);
|
||||||
|
default:
|
||||||
|
return Result.err(`Validation not implemented for provider: ${provider}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get provider metadata
|
||||||
|
*/
|
||||||
|
getProviderMeta: (provider: Provider) => {
|
||||||
|
return PROVIDER_META[provider];
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a provider is supported
|
||||||
|
*/
|
||||||
|
isProviderSupported: (provider: string): provider is Provider => {
|
||||||
|
return Object.values(Provider).includes(provider as Provider);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all supported providers
|
||||||
|
*/
|
||||||
|
getSupportedProviders: () => {
|
||||||
|
return Object.values(Provider);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Provider-specific validation functions
|
||||||
|
async function validateOpenRouterKey(key: string): Promise<Result<ProviderApiKeyData, string>> {
|
||||||
return await ResultAsync.fromPromise(
|
return await ResultAsync.fromPromise(
|
||||||
(async () => {
|
(async () => {
|
||||||
const res = await fetch('https://openrouter.ai/api/v1/key', {
|
const res = await fetch('https://openrouter.ai/api/v1/auth/key', {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${key}`,
|
Authorization: `Bearer ${key}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) throw new Error('Failed to get API key');
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
const { data } = await res.json();
|
const { data } = await res.json();
|
||||||
|
|
||||||
if (!data) throw new Error('No info returned for api key');
|
if (!data) {
|
||||||
|
throw new Error('No key information returned');
|
||||||
|
}
|
||||||
|
|
||||||
return data as OpenRouterApiKeyData;
|
return {
|
||||||
})(),
|
label: data.label || 'OpenRouter API Key',
|
||||||
(e) => `Failed to get API key ${e}`
|
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">
|
<script lang="ts">
|
||||||
import { Provider, type ProviderMeta } from '$lib/types';
|
import { Provider, PROVIDER_META } from '$lib/types';
|
||||||
import ProviderCard from './provider-card.svelte';
|
import ProviderCard from './provider-card.svelte';
|
||||||
|
|
||||||
const allProviders = Object.values(Provider);
|
const allProviders = Object.values(Provider);
|
||||||
|
|
||||||
const providersMeta: Record<Provider, ProviderMeta> = {
|
|
||||||
[Provider.OpenRouter]: {
|
|
||||||
title: 'OpenRouter',
|
|
||||||
link: 'https://openrouter.ai/settings/keys',
|
|
||||||
description: 'API Key for OpenRouter.',
|
|
||||||
models: ['a shit ton'],
|
|
||||||
placeholder: 'sk-or-...',
|
|
||||||
},
|
|
||||||
[Provider.HuggingFace]: {
|
|
||||||
title: 'HuggingFace',
|
|
||||||
link: 'https://huggingface.co/settings/tokens',
|
|
||||||
description: 'API Key for HuggingFace, for open-source models.',
|
|
||||||
placeholder: 'hf_...',
|
|
||||||
},
|
|
||||||
[Provider.OpenAI]: {
|
|
||||||
title: 'OpenAI',
|
|
||||||
link: 'https://platform.openai.com/account/api-keys',
|
|
||||||
description: 'API Key for OpenAI.',
|
|
||||||
models: ['gpt-3.5-turbo', 'gpt-4'],
|
|
||||||
placeholder: 'sk-...',
|
|
||||||
},
|
|
||||||
[Provider.Anthropic]: {
|
|
||||||
title: 'Anthropic',
|
|
||||||
link: 'https://console.anthropic.com/account/api-keys',
|
|
||||||
description: 'API Key for Anthropic.',
|
|
||||||
models: [
|
|
||||||
'Claude 3.5 Sonnet',
|
|
||||||
'Claude 3.7 Sonnet',
|
|
||||||
'Claude 3.7 Sonnet (Reasoning)',
|
|
||||||
'Claude 4 Opus',
|
|
||||||
'Claude 4 Sonnet',
|
|
||||||
'Claude 4 Sonnet (Reasoning)',
|
|
||||||
],
|
|
||||||
placeholder: 'sk-ant-...',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -49,17 +12,13 @@
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold">API Keys</h1>
|
<h1 class="text-2xl font-bold">API Keys</h1>
|
||||||
<h2 class="text-muted-foreground mt-2 text-sm">
|
<h2 class="text-muted-foreground mt-2 text-sm">
|
||||||
Bring your own API keys for select models. Messages sent using your API keys will not count
|
Add your API keys to access models from different AI providers. You need at least one API key to use the chat.
|
||||||
towards your monthly limits.
|
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex flex-col gap-4">
|
<div class="mt-8 flex flex-col gap-4">
|
||||||
{#each allProviders as provider (provider)}
|
{#each allProviders as provider (provider)}
|
||||||
<!-- only do OpenRouter for now -->
|
{@const meta = PROVIDER_META[provider]}
|
||||||
{#if provider === Provider.OpenRouter}
|
|
||||||
{@const meta = providersMeta[provider]}
|
|
||||||
<ProviderCard {provider} {meta} />
|
<ProviderCard {provider} {meta} />
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
import { useConvexClient } from 'convex-svelte';
|
import { useConvexClient } from 'convex-svelte';
|
||||||
import { ResultAsync } from 'neverthrow';
|
import { ResultAsync } from 'neverthrow';
|
||||||
import { resource } from 'runed';
|
import { resource } from 'runed';
|
||||||
import * as providers from '$lib/utils/providers';
|
import { ProviderUtils } from '$lib/utils/providers';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
|
|
@ -65,11 +65,8 @@
|
||||||
async (key) => {
|
async (key) => {
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
|
|
||||||
if (provider === Provider.OpenRouter) {
|
const result = await ProviderUtils.validateApiKey(provider, key);
|
||||||
return (await providers.OpenRouter.getApiKey(key)).unwrapOr(null);
|
return result.unwrapOr(null);
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -99,11 +96,17 @@
|
||||||
{#if apiKeyInfoResource.loading}
|
{#if apiKeyInfoResource.loading}
|
||||||
<div class="bg-input h-6 w-[200px] animate-pulse rounded-md"></div>
|
<div class="bg-input h-6 w-[200px] animate-pulse rounded-md"></div>
|
||||||
{:else if apiKeyInfoResource.current}
|
{:else if apiKeyInfoResource.current}
|
||||||
|
{#if apiKeyInfoResource.current.usage !== undefined && apiKeyInfoResource.current.limit_remaining !== undefined}
|
||||||
<span class="text-muted-foreground flex h-6 place-items-center text-xs">
|
<span class="text-muted-foreground flex h-6 place-items-center text-xs">
|
||||||
${apiKeyInfoResource.current?.usage.toFixed(3)} used ・ ${apiKeyInfoResource.current?.limit_remaining.toFixed(
|
${apiKeyInfoResource.current.usage.toFixed(3)} used ・ ${apiKeyInfoResource.current.limit_remaining.toFixed(
|
||||||
3
|
3
|
||||||
)} remaining
|
)} remaining
|
||||||
</span>
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted-foreground flex h-6 place-items-center text-xs">
|
||||||
|
✅ API key is valid
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<span
|
<span
|
||||||
class="flex h-6 w-fit place-items-center rounded-lg bg-red-500/50 px-2 text-xs text-red-500"
|
class="flex h-6 w-fit place-items-center rounded-lg bg-red-500/50 px-2 text-xs text-red-500"
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import { error, json, type RequestHandler } from '@sveltejs/kit';
|
import { error, json, type RequestHandler } from '@sveltejs/kit';
|
||||||
import { ResultAsync } from 'neverthrow';
|
import { ResultAsync } from 'neverthrow';
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
import { OPENROUTER_FREE_KEY } from '$env/static/private';
|
|
||||||
import { OpenAI } from 'openai';
|
|
||||||
import { ConvexHttpClient } from 'convex/browser';
|
import { ConvexHttpClient } from 'convex/browser';
|
||||||
import { PUBLIC_CONVEX_URL } from '$env/static/public';
|
import { PUBLIC_CONVEX_URL } from '$env/static/public';
|
||||||
import { api } from '$lib/backend/convex/_generated/api';
|
import { api } from '$lib/backend/convex/_generated/api';
|
||||||
import { parseMessageForRules } from '$lib/utils/rules';
|
import { parseMessageForRules } from '$lib/utils/rules';
|
||||||
import { Provider } from '$lib/types';
|
import { createModelManager } from '$lib/services/model-manager';
|
||||||
|
import type { UserApiKeys } from '$lib/services/model-manager';
|
||||||
const FREE_MODEL = 'google/gemma-3-27b-it';
|
|
||||||
|
|
||||||
const reqBodySchema = z.object({
|
const reqBodySchema = z.object({
|
||||||
prompt: z.string(),
|
prompt: z.string(),
|
||||||
|
|
@ -31,6 +28,29 @@ function response({ enhanced_prompt }: { enhanced_prompt: string }) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getUserApiKeys(sessionToken: string): Promise<UserApiKeys | null> {
|
||||||
|
const keysResult = await ResultAsync.fromPromise(
|
||||||
|
client.query(api.user_keys.all, {
|
||||||
|
session_token: sessionToken,
|
||||||
|
}),
|
||||||
|
(e) => `Failed to get user API keys: ${e}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (keysResult.isErr()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = keysResult.value;
|
||||||
|
return {
|
||||||
|
openai: keys.openai,
|
||||||
|
anthropic: keys.anthropic,
|
||||||
|
gemini: keys.gemini,
|
||||||
|
mistral: keys.mistral,
|
||||||
|
cohere: keys.cohere,
|
||||||
|
openrouter: keys.openrouter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
const bodyResult = await ResultAsync.fromPromise(
|
const bodyResult = await ResultAsync.fromPromise(
|
||||||
request.json(),
|
request.json(),
|
||||||
|
|
@ -53,39 +73,60 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
return error(401, 'You must be logged in to enhance a prompt');
|
return error(401, 'You must be logged in to enhance a prompt');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [rulesResult, keyResult] = await Promise.all([
|
// Get user API keys
|
||||||
ResultAsync.fromPromise(
|
const userApiKeys = await getUserApiKeys(session.session.token);
|
||||||
|
if (!userApiKeys) {
|
||||||
|
return error(500, 'Failed to get user API keys');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAnyKey = Object.values(userApiKeys).some((key) => key);
|
||||||
|
if (!hasAnyKey) {
|
||||||
|
return error(
|
||||||
|
400,
|
||||||
|
'No API keys configured. Please add at least one provider API key in settings to enhance prompts.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user rules for context
|
||||||
|
const rulesResult = await ResultAsync.fromPromise(
|
||||||
client.query(api.user_rules.all, {
|
client.query(api.user_rules.all, {
|
||||||
session_token: session.session.token,
|
session_token: session.session.token,
|
||||||
}),
|
}),
|
||||||
(e) => `Failed to get rules: ${e}`
|
(e) => `Failed to get rules: ${e}`
|
||||||
),
|
);
|
||||||
ResultAsync.fromPromise(
|
|
||||||
client.query(api.user_keys.get, {
|
|
||||||
provider: Provider.OpenRouter,
|
|
||||||
session_token: session.session.token,
|
|
||||||
}),
|
|
||||||
(e) => `Failed to get API key: ${e}`
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (rulesResult.isErr()) {
|
if (rulesResult.isErr()) {
|
||||||
return error(500, 'Failed to get rules');
|
return error(500, 'Failed to get rules');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keyResult.isErr()) {
|
|
||||||
return error(500, 'Failed to get key');
|
|
||||||
}
|
|
||||||
|
|
||||||
const mentionedRules = parseMessageForRules(
|
const mentionedRules = parseMessageForRules(
|
||||||
args.prompt,
|
args.prompt,
|
||||||
rulesResult.value.filter((r) => r.attach === 'manual')
|
rulesResult.value.filter((r) => r.attach === 'manual')
|
||||||
);
|
);
|
||||||
|
|
||||||
const openai = new OpenAI({
|
// Initialize model manager with user's API keys
|
||||||
baseURL: 'https://openrouter.ai/api/v1',
|
const modelManager = createModelManager();
|
||||||
apiKey: keyResult.value ?? OPENROUTER_FREE_KEY,
|
modelManager.initializeProviders(userApiKeys);
|
||||||
});
|
|
||||||
|
// Try to find a fast, cheap model for prompt enhancement
|
||||||
|
const availableModels = await modelManager.listAvailableModels();
|
||||||
|
const enhanceModel =
|
||||||
|
availableModels.find(
|
||||||
|
(model) =>
|
||||||
|
model.id.includes('kimi-k2') ||
|
||||||
|
model.id.includes('gemini-2.5-flash-lite') ||
|
||||||
|
model.id.includes('gpt-5-mini') ||
|
||||||
|
model.id.includes('mistral-small')
|
||||||
|
) || availableModels[0];
|
||||||
|
|
||||||
|
if (!enhanceModel) {
|
||||||
|
return error(500, 'No suitable models available for prompt enhancement');
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = modelManager.getProvider(enhanceModel.provider);
|
||||||
|
if (!provider) {
|
||||||
|
return error(500, `Provider ${enhanceModel.provider} not available`);
|
||||||
|
}
|
||||||
|
|
||||||
const enhancePrompt = `
|
const enhancePrompt = `
|
||||||
Enhance prompt below (wrapped in <prompt> tags) so that it can be better understood by LLMs You job is not to answer the prompt but simply prepare it to be answered by another LLM.
|
Enhance prompt below (wrapped in <prompt> tags) so that it can be better understood by LLMs You job is not to answer the prompt but simply prepare it to be answered by another LLM.
|
||||||
|
|
@ -107,23 +148,24 @@ ${args.prompt}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const enhancedResult = await ResultAsync.fromPromise(
|
const enhancedResult = await ResultAsync.fromPromise(
|
||||||
openai.chat.completions.create({
|
provider.generateCompletion({
|
||||||
model: FREE_MODEL,
|
model: enhanceModel.id,
|
||||||
messages: [{ role: 'user', content: enhancePrompt }],
|
messages: [{ role: 'user', content: enhancePrompt }],
|
||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
|
maxTokens: 1000,
|
||||||
}),
|
}),
|
||||||
(e) => `Enhance prompt API call failed: ${e}`
|
(e) => `Enhance prompt API call failed: ${e}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (enhancedResult.isErr()) {
|
if (enhancedResult.isErr()) {
|
||||||
return error(500, 'error enhancing the prompt');
|
return error(500, 'Error enhancing the prompt');
|
||||||
}
|
}
|
||||||
|
|
||||||
const enhancedResponse = enhancedResult.value;
|
const enhancedResponse = enhancedResult.value;
|
||||||
const enhanced = enhancedResponse.choices[0]?.message?.content;
|
const enhanced = enhancedResponse.content?.trim();
|
||||||
|
|
||||||
if (!enhanced) {
|
if (!enhanced) {
|
||||||
return error(500, 'error enhancing the prompt');
|
return error(500, 'Error enhancing the prompt');
|
||||||
}
|
}
|
||||||
|
|
||||||
return response({
|
return response({
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { PUBLIC_CONVEX_URL } from '$env/static/public';
|
import { PUBLIC_CONVEX_URL } from '$env/static/public';
|
||||||
import { OPENROUTER_FREE_KEY } from '$env/static/private';
|
|
||||||
import { api } from '$lib/backend/convex/_generated/api';
|
import { api } from '$lib/backend/convex/_generated/api';
|
||||||
import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel';
|
import type { Doc, Id } from '$lib/backend/convex/_generated/dataModel';
|
||||||
import { Provider, type Annotation } from '$lib/types';
|
import { Provider, type Annotation } from '$lib/types';
|
||||||
|
|
@ -8,12 +7,13 @@ import { waitUntil } from '@vercel/functions';
|
||||||
import { getSessionCookie } from 'better-auth/cookies';
|
import { getSessionCookie } from 'better-auth/cookies';
|
||||||
import { ConvexHttpClient } from 'convex/browser';
|
import { ConvexHttpClient } from 'convex/browser';
|
||||||
import { err, ok, Result, ResultAsync } from 'neverthrow';
|
import { err, ok, Result, ResultAsync } from 'neverthrow';
|
||||||
import OpenAI from 'openai';
|
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
import { generationAbortControllers } from './cache.js';
|
import { generationAbortControllers } from './cache.js';
|
||||||
import { md } from '$lib/utils/markdown-it.js';
|
import { md } from '$lib/utils/markdown-it.js';
|
||||||
import * as array from '$lib/utils/array';
|
import * as array from '$lib/utils/array';
|
||||||
import { parseMessageForRules } from '$lib/utils/rules.js';
|
import { parseMessageForRules } from '$lib/utils/rules.js';
|
||||||
|
import { createModelManager, type ChatModelManager } from '$lib/services/model-manager.js';
|
||||||
|
import type { UserApiKeys } from '$lib/services/model-manager.js';
|
||||||
|
|
||||||
// Set to true to enable debug logging
|
// Set to true to enable debug logging
|
||||||
const ENABLE_LOGGING = true;
|
const ENABLE_LOGGING = true;
|
||||||
|
|
@ -22,7 +22,6 @@ const reqBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
message: z.string().optional(),
|
message: z.string().optional(),
|
||||||
model_id: z.string(),
|
model_id: z.string(),
|
||||||
|
|
||||||
session_token: z.string(),
|
session_token: z.string(),
|
||||||
conversation_id: z.string().optional(),
|
conversation_id: z.string().optional(),
|
||||||
web_search_enabled: z.boolean().optional(),
|
web_search_enabled: z.boolean().optional(),
|
||||||
|
|
@ -40,7 +39,6 @@ const reqBodySchema = z
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.conversation_id === undefined && data.message === undefined) return false;
|
if (data.conversation_id === undefined && data.message === undefined) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -67,34 +65,45 @@ function log(message: string, startTime: number): void {
|
||||||
|
|
||||||
const client = new ConvexHttpClient(PUBLIC_CONVEX_URL);
|
const client = new ConvexHttpClient(PUBLIC_CONVEX_URL);
|
||||||
|
|
||||||
|
async function getUserApiKeys(sessionToken: string): Promise<Result<UserApiKeys, string>> {
|
||||||
|
const keysResult = await ResultAsync.fromPromise(
|
||||||
|
client.query(api.user_keys.all, {
|
||||||
|
session_token: sessionToken,
|
||||||
|
}),
|
||||||
|
(e) => `Failed to get user API keys: ${e}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (keysResult.isErr()) {
|
||||||
|
return err(keysResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = keysResult.value;
|
||||||
|
return ok({
|
||||||
|
openai: keys.openai,
|
||||||
|
anthropic: keys.anthropic,
|
||||||
|
gemini: keys.gemini,
|
||||||
|
mistral: keys.mistral,
|
||||||
|
cohere: keys.cohere,
|
||||||
|
openrouter: keys.openrouter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function generateConversationTitle({
|
async function generateConversationTitle({
|
||||||
conversationId,
|
conversationId,
|
||||||
sessionToken,
|
sessionToken,
|
||||||
startTime,
|
startTime,
|
||||||
keyResultPromise,
|
|
||||||
userMessage,
|
userMessage,
|
||||||
|
modelManager,
|
||||||
}: {
|
}: {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
sessionToken: string;
|
sessionToken: string;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
keyResultPromise: ResultAsync<string | null, string>;
|
|
||||||
userMessage: string;
|
userMessage: string;
|
||||||
|
modelManager: ChatModelManager;
|
||||||
}) {
|
}) {
|
||||||
log('Starting conversation title generation', startTime);
|
log('Starting conversation title generation', startTime);
|
||||||
|
|
||||||
const keyResult = await keyResultPromise;
|
// Check if conversation currently has default title
|
||||||
|
|
||||||
if (keyResult.isErr()) {
|
|
||||||
log(`Title generation: API key error: ${keyResult.error}`, startTime);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userKey = keyResult.value;
|
|
||||||
const actualKey = userKey || OPENROUTER_FREE_KEY;
|
|
||||||
|
|
||||||
log(`Title generation: Using ${userKey ? 'user' : 'free tier'} API key`, startTime);
|
|
||||||
|
|
||||||
// Only generate title if conversation currently has default title
|
|
||||||
const conversationResult = await ResultAsync.fromPromise(
|
const conversationResult = await ResultAsync.fromPromise(
|
||||||
client.query(api.conversations.get, {
|
client.query(api.conversations.get, {
|
||||||
session_token: sessionToken,
|
session_token: sessionToken,
|
||||||
|
|
@ -115,12 +124,25 @@ async function generateConversationTitle({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const openai = new OpenAI({
|
// Try to find a fast, cheap model for title generation
|
||||||
baseURL: 'https://openrouter.ai/api/v1',
|
const availableModels = await modelManager.listAvailableModels();
|
||||||
apiKey: actualKey,
|
const titleModel =
|
||||||
});
|
availableModels.find((model) => model.id.includes('kimi-k2')) ||
|
||||||
|
availableModels.find((model) => model.id.includes('gemini-2.5-flash-lite')) ||
|
||||||
|
availableModels.find((model) => model.id.includes('gpt-5-mini')) ||
|
||||||
|
availableModels[0];
|
||||||
|
|
||||||
|
if (!titleModel) {
|
||||||
|
log('Title generation: No suitable model available', startTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = modelManager.getProvider(titleModel.provider);
|
||||||
|
if (!provider) {
|
||||||
|
log(`Title generation: Provider ${titleModel.provider} not found`, startTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Create a prompt for title generation using only the first user message
|
|
||||||
const titlePrompt = `Based on this message:
|
const titlePrompt = `Based on this message:
|
||||||
"""${userMessage}"""
|
"""${userMessage}"""
|
||||||
|
|
||||||
|
|
@ -129,26 +151,25 @@ Generate only the title based on the message, nothing else. Don't name the title
|
||||||
|
|
||||||
Also, do not interact with the message directly or answer it. Just generate the title based on the message.
|
Also, do not interact with the message directly or answer it. Just generate the title based on the message.
|
||||||
|
|
||||||
If its a simple hi, just name it "Greeting" or something like that.
|
If its a simple hi, just name it "Greeting" or something like that.`;
|
||||||
`;
|
|
||||||
|
|
||||||
const titleResult = await ResultAsync.fromPromise(
|
const titleResult = await ResultAsync.fromPromise(
|
||||||
openai.chat.completions.create({
|
provider.generateCompletion({
|
||||||
model: 'mistralai/ministral-8b',
|
model: titleModel.id,
|
||||||
messages: [{ role: 'user', content: titlePrompt }],
|
messages: [{ role: 'user', content: titlePrompt }],
|
||||||
max_tokens: 20,
|
maxTokens: 1024,
|
||||||
temperature: 0.5,
|
temperature: 0.5,
|
||||||
}),
|
}),
|
||||||
(e) => `Title generation API call failed: ${e}`
|
(e) => `Title generation API call failed: ${e}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (titleResult.isErr()) {
|
if (titleResult.isErr()) {
|
||||||
log(`Title generation: OpenAI call failed: ${titleResult.error}`, startTime);
|
log(`Title generation: API call failed: ${titleResult.error}`, startTime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const titleResponse = titleResult.value;
|
const titleResponse = titleResult.value;
|
||||||
const rawTitle = titleResponse.choices[0]?.message?.content?.trim();
|
const rawTitle = titleResponse.content?.trim();
|
||||||
|
|
||||||
if (!rawTitle) {
|
if (!rawTitle) {
|
||||||
log('Title generation: No title generated', startTime);
|
log('Title generation: No title generated', startTime);
|
||||||
|
|
@ -180,20 +201,18 @@ async function generateAIResponse({
|
||||||
conversationId,
|
conversationId,
|
||||||
sessionToken,
|
sessionToken,
|
||||||
startTime,
|
startTime,
|
||||||
modelResultPromise,
|
modelId,
|
||||||
keyResultPromise,
|
modelManager,
|
||||||
rulesResultPromise,
|
rulesResultPromise,
|
||||||
userSettingsPromise,
|
|
||||||
abortSignal,
|
abortSignal,
|
||||||
reasoningEffort,
|
reasoningEffort,
|
||||||
}: {
|
}: {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
sessionToken: string;
|
sessionToken: string;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
keyResultPromise: ResultAsync<string | null, string>;
|
modelId: string;
|
||||||
modelResultPromise: ResultAsync<Doc<'user_enabled_models'> | null, string>;
|
modelManager: ChatModelManager;
|
||||||
rulesResultPromise: ResultAsync<Doc<'user_rules'>[], string>;
|
rulesResultPromise: ResultAsync<Doc<'user_rules'>[], string>;
|
||||||
userSettingsPromise: ResultAsync<Doc<'user_settings'> | null, string>;
|
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
reasoningEffort?: 'low' | 'medium' | 'high';
|
reasoningEffort?: 'low' | 'medium' | 'high';
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -204,10 +223,34 @@ async function generateAIResponse({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [modelResult, keyResult, messagesQueryResult, rulesResult, userSettingsResult] =
|
// Get model and provider
|
||||||
await Promise.all([
|
const model = await modelManager.getModel(modelId);
|
||||||
modelResultPromise,
|
if (!model) {
|
||||||
keyResultPromise,
|
handleGenerationError({
|
||||||
|
error: `Model ${modelId} not found or not available`,
|
||||||
|
conversationId,
|
||||||
|
messageId: undefined,
|
||||||
|
sessionToken,
|
||||||
|
startTime,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = modelManager.getProvider(model.provider);
|
||||||
|
if (!provider) {
|
||||||
|
handleGenerationError({
|
||||||
|
error: `Provider ${model.provider} not available`,
|
||||||
|
conversationId,
|
||||||
|
messageId: undefined,
|
||||||
|
sessionToken,
|
||||||
|
startTime,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Background: Using model ${modelId} with provider ${model.provider}`, startTime);
|
||||||
|
|
||||||
|
const [messagesQueryResult, rulesResult] = await Promise.all([
|
||||||
ResultAsync.fromPromise(
|
ResultAsync.fromPromise(
|
||||||
client.query(api.messages.getAllFromConversation, {
|
client.query(api.messages.getAllFromConversation, {
|
||||||
conversation_id: conversationId as Id<'conversations'>,
|
conversation_id: conversationId as Id<'conversations'>,
|
||||||
|
|
@ -216,34 +259,8 @@ async function generateAIResponse({
|
||||||
(e) => `Failed to get messages: ${e}`
|
(e) => `Failed to get messages: ${e}`
|
||||||
),
|
),
|
||||||
rulesResultPromise,
|
rulesResultPromise,
|
||||||
userSettingsPromise,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (modelResult.isErr()) {
|
|
||||||
handleGenerationError({
|
|
||||||
error: modelResult.error,
|
|
||||||
conversationId,
|
|
||||||
messageId: undefined,
|
|
||||||
sessionToken,
|
|
||||||
startTime,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = modelResult.value;
|
|
||||||
if (!model) {
|
|
||||||
handleGenerationError({
|
|
||||||
error: 'Model not found or not enabled',
|
|
||||||
conversationId,
|
|
||||||
messageId: undefined,
|
|
||||||
sessionToken,
|
|
||||||
startTime,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log('Background: Model found and enabled', startTime);
|
|
||||||
|
|
||||||
if (messagesQueryResult.isErr()) {
|
if (messagesQueryResult.isErr()) {
|
||||||
handleGenerationError({
|
handleGenerationError({
|
||||||
error: `messages query failed: ${messagesQueryResult.error}`,
|
error: `messages query failed: ${messagesQueryResult.error}`,
|
||||||
|
|
@ -262,14 +279,14 @@ async function generateAIResponse({
|
||||||
const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
|
const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
|
||||||
const webSearchEnabled = lastUserMessage?.web_search_enabled ?? false;
|
const webSearchEnabled = lastUserMessage?.web_search_enabled ?? false;
|
||||||
|
|
||||||
const modelId = webSearchEnabled ? `${model.model_id}:online` : model.model_id;
|
const finalModelId = webSearchEnabled ? `${modelId}:online` : modelId;
|
||||||
|
|
||||||
// Create assistant message
|
// Create assistant message
|
||||||
const messageCreationResult = await ResultAsync.fromPromise(
|
const messageCreationResult = await ResultAsync.fromPromise(
|
||||||
client.mutation(api.messages.create, {
|
client.mutation(api.messages.create, {
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
model_id: model.model_id,
|
model_id: modelId,
|
||||||
provider: Provider.OpenRouter,
|
provider: model.provider as Provider,
|
||||||
content: '',
|
content: '',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
session_token: sessionToken,
|
session_token: sessionToken,
|
||||||
|
|
@ -292,84 +309,6 @@ async function generateAIResponse({
|
||||||
const mid = messageCreationResult.value;
|
const mid = messageCreationResult.value;
|
||||||
log('Background: Assistant message created', startTime);
|
log('Background: Assistant message created', startTime);
|
||||||
|
|
||||||
if (keyResult.isErr()) {
|
|
||||||
handleGenerationError({
|
|
||||||
error: `API key query failed: ${keyResult.error}`,
|
|
||||||
conversationId,
|
|
||||||
messageId: mid,
|
|
||||||
sessionToken,
|
|
||||||
startTime,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userSettingsResult.isErr()) {
|
|
||||||
handleGenerationError({
|
|
||||||
error: `User settings query failed: ${userSettingsResult.error}`,
|
|
||||||
conversationId,
|
|
||||||
messageId: mid,
|
|
||||||
sessionToken,
|
|
||||||
startTime,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userKey = keyResult.value;
|
|
||||||
const userSettings = userSettingsResult.value;
|
|
||||||
let actualKey: string;
|
|
||||||
|
|
||||||
if (userKey) {
|
|
||||||
// User has their own API key
|
|
||||||
actualKey = userKey;
|
|
||||||
log('Background: Using user API key', startTime);
|
|
||||||
} else {
|
|
||||||
// User doesn't have API key, check if using a free model
|
|
||||||
const isFreeModel = model.model_id.endsWith(':free');
|
|
||||||
|
|
||||||
if (!isFreeModel) {
|
|
||||||
// For non-free models, check the 10 message limit
|
|
||||||
const freeMessagesUsed = userSettings?.free_messages_used || 0;
|
|
||||||
|
|
||||||
if (freeMessagesUsed >= 10) {
|
|
||||||
handleGenerationError({
|
|
||||||
error:
|
|
||||||
'Free message limit reached (10/10). Please add your own OpenRouter API key to continue chatting, or use a free model ending in ":free".',
|
|
||||||
conversationId,
|
|
||||||
messageId: mid,
|
|
||||||
sessionToken,
|
|
||||||
startTime,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment free message count before generating (only for non-free models)
|
|
||||||
const incrementResult = await ResultAsync.fromPromise(
|
|
||||||
client.mutation(api.user_settings.incrementFreeMessageCount, {
|
|
||||||
session_token: sessionToken,
|
|
||||||
}),
|
|
||||||
(e) => `Failed to increment free message count: ${e}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (incrementResult.isErr()) {
|
|
||||||
handleGenerationError({
|
|
||||||
error: `Failed to track free message usage: ${incrementResult.error}`,
|
|
||||||
conversationId,
|
|
||||||
messageId: mid,
|
|
||||||
sessionToken,
|
|
||||||
startTime,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log(`Background: Using free tier (${freeMessagesUsed + 1}/10 messages)`, startTime);
|
|
||||||
} else {
|
|
||||||
log(`Background: Using free model (${model.model_id}) - no message count`, startTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use environment OpenRouter key
|
|
||||||
actualKey = OPENROUTER_FREE_KEY;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rulesResult.isErr()) {
|
if (rulesResult.isErr()) {
|
||||||
handleGenerationError({
|
handleGenerationError({
|
||||||
error: `rules query failed: ${rulesResult.error}`,
|
error: `rules query failed: ${rulesResult.error}`,
|
||||||
|
|
@ -405,7 +344,7 @@ async function generateAIResponse({
|
||||||
attachedRules.push(...parsedRules);
|
attachedRules.push(...parsedRules);
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove duplicates
|
// Remove duplicates
|
||||||
attachedRules = array.fromMap(
|
attachedRules = array.fromMap(
|
||||||
array.toMap(attachedRules, (r) => [r._id, r]),
|
array.toMap(attachedRules, (r) => [r._id, r]),
|
||||||
(_k, v) => v
|
(_k, v) => v
|
||||||
|
|
@ -413,19 +352,14 @@ async function generateAIResponse({
|
||||||
|
|
||||||
log(`Background: ${attachedRules.length} rules attached`, startTime);
|
log(`Background: ${attachedRules.length} rules attached`, startTime);
|
||||||
|
|
||||||
const openai = new OpenAI({
|
|
||||||
baseURL: 'https://openrouter.ai/api/v1',
|
|
||||||
apiKey: actualKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
const formattedMessages = messages.map((m) => {
|
const formattedMessages = messages.map((m) => {
|
||||||
if (m.images && m.images.length > 0 && m.role === 'user') {
|
if (m.images && m.images.length > 0 && m.role === 'user') {
|
||||||
return {
|
return {
|
||||||
role: 'user' as const,
|
role: 'user' as const,
|
||||||
content: [
|
content: [
|
||||||
{ type: 'text' as const, text: m.content },
|
{ type: 'text', text: m.content },
|
||||||
...m.images.map((img) => ({
|
...m.images.map((img) => ({
|
||||||
type: 'image_url' as const,
|
type: 'image_url',
|
||||||
image_url: { url: img.url },
|
image_url: { url: img.url },
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
|
|
@ -462,20 +396,16 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate completion with streaming
|
||||||
const streamResult = await ResultAsync.fromPromise(
|
const streamResult = await ResultAsync.fromPromise(
|
||||||
openai.chat.completions.create(
|
provider.generateCompletion({
|
||||||
{
|
model: finalModelId,
|
||||||
model: modelId,
|
|
||||||
messages: messagesToSend,
|
messages: messagesToSend,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
stream: true,
|
stream: true,
|
||||||
reasoning_effort: reasoningEffort,
|
...(reasoningEffort && { reasoning_effort: reasoningEffort }),
|
||||||
},
|
}),
|
||||||
{
|
(e) => `API call failed: ${e}`
|
||||||
signal: abortSignal,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
(e) => `OpenAI API call failed: ${e}`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (streamResult.isErr()) {
|
if (streamResult.isErr()) {
|
||||||
|
|
@ -490,7 +420,7 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
const stream = streamResult.value;
|
const stream = streamResult.value;
|
||||||
log('Background: OpenAI stream created successfully', startTime);
|
log('Background: Stream created successfully', startTime);
|
||||||
|
|
||||||
let content = '';
|
let content = '';
|
||||||
let reasoning = '';
|
let reasoning = '';
|
||||||
|
|
@ -499,6 +429,8 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
||||||
const annotations: Annotation[] = [];
|
const annotations: Annotation[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Handle streaming response
|
||||||
|
if (stream && typeof stream[Symbol.asyncIterator] === 'function') {
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) {
|
||||||
if (abortSignal?.aborted) {
|
if (abortSignal?.aborted) {
|
||||||
log('AI response generation aborted during streaming', startTime);
|
log('AI response generation aborted during streaming', startTime);
|
||||||
|
|
@ -507,15 +439,19 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
||||||
|
|
||||||
chunkCount++;
|
chunkCount++;
|
||||||
|
|
||||||
// @ts-expect-error you're wrong
|
// Extract content from chunk based on the stream format
|
||||||
reasoning += chunk.choices[0]?.delta?.reasoning || '';
|
if (chunk && typeof chunk === 'object') {
|
||||||
content += chunk.choices[0]?.delta?.content || '';
|
const chunkContent = chunk.content || chunk.text || '';
|
||||||
// @ts-expect-error you're wrong
|
const chunkReasoning = chunk.reasoning || '';
|
||||||
annotations.push(...(chunk.choices[0]?.delta?.annotations ?? []));
|
const chunkAnnotations = chunk.annotations || [];
|
||||||
|
|
||||||
|
reasoning += chunkReasoning;
|
||||||
|
content += chunkContent;
|
||||||
|
annotations.push(...chunkAnnotations);
|
||||||
|
|
||||||
if (!content && !reasoning) continue;
|
if (!content && !reasoning) continue;
|
||||||
|
|
||||||
generationId = chunk.id;
|
generationId = chunk.id || generationId;
|
||||||
|
|
||||||
const updateResult = await ResultAsync.fromPromise(
|
const updateResult = await ResultAsync.fromPromise(
|
||||||
client.mutation(api.messages.updateContent, {
|
client.mutation(api.messages.updateContent, {
|
||||||
|
|
@ -537,56 +473,55 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle non-streaming response
|
||||||
|
const response = stream as any;
|
||||||
|
content = response.content || response.text || '';
|
||||||
|
reasoning = response.reasoning || '';
|
||||||
|
generationId = response.id;
|
||||||
|
|
||||||
|
if (response.annotations) {
|
||||||
|
annotations.push(...response.annotations);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateResult = await ResultAsync.fromPromise(
|
||||||
|
client.mutation(api.messages.updateContent, {
|
||||||
|
message_id: mid,
|
||||||
|
content,
|
||||||
|
reasoning: reasoning.length > 0 ? reasoning : undefined,
|
||||||
|
session_token: sessionToken,
|
||||||
|
generation_id: generationId,
|
||||||
|
annotations,
|
||||||
|
reasoning_effort: reasoningEffort,
|
||||||
|
}),
|
||||||
|
(e) => `Failed to update message content: ${e}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updateResult.isErr()) {
|
||||||
|
log(`Background message update failed: ${updateResult.error}`, startTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log(
|
log(
|
||||||
`Background stream processing completed. Processed ${chunkCount} chunks, final content length: ${content.length}`,
|
`Background stream processing completed. Processed ${chunkCount} chunks, final content length: ${content.length}`,
|
||||||
startTime
|
startTime
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!generationId) {
|
// Final message update with completion stats
|
||||||
log('Background: No generation id found', startTime);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentHtmlResultPromise = ResultAsync.fromPromise(
|
const contentHtmlResultPromise = ResultAsync.fromPromise(
|
||||||
md.renderAsync(content),
|
md.renderAsync(content),
|
||||||
(e) => `Failed to render HTML: ${e}`
|
(e) => `Failed to render HTML: ${e}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const generationStatsResult = await retryResult(
|
|
||||||
() => getGenerationStats(generationId!, actualKey),
|
|
||||||
{
|
|
||||||
delay: 500,
|
|
||||||
retries: 2,
|
|
||||||
startTime,
|
|
||||||
fnName: 'getGenerationStats',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (generationStatsResult.isErr()) {
|
|
||||||
log(`Background: Failed to get generation stats: ${generationStatsResult.error}`, startTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
// just default so we don't blow up
|
|
||||||
const generationStats = generationStatsResult.unwrapOr({
|
|
||||||
tokens_completion: undefined,
|
|
||||||
total_cost: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
log('Background: Got generation stats', startTime);
|
|
||||||
|
|
||||||
const contentHtmlResult = await contentHtmlResultPromise;
|
const contentHtmlResult = await contentHtmlResultPromise;
|
||||||
|
|
||||||
if (contentHtmlResult.isErr()) {
|
const [updateMessageResult, updateGeneratingResult] = await Promise.all([
|
||||||
log(`Background: Failed to render HTML: ${contentHtmlResult.error}`, startTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updateMessageResult, updateGeneratingResult, updateCostUsdResult] = await Promise.all([
|
|
||||||
ResultAsync.fromPromise(
|
ResultAsync.fromPromise(
|
||||||
client.mutation(api.messages.updateMessage, {
|
client.mutation(api.messages.updateMessage, {
|
||||||
message_id: mid,
|
message_id: mid,
|
||||||
token_count: generationStats.tokens_completion,
|
token_count: undefined, // Will be calculated by provider if available
|
||||||
cost_usd: generationStats.total_cost,
|
cost_usd: undefined, // Will be calculated by provider if available
|
||||||
generation_id: generationId,
|
generation_id: generationId,
|
||||||
session_token: sessionToken,
|
session_token: sessionToken,
|
||||||
content_html: contentHtmlResult.unwrapOr(undefined),
|
content_html: contentHtmlResult.unwrapOr(undefined),
|
||||||
|
|
@ -601,14 +536,6 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
||||||
}),
|
}),
|
||||||
(e) => `Failed to update generating status: ${e}`
|
(e) => `Failed to update generating status: ${e}`
|
||||||
),
|
),
|
||||||
ResultAsync.fromPromise(
|
|
||||||
client.mutation(api.conversations.updateCostUsd, {
|
|
||||||
conversation_id: conversationId as Id<'conversations'>,
|
|
||||||
cost_usd: generationStats.total_cost ?? 0,
|
|
||||||
session_token: sessionToken,
|
|
||||||
}),
|
|
||||||
(e) => `Failed to update cost usd: ${e}`
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (updateGeneratingResult.isErr()) {
|
if (updateGeneratingResult.isErr()) {
|
||||||
|
|
@ -624,13 +551,6 @@ ${attachedRules.map((r) => `- ${r.name}: ${r.rule}`).join('\n')}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
log('Background: Message updated', startTime);
|
log('Background: Message updated', startTime);
|
||||||
|
|
||||||
if (updateCostUsdResult.isErr()) {
|
|
||||||
log(`Background cost usd update failed: ${updateCostUsdResult.error}`, startTime);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log('Background: Cost usd updated', startTime);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleGenerationError({
|
handleGenerationError({
|
||||||
error: `Stream processing error: ${error}`,
|
error: `Stream processing error: ${error}`,
|
||||||
|
|
@ -672,7 +592,6 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
log('Schema validation passed', startTime);
|
log('Schema validation passed', startTime);
|
||||||
|
|
||||||
const cookie = getSessionCookie(request.headers);
|
const cookie = getSessionCookie(request.headers);
|
||||||
|
|
||||||
const sessionToken = cookie?.split('.')[0] ?? null;
|
const sessionToken = cookie?.split('.')[0] ?? null;
|
||||||
|
|
||||||
if (!sessionToken) {
|
if (!sessionToken) {
|
||||||
|
|
@ -680,29 +599,37 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
return error(401, 'Unauthorized');
|
return error(401, 'Unauthorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelResultPromise = ResultAsync.fromPromise(
|
// Get user API keys
|
||||||
client.query(api.user_enabled_models.get, {
|
const userApiKeysResult = await getUserApiKeys(sessionToken);
|
||||||
provider: Provider.OpenRouter,
|
if (userApiKeysResult.isErr()) {
|
||||||
model_id: args.model_id,
|
log(`Failed to get user API keys: ${userApiKeysResult.error}`, startTime);
|
||||||
session_token: sessionToken,
|
return error(500, 'Failed to get user API keys');
|
||||||
}),
|
}
|
||||||
(e) => `Failed to get model: ${e}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const keyResultPromise = ResultAsync.fromPromise(
|
const userApiKeys = userApiKeysResult.value;
|
||||||
client.query(api.user_keys.get, {
|
const hasAnyKey = Object.values(userApiKeys).some((key) => key);
|
||||||
provider: Provider.OpenRouter,
|
|
||||||
session_token: sessionToken,
|
|
||||||
}),
|
|
||||||
(e) => `Failed to get API key: ${e}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const userSettingsPromise = ResultAsync.fromPromise(
|
if (!hasAnyKey) {
|
||||||
client.query(api.user_settings.get, {
|
log('User has no API keys configured', startTime);
|
||||||
session_token: sessionToken,
|
return error(
|
||||||
}),
|
400,
|
||||||
(e) => `Failed to get user settings: ${e}`
|
'No API keys configured. Please add at least one provider API key in settings.'
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize model manager with user's API keys
|
||||||
|
const modelManager = createModelManager();
|
||||||
|
modelManager.initializeProviders(userApiKeys);
|
||||||
|
|
||||||
|
// Check if the requested model is available
|
||||||
|
const modelAvailable = await modelManager.isModelAvailable(args.model_id);
|
||||||
|
if (!modelAvailable) {
|
||||||
|
log(`Requested model ${args.model_id} not available`, startTime);
|
||||||
|
return error(
|
||||||
|
400,
|
||||||
|
`Model ${args.model_id} is not available. Please check your API keys and try a different model.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const rulesResultPromise = ResultAsync.fromPromise(
|
const rulesResultPromise = ResultAsync.fromPromise(
|
||||||
client.query(api.user_rules.all, {
|
client.query(api.user_rules.all, {
|
||||||
|
|
@ -715,7 +642,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
|
||||||
let conversationId = args.conversation_id;
|
let conversationId = args.conversation_id;
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
// technically zod should catch this but just in case
|
// Create new conversation
|
||||||
if (args.message === undefined) {
|
if (args.message === undefined) {
|
||||||
return error(400, 'You must provide a message when creating a new conversation');
|
return error(400, 'You must provide a message when creating a new conversation');
|
||||||
}
|
}
|
||||||
|
|
@ -746,8 +673,8 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
conversationId,
|
conversationId,
|
||||||
sessionToken,
|
sessionToken,
|
||||||
startTime,
|
startTime,
|
||||||
keyResultPromise,
|
|
||||||
userMessage: args.message,
|
userMessage: args.message,
|
||||||
|
modelManager,
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
log(`Background title generation error: ${error}`, startTime);
|
log(`Background title generation error: ${error}`, startTime);
|
||||||
})
|
})
|
||||||
|
|
@ -798,16 +725,15 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
generationAbortControllers.set(conversationId, abortController);
|
generationAbortControllers.set(conversationId, abortController);
|
||||||
|
|
||||||
// Start AI response generation in background - don't await
|
// Start AI response generation in background
|
||||||
waitUntil(
|
waitUntil(
|
||||||
generateAIResponse({
|
generateAIResponse({
|
||||||
conversationId,
|
conversationId,
|
||||||
sessionToken,
|
sessionToken,
|
||||||
startTime,
|
startTime,
|
||||||
modelResultPromise,
|
modelId: args.model_id,
|
||||||
keyResultPromise,
|
modelManager,
|
||||||
rulesResultPromise,
|
rulesResultPromise,
|
||||||
userSettingsPromise,
|
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
reasoningEffort: args.reasoning_effort,
|
reasoningEffort: args.reasoning_effort,
|
||||||
})
|
})
|
||||||
|
|
@ -834,57 +760,6 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
return response({ ok: true, conversation_id: conversationId });
|
return response({ ok: true, conversation_id: conversationId });
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getGenerationStats(
|
|
||||||
generationId: string,
|
|
||||||
token: string
|
|
||||||
): Promise<Result<Data, string>> {
|
|
||||||
try {
|
|
||||||
const generation = await fetch(`https://openrouter.ai/api/v1/generation?id=${generationId}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data } = await generation.json();
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return err('No data returned from OpenRouter');
|
|
||||||
}
|
|
||||||
|
|
||||||
return ok(data);
|
|
||||||
} catch {
|
|
||||||
return err('Failed to get generation stats');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function retryResult<T, E>(
|
|
||||||
fn: () => Promise<Result<T, E>>,
|
|
||||||
{
|
|
||||||
retries,
|
|
||||||
delay,
|
|
||||||
startTime,
|
|
||||||
fnName,
|
|
||||||
}: { retries: number; delay: number; startTime: number; fnName: string }
|
|
||||||
): Promise<Result<T, E>> {
|
|
||||||
let attempts = 0;
|
|
||||||
let lastResult: Result<T, E> | null = null;
|
|
||||||
|
|
||||||
while (attempts <= retries) {
|
|
||||||
lastResult = await fn();
|
|
||||||
|
|
||||||
if (lastResult.isOk()) return lastResult;
|
|
||||||
|
|
||||||
log(`Retrying ${fnName} ${attempts} failed: ${lastResult.error}`, startTime);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
attempts++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!lastResult) throw new Error('This should never happen');
|
|
||||||
|
|
||||||
return lastResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGenerationError({
|
async function handleGenerationError({
|
||||||
error,
|
error,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
|
@ -917,38 +792,3 @@ async function handleGenerationError({
|
||||||
|
|
||||||
log('Error updated', startTime);
|
log('Error updated', startTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiResponse {
|
|
||||||
data: Data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Data {
|
|
||||||
created_at: string;
|
|
||||||
model: string;
|
|
||||||
app_id: string | null;
|
|
||||||
external_user: string | null;
|
|
||||||
streamed: boolean;
|
|
||||||
cancelled: boolean;
|
|
||||||
latency: number;
|
|
||||||
moderation_latency: number | null;
|
|
||||||
generation_time: number;
|
|
||||||
tokens_prompt: number;
|
|
||||||
tokens_completion: number;
|
|
||||||
native_tokens_prompt: number;
|
|
||||||
native_tokens_completion: number;
|
|
||||||
native_tokens_reasoning: number;
|
|
||||||
native_tokens_cached: number;
|
|
||||||
num_media_prompt: number | null;
|
|
||||||
num_media_completion: number | null;
|
|
||||||
num_search_results: number | null;
|
|
||||||
origin: string;
|
|
||||||
is_byok: boolean;
|
|
||||||
finish_reason: string;
|
|
||||||
native_finish_reason: string;
|
|
||||||
usage: number;
|
|
||||||
id: string;
|
|
||||||
upstream_id: string;
|
|
||||||
total_cost: number;
|
|
||||||
cache_discount: number | null;
|
|
||||||
provider_name: string;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
1
tmp/kepler-ai-sdk
Submodule
1
tmp/kepler-ai-sdk
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 73461f942496d91e098d2d3d61c769571a13cb11
|
||||||
Loading…
Add table
Reference in a new issue