From aca4b112e4c70afc88b717567f3060978f3e8737 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Thu, 10 Jul 2025 13:07:51 +1000 Subject: [PATCH 01/12] 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 --- examples/chat-ui/package.json | 4 +- examples/chat-ui/pnpm-lock.yaml | 50 +- examples/chat-ui/scripts/update-models.ts | 105 +++ examples/chat-ui/src/data/models.json | 984 ++++++++++++++++++++++ 4 files changed, 1131 insertions(+), 12 deletions(-) create mode 100644 examples/chat-ui/scripts/update-models.ts create mode 100644 examples/chat-ui/src/data/models.json diff --git a/examples/chat-ui/package.json b/examples/chat-ui/package.json index bd3c94b..07f239c 100644 --- a/examples/chat-ui/package.json +++ b/examples/chat-ui/package.json @@ -12,7 +12,8 @@ "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", @@ -46,6 +47,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..363f653 100644 --- a/examples/chat-ui/pnpm-lock.yaml +++ b/examples/chat-ui/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: 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 +62,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 +80,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 +96,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 +110,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) @@ -1496,6 +1499,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 +2192,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 +2367,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'} @@ -2738,7 +2752,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 +2761,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 +3236,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 +3379,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 +3898,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 +4776,8 @@ snapshots: resolve-from@4.0.0: {} + resolve-pkg-maps@1.0.0: {} + reusify@1.1.0: {} rollup@4.40.2: @@ -4985,6 +5005,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 +5118,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 +5130,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/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 + } + } + } +} From 719f334006e7f0bfcbdf9d717c5db82691584c2f Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Thu, 10 Jul 2025 13:09:49 +1000 Subject: [PATCH 02/12] 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 --- .../chat-ui/src/components/OAuthCallback.tsx | 112 ++++++++- examples/chat-ui/src/hooks/useModels.ts | 126 ++++++++++ examples/chat-ui/src/types/models.ts | 124 ++++++---- examples/chat-ui/src/utils/apiKeys.ts | 19 +- examples/chat-ui/src/utils/auth.ts | 223 ++++++++++++++++++ 5 files changed, 528 insertions(+), 76 deletions(-) create mode 100644 examples/chat-ui/src/hooks/useModels.ts create mode 100644 examples/chat-ui/src/utils/auth.ts diff --git a/examples/chat-ui/src/components/OAuthCallback.tsx b/examples/chat-ui/src/components/OAuthCallback.tsx index d6f57fa..d73d99f 100644 --- a/examples/chat-ui/src/components/OAuthCallback.tsx +++ b/examples/chat-ui/src/components/OAuthCallback.tsx @@ -1,22 +1,108 @@ -import { useEffect } from 'react' -import { onMcpAuthorization } from 'use-mcp' +import React, { useEffect, useState } 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) -export function OAuthCallback() { useEffect(() => { - onMcpAuthorization() - }, []) + const handleCallback = async () => { + 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 || !state) { + throw new Error('Missing authorization code or state parameter') + } + + await completeOAuthFlow(provider, code, state) + setStatus('success') + + // Close popup after successful authentication + 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 = '/' + } + }, 1000) + } 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}. This window will close automatically.

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

Authentication Failed

+

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

