Skip to content

Commit c112aa1

Browse files
committed
feat(mavrochat): implement BYOK feature for unlimited API usage and enhance rate limiting logic
1 parent 18d6db7 commit c112aa1

File tree

15 files changed

+659
-191
lines changed

15 files changed

+659
-191
lines changed

apps/mavrochat/README.md

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ AI-powered chat application for developers with syntax highlighting, markdown su
44

55
## Features
66

7-
- 🤖 Multiple AI model support (currently GPT-4o)
7+
- 🤖 Multiple AI model support (GPT-4, GPT-3.5, Claude 3.5)
8+
- 🔑 Bring Your Own API Key (BYOK) support
89
- 📝 Markdown rendering with syntax-highlighted code blocks
910
- 🎨 Dark/light theme support
1011
- 💾 Chat history persistence
1112
- 🚀 Real-time streaming responses
1213
- 📋 One-click code copying
14+
- 🚦 No rate limits with your own API keys
1315

1416
## Development
1517

@@ -32,13 +34,36 @@ npm run build --workspace=mavrochat
3234
Create `.env.development` in the app root:
3335

3436
```env
35-
# Required
37+
# Required (for server-side API keys)
3638
OPENAI_API_KEY=your_openai_api_key
39+
CLAUDE_API_KEY=your_claude_api_key
3740
3841
# Optional
3942
NEXT_PUBLIC_APP_URL=http://localhost:3000
43+
44+
# Rate limiting (optional)
45+
UPSTASH_REDIS_REST_URL=your_upstash_url
46+
UPSTASH_REDIS_REST_TOKEN=your_upstash_token
4047
```
4148

