Added openrouter (#25)

* Add models.dev data fetching script and pnpm command

- Created scripts/update-models.ts to fetch and filter models from models.dev API
- Added pnpm update-models command to package.json
- Generated initial models.json with 39 models from 3 providers
- Filtered to only include anthropic, groq, and openrouter providers
- Added tsx as dev dependency for TypeScript execution

Amp-Thread: https://ampcode.com/threads/T-918d21db-3d47-4c6f-a3d8-96a60b59674a
Co-authored-by: Amp <amp@ampcode.com>

* Create new type system and authentication for multi-provider support

- Refactored types/models.ts with new Provider and Model interfaces
- Added support for OAuth authentication alongside API keys
- Created useModels hook for favorites management and model data loading
- Added comprehensive auth utils with OAuth PKCE flow implementation
- Updated apiKeys.ts to use new auth system for backward compatibility
- Created OAuthCallback component for OAuth flow completion
- Added support for OpenRouter OAuth configuration

Amp-Thread: https://ampcode.com/threads/T-918d21db-3d47-4c6f-a3d8-96a60b59674a
Co-authored-by: Amp <amp@ampcode.com>

* Implement comprehensive ModelSelector refactor with favorites and OAuth support

- Refactored ModelSelector to use new favorites-based system with search and filtering
- Added star ratings for favorite models with local storage persistence
- Added search functionality to filter models by name/provider
- Added "Tools Only" and "Favorites" filter toggles
- Updated authentication to support OAuth PKCE flow alongside API keys
- Added OpenRouter OAuth support with proper error handling
- Updated ConversationThread, ModelSelectionModal, and ApiKeyModal to use new Provider type
- Added OAuth success message handling in ChatApp
- Updated useStreamResponse to support OpenRouter via @ai-sdk/openai
- Fixed all TypeScript compilation errors for new model system
- Added proper auth headers for all three providers (Groq, Anthropic, OpenRouter)

Amp-Thread: https://ampcode.com/threads/T-918d21db-3d47-4c6f-a3d8-96a60b59674a
Co-authored-by: Amp <amp@ampcode.com>

* Add final touches and documentation for model selector system

- Fixed unused import in useStreamResponse.ts
- Added .env.example for OpenRouter OAuth configuration
- Created comprehensive MODEL_SELECTOR_GUIDE.md with usage instructions
- Documented all features including favorites, search, filtering, and OAuth
- Added setup instructions for OpenRouter OAuth
- Included troubleshooting guide and local storage details

Amp-Thread: https://ampcode.com/threads/T-918d21db-3d47-4c6f-a3d8-96a60b59674a
Co-authored-by: Amp <amp@ampcode.com>

* Implement clean provider-first model selector with favorites workflow

- Refactored ModelSelector to show providers first, then starred models
- Created ProviderModelsModal for provider-specific model selection and starring
- Removed search/filter complexity from main dropdown
- Added handleProviderSelect to route between OAuth/API key flows
- OAuth providers show model selection after successful authentication
- API key providers show model selection after key entry
- Starred models appear grayed out when provider not configured
- Clicking unconfigured starred model redirects to provider setup
- Added OAuth success message handling to auto-open provider models
- Clean two-step workflow: configure provider → select/star models

Amp-Thread: https://ampcode.com/threads/T-918d21db-3d47-4c6f-a3d8-96a60b59674a
Co-authored-by: Amp <amp@ampcode.com>

* Fix ModelSelectionModal to use provider-first workflow

- Replaced the old flat model list in ModelSelectionModal with provider-first design
- Shows 3 provider cards (Anthropic, Groq, OpenRouter) with auth status
- Added starred models section below providers (empty initially)
- OAuth flow for OpenRouter, API key modals for Anthropic/Groq
- Provider selection leads to ProviderModelsModal for browsing and starring
- Grayed out starred models when provider not configured
- Click unconfigured starred model redirects to provider setup
- OAuth success automatically opens provider models modal
- Clean, intuitive workflow: Select Provider → Configure Auth → Browse Models → Star Favorites

Amp-Thread: https://ampcode.com/threads/T-918d21db-3d47-4c6f-a3d8-96a60b59674a
Co-authored-by: Amp <amp@ampcode.com>

* Fix OpenRouter OAuth flow to match their API specification

- Updated authorization URL from /oauth/authorize to /auth
- Changed token URL to /api/v1/auth/keys
- Removed client_id requirement (OpenRouter doesn't use it)
- Removed scopes (OpenRouter doesn't use them)
- Updated OAuth flow to use callback_url instead of redirect_uri
- Changed token exchange to use JSON body instead of form data
- Handle OpenRouter's response format {key: "..."} instead of {access_token: "..."}
- Made state parameter optional for OpenRouter (they don't use it)
- Updated sessionStorage handling for providers without state parameter
- Simplified .env.example to remove client_id requirement

Amp-Thread: https://ampcode.com/threads/T-918d21db-3d47-4c6f-a3d8-96a60b59674a
Co-authored-by: Amp <amp@ampcode.com>

* Fix model selection and favorites system

- **Fixed multiple useModels hook instances**: Removed duplicate hook from ModelSelectionModal and consolidated all model state management in ChatApp
- **Fixed auto-starring**: Models are now automatically starred when selected from any UI (provider modal or favorites list)
- **Fixed favorites persistence**: Starred models now properly persist in localStorage and are restored on page refresh
- **Fixed selected model highlighting**: Currently selected model is now highlighted in the starred models list with blue border, ring effect, and checkmark
- **Improved prop passing**: Pass all model-related functions (addToFavorites, toggleFavorite, isFavorite, getFavoriteModels) down through ChatApp → ConversationThread → ModelSelectionModal
- **Removed debug logs**: Cleaned up console.log statements added during debugging

The root cause was multiple instances of the useModels hook running simultaneously, causing state conflicts where one instance would load favorites correctly but another would reset them to an empty array. Now there's a single source of truth for all model state in ChatApp.

This fixes the specific issues where:
1. Selected models weren't being auto-starred
2. Starred models list was forgotten on page refresh
3. Selected model highlighting wasn't working in the modal

Amp-Thread: https://ampcode.com/threads/T-918d21db-3d47-4c6f-a3d8-96a60b59674a
Co-authored-by: Amp <amp@ampcode.com>

* Replace nested modals with single modal navigation

- **Improved UX**: Replaced stacked modals with a single modal that switches between views
- **Added back navigation**: Provider models view now has a back button in the top left to return to main view
- **Unified modal structure**: Single modal container with conditional rendering for main vs provider views
- **Added search functionality**: Provider models view includes a search bar to filter models
- **Better visual hierarchy**: Provider view shows provider logo and name in header
- **Removed ProviderModelsModal**: Consolidated functionality into ModelSelectionModal
- **Enhanced interaction**: Cleaner navigation flow without modal stacking

Main view shows:
- List of providers with authentication status
- Starred models section

Provider view shows:
- Back button + provider logo/name in header
- Search bar for filtering models
- List of provider's models with star/select actions
- Model count at bottom

This creates a much more intuitive navigation experience similar to mobile app patterns, avoiding the confusing nested modal behavior.

Amp-Thread: https://ampcode.com/threads/T-918d21db-3d47-4c6f-a3d8-96a60b59674a
Co-authored-by: Amp <amp@ampcode.com>

* Fix modal scrolling for overflow content

- **Fixed height constraints**: Modal now uses flexbox layout with proper height management
- **Added flex-shrink-0 to header**: Prevents header from shrinking and maintains fixed height
- **Added flex-1 min-h-0 to content**: Allows content area to take remaining space and scroll properly
- **Improved responsive behavior**: Modal content now scrolls vertically when overflowing on smaller screens

Key changes:
- Modal container: Added `flex flex-col` for vertical layout
- Header: Added `flex-shrink-0` to maintain fixed height
- Content area: Added `flex-1 min-h-0` to fill remaining space and enable scrolling

This ensures that:
1. Header stays fixed at top
2. Content area takes remaining vertical space
3. Content scrolls independently when overflowing
4. Works properly on all screen sizes, including small screens with many models

Amp-Thread: https://ampcode.com/threads/T-918d21db-3d47-4c6f-a3d8-96a60b59674a
Co-authored-by: Amp <amp@ampcode.com>

* Make provider model rows fully clickable

- **Improved UX consistency**: Provider model rows now behave like starred model rows
- **Full row clickable**: Click anywhere in the row to select and star the model
- **Event handling**: Star button uses stopPropagation to prevent row click when toggling favorites
- **Visual clarity**: Replaced "Select" button with subtle "Click to select" hint
- **Added cursor pointer**: Clear visual indication that rows are clickable

Changes:
- Added `cursor-pointer` and `onClick` to model rows in provider view
- Star button now stops event propagation to allow independent toggling
- Removed separate "Select" button for cleaner, more consistent interface
- Added "Click to select" text hint on the right side

This creates a consistent interaction pattern across both:
- Main view starred models (click row to select)
- Provider view models (click row to select and star)

Users can now efficiently browse and select models with single clicks, while still having granular control over favorites via the star button.

Amp-Thread: https://ampcode.com/threads/T-918d21db-3d47-4c6f-a3d8-96a60b59674a
Co-authored-by: Amp <amp@ampcode.com>

* localstorage saving working

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Glen Maddern 2025-07-11 14:34:33 +10:00 • committed by GitHub
commit c260e971d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 2706 additions and 216 deletions

View file

@ -0,0 +1,3 @@
# OpenRouter OAuth Configuration
# Note: OpenRouter OAuth doesn't require client ID, only callback URL configuration
VITE_OPENROUTER_REDIRECT_URI=http://localhost:5002/oauth/openrouter/callback

View file

@ -0,0 +1,92 @@
# Model Selector Guide
This guide explains how to use the new favorites-based model selector system.
## Overview
The chat UI now supports:
- **39 models** from 3 providers (Anthropic, Groq, OpenRouter)
- **Favorites system** - star your preferred models
- **Search functionality** - find models by name or provider
- **Tool filtering** - show only models that support tool calling
- **OAuth authentication** - seamless OpenRouter integration
## Features
### Model Selection
- Click the model selector to view all available models
- Models are loaded from the live models.dev API
- Each model shows provider logo, name, and capabilities
### Favorites System
- ⭐ Star models to add them to your favorites
- Starred models are saved to local storage
- Use the "Favorites" filter to show only starred models
### Search & Filtering
- 🔍 Search box to find models by name or provider
- 🔧 "Tools Only" filter (appears when MCP tools are available)
- ⭐ "Favorites" filter to show only starred models
### Authentication
- **API Keys**: Enter manually for Anthropic and Groq
- **OAuth**: One-click authentication for OpenRouter
- Authentication status shown with icons (✓ or ⚠️)
## Provider Support
### Anthropic
- **Models**: 9 models including Claude 4 Sonnet
- **Auth**: API key (requires manual entry)
- **Tools**: All models support tool calling
### Groq
- **Models**: 13 models including Llama and Qwen variants
- **Auth**: API key (requires manual entry)
- **Tools**: 11 models support tool calling
### OpenRouter
- **Models**: 17 models from various providers
- **Auth**: OAuth PKCE flow (one-click authentication)
- **Tools**: All models support tool calling
## Setting Up OpenRouter OAuth
1. Create an OpenRouter account at https://openrouter.ai
2. Go to your dashboard and create an OAuth app
3. Set the redirect URI to: `http://localhost:5002/oauth/openrouter/callback`
4. Copy your client ID to your `.env` file:
```
VITE_OPENROUTER_CLIENT_ID=your_client_id_here
```
## Usage Tips
1. **Star your favorites** - This makes model selection much faster
2. **Use search** - With 39 models, search helps find what you need
3. **Filter by tools** - When using MCP tools, enable "Tools Only" filter
4. **Try different providers** - Each has unique models with different strengths
## Model Data Updates
Model data is fetched from models.dev API and can be updated with:
```bash
pnpm update-models
```
This will refresh the model list with the latest information from the API.
## Local Storage
The following preferences are saved locally:
- **Favorites**: `aiChatTemplate_favorites_v1`
- **Tokens**: `aiChatTemplate_token_[provider]`
- **Selected Model**: `aiChatTemplate_selectedModel`
## Troubleshooting
- **OAuth popup blocked**: Allow popups for this site
- **Authentication failed**: Check your API keys or re-authenticate
- **Model not working**: Verify the model supports the features you're using
- **Tools not showing**: Enable "Tools Only" filter when MCP tools are configured

View file

@ -12,11 +12,13 @@
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed",
"test:html": "playwright test --reporter=html"
"test:html": "playwright test --reporter=html",
"update-models": "tsx scripts/update-models.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^1.2.12",
"@ai-sdk/groq": "^1.2.9",
"@ai-sdk/openai": "^1.3.23",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.0.14",
"ai": "^4.1.61",
@ -46,6 +48,7 @@
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"prettier": "^3.5.3",
"tsx": "^4.20.3",
"typescript": "~5.7.2",
"typescript-eslint": "^8.24.1",
"use-mcp": "link:../..",

View file

@ -14,12 +14,15 @@ importers:
'@ai-sdk/groq':
specifier: ^1.2.9
version: 1.2.9(zod@3.24.4)
'@ai-sdk/openai':
specifier: ^1.3.23
version: 1.3.23(zod@3.24.4)
'@tailwindcss/typography':
specifier: ^0.5.16
version: 0.5.16(tailwindcss@4.1.6)
'@tailwindcss/vite':
specifier: ^4.0.14
version: 4.1.6(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))
version: 4.1.6(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3))
ai:
specifier: ^4.1.61
version: 4.3.15(react@19.1.0)(zod@3.24.4)
@ -62,7 +65,7 @@ importers:
version: 1.2.11(zod@3.24.4)
'@cloudflare/vite-plugin':
specifier: ^0.1.12
version: 0.1.21(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))(workerd@1.20250507.0)(wrangler@4.14.4(@cloudflare/workers-types@4.20250510.0))
version: 0.1.21(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3))(workerd@1.20250507.0)(wrangler@4.14.4(@cloudflare/workers-types@4.20250510.0))
'@cloudflare/workers-types':
specifier: ^4.20250317.0
version: 4.20250510.0
@ -80,7 +83,7 @@ importers:
version: 19.1.3(@types/react@19.1.3)
'@vitejs/plugin-react':
specifier: ^4.3.4
version: 4.4.1(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))
version: 4.4.1(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3))
eslint:
specifier: ^9.21.0
version: 9.26.0(jiti@2.4.2)
@ -96,6 +99,9 @@ importers:
prettier:
specifier: ^3.5.3
version: 3.5.3
tsx:
specifier: ^4.20.3
version: 4.20.3
typescript:
specifier: ~5.7.2
version: 5.7.3
@ -107,7 +113,7 @@ importers:
version: link:../..
vite:
specifier: ^6.2.0
version: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2)
version: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3)
wrangler:
specifier: ^4.1.0
version: 4.14.4(@cloudflare/workers-types@4.20250510.0)
@ -126,6 +132,12 @@ packages:
peerDependencies:
zod: ^3.0.0
'@ai-sdk/openai@1.3.23':
resolution: {integrity: sha512-86U7rFp8yacUAOE/Jz8WbGcwMCqWvjK33wk5DXkfnAOEn3mx2r7tNSJdjukQFZbAK97VMXGPPHxF+aEARDXRXQ==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
'@ai-sdk/provider-utils@1.0.22':
resolution: {integrity: sha512-YHK2rpj++wnLVc9vPGzGFP3Pjeld2MwhKinetA0zKXOoHAT/Jit5O8kZsxcSlJPu9wvcGT1UGZEjZrtO7PfFOQ==}
engines: {node: '>=18'}
@ -1496,6 +1508,9 @@ packages:
get-source@2.0.12:
resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==}
get-tsconfig@4.10.1:
resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==}
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@ -2186,6 +2201,9 @@ packages:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
@ -2358,6 +2376,11 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsx@4.20.3:
resolution: {integrity: sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==}
engines: {node: '>=18.0.0'}
hasBin: true
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@ -2572,6 +2595,12 @@ snapshots:
'@ai-sdk/provider-utils': 2.2.8(zod@3.24.4)
zod: 3.24.4
'@ai-sdk/openai@1.3.23(zod@3.24.4)':
dependencies:
'@ai-sdk/provider': 1.1.3
'@ai-sdk/provider-utils': 2.2.8(zod@3.24.4)
zod: 3.24.4
'@ai-sdk/provider-utils@1.0.22(zod@3.24.4)':
dependencies:
'@ai-sdk/provider': 0.0.26
@ -2738,7 +2767,7 @@ snapshots:
optionalDependencies:
workerd: 1.20250507.0
'@cloudflare/vite-plugin@0.1.21(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))(workerd@1.20250507.0)(wrangler@4.14.4(@cloudflare/workers-types@4.20250510.0))':
'@cloudflare/vite-plugin@0.1.21(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3))(workerd@1.20250507.0)(wrangler@4.14.4(@cloudflare/workers-types@4.20250510.0))':
dependencies:
'@cloudflare/unenv-preset': 2.3.1(unenv@2.0.0-rc.15)(workerd@1.20250507.0)
'@hattip/adapter-node': 0.0.49
@ -2747,7 +2776,7 @@ snapshots:
picocolors: 1.1.1
tinyglobby: 0.2.13
unenv: 2.0.0-rc.15
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2)
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3)
wrangler: 4.14.4(@cloudflare/workers-types@4.20250510.0)
ws: 8.18.0
transitivePeerDependencies:
@ -3222,12 +3251,12 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 4.1.6
'@tailwindcss/vite@4.1.6(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))':
'@tailwindcss/vite@4.1.6(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3))':
dependencies:
'@tailwindcss/node': 4.1.6
'@tailwindcss/oxide': 4.1.6
tailwindcss: 4.1.6
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2)
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3)
'@types/babel__core@7.20.5':
dependencies:
@ -3365,14 +3394,14 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react@4.4.1(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2))':
'@vitejs/plugin-react@4.4.1(vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3))':
dependencies:
'@babel/core': 7.27.1
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.1)
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.27.1)
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2)
vite: 6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3)
transitivePeerDependencies:
- supports-color
@ -3884,6 +3913,10 @@ snapshots:
data-uri-to-buffer: 2.0.2
source-map: 0.6.1
get-tsconfig@4.10.1:
dependencies:
resolve-pkg-maps: 1.0.0
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@ -4758,6 +4791,8 @@ snapshots:
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
reusify@1.1.0: {}
rollup@4.40.2:
@ -4985,6 +5020,13 @@ snapshots:
tslib@2.8.1: {}
tsx@4.20.3:
dependencies:
esbuild: 0.25.4
get-tsconfig: 4.10.1
optionalDependencies:
fsevents: 2.3.3
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@ -5091,7 +5133,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.2
vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2):
vite@6.3.5(jiti@2.4.2)(lightningcss@1.29.2)(tsx@4.20.3):
dependencies:
esbuild: 0.25.4
fdir: 6.4.4(picomatch@4.0.2)
@ -5103,6 +5145,7 @@ snapshots:
fsevents: 2.3.3
jiti: 2.4.2
lightningcss: 1.29.2
tsx: 4.20.3
which@2.0.2:
dependencies:

