diff --git a/examples/chat-ui/.env.example b/examples/chat-ui/.env.example new file mode 100644 index 0000000..06b8d52 --- /dev/null +++ b/examples/chat-ui/.env.example @@ -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 diff --git a/examples/chat-ui/MODEL_SELECTOR_GUIDE.md b/examples/chat-ui/MODEL_SELECTOR_GUIDE.md new file mode 100644 index 0000000..ea8a1bc --- /dev/null +++ b/examples/chat-ui/MODEL_SELECTOR_GUIDE.md @@ -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 diff --git a/examples/chat-ui/package.json b/examples/chat-ui/package.json index bd3c94b..8bfee13 100644 --- a/examples/chat-ui/package.json +++ b/examples/chat-ui/package.json @@ -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:../..", diff --git a/examples/chat-ui/pnpm-lock.yaml b/examples/chat-ui/pnpm-lock.yaml index a166b70..a5b8882 100644 --- a/examples/chat-ui/pnpm-lock.yaml +++ b/examples/chat-ui/pnpm-lock.yaml @@ -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: diff --git a/examples/chat-ui/scripts/update-models.ts b/examples/chat-ui/scripts/update-models.ts new file mode 100644 index 0000000..b42fc7e --- /dev/null +++ b/examples/chat-ui/scripts/update-models.ts @@ -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 +} + +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 { + 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> = { + 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() diff --git a/examples/chat-ui/src/App.tsx b/examples/chat-ui/src/App.tsx index eb3f347..f390de7 100644 --- a/examples/chat-ui/src/App.tsx +++ b/examples/chat-ui/src/App.tsx @@ -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 ( - } /> + } /> + } /> } /> diff --git a/examples/chat-ui/src/components/ApiKeyModal.tsx b/examples/chat-ui/src/components/ApiKeyModal.tsx index 5bd40cc..ed4cf69 100644 --- a/examples/chat-ui/src/components/ApiKeyModal.tsx +++ b/examples/chat-ui/src/components/ApiKeyModal.tsx @@ -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 } diff --git a/examples/chat-ui/src/components/ChatApp.tsx b/examples/chat-ui/src/components/ChatApp.tsx index 7de0313..6db7a43 100644 --- a/examples/chat-ui/src/components/ChatApp.tsx +++ b/examples/chat-ui/src/components/ChatApp.tsx @@ -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 = () => { const [conversations, setConversations] = useState([]) const [conversationId, setConversationId] = useState(undefined) + const [sidebarVisible, setSidebarVisible] = useState(false) const [selectedModel, setSelectedModel] = useState(getSelectedModel()) const [apiKeyUpdateTrigger, setApiKeyUpdateTrigger] = useState(0) const [mcpTools, setMcpTools] = useState([]) const [animationDelay] = useState(() => -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 = () => { } } - // 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 = () => { style={{ '--random-delay': `${animationDelay}s` } as React.CSSProperties} >
- {/* Sidebar and Navbar components hidden but kept in codebase */} - {/*{false && (*/} - {/* <>*/} - {/* */} - {/* */} - {/* */} - {/*)}*/} + {/* Sidebar and Navbar components */} + {false && ( + <> + + + + )}
= () => { apiKeyUpdateTrigger={apiKeyUpdateTrigger} mcpTools={mcpTools} onMcpToolsUpdate={setMcpTools} + addToFavorites={addToFavorites} + models={models} + toggleFavorite={toggleFavorite} + isFavorite={isFavorite} + getFavoriteModels={getFavoriteModels} />
diff --git a/examples/chat-ui/src/components/ChatSidebar.tsx b/examples/chat-ui/src/components/ChatSidebar.tsx index 77a019c..753ff5f 100644 --- a/examples/chat-ui/src/components/ChatSidebar.tsx +++ b/examples/chat-ui/src/components/ChatSidebar.tsx @@ -25,6 +25,7 @@ interface ChatSidebarProps { onModelChange: (model: Model) => void apiKeyUpdateTrigger: number onMcpToolsUpdate: (tools: Tool[]) => void + mcpTools: Tool[] } const ChatSidebar: React.FC = ({ @@ -40,6 +41,7 @@ const ChatSidebar: React.FC = ({ onModelChange, apiKeyUpdateTrigger, onMcpToolsUpdate, + mcpTools, }) => { const handleConversationClick = (id: number | undefined) => { setConversationId(id) @@ -135,7 +137,12 @@ const ChatSidebar: React.FC = ({ {/* Model Selector and MCP Servers at bottom */}
- + 0} + />
diff --git a/examples/chat-ui/src/components/ConversationThread.tsx b/examples/chat-ui/src/components/ConversationThread.tsx index d02947f..aac7ef1 100644 --- a/examples/chat-ui/src/components/ConversationThread.tsx +++ b/examples/chat-ui/src/components/ConversationThread.tsx @@ -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 = ({ @@ -44,6 +49,11 @@ const ConversationThread: React.FC = ({ apiKeyUpdateTrigger, mcpTools, onMcpToolsUpdate, + addToFavorites, + models, + toggleFavorite, + isFavorite, + getFavoriteModels, }) => { const [input, setInput] = useState('') const [apiKeyModal, setApiKeyModal] = useState<{ isOpen: boolean; model: Model | null }>({ @@ -258,7 +268,17 @@ const ConversationThread: React.FC = ({ @@ -268,6 +288,11 @@ const ConversationThread: React.FC = ({ selectedModel={selectedModel} onModelChange={onModelChange} apiKeyUpdateTrigger={apiKeyUpdateTrigger} + addToFavorites={addToFavorites} + models={models} + toggleFavorite={toggleFavorite} + isFavorite={isFavorite} + getFavoriteModels={getFavoriteModels} /> setMcpServerModal(false)} onToolsUpdate={onMcpToolsUpdate} /> diff --git a/examples/chat-ui/src/components/ModelSelectionModal.tsx b/examples/chat-ui/src/components/ModelSelectionModal.tsx index 7812c44..2207a86 100644 --- a/examples/chat-ui/src/components/ModelSelectionModal.tsx +++ b/examples/chat-ui/src/components/ModelSelectionModal.tsx @@ -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 = ({ @@ -18,20 +23,28 @@ const ModelSelectionModal: React.FC = ({ 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(null) + const [searchTerm, setSearchTerm] = useState('') const [apiKeyStatuses, setApiKeyStatuses] = useState>({}) useEffect(() => { const statuses: Record = {} - 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 = ({ } }, [isOpen]) - const handleModelSelect = (model: Model) => { - const hasKey = hasApiKey(model.provider.id) + // 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) + } + } + } - if (!hasKey) { - setApiKeyModal({ isOpen: true, model }) - } else { - onModelChange(model) - onClose() + 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 = ({ style={{ backgroundColor: 'rgba(0,0,0,0.8)' }} onClick={onClose} > -
e.stopPropagation()}> -
-

Select Model

+
e.stopPropagation()} + > +
+ {currentView === 'provider' && selectedProvider ? ( +
+ + {selectedProvider.logo} +

{selectedProvider.name} Models

+
+ ) : ( +

Select Model

+ )}
-
-
- {availableModels.map((model) => { - const isSelected = model.id === selectedModel.id - const hasKey = apiKeyStatuses[model.provider.id] - - return ( -
handleModelSelect(model)} - > -
-
- {model.provider.logo} -
-
{model.name}
-
{model.provider.name}
+
+ {currentView === 'main' ? ( + <> + {/* Providers Section */} +
+

Providers

+
+ {Object.values(providers).map((provider) => ( +
handleProviderSelect(provider)} + > +
+
+ {provider.logo} +
+

{provider.name}

+

+ {provider.authType === 'oauth' ? 'OAuth Authentication' : 'API Key Authentication'} +

+
+
+
+ {getStatusIcon(provider.id)} + {apiKeyStatuses[provider.id] && ( + + )} +
+ ))} +
+
-
- {getStatusIcon(model.provider.id)} - - {hasKey ? 'Configured' : 'Not configured'} - - {hasKey && ( - - )} +
+
+ + {isSelected && } + {model.provider.logo} +
+

{model.name}

+
+ {model.provider.name} + {model.supportsTools && 🔧} + {model.reasoning && 🧠} + {model.attachment && 📎} +
+
+
+
+ {!isConfigured && ( +
+ +
+ )} + +
+
+
+ ) + })} +
+ )} +
+ + ) : ( + /* Provider Models View */ + selectedProvider && ( + <> +
+

Select models to add to your favorites

+ + {/* Search Bar */} +
+ + 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" + /> +
+
+ + {/* Models List */} +
+ {getFilteredModels().map((model) => ( +
handleModelSelect(model)} + > +
+
+ +
+

{model.name}

+
+ {model.supportsTools && 🔧 Tools} + {model.reasoning && 🧠 Reasoning} + {model.attachment && 📎 Attachments} + + {(model.contextLimit / 1000).toFixed(0)}K context +
+
+
+
Click to select
+
+ ))} +
+ + {getFilteredModels().length === 0 && ( +
+

No models found matching "{searchTerm}"

+ )} + +
+ {getProviderModels().length} model{getProviderModels().length !== 1 ? 's' : ''} available
- ) - })} -
+ + ) + )}
@@ -149,7 +387,17 @@ const ModelSelectionModal: React.FC = ({ 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} /> diff --git a/examples/chat-ui/src/components/ModelSelector.tsx b/examples/chat-ui/src/components/ModelSelector.tsx index 91544e3..973e6b1 100644 --- a/examples/chat-ui/src/components/ModelSelector.tsx +++ b/examples/chat-ui/src/components/ModelSelector.tsx @@ -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 = ({ selectedModel, onModelChange, apiKeyUpdateTrigger }) => { +const ModelSelector: React.FC = ({ 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>({}) const dropdownRef = useRef(null) + const { models, loading, toggleFavorite, isFavorite, getFavoriteModels } = useModels() + useEffect(() => { const statuses: Record = {} - 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 = ({ 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 = ({ 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 = ({ selectedModel, onModelCha } } + if (loading) { + return
Loading models...
+ } + return ( <>
@@ -105,22 +189,74 @@ const ModelSelector: React.FC = ({ selectedModel, onModelCha {isOpen && (
- {availableModels.map((model) => ( - + ))} +
+
+ + {/* Starred Models Section */} +
+
+
⭐ Starred Models
+
+ + {getFavoriteModels().length === 0 ? ( +
+ No starred models yet. Click on a provider above to explore and star models. +
+ ) : ( +
+ {getFavoriteModels().map((model) => { + const isConfigured = apiKeyStatuses[model.provider.id] + return ( + + ) + })}
- {getApiKeyIcon(model.provider.id)} - - ))} + )} +
)}
@@ -128,9 +264,34 @@ const ModelSelector: React.FC = ({ selectedModel, onModelCha 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} /> + + 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} + /> ) } diff --git a/examples/chat-ui/src/components/OAuthCallback.tsx b/examples/chat-ui/src/components/OAuthCallback.tsx index d6f57fa..01beae1 100644 --- a/examples/chat-ui/src/components/OAuthCallback.tsx +++ b/examples/chat-ui/src/components/OAuthCallback.tsx @@ -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 = ({ provider }) => { + const [searchParams] = useSearchParams() + const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading') + const [error, setError] = useState(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 ( -
-
-

Authenticating...

-

Please wait while we complete your authentication.

-

This window should close automatically.

- -
-
+
+
+
+ {status === 'loading' && ( + <> +
+

Completing Authentication

+

Connecting to {provider}...

+ + )} + + {status === 'success' && ( + <> +
+ + + +
+

Authentication Successful!

+

Successfully connected to {provider}. You can now close this window.

+ + + )} + + {status === 'error' && ( + <> +
+ + + +
+

Authentication Failed

+

{error || 'An error occurred during authentication'}

+ + + )}
) } + +export default OAuthCallback diff --git a/examples/chat-ui/src/components/ProviderModelsModal.tsx b/examples/chat-ui/src/components/ProviderModelsModal.tsx new file mode 100644 index 0000000..8674057 --- /dev/null +++ b/examples/chat-ui/src/components/ProviderModelsModal.tsx @@ -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 = ({ + 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 = ( +
+
+ {/* Header */} +
+
+ {provider.logo} +
+

{provider.name} Models

+

Select models to add to your favorites

+
+
+ +
+ + {/* Search and Filters */} +
+
+ + 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" + /> +
+ + {toolsAvailable && ( + + )} +
+ + {/* Models List */} +
+ {filteredModels.length === 0 ? ( +
No models found matching your criteria
+ ) : ( + filteredModels.map((model) => ( +
+
+ + +
+
{model.name}
+
+ {model.supportsTools && 🔧 Tools} + {model.reasoning && 🧠 Reasoning} + {model.attachment && 📎 Attachments} + + {(model.contextLimit / 1000).toFixed(0)}K context +
+
+
+ + +
+ )) + )} +
+ + {/* Footer */} +
+
+ {filteredModels.length} model{filteredModels.length !== 1 ? 's' : ''} available + {toolsAvailable && showToolsOnly && ' with tool support'} +
+
+
+
+ ) + + return createPortal(modalContent, document.body) +} + +export default ProviderModelsModal diff --git a/examples/chat-ui/src/data/models.json b/examples/chat-ui/src/data/models.json new file mode 100644 index 0000000..5960a1c --- /dev/null +++ b/examples/chat-ui/src/data/models.json @@ -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 + } + } + } +} diff --git a/examples/chat-ui/src/hooks/useModels.ts b/examples/chat-ui/src/hooks/useModels.ts new file mode 100644 index 0000000..1d24257 --- /dev/null +++ b/examples/chat-ui/src/hooks/useModels.ts @@ -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([]) + const [favorites, setFavorites] = useState([]) + 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 +} diff --git a/examples/chat-ui/src/hooks/useStreamResponse.ts b/examples/chat-ui/src/hooks/useStreamResponse.ts index c66d910..c80c1cd 100644 --- a/examples/chat-ui/src/hooks/useStreamResponse.ts +++ b/examples/chat-ui/src/hooks/useStreamResponse.ts @@ -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) => { 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 + 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) diff --git a/examples/chat-ui/src/types/models.ts b/examples/chat-ui/src/types/models.ts index af98e53..6a58ce0 100644 --- a/examples/chat-ui/src/types/models.ts +++ b/examples/chat-ui/src/types/models.ts @@ -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 = { +export const providers: Record = { 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', }, -} - -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', - }, + 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 }, }, - // { - // 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_' diff --git a/examples/chat-ui/src/utils/apiKeys.ts b/examples/chat-ui/src/utils/apiKeys.ts index 3bb862f..989595e 100644 --- a/examples/chat-ui/src/utils/apiKeys.ts +++ b/examples/chat-ui/src/utils/apiKeys.ts @@ -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' diff --git a/examples/chat-ui/src/utils/auth.ts b/examples/chat-ui/src/utils/auth.ts new file mode 100644 index 0000000..0b55c39 --- /dev/null +++ b/examples/chat-ui/src/utils/auth.ts @@ -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 { + 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 { + 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 { + 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> { + 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` +} diff --git a/examples/chat-ui/src/utils/modelPreferences.ts b/examples/chat-ui/src/utils/modelPreferences.ts index 13bb3c4..0475790 100644 --- a/examples/chat-ui/src/utils/modelPreferences.ts +++ b/examples/chat-ui/src/utils/modelPreferences.ts @@ -1,13 +1,67 @@ -import { availableModels, type Model } from '../types/models' +import { type Model, providers, type SupportedProvider } from '../types/models' + +// Import models data directly +import modelsData from '../data/models.json' + +// 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 +} const MODEL_PREFERENCE_KEY = 'aiChatTemplate_selectedModel' +function getAvailableModels(): Model[] { + const models: Model[] = [] + for (const [providerId, providerModels] of Object.entries(modelsData)) { + const provider = providers[providerId as keyof typeof providers] + if (!provider) continue + + 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 as any, modelId), + } + models.push(model) + } + } + return models +} + export const getSelectedModel = (): Model => { const saved = localStorage.getItem(MODEL_PREFERENCE_KEY) if (saved) { try { const parsed = JSON.parse(saved) // Find the model by ID to ensure it still exists + const availableModels = getAvailableModels() const model = availableModels.find((m) => m.id === parsed.id) if (model) { return model @@ -17,6 +71,7 @@ export const getSelectedModel = (): Model => { } } // Default to first available model + const availableModels = getAvailableModels() return availableModels[0] }