49+
## API Keys & Authentication
50+
51+
### Using Your Own API Keys
52+
53+
MavroChat supports bringing your own API keys (BYOK) to bypass rate limits:
54+
55+
1. Click the key icon next to the model selector
56+
2. Enter your API key for the provider you want to use:
57+
- **OpenAI**: Get your key from [platform.openai.com/api-keys](https://platform.openai.com/api-keys)
58+
- **Anthropic**: Get your key from [console.anthropic.com/api-keys](https://console.anthropic.com/api-keys)
59+
3. Your keys are stored locally in your browser and never sent to our servers
60+
4. With your own API key, you have unlimited usage
61+
62+
### Rate Limits
63+
64+
- **Without API key**: 10 messages per day
65+
- **With your own API key**: Unlimited messages
66+
4267
## Project Structure
4368

4469
```
@@ -51,11 +76,15 @@ src/
5176
├── components/ # React components
5277
│ ├── ChatContainer.tsx # Main chat UI
5378
│ ├── MessageInput.tsx # Message input
54-
│ └── CodeBlock.tsx # Code rendering
79+
│ ├── CodeBlock.tsx # Code rendering
80+
│ └── ApiTokenIndicator.tsx # API key management
5581
├── context/ # React contexts
56-
│ └── ModelContext.tsx # AI model selection
82+
│ ├── ModelContext.tsx # AI model selection
83+
│ └── ApiTokenContext.tsx # API token management
5784
├── hooks/ # Custom React hooks
85+
│ └── useApiTokens.ts # Local storage for tokens
5886
└── lib/ # Utilities & helpers
87+
└── rate-limit.ts # Rate limiting logic
5988
```
6089

6190
## API Routes
@@ -64,17 +93,24 @@ src/
6493

6594
Handles chat completions with streaming support.
6695

67-
**Request:**
96+
**Request Headers:**
97+
- `x-model`: AI model to use (e.g., "gpt-4o", "claude-3-5-sonnet-latest")
98+
- `x-api-key`: (Optional) Your own API key to bypass rate limits
99+
100+
**Request Body:**
68101

69102
```json
70103
{
71-
"messages": [{ "role": "user", "content": "Hello" }],
72-
"model": "gpt-4o"
104+
"messages": [{ "role": "user", "content": "Hello" }]
73105
}
74106
```
75107

76108
**Response:** Server-sent events stream
77109

110+
**Rate Limiting:**
111+
- Requests without `x-api-key` header are rate-limited
112+
- Requests with valid `x-api-key` bypass all rate limits
113+
78114
## Key Dependencies
79115

80116
- **Next.js 15** - React framework

apps/mavrochat/src/app/api/chat/route.ts

Lines changed: 73 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
import { openai } from '@ai-sdk/openai';
1+
import { openai, createOpenAI } from '@ai-sdk/openai';
22
import { createAnthropic } from '@ai-sdk/anthropic';
33
import { streamText, tool } from 'ai';
44
import { z } from 'zod';
55
import { NextResponse } from 'next/server';
66
import { checkRateLimit } from '@/lib/rate-limit';
77
import { headers } from 'next/headers';
88

9-
// Create Anthropic instance with custom API key
10-
const anthropic = createAnthropic({
9+
// Create default Anthropic instance (can be overridden with custom API key)
10+
const defaultAnthropic = createAnthropic({
1111
apiKey: process.env.CLAUDE_API_KEY,
1212
});
1313

@@ -36,66 +36,93 @@ const allowedModels = [
3636
type AllowedModel = (typeof allowedModels)[number];
3737

3838
// Provider configuration
39-
const modelProviders = {
39+
const defaultModelProviders = {
4040
'gpt-4o': openai,
4141
'gpt-4o-mini': openai,
4242
'gpt-3.5-turbo': openai,
43-
'claude-3-5-sonnet-latest': anthropic,
44-
'claude-3-5-haiku-latest': anthropic,
43+
'claude-3-5-sonnet-latest': defaultAnthropic,
44+
'claude-3-5-haiku-latest': defaultAnthropic,
4545
} as const;
4646

47-
function getModelProvider(model: AllowedModel) {
48-
const provider = modelProviders[model];
47+
function getModelProvider(model: AllowedModel, customApiKey?: string) {
48+
// If custom API key is provided, create a new provider instance
49+
if (customApiKey) {
50+
if (model.includes('claude')) {
51+
const customAnthropic = createAnthropic({
52+
apiKey: customApiKey,
53+
});
54+
return customAnthropic(model);
55+
} else if (model.includes('gpt') || model.includes('o1')) {
56+
const customOpenai = createOpenAI({
57+
apiKey: customApiKey,
58+
});
59+
return customOpenai(model);
60+
}
61+
}
62+
63+
// Use default provider
64+
const provider = defaultModelProviders[model];
4965
return provider(model);
5066
}
5167

5268
export async function POST(req: Request) {
5369
try {
54-
// Check rate limit using IP address
70+
// Get custom API key from headers
5571
const headersList = await headers();
56-
const ipAddress =
57-
headersList.get('x-forwarded-for') ||
58-
headersList.get('x-real-ip') ||
59-
'anonymous';
72+
const customApiKey = headersList.get('x-api-key') || undefined;
6073

61-
// TODO: When auth is implemented, determine tier based on user
62-
// For now, everyone is anonymous
63-
const userTier = 'anonymous'; // Will be: getUserTier(request)
74+
// Skip rate limiting if custom API key is provided
75+
if (!customApiKey) {
76+
// Check rate limit using IP address
77+
const ipAddress =
78+
headersList.get('x-forwarded-for') ||
79+
headersList.get('x-real-ip') ||
80+
'anonymous';
6481

65-
const { success, limit, reset, remaining } = await checkRateLimit(
66-
ipAddress,
67-
userTier,
68-
);
82+
// TODO: When auth is implemented, determine tier based on user
83+
// For now, everyone is anonymous
84+
const userTier = 'anonymous'; // Will be: getUserTier(request)
6985

70-
if (!success) {
71-
const resetDate = new Date(reset);
72-
const hoursUntilReset = Math.ceil(
73-
(reset - Date.now()) / (1000 * 60 * 60),
86+
const { success, limit, reset, remaining } = await checkRateLimit(
87+
ipAddress,
88+
userTier,
7489
);
7590

76-
return NextResponse.json(
77-
{
78-
success: false,
79-
error: {
80-
code: 'RATE_LIMIT_EXCEEDED',
81-
message: `You've reached your daily limit of ${limit} messages. Come back in ${hoursUntilReset} hours or check out the code on GitHub to run your own instance!`,
82-
details: {
83-
limit,
84-
reset: resetDate.toISOString(),
85-
remaining,
86-
resetIn: `${hoursUntilReset} hours`,
91+
if (!success) {
92+
const resetDate = new Date(reset);
93+
const msUntilReset = reset - Date.now();
94+
const hoursUntilReset = Math.ceil(
95+
msUntilReset / (1000 * 60 * 60),
96+
);
97+
const secondsUntilReset = Math.ceil(msUntilReset / 1000);
98+
99+
return NextResponse.json(
100+
{
101+
success: false,
102+
error: {
103+
code: 'RATE_LIMIT_EXCEEDED',
104+
message:
105+
secondsUntilReset < 60
106+
? `You've reached your limit of ${limit} message. Try again in ${secondsUntilReset} seconds or bring your own API key for unlimited usage!`
107+
: `You've reached your daily limit of ${limit} messages. Come back in ${hoursUntilReset} hours or bring your own API key for unlimited usage!`,
108+
details: {
109+
limit,
110+
reset: resetDate.toISOString(),
111+
remaining,
112+
resetIn: `${hoursUntilReset} hours`,
113+
},
87114
},
88115
},
89-
},
90-
{
91-
status: 429,
92-
headers: {
93-
'X-RateLimit-Limit': limit.toString(),
94-
'X-RateLimit-Remaining': remaining.toString(),
95-
'X-RateLimit-Reset': new Date(reset).toISOString(),
116+
{
117+
status: 429,
118+
headers: {
119+
'X-RateLimit-Limit': limit.toString(),
120+
'X-RateLimit-Remaining': remaining.toString(),
121+
'X-RateLimit-Reset': new Date(reset).toISOString(),
122+
},
96123
},
97-
},
98-
);
124+
);
125+
}
99126
}
100127

101128
// Parse request body
@@ -148,13 +175,14 @@ export async function POST(req: Request) {
148175
console.log('Chat API request:', {
149176
model,
150177
messageCount: messages.length,
178+
usingCustomApiKey: !!customApiKey,
151179
timestamp: new Date().toISOString(),
152180
});
153181

154182
// Create the stream
155183
const result = streamText({
156184
system: 'You are a helpful assistant. Respond to the user in Markdown format.',
157-
model: getModelProvider(model),
185+
model: getModelProvider(model, customApiKey),
158186
messages,
159187
tools: {
160188
weather: tool({

apps/mavrochat/src/app/landing/page.tsx

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
ChevronsDown,
88
Sparkles,
99
Code2,
10-
Wrench,
1110
GitBranch,
1211
Bug,
1312
FileCode,
@@ -16,15 +15,23 @@ import {
1615
ArrowRight,
1716
Github,
1817
Star,
18+
Key,
19+
Infinity as InfinityIcon,
1920
} from 'lucide-react';
2021
import { useRef } from 'react';
2122

2223
export default function LandingPage() {
2324
const features = [
25+
{
26+
title: 'Bring Your Own Key',
27+
description:
28+
'Use your own OpenAI or Anthropic API keys for unlimited usage with no rate limits.',
29+
icon: Key,
30+
},
2431
{
2532
title: 'Model Selector',
2633
description:
27-
"Swap between AI models on the fly so you're ready for every new release.",
34+
'Choose between GPT-4, GPT-3.5, Claude 3.5 Sonnet & Haiku models on the fly.',
2835
icon: Sparkles,
2936
},
3037
{
@@ -33,12 +40,6 @@ export default function LandingPage() {
3340
'Streaming responses with syntax-highlighted code blocks you can copy in one click.',
3441
icon: Code2,
3542
},
36-
{
37-
title: 'Built-in Tools',
38-
description:
39-
'Instant function calling for weather, unit conversion, and any custom logic you add.',
40-
icon: Wrench,
41-
},
4243
{
4344
title: 'Open Source',
4445
description:
@@ -157,7 +158,10 @@ export default function LandingPage() {
157158
<span>Open Source</span>
158159
</div>
159160
<div className="w-1 h-1 bg-muted-foreground rounded-full" />
160-
<span>Self-Hostable</span>
161+
<div className="flex items-center gap-1">
162+
<InfinityIcon className="h-4 w-4" />
163+
<span>Unlimited with BYOK</span>
164+
</div>
161165
<div className="w-1 h-1 bg-muted-foreground rounded-full" />
162166
<span>Developer First</span>
163167
</motion.div>
@@ -275,11 +279,15 @@ export default function LandingPage() {
275279
{[
276280
{
277281
q: 'Is MavroChat free?',
278-
a: 'Yes, MavroChat is completely open-source. You can self-host or use the public instance for free.',
282+
a: 'Yes! You get 10 free messages per day. For unlimited usage, bring your own API key from OpenAI or Anthropic - no subscription required.',
279283
},
280284
{
281285
q: 'Which AI models are supported?',
282-
a: 'Currently GPT-4o. More models (Anthropic, Gemini, Llama) are on the roadmap and the selector is future-proof.',
286+
a: 'GPT-4o, GPT-4o-mini, GPT-3.5-turbo, Claude 3.5 Sonnet, and Claude 3.5 Haiku. Switch between them instantly.',
287+
},
288+
{
289+
q: 'How does Bring Your Own Key work?',
290+
a: 'Simply click the key icon next to the model selector and enter your OpenAI or Anthropic API key. Keys are stored locally in your browser for security and give you unlimited usage.',
283291
},
284292
{
285293
q: 'Can I add my own tools?',

apps/mavrochat/src/app/layout.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import './globals.css';
55
import { Providers } from '../providers';
66
import { Header, ThemeToggle, LogoButton } from '@repo/ui/components';
77
import { ConditionalModelSelector } from '../components/ConditionalModelSelector';
8+
import { ApiTokenIndicator } from '../components/ApiTokenIndicator';
89
import { getOriginFor } from '@repo/ui/lib/utils';
910
import { sharedConfig } from '@repo/shared-config';
1011
import { Analytics } from '@vercel/analytics/react';
@@ -75,7 +76,7 @@ export default function RootLayout({
7576
className={`${geistSans.variable} ${geistMono.variable} antialiased flex flex-col min-h-screen`}
7677
>
7778
<Providers>
78-
<Header className="bg-background border-b xl:bg-transparent xl:border-none">
79+
<Header className="bg-background border-b 2xl:bg-transparent 2xl:border-none">
7980
<div className="flex items-center justify-between w-full">
8081
<div className="flex items-center gap-4">
8182
<LogoButton
@@ -84,11 +85,12 @@ export default function RootLayout({
8485
href={getOriginFor('mavrodev')}
8586
/>
8687
<ConditionalModelSelector />
88+
<ApiTokenIndicator />
8789
</div>
8890
<ThemeToggle />
8991
</div>
9092
</Header>
91-
{children}
93+
<div className="flex-1 flex flex-col">{children}</div>
9294
<Analytics />
9395
</Providers>
9496
</body>

0 commit comments

Comments
 (0)