View file

@ -0,0 +1,105 @@
#!/usr/bin/env tsx
import { writeFileSync } from 'fs'
import { join } from 'path'
interface ModelData {
id: string
name: string
attachment: boolean
reasoning: boolean
temperature: boolean
tool_call: boolean
knowledge: string
release_date: string
last_updated: string
modalities: {
input: string[]
output: string[]
}
open_weights: boolean
limit: {
context: number
output: number
}
cost?: {
input: number
output: number
cache_read?: number
cache_write?: number
}
}
interface ProviderData {
models: Record<string, ModelData>
}
interface ModelsDevData {
anthropic: ProviderData
groq: ProviderData
openrouter: ProviderData
[key: string]: ProviderData
}
const SUPPORTED_PROVIDERS = ['anthropic', 'groq', 'openrouter'] as const
type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number]
async function fetchModelsData(): Promise<ModelsDevData> {
console.log('Fetching models data from models.dev...')
const response = await fetch('https://models.dev/api.json')
if (!response.ok) {
throw new Error(`Failed to fetch models data: ${response.status} ${response.statusText}`)
}
return await response.json()
}
function filterAndTransformModels(data: ModelsDevData) {
const filtered: Record<SupportedProvider, Record<string, ModelData>> = {
anthropic: {},
groq: {},
openrouter: {},
}
// Filter by supported providers
for (const provider of SUPPORTED_PROVIDERS) {
if (data[provider] && data[provider].models) {
filtered[provider] = data[provider].models
}
}
return filtered
}
async function main() {
try {
const data = await fetchModelsData()
const filteredData = filterAndTransformModels(data)
const outputPath = join(process.cwd(), 'src', 'data', 'models.json')
writeFileSync(outputPath, JSON.stringify(filteredData, null, 2))
console.log(`✅ Models data updated successfully at ${outputPath}`)
// Print summary
let totalModels = 0
let toolSupportingModels = 0
for (const [provider, models] of Object.entries(filteredData)) {
const modelCount = Object.keys(models).length
const toolModels = Object.values(models).filter((m) => m.tool_call).length
console.log(` ${provider}: ${modelCount} models (${toolModels} support tools)`)
totalModels += modelCount
toolSupportingModels += toolModels
}
console.log(`\nTotal: ${totalModels} models (${toolSupportingModels} support tools)`)
} catch (error) {
console.error('❌ Failed to update models data:', error)
process.exit(1)
}
}
main()

View file