+ + + )}
) } + +export default OAuthCallback diff --git a/examples/chat-ui/src/hooks/useModels.ts b/examples/chat-ui/src/hooks/useModels.ts new file mode 100644 index 0000000..7bb71c4 --- /dev/null +++ b/examples/chat-ui/src/hooks/useModels.ts @@ -0,0 +1,126 @@ +import { useState, useEffect, useCallback } from 'react' +import { Model, ModelData, 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) { + setFavorites(JSON.parse(saved)) + } + } catch (error) { + console.error('Failed to load favorites from localStorage:', error) + } + } + + loadModels() + loadFavorites() + }, []) + + // Save favorites to localStorage when they change + useEffect(() => { + try { + localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites)) + } catch (error) { + console.error('Failed to save favorites to localStorage:', error) + } + }, [favorites]) + + const toggleFavorite = useCallback((modelId: string) => { + setFavorites((prev) => { + if (prev.includes(modelId)) { + return prev.filter((id) => id !== modelId) + } else { + return [...prev, modelId] + } + }) + }, []) + + 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, + 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/types/models.ts b/examples/chat-ui/src/types/models.ts index af98e53..80c5e8c 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/oauth/authorize', + tokenUrl: 'https://openrouter.ai/oauth/token', + clientId: import.meta.env.VITE_OPENROUTER_CLIENT_ID || '', + scopes: ['openid', 'model.read', 'model.request'], }, }, - // { - // 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..5abfdd3 --- /dev/null +++ b/examples/chat-ui/src/utils/auth.ts @@ -0,0 +1,223 @@ +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 } + sessionStorage.setItem(`pkce_${providerId}_${state}`, JSON.stringify(pkceState)) + + // Construct authorization URL + const 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 { + const provider = providers[providerId] + if (!provider.oauth) { + throw new Error(`Provider ${providerId} does not support OAuth`) + } + + // Retrieve PKCE state + const pkceStateJson = sessionStorage.getItem(`pkce_${providerId}_${state}`) + if (!pkceStateJson) { + throw new Error('PKCE state not found. Please try again.') + } + + const pkceState: PKCEState = JSON.parse(pkceStateJson) + + // Exchange code for token + const tokenResponse = await fetch(provider.oauth.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: provider.oauth.clientId, + code, + redirect_uri: getRedirectUri(providerId), + code_verifier: pkceState.code_verifier, + }), + }) + + if (!tokenResponse.ok) { + throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`) + } + + const tokenData = await tokenResponse.json() + + // Store token with expiration + const token: OAuthToken = { + 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 + 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` +} From 88742580e151093ba10fee1833d2efd911c4ea36 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Thu, 10 Jul 2025 13:17:18 +1000 Subject: [PATCH 03/12] 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 --- examples/chat-ui/package.json | 1 + examples/chat-ui/pnpm-lock.yaml | 15 ++ examples/chat-ui/src/App.tsx | 5 +- .../chat-ui/src/components/ApiKeyModal.tsx | 4 +- examples/chat-ui/src/components/ChatApp.tsx | 73 ++++--- .../chat-ui/src/components/ChatSidebar.tsx | 9 +- .../src/components/ConversationThread.tsx | 12 +- .../src/components/ModelSelectionModal.tsx | 26 ++- .../chat-ui/src/components/ModelSelector.tsx | 179 +++++++++++++++--- examples/chat-ui/src/hooks/useModels.ts | 2 +- .../chat-ui/src/hooks/useStreamResponse.ts | 34 +++- .../chat-ui/src/utils/modelPreferences.ts | 32 +++- 12 files changed, 312 insertions(+), 80 deletions(-) diff --git a/examples/chat-ui/package.json b/examples/chat-ui/package.json index 07f239c..8bfee13 100644 --- a/examples/chat-ui/package.json +++ b/examples/chat-ui/package.json @@ -18,6 +18,7 @@ "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", diff --git a/examples/chat-ui/pnpm-lock.yaml b/examples/chat-ui/pnpm-lock.yaml index 363f653..a5b8882 100644 --- a/examples/chat-ui/pnpm-lock.yaml +++ b/examples/chat-ui/pnpm-lock.yaml @@ -14,6 +14,9 @@ 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) @@ -129,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'} @@ -2586,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 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..e3364e8 100644 --- a/examples/chat-ui/src/components/ChatApp.tsx +++ b/examples/chat-ui/src/components/ChatApp.tsx @@ -1,5 +1,7 @@ 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' @@ -14,6 +16,7 @@ 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) @@ -25,6 +28,18 @@ const ChatApp: React.FC = () => { 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 +80,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 +112,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 && ( + <> + + + + )}
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..cd0ff7f 100644 --- a/examples/chat-ui/src/components/ConversationThread.tsx +++ b/examples/chat-ui/src/components/ConversationThread.tsx @@ -258,7 +258,17 @@ const ConversationThread: React.FC = ({ diff --git a/examples/chat-ui/src/components/ModelSelectionModal.tsx b/examples/chat-ui/src/components/ModelSelectionModal.tsx index 7812c44..4eb15ab 100644 --- a/examples/chat-ui/src/components/ModelSelectionModal.tsx +++ b/examples/chat-ui/src/components/ModelSelectionModal.tsx @@ -1,7 +1,8 @@ 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 { type Model } from '../types/models' +import { useModels } from '../hooks/useModels' +import { hasApiKey, setApiKey, clearApiKey } from '../utils/auth' import ApiKeyModal from './ApiKeyModal' interface ModelSelectionModalProps { @@ -24,14 +25,15 @@ const ModelSelectionModal: React.FC = ({ model: null, }) const [apiKeyStatuses, setApiKeyStatuses] = useState>({}) + const { models } = useModels() 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) { @@ -68,7 +70,7 @@ const ModelSelectionModal: React.FC = ({ const handleClearApiKey = (providerId: string, e: React.MouseEvent) => { e.stopPropagation() - clearApiKey(providerId) + clearApiKey(providerId as any) setApiKeyStatuses((prev) => ({ ...prev, [providerId]: false })) } @@ -101,7 +103,7 @@ const ModelSelectionModal: React.FC = ({
- {availableModels.map((model) => { + {models.map((model: Model) => { const isSelected = model.id === selectedModel.id const hasKey = apiKeyStatuses[model.provider.id] @@ -149,7 +151,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..95bcf6f 100644 --- a/examples/chat-ui/src/components/ModelSelector.tsx +++ b/examples/chat-ui/src/components/ModelSelector.tsx @@ -1,17 +1,22 @@ 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, Search } from 'lucide-react' +import { type Model } from '../types/models' +import { useModels } from '../hooks/useModels' +import { hasApiKey, setApiKey, clearApiKey, beginOAuthFlow, clearOAuthToken } from '../utils/auth' import ApiKeyModal from './ApiKeyModal' 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 [searchTerm, setSearchTerm] = useState('') + const [showFavoritesOnly, setShowFavoritesOnly] = useState(false) + const [showToolsOnly, setShowToolsOnly] = useState(false) const [apiKeyModal, setApiKeyModal] = useState<{ isOpen: boolean; model: Model | null }>({ isOpen: false, model: null, @@ -19,13 +24,15 @@ const ModelSelector: React.FC = ({ selectedModel, onModelCha const [apiKeyStatuses, setApiKeyStatuses] = useState>({}) const dropdownRef = useRef(null) + const { models, loading, toggleFavorite, isFavorite } = useModels() + useEffect(() => { const statuses: Record = {} - availableModels.forEach((model) => { + models.forEach((model) => { statuses[model.provider.id] = hasApiKey(model.provider.id) }) setApiKeyStatuses(statuses) - }, [apiKeyUpdateTrigger]) + }, [apiKeyUpdateTrigger, models]) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -44,7 +51,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) } @@ -61,7 +82,12 @@ const ModelSelector: React.FC = ({ selectedModel, onModelCha 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 })) } @@ -83,6 +109,40 @@ const ModelSelector: React.FC = ({ selectedModel, onModelCha } } + const getFilteredModels = () => { + let filteredModels = models + + // Filter by search term + if (searchTerm) { + filteredModels = filteredModels.filter( + (model) => + model.name.toLowerCase().includes(searchTerm.toLowerCase()) || + model.provider.name.toLowerCase().includes(searchTerm.toLowerCase()) + ) + } + + // Filter by favorites + if (showFavoritesOnly) { + filteredModels = filteredModels.filter((model) => isFavorite(model.id)) + } + + // Filter by tool support + if (showToolsOnly) { + filteredModels = filteredModels.filter((model) => model.supportsTools) + } + + return filteredModels + } + + const handleStarClick = (modelId: string, e: React.MouseEvent) => { + e.stopPropagation() + toggleFavorite(modelId) + } + + if (loading) { + return
Loading models...
+ } + return ( <>
@@ -104,23 +164,82 @@ const ModelSelector: React.FC = ({ selectedModel, onModelCha {isOpen && ( -
- {availableModels.map((model) => ( - + + {toolsAvailable && ( + + )} +
+
+ + {/* Models list */} +
+ {getFilteredModels().map((model) => ( + + + {model.provider.logo} + +
+
{model.name}
+
+ {model.provider.name} + {model.supportsTools && 🔧} + {model.reasoning && 🧠} +
+
-
- {getApiKeyIcon(model.provider.id)} - - ))} + {getApiKeyIcon(model.provider.id)} + + ))} + + {getFilteredModels().length === 0 && ( +
No models found matching your criteria
+ )} +
)}
@@ -128,7 +247,17 @@ 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} /> diff --git a/examples/chat-ui/src/hooks/useModels.ts b/examples/chat-ui/src/hooks/useModels.ts index 7bb71c4..9c6b205 100644 --- a/examples/chat-ui/src/hooks/useModels.ts +++ b/examples/chat-ui/src/hooks/useModels.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react' -import { Model, ModelData, providers, SupportedProvider, SUPPORTED_PROVIDERS, FAVORITES_KEY } from '../types/models' +import { Model, providers, SupportedProvider, SUPPORTED_PROVIDERS, FAVORITES_KEY } from '../types/models' // Load models data from generated JSON import modelsData from '../data/models.json' diff --git a/examples/chat-ui/src/hooks/useStreamResponse.ts b/examples/chat-ui/src/hooks/useStreamResponse.ts index c66d910..5ec1137 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 { getApiKey, 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/utils/modelPreferences.ts b/examples/chat-ui/src/utils/modelPreferences.ts index 13bb3c4..0a77658 100644 --- a/examples/chat-ui/src/utils/modelPreferences.ts +++ b/examples/chat-ui/src/utils/modelPreferences.ts @@ -1,13 +1,42 @@ -import { availableModels, type Model } from '../types/models' +import { type Model, providers } from '../types/models' + +// Import models data directly +import modelsData from '../data/models.json' 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, + } + 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 +46,7 @@ export const getSelectedModel = (): Model => { } } // Default to first available model + const availableModels = getAvailableModels() return availableModels[0] } From 5bd11e76178e480608735f1b3c799ff0e18d8113 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Thu, 10 Jul 2025 13:23:30 +1000 Subject: [PATCH 04/12] 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 --- examples/chat-ui/.env.example | 3 + examples/chat-ui/MODEL_SELECTOR_GUIDE.md | 92 +++++++++++++++++++ .../chat-ui/src/hooks/useStreamResponse.ts | 2 +- 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 examples/chat-ui/.env.example create mode 100644 examples/chat-ui/MODEL_SELECTOR_GUIDE.md diff --git a/examples/chat-ui/.env.example b/examples/chat-ui/.env.example new file mode 100644 index 0000000..e151c82 --- /dev/null +++ b/examples/chat-ui/.env.example @@ -0,0 +1,3 @@ +# OpenRouter OAuth Configuration +VITE_OPENROUTER_CLIENT_ID=your_openrouter_client_id_here +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/src/hooks/useStreamResponse.ts b/examples/chat-ui/src/hooks/useStreamResponse.ts index 5ec1137..c80c1cd 100644 --- a/examples/chat-ui/src/hooks/useStreamResponse.ts +++ b/examples/chat-ui/src/hooks/useStreamResponse.ts @@ -5,7 +5,7 @@ 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, getAuthHeaders } from '../utils/auth' +import { getAuthHeaders } from '../utils/auth' import { type Tool } from 'use-mcp/react' import { useConversationUpdater } from './useConversationUpdater' From 5c07aebfae8627308a6c530b0311e73b81d1d8e8 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Thu, 10 Jul 2025 14:33:37 +1000 Subject: [PATCH 05/12] Implement clean provider-first model selector with favorites workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../chat-ui/src/components/ModelSelector.tsx | 236 ++++++++++-------- .../src/components/ProviderModelsModal.tsx | 143 +++++++++++ 2 files changed, 277 insertions(+), 102 deletions(-) create mode 100644 examples/chat-ui/src/components/ProviderModelsModal.tsx diff --git a/examples/chat-ui/src/components/ModelSelector.tsx b/examples/chat-ui/src/components/ModelSelector.tsx index 95bcf6f..973e6b1 100644 --- a/examples/chat-ui/src/components/ModelSelector.tsx +++ b/examples/chat-ui/src/components/ModelSelector.tsx @@ -1,9 +1,10 @@ import React, { useState, useEffect, useRef } from 'react' -import { ChevronDown, AlertCircle, X, Star, Search } from 'lucide-react' -import { type Model } from '../types/models' +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 @@ -14,17 +15,18 @@ interface ModelSelectorProps { const ModelSelector: React.FC = ({ selectedModel, onModelChange, apiKeyUpdateTrigger, toolsAvailable = false }) => { const [isOpen, setIsOpen] = useState(false) - const [searchTerm, setSearchTerm] = useState('') - const [showFavoritesOnly, setShowFavoritesOnly] = useState(false) - const [showToolsOnly, setShowToolsOnly] = 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 } = useModels() + const { models, loading, toggleFavorite, isFavorite, getFavoriteModels } = useModels() useEffect(() => { const statuses: Record = {} @@ -34,6 +36,21 @@ const ModelSelector: React.FC = ({ selectedModel, onModelCha setApiKeyStatuses(statuses) }, [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) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { @@ -76,7 +93,11 @@ 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 }) } } @@ -91,6 +112,39 @@ const ModelSelector: React.FC = ({ selectedModel, onModelCha 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] @@ -109,36 +163,6 @@ const ModelSelector: React.FC = ({ selectedModel, onModelCha } } - const getFilteredModels = () => { - let filteredModels = models - - // Filter by search term - if (searchTerm) { - filteredModels = filteredModels.filter( - (model) => - model.name.toLowerCase().includes(searchTerm.toLowerCase()) || - model.provider.name.toLowerCase().includes(searchTerm.toLowerCase()) - ) - } - - // Filter by favorites - if (showFavoritesOnly) { - filteredModels = filteredModels.filter((model) => isFavorite(model.id)) - } - - // Filter by tool support - if (showToolsOnly) { - filteredModels = filteredModels.filter((model) => model.supportsTools) - } - - return filteredModels - } - - const handleStarClick = (modelId: string, e: React.MouseEvent) => { - e.stopPropagation() - toggleFavorite(modelId) - } - if (loading) { return
Loading models...
} @@ -164,80 +188,73 @@ const ModelSelector: React.FC = ({ selectedModel, onModelCha {isOpen && ( -
- {/* Search and filters */} +
+ {/* Providers Section */}
-
- - 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 && ( +
Providers
+
+ {Object.values(providers).map((provider) => ( - )} + ))}
- {/* Models list */} + {/* Starred Models Section */}
- {getFilteredModels().map((model) => ( - - - {model.provider.logo} - -
-
{model.name}
-
- {model.provider.name} - {model.supportsTools && 🔧} - {model.reasoning && 🧠} -
-
-
- {getApiKeyIcon(model.provider.id)} - - ))} +
+
⭐ Starred Models
+
- {getFilteredModels().length === 0 && ( -
No models found matching your criteria
+ {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 ( + + ) + })} +
)}
@@ -260,6 +277,21 @@ const ModelSelector: React.FC = ({ selectedModel, onModelCha } 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/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 From df349fbb3a42c09db64fcbd6935eed0c645ae100 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Thu, 10 Jul 2025 14:45:17 +1000 Subject: [PATCH 06/12] Fix ModelSelectionModal to use provider-first workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/components/ModelSelectionModal.tsx | 199 ++++++++++++++---- 1 file changed, 158 insertions(+), 41 deletions(-) diff --git a/examples/chat-ui/src/components/ModelSelectionModal.tsx b/examples/chat-ui/src/components/ModelSelectionModal.tsx index 4eb15ab..15342c6 100644 --- a/examples/chat-ui/src/components/ModelSelectionModal.tsx +++ b/examples/chat-ui/src/components/ModelSelectionModal.tsx @@ -1,9 +1,10 @@ import React, { useState, useEffect } from 'react' -import { X, AlertCircle, CheckCircle } from 'lucide-react' -import { type Model } from '../types/models' +import { X, AlertCircle, CheckCircle, Star } from 'lucide-react' +import { type Model, type Provider, providers } from '../types/models' import { useModels } from '../hooks/useModels' -import { hasApiKey, setApiKey, clearApiKey } from '../utils/auth' +import { hasApiKey, setApiKey, clearApiKey, beginOAuthFlow } from '../utils/auth' import ApiKeyModal from './ApiKeyModal' +import ProviderModelsModal from './ProviderModelsModal' interface ModelSelectionModalProps { isOpen: boolean @@ -24,8 +25,12 @@ const ModelSelectionModal: React.FC = ({ isOpen: false, model: null, }) + const [providerModelsModal, setProviderModelsModal] = useState<{ isOpen: boolean; provider: Provider | null }>({ + isOpen: false, + provider: null, + }) const [apiKeyStatuses, setApiKeyStatuses] = useState>({}) - const { models } = useModels() + const { models, toggleFavorite, isFavorite, getFavoriteModels } = useModels() useEffect(() => { const statuses: Record = {} @@ -47,24 +52,30 @@ const ModelSelectionModal: React.FC = ({ } }, [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) { + setProviderModelsModal({ isOpen: true, provider }) + } + } } - } + + window.addEventListener('message', handleMessage) + return () => window.removeEventListener('message', handleMessage) + }, [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() + setProviderModelsModal({ isOpen: true, provider }) } } @@ -74,6 +85,35 @@ const ModelSelectionModal: React.FC = ({ 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 }) + } 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 + setProviderModelsModal({ isOpen: true, provider }) + } else { + // Show API key modal first + setApiKeyModal({ isOpen: true, model: { provider } as Model }) + } + } + } + const getStatusIcon = (providerId: string) => { const hasKey = apiKeyStatuses[providerId] @@ -102,38 +142,33 @@ const ModelSelectionModal: React.FC = ({
-
- {models.map((model: Model) => { - const isSelected = model.id === selectedModel.id - const hasKey = apiKeyStatuses[model.provider.id] - - return ( + {/* Providers Section */} +
+

Providers

+
+ {Object.values(providers).map((provider) => (
handleModelSelect(model)} + key={provider.id} + className="border rounded-lg p-4 cursor-pointer transition-colors hover:border-zinc-300 hover:bg-zinc-50" + onClick={() => handleProviderSelect(provider)} >
- {model.provider.logo} + {provider.logo}
-
{model.name}
-
{model.provider.name}
+

{provider.name}

+

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

- -
- {getStatusIcon(model.provider.id)} - - {hasKey ? 'Configured' : 'Not configured'} - - {hasKey && ( +
+ {getStatusIcon(provider.id)} + {apiKeyStatuses[provider.id] && ( @@ -141,8 +176,74 @@ const ModelSelectionModal: React.FC = ({
- ) - })} + ))} +
+
+ + {/* 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] + const isSelected = model.id === selectedModel.id + return ( +
(isConfigured ? onModelChange(model) : handleProviderSelect(model.provider))} + > +
+
+ + {model.provider.logo} +
+

{model.name}

+
+ {model.provider.name} + {model.supportsTools && 🔧} + {model.reasoning && 🧠} + {model.attachment && 📎} +
+
+
+
+ {!isConfigured && ( +
+ +
+ )} + {isConfigured && ( + + )} +
+
+
+ ) + })} +
+ )}
@@ -164,6 +265,22 @@ const ModelSelectionModal: React.FC = ({ } 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 }) + onClose() + }} + toolsAvailable={false} + /> ) } From fa9ceb6aefff051dfe1cc95669f5a155a14c6e46 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Thu, 10 Jul 2025 15:50:08 +1000 Subject: [PATCH 07/12] 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 --- examples/chat-ui/.env.example | 2 +- .../chat-ui/src/components/OAuthCallback.tsx | 8 +- examples/chat-ui/src/types/models.ts | 8 +- examples/chat-ui/src/utils/auth.ts | 133 +++++++++++++----- 4 files changed, 107 insertions(+), 44 deletions(-) diff --git a/examples/chat-ui/.env.example b/examples/chat-ui/.env.example index e151c82..06b8d52 100644 --- a/examples/chat-ui/.env.example +++ b/examples/chat-ui/.env.example @@ -1,3 +1,3 @@ # OpenRouter OAuth Configuration -VITE_OPENROUTER_CLIENT_ID=your_openrouter_client_id_here +# 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/src/components/OAuthCallback.tsx b/examples/chat-ui/src/components/OAuthCallback.tsx index d73d99f..8e150b0 100644 --- a/examples/chat-ui/src/components/OAuthCallback.tsx +++ b/examples/chat-ui/src/components/OAuthCallback.tsx @@ -23,11 +23,13 @@ const OAuthCallback: React.FC = ({ provider }) => { throw new Error(`OAuth error: ${error}`) } - if (!code || !state) { - throw new Error('Missing authorization code or state parameter') + if (!code) { + throw new Error('Missing authorization code') } - await completeOAuthFlow(provider, code, state) + // 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 diff --git a/examples/chat-ui/src/types/models.ts b/examples/chat-ui/src/types/models.ts index 80c5e8c..6a58ce0 100644 --- a/examples/chat-ui/src/types/models.ts +++ b/examples/chat-ui/src/types/models.ts @@ -90,10 +90,10 @@ export const providers: Record = { documentationUrl: 'https://openrouter.ai/docs', authType: 'oauth', oauth: { - authorizeUrl: 'https://openrouter.ai/oauth/authorize', - tokenUrl: 'https://openrouter.ai/oauth/token', - clientId: import.meta.env.VITE_OPENROUTER_CLIENT_ID || '', - scopes: ['openid', 'model.read', 'model.request'], + 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 }, }, } diff --git a/examples/chat-ui/src/utils/auth.ts b/examples/chat-ui/src/utils/auth.ts index 5abfdd3..018c662 100644 --- a/examples/chat-ui/src/utils/auth.ts +++ b/examples/chat-ui/src/utils/auth.ts @@ -115,17 +115,29 @@ export async function beginOAuthFlow(providerId: SupportedProvider): Promise key.startsWith(`pkce_${providerId}_`)) + + if (pkceKeys.length === 0) { + throw new Error('PKCE state not found. Please try again.') + } + + // Use the most recent one (they should all be the same since we only allow one at a time) + const pkceStateJson = sessionStorage.getItem(pkceKeys[0])! + pkceState = JSON.parse(pkceStateJson) + + // Clean up the state + sessionStorage.removeItem(pkceKeys[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) + } // Exchange code for token - const tokenResponse = await fetch(provider.oauth.tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - client_id: provider.oauth.clientId, - code, - redirect_uri: getRedirectUri(providerId), - code_verifier: pkceState.code_verifier, - }), - }) + let tokenResponse: Response + + if (providerId === 'openrouter') { + // OpenRouter uses JSON body instead of form data + tokenResponse = await fetch(provider.oauth.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + code, + code_verifier: pkceState.code_verifier, + code_challenge_method: 'S256', + }), + }) + } else { + // Standard OAuth2 flow for other providers + tokenResponse = await fetch(provider.oauth.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: provider.oauth.clientId, + code, + redirect_uri: getRedirectUri(providerId), + code_verifier: pkceState.code_verifier, + }), + }) + } if (!tokenResponse.ok) { throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`) @@ -171,17 +219,30 @@ export async function completeOAuthFlow(providerId: SupportedProvider, code: str const tokenData = await tokenResponse.json() // Store token with expiration - const token: OAuthToken = { - 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', + 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 - sessionStorage.removeItem(`pkce_${providerId}_${state}`) + // 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 From cf39b7bf2c0f746d265c33f9bcf4b9169e0b63ff Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Thu, 10 Jul 2025 16:32:48 +1000 Subject: [PATCH 08/12] Fix model selection and favorites system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **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 --- examples/chat-ui/src/components/ChatApp.tsx | 7 +++++ .../src/components/ConversationThread.tsx | 15 ++++++++++ .../src/components/ModelSelectionModal.tsx | 28 ++++++++++++++++--- examples/chat-ui/src/hooks/useModels.ts | 13 ++++++++- .../chat-ui/src/utils/modelPreferences.ts | 27 +++++++++++++++++- 5 files changed, 84 insertions(+), 6 deletions(-) diff --git a/examples/chat-ui/src/components/ChatApp.tsx b/examples/chat-ui/src/components/ChatApp.tsx index e3364e8..6db7a43 100644 --- a/examples/chat-ui/src/components/ChatApp.tsx +++ b/examples/chat-ui/src/components/ChatApp.tsx @@ -7,6 +7,7 @@ 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' @@ -23,6 +24,7 @@ const ChatApp: React.FC = () => { 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) @@ -146,6 +148,11 @@ const ChatApp: React.FC = () => { apiKeyUpdateTrigger={apiKeyUpdateTrigger} mcpTools={mcpTools} onMcpToolsUpdate={setMcpTools} + addToFavorites={addToFavorites} + models={models} + toggleFavorite={toggleFavorite} + isFavorite={isFavorite} + getFavoriteModels={getFavoriteModels} />
diff --git a/examples/chat-ui/src/components/ConversationThread.tsx b/examples/chat-ui/src/components/ConversationThread.tsx index cd0ff7f..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 }>({ @@ -278,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 15342c6..f1db9df 100644 --- a/examples/chat-ui/src/components/ModelSelectionModal.tsx +++ b/examples/chat-ui/src/components/ModelSelectionModal.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect } from 'react' import { X, AlertCircle, CheckCircle, Star } from 'lucide-react' import { type Model, type Provider, providers } from '../types/models' -import { useModels } from '../hooks/useModels' import { hasApiKey, setApiKey, clearApiKey, beginOAuthFlow } from '../utils/auth' import ApiKeyModal from './ApiKeyModal' import ProviderModelsModal from './ProviderModelsModal' @@ -12,6 +11,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 = ({ @@ -20,6 +24,11 @@ const ModelSelectionModal: React.FC = ({ selectedModel, onModelChange, apiKeyUpdateTrigger, + addToFavorites, + models, + toggleFavorite, + isFavorite, + getFavoriteModels, }) => { const [apiKeyModal, setApiKeyModal] = useState<{ isOpen: boolean; model: Model | null }>({ isOpen: false, @@ -30,7 +39,7 @@ const ModelSelectionModal: React.FC = ({ provider: null, }) const [apiKeyStatuses, setApiKeyStatuses] = useState>({}) - const { models, toggleFavorite, isFavorite, getFavoriteModels } = useModels() + // Remove the local useModels hook - we now get everything from props useEffect(() => { const statuses: Record = {} @@ -201,13 +210,23 @@ const ModelSelectionModal: React.FC = ({
(isConfigured ? onModelChange(model) : handleProviderSelect(model.provider))} + onClick={() => { + if (isConfigured) { + onModelChange(model) + addToFavorites(model.id) + } else { + handleProviderSelect(model.provider) + } + }} >
+ {isSelected && } {model.provider.logo}

{model.name}

@@ -276,6 +295,7 @@ const ModelSelectionModal: React.FC = ({ isFavorite={isFavorite} onModelSelect={(model) => { onModelChange(model) + addToFavorites(model.id) setProviderModelsModal({ isOpen: false, provider: null }) onClose() }} diff --git a/examples/chat-ui/src/hooks/useModels.ts b/examples/chat-ui/src/hooks/useModels.ts index 9c6b205..49dabb0 100644 --- a/examples/chat-ui/src/hooks/useModels.ts +++ b/examples/chat-ui/src/hooks/useModels.ts @@ -45,7 +45,8 @@ export function useModels() { try { const saved = localStorage.getItem(FAVORITES_KEY) if (saved) { - setFavorites(JSON.parse(saved)) + const parsed = JSON.parse(saved) + setFavorites(parsed) } } catch (error) { console.error('Failed to load favorites from localStorage:', error) @@ -75,6 +76,15 @@ export function useModels() { }) }, []) + 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) @@ -95,6 +105,7 @@ export function useModels() { favorites, loading, toggleFavorite, + addToFavorites, isFavorite, getFavoriteModels, getToolSupportingModels, diff --git a/examples/chat-ui/src/utils/modelPreferences.ts b/examples/chat-ui/src/utils/modelPreferences.ts index 0a77658..0475790 100644 --- a/examples/chat-ui/src/utils/modelPreferences.ts +++ b/examples/chat-ui/src/utils/modelPreferences.ts @@ -1,8 +1,32 @@ -import { type Model, providers } 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[] { @@ -23,6 +47,7 @@ function getAvailableModels(): Model[] { contextLimit: modelData.limit.context, outputLimit: modelData.limit.output, cost: modelData.cost, + providerOptions: getProviderOptions(providerId as any, modelId), } models.push(model) } From daf540f28f2f096c90a128b8ff5457848ffccd55 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Thu, 10 Jul 2025 16:40:41 +1000 Subject: [PATCH 09/12] 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 --- .../src/components/ModelSelectionModal.tsx | 346 +++++++++++------- 1 file changed, 220 insertions(+), 126 deletions(-) diff --git a/examples/chat-ui/src/components/ModelSelectionModal.tsx b/examples/chat-ui/src/components/ModelSelectionModal.tsx index f1db9df..c7ff181 100644 --- a/examples/chat-ui/src/components/ModelSelectionModal.tsx +++ b/examples/chat-ui/src/components/ModelSelectionModal.tsx @@ -1,9 +1,8 @@ import React, { useState, useEffect } from 'react' -import { X, AlertCircle, CheckCircle, Star } from 'lucide-react' +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' -import ProviderModelsModal from './ProviderModelsModal' interface ModelSelectionModalProps { isOpen: boolean @@ -34,12 +33,10 @@ const ModelSelectionModal: React.FC = ({ isOpen: false, model: null, }) - const [providerModelsModal, setProviderModelsModal] = useState<{ isOpen: boolean; provider: Provider | null }>({ - isOpen: false, - provider: null, - }) + const [currentView, setCurrentView] = useState<'main' | 'provider'>('main') + const [selectedProvider, setSelectedProvider] = useState(null) + const [searchTerm, setSearchTerm] = useState('') const [apiKeyStatuses, setApiKeyStatuses] = useState>({}) - // Remove the local useModels hook - we now get everything from props useEffect(() => { const statuses: Record = {} @@ -67,7 +64,8 @@ const ModelSelectionModal: React.FC = ({ if (event.data.type === 'oauth_success' && event.data.provider && isOpen) { const provider = providers[event.data.provider as keyof typeof providers] if (provider) { - setProviderModelsModal({ isOpen: true, provider }) + setCurrentView('provider') + setSelectedProvider(provider) } } } @@ -76,6 +74,15 @@ const ModelSelectionModal: React.FC = ({ 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) @@ -84,7 +91,8 @@ const ModelSelectionModal: React.FC = ({ // Show provider models after API key is saved const provider = apiKeyModal.model.provider setApiKeyModal({ isOpen: false, model: null }) - setProviderModelsModal({ isOpen: true, provider }) + setCurrentView('provider') + setSelectedProvider(provider) } } @@ -99,7 +107,8 @@ const ModelSelectionModal: React.FC = ({ // Check if already authenticated if (hasApiKey(provider.id)) { // Show provider models to select from - setProviderModelsModal({ isOpen: true, provider }) + setCurrentView('provider') + setSelectedProvider(provider) } else { // Start OAuth flow beginOAuthFlow(provider.id) @@ -115,7 +124,8 @@ const ModelSelectionModal: React.FC = ({ // Check if already has API key if (hasApiKey(provider.id)) { // Show provider models to select from - setProviderModelsModal({ isOpen: true, provider }) + setCurrentView('provider') + setSelectedProvider(provider) } else { // Show API key modal first setApiKeyModal({ isOpen: true, model: { provider } as Model }) @@ -123,6 +133,29 @@ const ModelSelectionModal: React.FC = ({ } } + 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] @@ -144,126 +177,204 @@ const ModelSelectionModal: React.FC = ({ >
e.stopPropagation()}>
-

Select Model

+ {currentView === 'provider' && selectedProvider ? ( +
+ + {selectedProvider.logo} +

{selectedProvider.name} Models

+
+ ) : ( +

Select Model

+ )}
- {/* Providers Section */} -
-

Providers

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

{provider.name}

-

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

+ {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(provider.id)} - {apiKeyStatuses[provider.id] && ( -
+
+ + {/* 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] + const isSelected = model.id === selectedModel.id + return ( +
{ + if (isConfigured) { + onModelChange(model) + addToFavorites(model.id) + } else { + handleProviderSelect(model.provider) + } + }} > - - - )} -
+
+
+ + {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" + />
- ))} -
-
- {/* 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] - const isSelected = model.id === selectedModel.id - return ( -
{ - if (isConfigured) { - onModelChange(model) - addToFavorites(model.id) - } else { - handleProviderSelect(model.provider) - } - }} - > + {/* Models List */} +
+ {getFilteredModels().map((model) => ( +
- - {isSelected && } - {model.provider.logo} +

{model.name}

- {model.provider.name} - {model.supportsTools && 🔧} - {model.reasoning && 🧠} - {model.attachment && 📎} + {model.supportsTools && 🔧 Tools} + {model.reasoning && 🧠 Reasoning} + {model.attachment && 📎 Attachments} + + {(model.contextLimit / 1000).toFixed(0)}K context
-
- {!isConfigured && ( -
- -
- )} - {isConfigured && ( - - )} -
+
- ) - })} -
- )} -
+ ))} +
+ + {getFilteredModels().length === 0 && ( +
+

No models found matching "{searchTerm}"

+
+ )} + +
+ {getProviderModels().length} model{getProviderModels().length !== 1 ? 's' : ''} available +
+ + ) + )}
@@ -284,23 +395,6 @@ const ModelSelectionModal: React.FC = ({ } onSave={handleApiKeySave} /> - - setProviderModelsModal({ isOpen: false, provider: null })} - provider={providerModelsModal.provider} - models={models} - favorites={[]} - onToggleFavorite={toggleFavorite} - isFavorite={isFavorite} - onModelSelect={(model) => { - onModelChange(model) - addToFavorites(model.id) - setProviderModelsModal({ isOpen: false, provider: null }) - onClose() - }} - toolsAvailable={false} - /> ) } From cfde169856190a334b04bd7452fd4383810fc473 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Thu, 10 Jul 2025 16:44:57 +1000 Subject: [PATCH 10/12] 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 --- examples/chat-ui/src/components/ModelSelectionModal.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/examples/chat-ui/src/components/ModelSelectionModal.tsx b/examples/chat-ui/src/components/ModelSelectionModal.tsx index c7ff181..8f62591 100644 --- a/examples/chat-ui/src/components/ModelSelectionModal.tsx +++ b/examples/chat-ui/src/components/ModelSelectionModal.tsx @@ -175,8 +175,11 @@ const ModelSelectionModal: React.FC = ({ style={{ backgroundColor: 'rgba(0,0,0,0.8)' }} onClick={onClose} > -
e.stopPropagation()}> -
+
e.stopPropagation()} + > +
{currentView === 'provider' && selectedProvider ? (
-
+
{currentView === 'main' ? ( <> {/* Providers Section */} From 41cd8b31864c6bc7a3bf2c9f964a6e3732b0f93f Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Thu, 10 Jul 2025 17:01:17 +1000 Subject: [PATCH 11/12] 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 --- .../src/components/ModelSelectionModal.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/examples/chat-ui/src/components/ModelSelectionModal.tsx b/examples/chat-ui/src/components/ModelSelectionModal.tsx index 8f62591..2207a86 100644 --- a/examples/chat-ui/src/components/ModelSelectionModal.tsx +++ b/examples/chat-ui/src/components/ModelSelectionModal.tsx @@ -334,11 +334,18 @@ const ModelSelectionModal: React.FC = ({ {/* Models List */}
{getFilteredModels().map((model) => ( -
+
handleModelSelect(model)} + >
- +
Click to select
))} From f35261d57053c7176f8880cd4cc254ad19b6630f Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Fri, 11 Jul 2025 14:27:41 +1000 Subject: [PATCH 12/12] localstorage saving working --- .../chat-ui/src/components/OAuthCallback.tsx | 20 ++++- examples/chat-ui/src/hooks/useModels.ts | 5 +- examples/chat-ui/src/utils/auth.ts | 73 +++++++++++++++---- 3 files changed, 79 insertions(+), 19 deletions(-) diff --git a/examples/chat-ui/src/components/OAuthCallback.tsx b/examples/chat-ui/src/components/OAuthCallback.tsx index 8e150b0..01beae1 100644 --- a/examples/chat-ui/src/components/OAuthCallback.tsx +++ b/examples/chat-ui/src/components/OAuthCallback.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useState, useRef } from 'react' import { useSearchParams } from 'react-router-dom' import { completeOAuthFlow } from '../utils/auth' import { SupportedProvider } from '../types/models' @@ -11,9 +11,16 @@ 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) useEffect(() => { 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') @@ -33,6 +40,7 @@ const OAuthCallback: React.FC = ({ provider }) => { 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 }, '*') @@ -41,7 +49,7 @@ const OAuthCallback: React.FC = ({ provider }) => { // Redirect to main page if not in popup window.location.href = '/' } - }, 1000) + }, 3000) } catch (err) { console.error('OAuth callback error:', err) setError(err instanceof Error ? err.message : 'Unknown error') @@ -76,7 +84,13 @@ const OAuthCallback: React.FC = ({ provider }) => {

Authentication Successful!

-

Successfully connected to {provider}. This window will close automatically.

+

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

+ )} diff --git a/examples/chat-ui/src/hooks/useModels.ts b/examples/chat-ui/src/hooks/useModels.ts index 49dabb0..1d24257 100644 --- a/examples/chat-ui/src/hooks/useModels.ts +++ b/examples/chat-ui/src/hooks/useModels.ts @@ -59,12 +59,15 @@ export function useModels() { // 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]) + }, [favorites, loading]) const toggleFavorite = useCallback((modelId: string) => { setFavorites((prev) => { diff --git a/examples/chat-ui/src/utils/auth.ts b/examples/chat-ui/src/utils/auth.ts index 018c662..0b55c39 100644 --- a/examples/chat-ui/src/utils/auth.ts +++ b/examples/chat-ui/src/utils/auth.ts @@ -148,6 +148,8 @@ export async function beginOAuthFlow(providerId: SupportedProvider): 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`) @@ -161,22 +163,33 @@ export async function completeOAuthFlow(providerId: SupportedProvider, code: str 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 (they should all be the same since we only allow one at a time) - const pkceStateJson = sessionStorage.getItem(pkceKeys[0])! + // 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(pkceKeys[0]) + 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 @@ -184,39 +197,69 @@ export async function completeOAuthFlow(providerId: SupportedProvider, code: str 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({ - code, - code_verifier: pkceState.code_verifier, - code_challenge_method: 'S256', - }), + 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: new URLSearchParams({ - grant_type: 'authorization_code', - client_id: provider.oauth.clientId, - code, - redirect_uri: getRedirectUri(providerId), - code_verifier: pkceState.code_verifier, - }), + 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