@ -1,12 +1,13 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import ChatApp from './components/ChatApp'
import { OAuthCallback } from './components/OAuthCallback'
import OAuthCallback from './components/OAuthCallback'
function App() {
return (
<Router>
<Routes>
<Route path="/oauth/callback" element={<OAuthCallback />} />
<Route path="/oauth/openrouter/callback" element={<OAuthCallback provider="openrouter" />} />
<Route path="/oauth/callback" element={<OAuthCallback provider="openrouter" />} />
<Route path="/" element={<ChatApp />} />
</Routes>
</Router>

View file

@ -1,12 +1,12 @@
import React, { useState } from 'react'
import { createPortal } from 'react-dom'
import { X, Eye, EyeOff } from 'lucide-react'
import { type ModelProvider } from '../types/models'
import { type Provider } from '../types/models'
interface ApiKeyModalProps {
isOpen: boolean
onClose: () => void
provider: ModelProvider
provider: Provider
onSave: (apiKey: string) => void
}

View file

@ -1,10 +1,13 @@
import React, { useState, useEffect } from 'react'
import ConversationThread from './ConversationThread.tsx'
import ChatSidebar from './ChatSidebar'
import ChatNavbar from './ChatNavbar'
import { storeName } from '../consts.ts'
import { type Conversation } from '../types'
import { useIndexedDB } from '../hooks/useIndexedDB'
import { type Model } from '../types/models'
import { getSelectedModel, setSelectedModel as saveSelectedModel } from '../utils/modelPreferences'
import { useModels } from '../hooks/useModels'
import { type IDBPDatabase } from 'idb'
import { type Tool } from 'use-mcp/react'
@ -14,17 +17,31 @@ interface ChatAppProps {}
const ChatApp: React.FC<ChatAppProps> = () => {
const [conversations, setConversations] = useState<Conversation[]>([])
const [conversationId, setConversationId] = useState<number | undefined>(undefined)
const [sidebarVisible, setSidebarVisible] = useState(false)
const [selectedModel, setSelectedModel] = useState<Model>(getSelectedModel())
const [apiKeyUpdateTrigger, setApiKeyUpdateTrigger] = useState<number>(0)
const [mcpTools, setMcpTools] = useState<Tool[]>([])
const [animationDelay] = useState<number>(() => -Math.random() * 60)
const db = useIndexedDB()
const { models, addToFavorites, toggleFavorite, isFavorite, getFavoriteModels } = useModels()
const handleApiKeyUpdate = () => {
setApiKeyUpdateTrigger((prev) => prev + 1)
}
// Handle OAuth success messages from popups
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'oauth_success') {
handleApiKeyUpdate()
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [])
const handleModelChange = (model: Model) => {
setSelectedModel(model)
saveSelectedModel(model)
@ -65,14 +82,12 @@ const ChatApp: React.FC<ChatAppProps> = () => {
}
}
// const editConversationTitle = async (id: number, newTitle: string) => {
// const conversation = (await db!.get(storeName, id)) as Conversation;
// conversation.title = newTitle;
// await db!.put(storeName, conversation);
// setConversations((prev) =>
// prev.map((conv) => (conv.id === id ? { ...conv, title: newTitle } : conv))
// );
// };
const editConversationTitle = async (id: number, newTitle: string) => {
const conversation = (await db!.get(storeName, id)) as Conversation
conversation.title = newTitle
await db!.put(storeName, conversation)
setConversations((prev) => prev.map((conv) => (conv.id === id ? { ...conv, title: newTitle } : conv)))
}
const startNewConversation = async () => {
//create unique id for new conversation
@ -99,29 +114,27 @@ const ChatApp: React.FC<ChatAppProps> = () => {
style={{ '--random-delay': `${animationDelay}s` } as React.CSSProperties}
>
<div className="flex flex-row flex-grow flex-1 min-h-screen relative">
{/* Sidebar and Navbar components hidden but kept in codebase */}
{/*{false && (*/}
{/* <>*/}
{/* <ChatSidebar*/}
{/* sidebarVisible={sidebarVisible}*/}
{/* setSidebarVisible={setSidebarVisible}*/}
{/* conversations={conversations}*/}
{/* conversationId={conversationId}*/}
{/* setConversationId={setConversationId}*/}
{/* deleteConversation={deleteConversation}*/}
{/* editConversationTitle={editConversationTitle}*/}
{/* startNewConversation={startNewConversation}*/}
{/* selectedModel={selectedModel}*/}
{/* onModelChange={handleModelChange}*/}
{/* apiKeyUpdateTrigger={apiKeyUpdateTrigger}*/}
{/* onMcpToolsUpdate={setMcpTools}*/}
{/* />*/}
{/* <ChatNavbar*/}
{/* sidebarVisible={sidebarVisible}*/}
{/* setSidebarVisible={setSidebarVisible}*/}
{/* />*/}
{/* </>*/}
{/*)}*/}
{/* Sidebar and Navbar components */}
{false && (
<>
<ChatSidebar
sidebarVisible={sidebarVisible}
setSidebarVisible={setSidebarVisible}
conversations={conversations}
conversationId={conversationId}
setConversationId={setConversationId}
deleteConversation={deleteConversation}
editConversationTitle={editConversationTitle}
startNewConversation={startNewConversation}
selectedModel={selectedModel}
onModelChange={handleModelChange}
apiKeyUpdateTrigger={apiKeyUpdateTrigger}
onMcpToolsUpdate={setMcpTools}
mcpTools={mcpTools}
/>
<ChatNavbar sidebarVisible={sidebarVisible} setSidebarVisible={setSidebarVisible} />
</>
)}
<div className="flex flex-col flex-grow h-full w-full">
<ConversationThread
conversations={conversations}
@ -135,6 +148,11 @@ const ChatApp: React.FC<ChatAppProps> = () => {
apiKeyUpdateTrigger={apiKeyUpdateTrigger}
mcpTools={mcpTools}
onMcpToolsUpdate={setMcpTools}
addToFavorites={addToFavorites}
models={models}
toggleFavorite={toggleFavorite}
isFavorite={isFavorite}
getFavoriteModels={getFavoriteModels}
/>
</div>
</div>

View file

@ -25,6 +25,7 @@ interface ChatSidebarProps {
onModelChange: (model: Model) => void
apiKeyUpdateTrigger: number
onMcpToolsUpdate: (tools: Tool[]) => void
mcpTools: Tool[]
}
const ChatSidebar: React.FC<ChatSidebarProps> = ({
@ -40,6 +41,7 @@ const ChatSidebar: React.FC<ChatSidebarProps> = ({
onModelChange,
apiKeyUpdateTrigger,
onMcpToolsUpdate,
mcpTools,
}) => {
const handleConversationClick = (id: number | undefined) => {
setConversationId(id)
@ -135,7 +137,12 @@ const ChatSidebar: React.FC<ChatSidebarProps> = ({
{/* Model Selector and MCP Servers at bottom */}
<div className="p-2 border-t border-zinc-200 space-y-3">
<ModelSelector selectedModel={selectedModel} onModelChange={onModelChange} apiKeyUpdateTrigger={apiKeyUpdateTrigger} />
<ModelSelector
selectedModel={selectedModel}
onModelChange={onModelChange}
apiKeyUpdateTrigger={apiKeyUpdateTrigger}
toolsAvailable={mcpTools.length > 0}
/>
<McpServers onToolsUpdate={onMcpToolsUpdate} />
</div>
</div>

View file

@ -30,6 +30,11 @@ interface ConversationThreadProps {
apiKeyUpdateTrigger: number
mcpTools: Tool[]
onMcpToolsUpdate: (tools: Tool[]) => void
addToFavorites: (modelId: string) => void
models: Model[]
toggleFavorite: (modelId: string) => void
isFavorite: (modelId: string) => boolean
getFavoriteModels: () => Model[]
}
const ConversationThread: React.FC<ConversationThreadProps> = ({
@ -44,6 +49,11 @@ const ConversationThread: React.FC<ConversationThreadProps> = ({
apiKeyUpdateTrigger,
mcpTools,
onMcpToolsUpdate,
addToFavorites,
models,
toggleFavorite,
isFavorite,
getFavoriteModels,
}) => {
const [input, setInput] = useState<string>('')
const [apiKeyModal, setApiKeyModal] = useState<{ isOpen: boolean; model: Model | null }>({
@ -258,7 +268,17 @@ const ConversationThread: React.FC<ConversationThreadProps> = ({
<ApiKeyModal
isOpen={apiKeyModal.isOpen}
onClose={handleApiKeyCancel}
provider={apiKeyModal.model?.provider ?? { id: '', name: '', baseUrl: '', apiKeyHeader: '', documentationUrl: '' }}
provider={
apiKeyModal.model?.provider ?? {
id: 'unknown' as any,
name: 'Unknown',
baseUrl: '',
logo: '',
documentationUrl: '',
authType: 'apiKey',
apiKeyHeader: '',
}
}
onSave={handleApiKeySave}
/>
@ -268,6 +288,11 @@ const ConversationThread: React.FC<ConversationThreadProps> = ({
selectedModel={selectedModel}
onModelChange={onModelChange}
apiKeyUpdateTrigger={apiKeyUpdateTrigger}
addToFavorites={addToFavorites}
models={models}
toggleFavorite={toggleFavorite}
isFavorite={isFavorite}
getFavoriteModels={getFavoriteModels}
/>
<McpServerModal isOpen={mcpServerModal} onClose={() => setMcpServerModal(false)} onToolsUpdate={onMcpToolsUpdate} />

View file

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'
import { X, AlertCircle, CheckCircle } from 'lucide-react'
import { type Model, availableModels } from '../types/models'
import { hasApiKey, setApiKey, clearApiKey } from '../utils/apiKeys'
import { X, AlertCircle, CheckCircle, Star, ArrowLeft, Search } from 'lucide-react'
import { type Model, type Provider, providers } from '../types/models'
import { hasApiKey, setApiKey, clearApiKey, beginOAuthFlow } from '../utils/auth'
import ApiKeyModal from './ApiKeyModal'
interface ModelSelectionModalProps {
@ -10,6 +10,11 @@ interface ModelSelectionModalProps {
selectedModel: Model
onModelChange: (model: Model) => void
apiKeyUpdateTrigger: number
addToFavorites: (modelId: string) => void
models: Model[]
toggleFavorite: (modelId: string) => void
isFavorite: (modelId: string) => boolean
getFavoriteModels: () => Model[]
}
const ModelSelectionModal: React.FC<ModelSelectionModalProps> = ({
@ -18,20 +23,28 @@ const ModelSelectionModal: React.FC<ModelSelectionModalProps> = ({
selectedModel,
onModelChange,
apiKeyUpdateTrigger,
addToFavorites,
models,
toggleFavorite,
isFavorite,
getFavoriteModels,
}) => {
const [apiKeyModal, setApiKeyModal] = useState<{ isOpen: boolean; model: Model | null }>({
isOpen: false,
model: null,
})
const [currentView, setCurrentView] = useState<'main' | 'provider'>('main')
const [selectedProvider, setSelectedProvider] = useState<Provider | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const [apiKeyStatuses, setApiKeyStatuses] = useState<Record<string, boolean>>({})
useEffect(() => {
const statuses: Record<string, boolean> = {}
availableModels.forEach((model) => {
models.forEach((model) => {
statuses[model.provider.id] = hasApiKey(model.provider.id)
})
setApiKeyStatuses(statuses)
}, [apiKeyUpdateTrigger])
}, [apiKeyUpdateTrigger, models])
useEffect(() => {
if (isOpen) {
@ -45,33 +58,104 @@ const ModelSelectionModal: React.FC<ModelSelectionModalProps> = ({
}
}, [isOpen])
const handleModelSelect = (model: Model) => {
const hasKey = hasApiKey(model.provider.id)
if (!hasKey) {
setApiKeyModal({ isOpen: true, model })
} else {
onModelChange(model)
onClose()
// Handle OAuth success - show provider models when OAuth completes
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'oauth_success' && event.data.provider && isOpen) {
const provider = providers[event.data.provider as keyof typeof providers]
if (provider) {
setCurrentView('provider')
setSelectedProvider(provider)
}
}
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [isOpen])
// Reset view when modal closes
useEffect(() => {
if (!isOpen) {
setCurrentView('main')
setSelectedProvider(null)
setSearchTerm('')
}
}, [isOpen])
const handleApiKeySave = (apiKey: string) => {
if (apiKeyModal.model) {
setApiKey(apiKeyModal.model.provider.id, apiKey)
setApiKeyStatuses((prev) => ({ ...prev, [apiKeyModal.model!.provider.id]: true }))
onModelChange(apiKeyModal.model)
// Show provider models after API key is saved
const provider = apiKeyModal.model.provider
setApiKeyModal({ isOpen: false, model: null })
onClose()
setCurrentView('provider')
setSelectedProvider(provider)
}
}
const handleClearApiKey = (providerId: string, e: React.MouseEvent) => {
e.stopPropagation()
clearApiKey(providerId)
clearApiKey(providerId as any)
setApiKeyStatuses((prev) => ({ ...prev, [providerId]: false }))
}
const handleProviderSelect = (provider: Provider) => {
if (provider.authType === 'oauth') {
// Check if already authenticated
if (hasApiKey(provider.id)) {
// Show provider models to select from
setCurrentView('provider')
setSelectedProvider(provider)
} else {
// Start OAuth flow
beginOAuthFlow(provider.id)
.then(() => {
// OAuth flow started successfully - modal will be shown via OAuth success handler
})
.catch((error) => {
console.error('Failed to start OAuth flow:', error)
alert('Failed to start authentication. Please try again.')
})
}
} else {
// Check if already has API key
if (hasApiKey(provider.id)) {
// Show provider models to select from
setCurrentView('provider')
setSelectedProvider(provider)
} else {
// Show API key modal first
setApiKeyModal({ isOpen: true, model: { provider } as Model })
}
}
}
const handleBackToMain = () => {
setCurrentView('main')
setSelectedProvider(null)
setSearchTerm('')
}
const getProviderModels = () => {
if (!selectedProvider) return []
return models.filter((model) => model.provider.id === selectedProvider.id)
}
const getFilteredModels = () => {
const providerModels = getProviderModels()
if (!searchTerm) return providerModels
return providerModels.filter((model) => model.name.toLowerCase().includes(searchTerm.toLowerCase()))
}
const handleModelSelect = (model: Model) => {
onModelChange(model)
addToFavorites(model.id)
onClose()
}
const getStatusIcon = (providerId: string) => {
const hasKey = apiKeyStatuses[providerId]
@ -91,57 +175,211 @@ const ModelSelectionModal: React.FC<ModelSelectionModalProps> = ({
style={{ backgroundColor: 'rgba(0,0,0,0.8)' }}
onClick={onClose}
>
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-6 border-b border-zinc-200">
<h2 className="text-xl font-semibold text-zinc-900">Select Model</h2>
<div
className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-6 border-b border-zinc-200 flex-shrink-0">
{currentView === 'provider' && selectedProvider ? (
<div className="flex items-center gap-3">
<button onClick={handleBackToMain} className="text-zinc-400 hover:text-zinc-600 p-1 cursor-pointer">
<ArrowLeft size={20} />
</button>
<span className="text-2xl">{selectedProvider.logo}</span>
<h2 className="text-xl font-semibold text-zinc-900">{selectedProvider.name} Models</h2>
</div>
) : (
<h2 className="text-xl font-semibold text-zinc-900">Select Model</h2>
)}
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-600 p-1 cursor-pointer">
<X size={24} />
</button>
</div>
<div className="p-6 overflow-y-auto">
<div className="space-y-3">
{availableModels.map((model) => {
const isSelected = model.id === selectedModel.id
const hasKey = apiKeyStatuses[model.provider.id]
return (
<div
key={model.id}
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
isSelected ? 'border-blue-500 bg-blue-50' : 'border-zinc-200 hover:border-zinc-300 hover:bg-zinc-50'
}`}
onClick={() => handleModelSelect(model)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{model.provider.logo}</span>
<div>
<div className="font-medium text-zinc-900">{model.name}</div>
<div className="text-sm text-zinc-500">{model.provider.name}</div>
<div className="p-6 overflow-y-auto flex-1 min-h-0">
{currentView === 'main' ? (
<>
{/* Providers Section */}
<div className="mb-6">
<h3 className="text-lg font-medium text-zinc-900 mb-3">Providers</h3>
<div className="space-y-2">
{Object.values(providers).map((provider) => (
<div
key={provider.id}
className="border rounded-lg p-4 cursor-pointer transition-colors hover:border-zinc-300 hover:bg-zinc-50"
onClick={() => handleProviderSelect(provider)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{provider.logo}</span>
<div>
<h4 className="font-medium text-zinc-900">{provider.name}</h4>
<p className="text-sm text-zinc-500">
{provider.authType === 'oauth' ? 'OAuth Authentication' : 'API Key Authentication'}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{getStatusIcon(provider.id)}
{apiKeyStatuses[provider.id] && (
<button
onClick={(e) => handleClearApiKey(provider.id, e)}
className="text-zinc-400 hover:text-red-500 p-1"
title="Clear credentials"
>
<X size={16} />
</button>
)}
</div>
</div>
</div>
))}
</div>
</div>
<div className="flex items-center gap-2">
{getStatusIcon(model.provider.id)}
<span className={`text-sm ${hasKey ? 'text-green-600' : 'text-red-600'}`}>
{hasKey ? 'Configured' : 'Not configured'}
</span>
{hasKey && (
<button
onClick={(e) => handleClearApiKey(model.provider.id, e)}
className="text-zinc-400 hover:text-red-500 p-1 ml-2"
title="Clear API key"
{/* Starred Models Section */}
<div>
<h3 className="text-lg font-medium text-zinc-900 mb-3 flex items-center gap-2">
<Star size={20} fill="currentColor" className="text-yellow-500" />
Starred Models
</h3>
{getFavoriteModels().length === 0 ? (
<div className="text-center py-8 text-zinc-500">
<Star size={48} className="mx-auto mb-3 text-zinc-300" />
<p className="text-lg font-medium">No starred models yet</p>
<p className="text-sm">Click on a provider above to explore and star models</p>
</div>
) : (
<div className="space-y-2">
{getFavoriteModels().map((model) => {
const isConfigured = apiKeyStatuses[model.provider.id]
const isSelected = model.id === selectedModel.id
return (
<div
key={model.id}
className={`border rounded-lg p-4 cursor-pointer transition-colors ${
isSelected
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-200'
: 'border-zinc-200 hover:border-zinc-300 hover:bg-zinc-50'
} ${!isConfigured ? 'opacity-50' : ''}`}
onClick={() => {
if (isConfigured) {
onModelChange(model)
addToFavorites(model.id)
} else {
handleProviderSelect(model.provider)
}
}}
>
<X size={16} />
</button>
)}
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Star size={16} fill="currentColor" className="text-yellow-500" />
{isSelected && <span className="text-blue-500">✓</span>}
<span className="text-lg">{model.provider.logo}</span>
<div>
<h4 className="font-medium text-zinc-900">{model.name}</h4>
<div className="text-sm text-zinc-500 flex items-center gap-2">
{model.provider.name}
{model.supportsTools && <span className="text-green-600">🔧</span>}
{model.reasoning && <span className="text-purple-600">🧠</span>}
{model.attachment && <span className="text-blue-600">📎</span>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{!isConfigured && (
<div title="Provider not configured">
<AlertCircle size={16} className="text-orange-500" />
</div>
)}
<button
onClick={(e) => {
e.stopPropagation()
toggleFavorite(model.id)
}}
className="text-yellow-500 hover:text-yellow-600 p-1"
title="Remove from favorites"
>
<Star size={16} fill="currentColor" />
</button>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</>
) : (
/* Provider Models View */
selectedProvider && (
<>
<div className="mb-4">
<p className="text-zinc-600 mb-4">Select models to add to your favorites</p>
{/* Search Bar */}
<div className="relative mb-4">
<Search size={20} className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400" />
<input
type="text"
placeholder="Search models..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-zinc-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
)
})}
</div>
{/* Models List */}
<div className="space-y-2">
{getFilteredModels().map((model) => (
<div
key={model.id}
className="border rounded-lg p-4 hover:border-zinc-300 hover:bg-zinc-50 transition-colors cursor-pointer"
onClick={() => handleModelSelect(model)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={(e) => {
e.stopPropagation()
toggleFavorite(model.id)
}}
className={`p-1 rounded ${isFavorite(model.id) ? 'text-yellow-500' : 'text-zinc-300 hover:text-yellow-500'}`}
title={isFavorite(model.id) ? 'Remove from favorites' : 'Add to favorites'}
>
<Star size={16} fill={isFavorite(model.id) ? 'currentColor' : 'none'} />
</button>
<div>
<h4 className="font-medium text-zinc-900">{model.name}</h4>
<div className="text-sm text-zinc-500 flex items-center gap-2">
{model.supportsTools && <span className="text-green-600">🔧 Tools</span>}
{model.reasoning && <span className="text-purple-600">🧠 Reasoning</span>}
{model.attachment && <span className="text-blue-600">📎 Attachments</span>}
<span>•</span>
<span>{(model.contextLimit / 1000).toFixed(0)}K context</span>
</div>
</div>
</div>
<div className="text-sm text-zinc-400">Click to select</div>
</div>
</div>
))}
</div>
{getFilteredModels().length === 0 && (
<div className="text-center py-8 text-zinc-500">
<p>No models found matching "{searchTerm}"</p>
</div>
)}
<div className="mt-4 text-center text-sm text-zinc-500">
{getProviderModels().length} model{getProviderModels().length !== 1 ? 's' : ''} available
</div>
</>
)
)}
</div>
</div>
</div>
@ -149,7 +387,17 @@ const ModelSelectionModal: React.FC<ModelSelectionModalProps> = ({
<ApiKeyModal
isOpen={apiKeyModal.isOpen}
onClose={() => setApiKeyModal({ isOpen: false, model: null })}
provider={apiKeyModal.model?.provider ?? { id: '', name: '', baseUrl: '', apiKeyHeader: '', documentationUrl: '' }}
provider={
apiKeyModal.model?.provider ?? {
id: 'unknown' as any,
name: 'Unknown',
baseUrl: '',
logo: '',
documentationUrl: '',
authType: 'apiKey',
apiKeyHeader: '',
}
}
onSave={handleApiKeySave}
/>
</>

View file

@ -1,31 +1,55 @@
import React, { useState, useEffect, useRef } from 'react'
import { ChevronDown, AlertCircle, X } from 'lucide-react'
import { availableModels, type Model } from '../types/models'
import { hasApiKey, setApiKey, clearApiKey } from '../utils/apiKeys'
import { ChevronDown, AlertCircle, X, Star } from 'lucide-react'
import { type Model, type Provider, providers } from '../types/models'
import { useModels } from '../hooks/useModels'
import { hasApiKey, setApiKey, clearApiKey, beginOAuthFlow, clearOAuthToken } from '../utils/auth'
import ApiKeyModal from './ApiKeyModal'
import ProviderModelsModal from './ProviderModelsModal'
interface ModelSelectorProps {
selectedModel: Model
onModelChange: (model: Model) => void
apiKeyUpdateTrigger: number
toolsAvailable?: boolean
}
const ModelSelector: React.FC<ModelSelectorProps> = ({ selectedModel, onModelChange, apiKeyUpdateTrigger }) => {
const ModelSelector: React.FC<ModelSelectorProps> = ({ selectedModel, onModelChange, apiKeyUpdateTrigger, toolsAvailable = false }) => {
const [isOpen, setIsOpen] = useState(false)
const [apiKeyModal, setApiKeyModal] = useState<{ isOpen: boolean; model: Model | null }>({
isOpen: false,
model: null,
})
const [providerModelsModal, setProviderModelsModal] = useState<{ isOpen: boolean; provider: Provider | null }>({
isOpen: false,
provider: null,
})
const [apiKeyStatuses, setApiKeyStatuses] = useState<Record<string, boolean>>({})
const dropdownRef = useRef<HTMLDivElement>(null)
const { models, loading, toggleFavorite, isFavorite, getFavoriteModels } = useModels()
useEffect(() => {
const statuses: Record<string, boolean> = {}
availableModels.forEach((model) => {
models.forEach((model) => {
statuses[model.provider.id] = hasApiKey(model.provider.id)
})
setApiKeyStatuses(statuses)
}, [apiKeyUpdateTrigger])
}, [apiKeyUpdateTrigger, models])
// Handle OAuth success - show provider models when OAuth completes
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'oauth_success' && event.data.provider) {
const provider = providers[event.data.provider as keyof typeof providers]
if (provider) {
setProviderModelsModal({ isOpen: true, provider })
}
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -44,7 +68,21 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ selectedModel, onModelCha
const hasKey = hasApiKey(model.provider.id)
if (!hasKey) {
setApiKeyModal({ isOpen: true, model })
if (model.provider.authType === 'oauth') {
// Start OAuth flow
beginOAuthFlow(model.provider.id)
.then(() => {
// OAuth flow started successfully
setIsOpen(false)
})
.catch((error) => {
console.error('Failed to start OAuth flow:', error)
alert('Failed to start authentication. Please try again.')
})
} else {
// Show API key modal
setApiKeyModal({ isOpen: true, model })
}
} else {
onModelChange(model)
}
@ -55,16 +93,58 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ selectedModel, onModelCha
if (apiKeyModal.model) {
setApiKey(apiKeyModal.model.provider.id, apiKey)
setApiKeyStatuses((prev) => ({ ...prev, [apiKeyModal.model!.provider.id]: true }))
onModelChange(apiKeyModal.model)
// If this was triggered from provider selection, show the provider models
const provider = apiKeyModal.model.provider
setApiKeyModal({ isOpen: false, model: null })
setProviderModelsModal({ isOpen: true, provider })
}
}
const handleClearApiKey = (providerId: string, e: React.MouseEvent) => {
e.stopPropagation()
clearApiKey(providerId)
const provider = models.find((m) => m.provider.id === providerId)?.provider
if (provider?.authType === 'oauth') {
clearOAuthToken(providerId as any)
} else {
clearApiKey(providerId as any)
}
setApiKeyStatuses((prev) => ({ ...prev, [providerId]: false }))
}
const handleProviderSelect = (provider: Provider) => {
if (provider.authType === 'oauth') {
// Check if already authenticated
if (hasApiKey(provider.id)) {
// Show provider models to select from
setProviderModelsModal({ isOpen: true, provider })
setIsOpen(false)
} else {
// Start OAuth flow
beginOAuthFlow(provider.id)
.then(() => {
// OAuth flow started successfully
setIsOpen(false)
})
.catch((error) => {
console.error('Failed to start OAuth flow:', error)
alert('Failed to start authentication. Please try again.')
})
}
} else {
// Check if already has API key
if (hasApiKey(provider.id)) {
// Show provider models to select from
setProviderModelsModal({ isOpen: true, provider })
setIsOpen(false)
} else {
// Show API key modal first
setApiKeyModal({ isOpen: true, model: { provider } as Model })
setIsOpen(false)
}
}
}
const getApiKeyIcon = (providerId: string) => {
const hasKey = apiKeyStatuses[providerId]
@ -83,6 +163,10 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ selectedModel, onModelCha
}
}
if (loading) {
return <div className="w-full p-3 text-center text-zinc-500">Loading models...</div>
}
return (
<>
<div className="relative" ref={dropdownRef}>
@ -105,22 +189,74 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ selectedModel, onModelCha
{isOpen && (
<div className="absolute bottom-full left-0 right-0 mb-2 bg-white border border-zinc-200 rounded-lg shadow-lg z-50">
{availableModels.map((model) => (
<button
key={model.id}
onClick={() => handleModelSelect(model)}
className="w-full flex items-center justify-between p-3 text-left hover:bg-zinc-50 first:rounded-t-lg last:rounded-b-lg"
>
<div className="flex items-center gap-2">
<span className="text-lg">{model.provider.logo}</span>
<div>
<div className="font-medium text-zinc-900">{model.name}</div>
<div className="text-xs text-zinc-500">{model.provider.name}</div>
</div>
{/* Providers Section */}
<div className="p-3 border-b border-zinc-200">
<div className="text-sm font-medium text-zinc-700 mb-2">Providers</div>
<div className="space-y-1">
{Object.values(providers).map((provider) => (
<button
key={provider.id}
onClick={() => handleProviderSelect(provider)}
className="w-full flex items-center justify-between p-2 text-left hover:bg-zinc-50 rounded-md"
>
<div className="flex items-center gap-3">
<span className="text-lg">{provider.logo}</span>
<div>
<div className="font-medium text-zinc-900">{provider.name}</div>
<div className="text-xs text-zinc-500">{provider.authType === 'oauth' ? 'OAuth' : 'API Key'}</div>
</div>
</div>
{getApiKeyIcon(provider.id)}
</button>
))}
</div>
</div>
{/* Starred Models Section */}
<div className="max-h-64 overflow-y-auto">
<div className="p-3 pb-2">
<div className="text-sm font-medium text-zinc-700 mb-2">⭐ Starred Models</div>
</div>
{getFavoriteModels().length === 0 ? (
<div className="px-3 pb-3 text-center text-zinc-500 text-sm">
No starred models yet. Click on a provider above to explore and star models.
</div>
{getApiKeyIcon(model.provider.id)}
</button>
))}
) : (
<div className="pb-2">
{getFavoriteModels().map((model) => {
const isConfigured = apiKeyStatuses[model.provider.id]
return (
<button
key={model.id}
onClick={() => (isConfigured ? handleModelSelect(model) : handleProviderSelect(model.provider))}
className={`w-full flex items-center justify-between p-3 text-left hover:bg-zinc-50 border-b border-zinc-100 last:border-b-0 ${
!isConfigured ? 'opacity-50' : ''
}`}
>
<div className="flex items-center gap-3">
<Star size={16} fill="currentColor" className="text-yellow-500" />
<span className="text-lg">{model.provider.logo}</span>
<div className="flex-1">
<div className="font-medium text-zinc-900">{model.name}</div>
<div className="text-xs text-zinc-500 flex items-center gap-1">
{model.provider.name}
{model.supportsTools && <span className="text-green-600">🔧</span>}
{model.reasoning && <span className="text-purple-600">🧠</span>}
</div>
</div>
</div>
{!isConfigured && (
<div className="text-orange-500 p-1" title="Provider not configured">
<AlertCircle size={16} />
</div>
)}
</button>
)
})}
</div>
)}
</div>
</div>
)}
</div>
@ -128,9 +264,34 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ selectedModel, onModelCha
<ApiKeyModal
isOpen={apiKeyModal.isOpen}
onClose={() => setApiKeyModal({ isOpen: false, model: null })}
provider={apiKeyModal.model?.provider ?? { id: '', name: '', baseUrl: '', apiKeyHeader: '', documentationUrl: '' }}
provider={
apiKeyModal.model?.provider ?? {
id: 'unknown' as any,
name: 'Unknown',
baseUrl: '',
logo: '',
documentationUrl: '',
authType: 'apiKey',
apiKeyHeader: '',
}
}
onSave={handleApiKeySave}
/>
<ProviderModelsModal
isOpen={providerModelsModal.isOpen}
onClose={() => setProviderModelsModal({ isOpen: false, provider: null })}
provider={providerModelsModal.provider}
models={models}
favorites={[]}
onToggleFavorite={toggleFavorite}
isFavorite={isFavorite}
onModelSelect={(model) => {
onModelChange(model)
setProviderModelsModal({ isOpen: false, provider: null })
}}
toolsAvailable={toolsAvailable}
/>
</>
)
}

View file

@ -1,22 +1,124 @@
import { useEffect } from 'react'
import { onMcpAuthorization } from 'use-mcp'
import React, { useEffect, useState, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import { completeOAuthFlow } from '../utils/auth'
import { SupportedProvider } from '../types/models'
interface OAuthCallbackProps {
provider: SupportedProvider
}
const OAuthCallback: React.FC<OAuthCallbackProps> = ({ provider }) => {
const [searchParams] = useSearchParams()
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [error, setError] = useState<string | null>(null)
const executedRef = useRef(false)
export function OAuthCallback() {
useEffect(() => {
onMcpAuthorization()
}, [])
const handleCallback = async () => {
if (executedRef.current) {
console.log('DEBUG: Skipping duplicate OAuth callback execution')
return
}
executedRef.current = true
try {
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
if (error) {
throw new Error(`OAuth error: ${error}`)
}
if (!code) {
throw new Error('Missing authorization code')
}
// OpenRouter doesn't use state parameter, but other providers might
const stateToUse = state || 'no-state'
await completeOAuthFlow(provider, code, stateToUse)
setStatus('success')
// Close popup after successful authentication
// Give extra time for debugging in development
setTimeout(() => {
if (window.opener) {
window.opener.postMessage({ type: 'oauth_success', provider }, '*')
window.close()
} else {
// Redirect to main page if not in popup
window.location.href = '/'
}
}, 3000)
} catch (err) {
console.error('OAuth callback error:', err)
setError(err instanceof Error ? err.message : 'Unknown error')
setStatus('error')
}
}
handleCallback()
}, [searchParams, provider])
return (
<div className="min-h-screen animated-bg-container flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl p-8 text-center max-w-md w-full">
<h1 className="text-2xl font-bold text-zinc-900 mb-4">Authenticating...</h1>
<p className="text-zinc-600 mb-2">Please wait while we complete your authentication.</p>
<p className="text-sm text-zinc-500">This window should close automatically.</p>
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-8">
<div className="text-center">
{status === 'loading' && (
<>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">Completing Authentication</h2>
<p className="text-gray-600">Connecting to {provider}...</p>
</>
)}
<div className="mt-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
{status === 'success' && (
<>
<div className="text-green-500 mb-4">
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">Authentication Successful!</h2>
<p className="text-gray-600 mb-4">Successfully connected to {provider}. You can now close this window.</p>
<button
onClick={() => window.close()}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Close Window
</button>
</>
)}
{status === 'error' && (
<>
<div className="text-red-500 mb-4">
<svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">Authentication Failed</h2>
<p className="text-gray-600 mb-4">{error || 'An error occurred during authentication'}</p>
<button
onClick={() => window.close()}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Close Window
</button>
</>
)}
</div>
</div>
</div>
)
}
export default OAuthCallback

View file

@ -0,0 +1,143 @@
import React, { useState } from 'react'
import { createPortal } from 'react-dom'
import { X, Star, Search } from 'lucide-react'
import { type Model, type Provider } from '../types/models'
interface ProviderModelsModalProps {
isOpen: boolean
onClose: () => void
provider: Provider | null
models: Model[]
favorites: string[]
onToggleFavorite: (modelId: string) => void
isFavorite: (modelId: string) => boolean
onModelSelect: (model: Model) => void
toolsAvailable?: boolean
}
const ProviderModelsModal: React.FC<ProviderModelsModalProps> = ({
isOpen,
onClose,
provider,
models,
onToggleFavorite,
isFavorite,
onModelSelect,
toolsAvailable = false,
}) => {
const [searchTerm, setSearchTerm] = useState('')
const [showToolsOnly, setShowToolsOnly] = useState(false)
if (!isOpen || !provider) return null
const providerModels = models.filter((model) => model.provider.id === provider.id)
const filteredModels = providerModels.filter((model) => {
const matchesSearch = model.name.toLowerCase().includes(searchTerm.toLowerCase())
const matchesTools = !showToolsOnly || model.supportsTools
return matchesSearch && matchesTools
})
const handleStarClick = (modelId: string, e: React.MouseEvent) => {
e.stopPropagation()
onToggleFavorite(modelId)
}
const modalContent = (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-[80vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-zinc-200">
<div className="flex items-center gap-3">
<span className="text-2xl">{provider.logo}</span>
<div>
<h2 className="text-lg font-semibold text-zinc-900">{provider.name} Models</h2>
<p className="text-sm text-zinc-500">Select models to add to your favorites</p>
</div>
</div>
<button onClick={onClose} className="p-2 text-zinc-400 hover:text-zinc-600 rounded-md hover:bg-zinc-100">
<X size={20} />
</button>
</div>
{/* Search and Filters */}
<div className="p-4 border-b border-zinc-200">
<div className="relative mb-3">
<Search size={16} className="absolute left-3 top-2.5 text-zinc-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search models..."
className="w-full pl-10 pr-3 py-2 text-sm border border-zinc-200 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{toolsAvailable && (
<button
onClick={() => setShowToolsOnly(!showToolsOnly)}
className={`px-3 py-1 text-xs rounded-full border transition-colors ${
showToolsOnly ? 'bg-green-100 text-green-700 border-green-300' : 'bg-white text-zinc-600 border-zinc-200 hover:bg-zinc-50'
}`}
>
🔧 Tools Only
</button>
)}
</div>
{/* Models List */}
<div className="max-h-96 overflow-y-auto">
{filteredModels.length === 0 ? (
<div className="p-8 text-center text-zinc-500">No models found matching your criteria</div>
) : (
filteredModels.map((model) => (
<div
key={model.id}
className="flex items-center justify-between p-4 border-b border-zinc-100 last:border-b-0 hover:bg-zinc-50"
>
<div className="flex items-center gap-3 flex-1">
<button
onClick={(e) => handleStarClick(model.id, e)}
className={`transition-colors ${isFavorite(model.id) ? 'text-yellow-500' : 'text-zinc-300 hover:text-yellow-400'}`}
>
<Star size={18} fill={isFavorite(model.id) ? 'currentColor' : 'none'} />
</button>
<div className="flex-1">
<div className="font-medium text-zinc-900">{model.name}</div>
<div className="text-sm text-zinc-500 flex items-center gap-2">
{model.supportsTools && <span className="text-green-600">🔧 Tools</span>}
{model.reasoning && <span className="text-purple-600">🧠 Reasoning</span>}
{model.attachment && <span className="text-blue-600">📎 Attachments</span>}
<span className="text-zinc-400">•</span>
<span>{(model.contextLimit / 1000).toFixed(0)}K context</span>
</div>
</div>
</div>
<button
onClick={() => onModelSelect(model)}
className="px-3 py-1 text-sm bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors"
>
Select
</button>
</div>
))
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-zinc-200 bg-zinc-50">
<div className="text-sm text-zinc-600">
{filteredModels.length} model{filteredModels.length !== 1 ? 's' : ''} available
{toolsAvailable && showToolsOnly && ' with tool support'}
</div>
</div>
</div>
</div>
)
return createPortal(modalContent, document.body)
}
export default ProviderModelsModal

View file

@ -0,0 +1,984 @@
{
"anthropic": {
"claude-3-haiku-20240307": {
"id": "claude-3-haiku-20240307",
"name": "Claude Haiku 3",
"attachment": true,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2023-08-31",
"release_date": "2024-03-13",
"last_updated": "2024-03-13",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 0.25,
"output": 1.25,
"cache_read": 0.03,
"cache_write": 0.3
},
"limit": {
"context": 200000,
"output": 4096
}
},
"claude-3-opus-20240229": {
"id": "claude-3-opus-20240229",
"name": "Claude Opus 3",
"attachment": true,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2023-08-31",
"release_date": "2024-02-29",
"last_updated": "2024-02-29",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 15,
"output": 75,
"cache_read": 1.5,
"cache_write": 18.75
},
"limit": {
"context": 200000,
"output": 4096
}
},
"claude-3-5-haiku-20241022": {
"id": "claude-3-5-haiku-20241022",
"name": "Claude Haiku 3.5",
"attachment": true,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2024-07-31",
"release_date": "2024-10-22",
"last_updated": "2024-10-22",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 0.8,
"output": 4,
"cache_read": 0.08,
"cache_write": 1
},
"limit": {
"context": 200000,
"output": 8192
}
},
"claude-sonnet-4-20250514": {
"id": "claude-sonnet-4-20250514",
"name": "Claude Sonnet 4",
"attachment": true,
"reasoning": true,
"temperature": true,
"tool_call": true,
"knowledge": "2025-03-31",
"release_date": "2025-05-22",
"last_updated": "2025-05-22",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 3,
"output": 15,
"cache_read": 0.3,
"cache_write": 3.75
},
"limit": {
"context": 200000,
"output": 64000
}
},
"claude-3-sonnet-20240229": {
"id": "claude-3-sonnet-20240229",
"name": "Claude Sonnet 3",
"attachment": true,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2023-08-31",
"release_date": "2024-03-04",
"last_updated": "2024-03-04",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 3,
"output": 15,
"cache_read": 0.3,
"cache_write": 0.3
},
"limit": {
"context": 200000,
"output": 4096
}
},
"claude-3-5-sonnet-20240620": {
"id": "claude-3-5-sonnet-20240620",
"name": "Claude Sonnet 3.5",
"attachment": true,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2024-04-30",
"release_date": "2024-06-20",
"last_updated": "2024-06-20",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 3,
"output": 15,
"cache_read": 0.3,
"cache_write": 3.75
},
"limit": {
"context": 200000,
"output": 8192
}
},
"claude-opus-4-20250514": {
"id": "claude-opus-4-20250514",
"name": "Claude Opus 4",
"attachment": true,
"reasoning": true,
"temperature": true,
"tool_call": true,
"knowledge": "2025-03-31",
"release_date": "2025-05-22",
"last_updated": "2025-05-22",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 15,
"output": 75,
"cache_read": 1.5,
"cache_write": 18.75
},
"limit": {
"context": 200000,
"output": 32000
}
},
"claude-3-5-sonnet-20241022": {
"id": "claude-3-5-sonnet-20241022",
"name": "Claude Sonnet 3.5 v2",
"attachment": true,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2024-04-30",
"release_date": "2024-10-22",
"last_updated": "2024-10-22",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 3,
"output": 15,
"cache_read": 0.3,
"cache_write": 3.75
},
"limit": {
"context": 200000,
"output": 8192
}
},
"claude-3-7-sonnet-20250219": {
"id": "claude-3-7-sonnet-20250219",
"name": "Claude Sonnet 3.7",
"attachment": true,
"reasoning": true,
"temperature": true,
"tool_call": true,
"knowledge": "2024-10-31",
"release_date": "2025-02-19",
"last_updated": "2025-02-19",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 3,
"output": 15,
"cache_read": 0.3,
"cache_write": 3.75
},
"limit": {
"context": 200000,
"output": 64000
}
}
},
"groq": {
"llama-3.3-70b-versatile": {
"id": "llama-3.3-70b-versatile",
"name": "Llama 3.3 70B Versatile",
"attachment": false,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2023-12",
"release_date": "2024-12-06",
"last_updated": "2024-12-06",
"modalities": {
"input": ["text"],
"output": ["text"]
},
"open_weights": true,
"cost": {
"input": 0.59,
"output": 0.79
},
"limit": {
"context": 131072,
"output": 32768
}
},
"llama-3.1-8b-instant": {
"id": "llama-3.1-8b-instant",
"name": "Llama 3.1 8B Instant",
"attachment": false,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2023-12",
"release_date": "2024-07-23",
"last_updated": "2024-07-23",
"modalities": {
"input": ["text"],
"output": ["text"]
},
"open_weights": true,
"cost": {
"input": 0.05,
"output": 0.08
},
"limit": {
"context": 131072,
"output": 8192
}
},
"llama-guard-3-8b": {
"id": "llama-guard-3-8b",
"name": "Llama Guard 3 8B",
"attachment": false,
"reasoning": false,
"temperature": true,
"tool_call": false,
"release_date": "2024-07-23",
"last_updated": "2024-07-23",
"modalities": {
"input": ["text"],
"output": ["text"]
},
"open_weights": true,
"cost": {
"input": 0.2,
"output": 0.2
},
"limit": {
"context": 8192,
"output": 8192
}
},
"gemma2-9b-it": {
"id": "gemma2-9b-it",
"name": "Gemma 2 9B",
"attachment": false,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2024-06",
"release_date": "2024-06-27",
"last_updated": "2024-06-27",
"modalities": {
"input": ["text"],
"output": ["text"]
},
"open_weights": true,
"cost": {
"input": 0.2,
"output": 0.2
},
"limit": {
"context": 8192,
"output": 8192
}
},
"llama3-8b-8192": {
"id": "llama3-8b-8192",
"name": "Llama 3 8B",
"attachment": false,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2023-03",
"release_date": "2024-04-18",
"last_updated": "2024-04-18",
"modalities": {
"input": ["text"],
"output": ["text"]
},
"open_weights": true,
"cost": {
"input": 0.05,
"output": 0.08
},
"limit": {
"context": 8192,
"output": 8192
}
},
"llama3-70b-8192": {
"id": "llama3-70b-8192",
"name": "Llama 3 70B",
"attachment": false,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2023-03",
"release_date": "2024-04-18",
"last_updated": "2024-04-18",
"modalities": {
"input": ["text"],
"output": ["text"]
},
"open_weights": true,
"cost": {
"input": 0.59,
"output": 0.79
},
"limit": {
"context": 8192,
"output": 8192
}
},
"deepseek-r1-distill-llama-70b": {
"id": "deepseek-r1-distill-llama-70b",
"name": "DeepSeek R1 Distill Llama 70B",
"attachment": false,
"reasoning": true,
"temperature": true,
"tool_call": true,
"knowledge": "2024-07",
"release_date": "2025-01-20",
"last_updated": "2025-01-20",
"modalities": {
"input": ["text"],
"output": ["text"]
},
"open_weights": true,
"cost": {
"input": 0.75,
"output": 0.99
},
"limit": {
"context": 131072,
"output": 8192
}
},
"qwen-qwq-32b": {
"id": "qwen-qwq-32b",
"name": "Qwen QwQ 32B",
"attachment": false,
"reasoning": true,
"temperature": true,
"tool_call": true,
"knowledge": "2024-09",
"release_date": "2024-11-27",
"last_updated": "2024-11-27",
"modalities": {
"input": ["text"],
"output": ["text"]
},
"open_weights": true,
"cost": {
"input": 0.29,
"output": 0.39
},
"limit": {
"context": 131072,
"output": 16384
}
},
"mistral-saba-24b": {
"id": "mistral-saba-24b",
"name": "Mistral Saba 24B",
"attachment": false,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2024-08",
"release_date": "2025-02-06",
"last_updated": "2025-02-06",
"modalities": {
"input": ["text"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 0.79,
"output": 0.79
},
"limit": {
"context": 32768,
"output": 32768
}
},
"qwen/qwen3-32b": {
"id": "qwen/qwen3-32b",
"name": "Qwen3 32B",
"attachment": false,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2024-11-08",
"release_date": "2024-12-23",
"last_updated": "2024-12-23",
"modalities": {
"input": ["text"],
"output": ["text"]
},
"open_weights": true,
"cost": {
"input": 0.29,
"output": 0.59
},
"limit": {
"context": 131072,
"output": 16384
}
},
"meta-llama/llama-4-maverick-17b-128e-instruct": {
"id": "meta-llama/llama-4-maverick-17b-128e-instruct",
"name": "Llama 4 Maverick 17B",
"attachment": false,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2024-08",
"release_date": "2025-04-05",
"last_updated": "2025-04-05",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": true,
"cost": {
"input": 0.2,
"output": 0.6
},
"limit": {
"context": 131072,
"output": 8192
}
},
"meta-llama/llama-4-scout-17b-16e-instruct": {
"id": "meta-llama/llama-4-scout-17b-16e-instruct",
"name": "Llama 4 Scout 17B",
"attachment": false,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2024-08",
"release_date": "2025-04-05",
"last_updated": "2025-04-05",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": true,
"cost": {
"input": 0.11,
"output": 0.34
},
"limit": {
"context": 131072,
"output": 8192
}
},
"meta-llama/llama-guard-4-12b": {
"id": "meta-llama/llama-guard-4-12b",
"name": "Llama Guard 4 12B",
"attachment": false,
"reasoning": false,
"temperature": true,
"tool_call": false,
"release_date": "2025-04-05",
"last_updated": "2025-04-05",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": true,
"cost": {
"input": 0.2,
"output": 0.2
},
"limit": {
"context": 131072,
"output": 128
}
}
},
"openrouter": {
"openai/gpt-4.1-mini": {
"id": "openai/gpt-4.1-mini",
"name": "GPT-4.1 Mini",
"attachment": true,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2024-04",
"release_date": "2025-04-14",
"last_updated": "2025-04-14",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 0.4,
"output": 1.6,
"cache_read": 0.1
},
"limit": {
"context": 1047576,
"output": 32768
}
},
"openai/gpt-4.1": {
"id": "openai/gpt-4.1",
"name": "GPT-4.1",
"attachment": true,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2024-04",
"release_date": "2025-04-14",
"last_updated": "2025-04-14",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 2,
"output": 8,
"cache_read": 0.5
},
"limit": {
"context": 1047576,
"output": 32768
}
},
"openai/gpt-4o-mini": {
"id": "openai/gpt-4o-mini",
"name": "GPT-4o-mini",
"attachment": true,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2024-10",
"release_date": "2024-07-18",
"last_updated": "2024-07-18",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 0.15,
"output": 0.6,
"cache_read": 0.08
},
"limit": {
"context": 128000,
"output": 16384
}
},
"openai/o4-mini": {
"id": "openai/o4-mini",
"name": "o4 Mini",
"attachment": true,
"reasoning": true,
"temperature": true,
"tool_call": true,
"knowledge": "2024-06",
"release_date": "2025-04-16",
"last_updated": "2025-04-16",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 1.1,
"output": 4.4,
"cache_read": 0.28
},
"limit": {
"context": 200000,
"output": 100000
}
},
"anthropic/claude-4-sonnet-20250522": {
"id": "anthropic/claude-4-sonnet-20250522",
"name": "Claude Sonnet 4",
"attachment": true,
"reasoning": true,
"temperature": true,
"tool_call": true,
"knowledge": "2025-03-31",
"release_date": "2025-05-22",
"last_updated": "2025-05-22",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 3,
"output": 15,
"cache_read": 0.3,
"cache_write": 3.75
},
"limit": {
"context": 200000,
"output": 64000
}
},
"anthropic/claude-3.7-sonnet": {
"id": "anthropic/claude-3.7-sonnet",
"name": "Claude Sonnet 3.7",
"attachment": true,
"reasoning": true,
"temperature": true,
"tool_call": true,
"knowledge": "2024-01",
"release_date": "2025-02-19",
"last_updated": "2025-02-19",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 15,
"output": 75,
"cache_read": 1.5,
"cache_write": 18.75
},
"limit": {
"context": 200000,
"output": 128000
}
},
"anthropic/claude-opus-4": {
"id": "anthropic/claude-opus-4",
"name": "Claude Opus 4",
"attachment": true,
"reasoning": true,
"temperature": true,
"tool_call": true,
"knowledge": "2025-03-31",
"release_date": "2025-05-22",
"last_updated": "2025-05-22",
"modalities": {
"input": ["text", "image"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 15,
"output": 75,
"cache_read": 1.5,
"cache_write": 18.75
},
"limit": {
"context": 200000,
"output": 32000
}
},
"x-ai/grok-3-mini": {
"id": "x-ai/grok-3-mini",
"name": "Grok 3 Mini",
"attachment": false,
"reasoning": true,
"temperature": true,
"tool_call": true,
"knowledge": "2024-11",
"release_date": "2025-02-17",
"last_updated": "2025-02-17",
"modalities": {
"input": ["text"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 0.3,
"output": 0.5,
"cache_read": 0.075,
"cache_write": 0.5
},
"limit": {
"context": 131072,
"output": 8192
}
},
"x-ai/grok-3-mini-beta": {
"id": "x-ai/grok-3-mini-beta",
"name": "Grok 3 Mini Beta",
"attachment": false,
"reasoning": true,
"temperature": true,
"tool_call": true,
"knowledge": "2024-11",
"release_date": "2025-02-17",
"last_updated": "2025-02-17",
"modalities": {
"input": ["text"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 0.3,
"output": 0.5,
"cache_read": 0.075,
"cache_write": 0.5
},
"limit": {
"context": 131072,
"output": 8192
}
},
"x-ai/grok-3": {
"id": "x-ai/grok-3",
"name": "Grok 3",
"attachment": false,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2024-11",
"release_date": "2025-02-17",
"last_updated": "2025-02-17",
"modalities": {
"input": ["text"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 3,
"output": 15,
"cache_read": 0.75,
"cache_write": 15
},
"limit": {
"context": 131072,
"output": 8192
}
},
"x-ai/grok-3-beta": {
"id": "x-ai/grok-3-beta",
"name": "Grok 3 Beta",
"attachment": false,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2024-11",
"release_date": "2025-02-17",
"last_updated": "2025-02-17",
"modalities": {
"input": ["text"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 3,
"output": 15,
"cache_read": 0.75,
"cache_write": 15
},
"limit": {
"context": 131072,
"output": 8192
}
},
"google/gemini-2.5-pro-preview-05-06": {
"id": "google/gemini-2.5-pro-preview-05-06",
"name": "Gemini 2.5 Pro Preview 05-06",
"attachment": true,
"reasoning": true,
"temperature": true,
"tool_call": true,
"knowledge": "2025-01",
"release_date": "2025-05-06",
"last_updated": "2025-05-06",
"modalities": {
"input": ["text", "image", "audio", "video", "pdf"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 1.25,
"output": 10,
"cache_read": 0.31
},
"limit": {
"context": 1048576,
"output": 65536
}
},
"google/gemini-2.5-pro-preview-06-05": {
"id": "google/gemini-2.5-pro-preview-06-05",
"name": "Gemini 2.5 Pro Preview 06-05",
"attachment": true,
"reasoning": true,
"temperature": true,
"tool_call": true,
"knowledge": "2025-01",
"release_date": "2025-06-05",
"last_updated": "2025-06-05",
"modalities": {
"input": ["text", "image", "audio", "video", "pdf"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 1.25,
"output": 10,
"cache_read": 0.31
},
"limit": {
"context": 1048576,
"output": 65536
}
},
"google/gemini-2.5-pro": {
"id": "google/gemini-2.5-pro",
"name": "Gemini 2.5 Pro",
"attachment": true,
"reasoning": true,
"temperature": true,
"tool_call": true,
"knowledge": "2025-01",
"release_date": "2025-03-20",
"last_updated": "2025-06-05",
"modalities": {
"input": ["text", "image", "audio", "video", "pdf"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 1.25,
"output": 10,
"cache_read": 0.31
},
"limit": {
"context": 1048576,
"output": 65536
}
},
"google/gemini-2.5-flash-preview-04-17": {
"id": "google/gemini-2.5-flash-preview-04-17",
"name": "Gemini 2.5 Flash Preview 04-17",
"attachment": true,
"reasoning": true,
"temperature": true,
"tool_call": true,
"knowledge": "2025-01",
"release_date": "2025-04-17",
"last_updated": "2025-04-17",
"modalities": {
"input": ["text", "image", "audio", "video", "pdf"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 0.15,
"output": 0.6,
"cache_read": 0.0375
},
"limit": {
"context": 1048576,
"output": 65536
}
},
"google/gemini-2.5-flash-preview-05-20": {
"id": "google/gemini-2.5-flash-preview-05-20",
"name": "Gemini 2.5 Flash Preview 05-20",
"attachment": true,
"reasoning": true,
"temperature": true,
"tool_call": true,
"knowledge": "2025-01",
"release_date": "2025-05-20",
"last_updated": "2025-05-20",
"modalities": {
"input": ["text", "image", "audio", "video", "pdf"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 0.15,
"output": 0.6,
"cache_read": 0.0375
},
"limit": {
"context": 1048576,
"output": 65536
}
},
"google/gemini-2.0-flash-001": {
"id": "google/gemini-2.0-flash-001",
"name": "Gemini 2.0 Flash",
"attachment": true,
"reasoning": false,
"temperature": true,
"tool_call": true,
"knowledge": "2024-06",
"release_date": "2024-12-11",
"last_updated": "2024-12-11",
"modalities": {
"input": ["text", "image", "audio", "video", "pdf"],
"output": ["text"]
},
"open_weights": false,
"cost": {
"input": 0.1,
"output": 0.4,
"cache_read": 0.025
},
"limit": {
"context": 1048576,
"output": 8192
}
}
}
}

View file

@ -0,0 +1,140 @@
import { useState, useEffect, useCallback } from 'react'
import { Model, providers, SupportedProvider, SUPPORTED_PROVIDERS, FAVORITES_KEY } from '../types/models'
// Load models data from generated JSON
import modelsData from '../data/models.json'
export function useModels() {
const [models, setModels] = useState<Model[]>([])
const [favorites, setFavorites] = useState<string[]>([])
const [loading, setLoading] = useState(true)
// Load models from data and favorites from localStorage
useEffect(() => {
const loadModels = () => {
const allModels: Model[] = []
// Process each provider's models
for (const providerId of SUPPORTED_PROVIDERS) {
const provider = providers[providerId]
const providerModels = modelsData[providerId] || {}
for (const [modelId, modelData] of Object.entries(providerModels)) {
const model: Model = {
id: `${providerId}:${modelId}`,
name: modelData.name,
provider,
modelId,
supportsTools: modelData.tool_call,
reasoning: modelData.reasoning,
attachment: modelData.attachment,
contextLimit: modelData.limit.context,
outputLimit: modelData.limit.output,
cost: modelData.cost,
providerOptions: getProviderOptions(providerId, modelId),
}
allModels.push(model)
}
}
setModels(allModels)
setLoading(false)
}
const loadFavorites = () => {
try {
const saved = localStorage.getItem(FAVORITES_KEY)
if (saved) {
const parsed = JSON.parse(saved)
setFavorites(parsed)
}
} catch (error) {
console.error('Failed to load favorites from localStorage:', error)
}
}
loadModels()
loadFavorites()
}, [])
// Save favorites to localStorage when they change
useEffect(() => {
// Only save if favorites array has been loaded (not during initial state)
if (loading) return
try {
localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites))
} catch (error) {
console.error('Failed to save favorites to localStorage:', error)
}
}, [favorites, loading])
const toggleFavorite = useCallback((modelId: string) => {
setFavorites((prev) => {
if (prev.includes(modelId)) {
return prev.filter((id) => id !== modelId)
} else {
return [...prev, modelId]
}
})
}, [])
const addToFavorites = useCallback((modelId: string) => {
setFavorites((prev) => {
if (!prev.includes(modelId)) {
return [...prev, modelId]
}
return prev
})
}, [])
const isFavorite = useCallback(
(modelId: string) => {
return favorites.includes(modelId)
},
[favorites]
)
const getFavoriteModels = useCallback(() => {
return models.filter((model) => favorites.includes(model.id))
}, [models, favorites])
const getToolSupportingModels = useCallback(() => {
return models.filter((model) => model.supportsTools)
}, [models])
return {
models,
favorites,
loading,
toggleFavorite,
addToFavorites,
isFavorite,
getFavoriteModels,
getToolSupportingModels,
}
}
// Helper function to get provider-specific options
function getProviderOptions(providerId: SupportedProvider, modelId: string) {
// Add any provider-specific options here
switch (providerId) {
case 'groq':
// Handle specific Groq model options
if (modelId === 'qwen/qwen3-32b') {
return {
groq: {
reasoningFormat: 'parsed',
},
}
}
break
case 'anthropic':
// Handle specific Anthropic model options
break
case 'openrouter':
// Handle specific OpenRouter model options
break
}
return undefined
}

View file

@ -2,9 +2,10 @@ import { useRef, useState } from 'react'
import { CoreMessage, jsonSchema, streamText, tool } from 'ai'
import { createGroq } from '@ai-sdk/groq'
import { createAnthropic } from '@ai-sdk/anthropic'
import { createOpenAI } from '@ai-sdk/openai'
import { type AssistantMessage, type Conversation, type Message, type SystemMessage, type UserMessage } from '../types'
import { type Model } from '../types/models'
import { getApiKey } from '../utils/apiKeys'
import { getAuthHeaders } from '../utils/auth'
import { type Tool } from 'use-mcp/react'
import { useConversationUpdater } from './useConversationUpdater'
@ -82,16 +83,18 @@ export const useStreamResponse = ({
// return reasoningModels.includes(model.modelId)
// }
const getModelInstance = (model: Model, apiKey: string) => {
const getModelInstance = async (model: Model, authHeaders: Record<string, string>) => {
let baseModel
switch (model.provider.id) {
case 'groq': {
const apiKey = authHeaders.Authorization?.replace('Bearer ', '')
const groqProvider = createGroq({ apiKey })
baseModel = groqProvider(model.modelId)
break
}
case 'anthropic': {
const apiKey = authHeaders['x-api-key']
const anthropicProvider = createAnthropic({
apiKey,
headers: {
@ -101,6 +104,15 @@ export const useStreamResponse = ({
baseModel = anthropicProvider(model.modelId)
break
}
case 'openrouter': {
const apiKey = authHeaders.Authorization?.replace('Bearer ', '')
const openrouterProvider = createOpenAI({
apiKey,
baseURL: 'https://openrouter.ai/api/v1',
})
baseModel = openrouterProvider(model.modelId)
break
}
default:
throw new Error(`Unsupported provider: ${model.provider.id}`)
}
@ -112,21 +124,25 @@ export const useStreamResponse = ({
}
const streamResponse = async (messages: Message[]) => {
// Check if API key is available
let apiKey = getApiKey(selectedModel.provider.id)
if (!apiKey) {
// Check if authentication is available
let authHeaders: Record<string, string>
try {
authHeaders = await getAuthHeaders(selectedModel.provider.id)
} catch (error) {
// Authentication not available, prompt user
const keyProvided = await onApiKeyRequired(selectedModel)
if (!keyProvided) {
return // User cancelled
}
apiKey = getApiKey(selectedModel.provider.id)
if (!apiKey) {
throw new Error('No API key provided')
try {
authHeaders = await getAuthHeaders(selectedModel.provider.id)
} catch (error) {
throw new Error('No valid authentication found')
}
}
try {
const modelInstance = getModelInstance(selectedModel, apiKey)
const modelInstance = await getModelInstance(selectedModel, authHeaders)
setStreamStarted(true)

View file

@ -1,73 +1,105 @@
export interface ModelProvider {
// Types for models.dev API data
export interface ModelData {
id: string
name: string
attachment: boolean
reasoning: boolean
temperature: boolean
tool_call: boolean
knowledge: string
release_date: string
last_updated: string
modalities: {
input: string[]
output: string[]
}
open_weights: boolean
limit: {
context: number
output: number
}
cost?: {
input: number
output: number
cache_read?: number
cache_write?: number
}
}
export type SupportedProvider = 'anthropic' | 'groq' | 'openrouter'
export interface Provider {
id: SupportedProvider
name: string
baseUrl: string
apiKeyHeader: string
logo: string
documentationUrl: string
logo?: string
authType: 'apiKey' | 'oauth'
apiKeyHeader?: string
oauth?: {
authorizeUrl: string
tokenUrl: string
clientId: string
scopes: string[]
}
}
export interface Model {
id: string
name: string
provider: ModelProvider
provider: Provider
modelId: string
supportsTools: boolean
reasoning: boolean
attachment: boolean
contextLimit: number
outputLimit: number
cost?: {
input: number
output: number
cache_read?: number
cache_write?: number
}
providerOptions?: any
}
export const providers: Record<string, ModelProvider> = {
export const providers: Record<SupportedProvider, Provider> = {
groq: {
id: 'groq',
name: 'Groq',
baseUrl: 'https://api.groq.com/openai/v1',
apiKeyHeader: 'Authorization',
documentationUrl: 'https://console.groq.com/docs',
logo: '🚀',
documentationUrl: 'https://console.groq.com/docs',
authType: 'apiKey',
apiKeyHeader: 'Authorization',
},
anthropic: {
id: 'anthropic',
name: 'Anthropic',
baseUrl: 'https://api.anthropic.com/v1',
apiKeyHeader: 'x-api-key',
documentationUrl: 'https://docs.anthropic.com/',
logo: '🤖',
documentationUrl: 'https://docs.anthropic.com/',
authType: 'apiKey',
apiKeyHeader: 'x-api-key',
},
openrouter: {
id: 'openrouter',
name: 'OpenRouter',
baseUrl: 'https://openrouter.ai/api/v1',
logo: '🌐',
documentationUrl: 'https://openrouter.ai/docs',
authType: 'oauth',
oauth: {
authorizeUrl: 'https://openrouter.ai/auth',
tokenUrl: 'https://openrouter.ai/api/v1/auth/keys',
clientId: '', // OpenRouter doesn't use client_id
scopes: [], // OpenRouter doesn't use scopes
},
},
}
export const availableModels: Model[] = [
{
id: 'llama-4-maverick-17b-128e-instruct',
name: 'Llama 4 Maverick 17B',
provider: providers.groq,
modelId: 'meta-llama/llama-4-maverick-17b-128e-instruct',
},
{
id: 'qwen-3-32b',
name: 'Qwen3 32B',
provider: providers.groq,
modelId: 'qwen/qwen3-32b',
providerOptions: {
groq: {
reasoningFormat: 'parsed',
},
},
},
// {
// id: 'qwen-qwq-32b',
// name: 'Qwen QwQ 32B (Reasoning)',
// provider: providers.groq,
// modelId: 'qwen-qwq-32b',
// },
// {
// id: 'deepseek-r1-distill-llama-70b',
// name: 'DeepSeek R1 Distil Llama 70B (Reasoning)',
// provider: providers.groq,
// modelId: 'deepseek-r1-distill-llama-70b',
// },
{
id: 'claude-4-sonnet-20250514',
name: 'Claude 4 Sonnet',
provider: providers.anthropic,
modelId: 'claude-4-sonnet-20250514',
},
]
export const SUPPORTED_PROVIDERS: readonly SupportedProvider[] = ['anthropic', 'groq', 'openrouter']
// Storage keys for user preferences
export const FAVORITES_KEY = 'aiChatTemplate_favorites_v1'
export const PROVIDER_TOKEN_KEY_PREFIX = 'aiChatTemplate_token_'

View file

@ -1,17 +1,2 @@
const API_KEY_PREFIX = 'aiChatTemplate_apiKey_'
export const getApiKey = (providerId: string): string | null => {
return localStorage.getItem(`${API_KEY_PREFIX}${providerId}`)
}
export const setApiKey = (providerId: string, apiKey: string): void => {
localStorage.setItem(`${API_KEY_PREFIX}${providerId}`, apiKey)
}
export const clearApiKey = (providerId: string): void => {
localStorage.removeItem(`${API_KEY_PREFIX}${providerId}`)
}
export const hasApiKey = (providerId: string): boolean => {
return getApiKey(providerId) !== null
}
// Re-export functions from new auth system for backward compatibility
export { getApiKey, setApiKey, clearApiKey, hasApiKey } from './auth'

View file

@ -0,0 +1,327 @@
import { SupportedProvider, providers, PROVIDER_TOKEN_KEY_PREFIX } from '../types/models'
// Types for OAuth tokens
export interface OAuthToken {
access_token: string
refresh_token?: string
expires_at?: number
token_type: 'Bearer'
}
// Types for PKCE flow
interface PKCEState {
code_verifier: string
state: string
}
// Generate a random code verifier for PKCE
function generateCodeVerifier(): string {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
// Generate code challenge from verifier
async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const digest = await crypto.subtle.digest('SHA-256', data)
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
// Generate random state parameter
function generateState(): string {
const array = new Uint8Array(16)
crypto.getRandomValues(array)
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
// API Key functions (existing functionality)
export function hasApiKey(providerId: SupportedProvider): boolean {
const provider = providers[providerId]
if (provider.authType === 'oauth') {
return hasOAuthToken(providerId)
}
const key = localStorage.getItem(PROVIDER_TOKEN_KEY_PREFIX + providerId)
return key !== null && key.length > 0
}
export function getApiKey(providerId: SupportedProvider): string | null {
return localStorage.getItem(PROVIDER_TOKEN_KEY_PREFIX + providerId)
}
export function setApiKey(providerId: SupportedProvider, apiKey: string): void {
localStorage.setItem(PROVIDER_TOKEN_KEY_PREFIX + providerId, apiKey)
}
export function clearApiKey(providerId: SupportedProvider): void {
localStorage.removeItem(PROVIDER_TOKEN_KEY_PREFIX + providerId)
}
// OAuth functions
export function hasOAuthToken(providerId: SupportedProvider): boolean {
const token = getOAuthToken(providerId)
if (!token) return false
// Check if token is expired
if (token.expires_at && token.expires_at < Date.now()) {
return false
}
return true
}
export function getOAuthToken(providerId: SupportedProvider): OAuthToken | null {
try {
const tokenJson = localStorage.getItem(PROVIDER_TOKEN_KEY_PREFIX + providerId)
if (!tokenJson) return null
const token = JSON.parse(tokenJson) as OAuthToken
return token
} catch (error) {
console.error('Failed to parse OAuth token:', error)
return null
}
}
export function setOAuthToken(providerId: SupportedProvider, token: OAuthToken): void {
localStorage.setItem(PROVIDER_TOKEN_KEY_PREFIX + providerId, JSON.stringify(token))
}
export function clearOAuthToken(providerId: SupportedProvider): void {
localStorage.removeItem(PROVIDER_TOKEN_KEY_PREFIX + providerId)
}
// PKCE OAuth flow functions
export async function beginOAuthFlow(providerId: SupportedProvider): Promise<void> {
const provider = providers[providerId]
if (!provider.oauth) {
throw new Error(`Provider ${providerId} does not support OAuth`)
}
const codeVerifier = generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
const state = generateState()
// Store PKCE state in sessionStorage
const pkceState: PKCEState = { code_verifier: codeVerifier, state }
const storageKey = providerId === 'openrouter' ? `pkce_${providerId}_${Date.now()}` : `pkce_${providerId}_${state}`
sessionStorage.setItem(storageKey, JSON.stringify(pkceState))
// Construct authorization URL based on provider
let authUrl: URL
if (providerId === 'openrouter') {
// OpenRouter uses a different OAuth flow
authUrl = new URL(provider.oauth.authorizeUrl)
authUrl.searchParams.set('callback_url', getRedirectUri(providerId))
authUrl.searchParams.set('code_challenge', codeChallenge)
authUrl.searchParams.set('code_challenge_method', 'S256')
} else {
// Standard OAuth2 flow for other providers
authUrl = new URL(provider.oauth.authorizeUrl)
authUrl.searchParams.set('client_id', provider.oauth.clientId)
authUrl.searchParams.set('redirect_uri', getRedirectUri(providerId))
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('scope', provider.oauth.scopes.join(' '))
authUrl.searchParams.set('state', state)
authUrl.searchParams.set('code_challenge', codeChallenge)
authUrl.searchParams.set('code_challenge_method', 'S256')
}
// Open popup or redirect
const popup = window.open(authUrl.toString(), `oauth_${providerId}`, 'width=600,height=700')
if (!popup) {
throw new Error('Failed to open OAuth popup. Please allow popups for this site.')
}
}
export async function completeOAuthFlow(providerId: SupportedProvider, code: string, state: string): Promise<void> {
console.log('DEBUG: Starting OAuth completion for', providerId, 'with code:', code?.substring(0, 10) + '...', 'state:', state)
const provider = providers[providerId]
if (!provider.oauth) {
throw new Error(`Provider ${providerId} does not support OAuth`)
}
// Retrieve PKCE state
let pkceState: PKCEState
if (state === 'no-state' && providerId === 'openrouter') {
// OpenRouter doesn't use state, find the most recent PKCE state for this provider
const allKeys = Object.keys(sessionStorage)
const pkceKeys = allKeys.filter((key) => key.startsWith(`pkce_${providerId}_`))
console.log('DEBUG: Found PKCE keys:', pkceKeys)
if (pkceKeys.length === 0) {
throw new Error('PKCE state not found. Please try again.')
}
// Use the most recent one (sort by timestamp)
const sortedKeys = pkceKeys.sort((a, b) => {
const aTime = parseInt(a.split('_').pop() || '0')
const bTime = parseInt(b.split('_').pop() || '0')
return bTime - aTime // Most recent first
})
const pkceStateJson = sessionStorage.getItem(sortedKeys[0])!
pkceState = JSON.parse(pkceStateJson)
console.log('DEBUG: Using PKCE state:', { key: sortedKeys[0], state: pkceState })
// Clean up the state
sessionStorage.removeItem(sortedKeys[0])
} else {
const pkceStateJson = sessionStorage.getItem(`pkce_${providerId}_${state}`)
if (!pkceStateJson) {
throw new Error('PKCE state not found. Please try again.')
}
pkceState = JSON.parse(pkceStateJson)
console.log('DEBUG: Using PKCE state for state', state, ':', pkceState)
}
// Exchange code for token
let tokenResponse: Response
if (providerId === 'openrouter') {
// OpenRouter uses JSON body instead of form data
const requestBody = {
code,
code_verifier: pkceState.code_verifier,
code_challenge_method: 'S256',
}
console.log('DEBUG: OpenRouter token request:', {
url: provider.oauth.tokenUrl,
body: requestBody,
})
const startTime = performance.now()
tokenResponse = await fetch(provider.oauth.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
const endTime = performance.now()
console.log('DEBUG: OpenRouter token response:', {
status: tokenResponse.status,
statusText: tokenResponse.statusText,
headers: Object.fromEntries(tokenResponse.headers.entries()),
duration: `${endTime - startTime}ms`,
})
} else {
// Standard OAuth2 flow for other providers
const requestBody = new URLSearchParams({
grant_type: 'authorization_code',
client_id: provider.oauth.clientId,
code,
redirect_uri: getRedirectUri(providerId),
code_verifier: pkceState.code_verifier,
})
console.log('DEBUG: Standard OAuth token request:', {
url: provider.oauth.tokenUrl,
body: Object.fromEntries(requestBody.entries()),
})
tokenResponse = await fetch(provider.oauth.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: requestBody,
})
console.log('DEBUG: Standard OAuth token response:', {
status: tokenResponse.status,
statusText: tokenResponse.statusText,
headers: Object.fromEntries(tokenResponse.headers.entries()),
})
}
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text()
console.error('DEBUG: Token exchange error response:', errorText)
throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`)
}
const tokenData = await tokenResponse.json()
console.log('DEBUG: Token response data:', tokenData)
// Store token with expiration
let token: OAuthToken
if (providerId === 'openrouter') {
// OpenRouter returns { key: "..." }
token = {
access_token: tokenData.key,
token_type: 'Bearer',
}
} else {
// Standard OAuth2 response
token = {
access_token: tokenData.access_token,
refresh_token: tokenData.refresh_token,
expires_at: tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : undefined,
token_type: 'Bearer',
}
}
setOAuthToken(providerId, token)
// Clean up PKCE state (already cleaned up above for OpenRouter)
if (state !== 'no-state') {
sessionStorage.removeItem(`pkce_${providerId}_${state}`)
}
}
// Get authentication headers for API calls
export async function getAuthHeaders(providerId: SupportedProvider): Promise<Record<string, string>> {
const provider = providers[providerId]
if (provider.authType === 'oauth') {
const token = getOAuthToken(providerId)
if (!token) {
throw new Error(`No OAuth token found for ${providerId}`)
}
return {
Authorization: `Bearer ${token.access_token}`,
}
} else {
// API key authentication
const apiKey = getApiKey(providerId)
if (!apiKey) {
throw new Error(`No API key found for ${providerId}`)
}
if (provider.apiKeyHeader === 'Authorization') {
return {
Authorization: `Bearer ${apiKey}`,
}
} else {
return {
[provider.apiKeyHeader!]: apiKey,
}
}
}
}
// Helper function to get redirect URI
function getRedirectUri(providerId: SupportedProvider): string {
const baseUrl = window.location.origin
return `${baseUrl}/oauth/${providerId}/callback`
}

Some files were not shown because too many files have changed in this diff Show more