diff --git a/.changeset/tall-points-hang.md b/.changeset/tall-points-hang.md new file mode 100644 index 00000000..46d57104 --- /dev/null +++ b/.changeset/tall-points-hang.md @@ -0,0 +1,5 @@ +--- +'@srcbook/web': patch +--- + +- Add essential support for OpenRouter models. diff --git a/.changeset/thin-ears-approve.md b/.changeset/thin-ears-approve.md new file mode 100644 index 00000000..bd8481f1 --- /dev/null +++ b/.changeset/thin-ears-approve.md @@ -0,0 +1,5 @@ +--- +'@srcbook/web': patch +--- + +Auto-populate available models in Settings. diff --git a/.changeset/wicked-geckos-relate.md b/.changeset/wicked-geckos-relate.md new file mode 100644 index 00000000..32efbb7c --- /dev/null +++ b/.changeset/wicked-geckos-relate.md @@ -0,0 +1,5 @@ +--- +'srcbook': patch +--- + +Add GitHub Pages documentation repository structure and configuration. diff --git a/.gitignore b/.gitignore index 7efe4c7b..c37aebf0 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,7 @@ srcbook/lib/**/* # Aide *.code-workspace +# Docs folder +docs/ + vite.config.ts.timestamp-*.mjs \ No newline at end of file diff --git a/packages/api/ai/config.mts b/packages/api/ai/config.mts index 499b37d0..7a27ec30 100644 --- a/packages/api/ai/config.mts +++ b/packages/api/ai/config.mts @@ -49,6 +49,17 @@ export async function getModel(): Promise { }); return xai(model); + case 'openrouter': + if (!config.openrouterKey) { + throw new Error('OpenRouter API key is not set'); + } + const openrouter = createOpenAI({ + compatibility: 'compatible', + baseURL: 'https://openrouter.ai/api/v1', + apiKey: config.openrouterKey, + }); + return openrouter(model); + case 'custom': if (typeof aiBaseUrl !== 'string') { throw new Error('Local AI base URL is not set'); diff --git a/packages/api/db/schema.mts b/packages/api/db/schema.mts index 512bbe4a..7d0700f8 100644 --- a/packages/api/db/schema.mts +++ b/packages/api/db/schema.mts @@ -10,6 +10,7 @@ export const configs = sqliteTable('config', { anthropicKey: text('anthropic_api_key'), xaiKey: text('xai_api_key'), geminiKey: text('gemini_api_key'), + openrouterKey: text('openrouter_api_key'), customApiKey: text('custom_api_key'), // TODO: This is deprecated in favor of SRCBOOK_DISABLE_ANALYTICS env variable. Remove this. enabledAnalytics: integer('enabled_analytics', { mode: 'boolean' }).notNull().default(true), diff --git a/packages/api/drizzle/0016_add_openrouter_api_key.sql b/packages/api/drizzle/0016_add_openrouter_api_key.sql new file mode 100644 index 00000000..51803a60 --- /dev/null +++ b/packages/api/drizzle/0016_add_openrouter_api_key.sql @@ -0,0 +1 @@ +ALTER TABLE `config` ADD `openrouter_api_key` text; \ No newline at end of file diff --git a/packages/shared/src/ai.mts b/packages/shared/src/ai.mts index b6753f5a..52db8df4 100644 --- a/packages/shared/src/ai.mts +++ b/packages/shared/src/ai.mts @@ -3,6 +3,7 @@ export const AiProvider = { Anthropic: 'anthropic', XAI: 'Xai', Gemini: 'Gemini', + OpenRouter: 'openrouter', Custom: 'custom', } as const; @@ -14,6 +15,7 @@ export const defaultModels: Record = { [AiProvider.Custom]: 'mistral-nemo', [AiProvider.XAI]: 'grok-beta', [AiProvider.Gemini]: 'gemini-1.5-pro-latest', + [AiProvider.OpenRouter]: 'anthropic/claude-3-opus-20240229', } as const; export function isValidProvider(provider: string): provider is AiProviderType { diff --git a/packages/web/src/components/use-settings.tsx b/packages/web/src/components/use-settings.tsx index c9e99992..f5d08bcf 100644 --- a/packages/web/src/components/use-settings.tsx +++ b/packages/web/src/components/use-settings.tsx @@ -1,11 +1,25 @@ -import React, { createContext, useContext } from 'react'; +import React, { createContext, useContext, useState, useEffect } from 'react'; import { useRevalidator } from 'react-router-dom'; import { updateConfig as updateConfigServer } from '@/lib/server'; import type { SettingsType } from '@/types'; +export type OpenRouterModel = { + id: string; + name: string; + provider: string; + description?: string; + pricing?: Record; + context_length?: number; +}; + +export type GroupedOpenRouterModels = Record; + export type SettingsContextValue = SettingsType & { aiEnabled: boolean; updateConfig: (newConfig: Partial) => Promise; + openRouterModels: GroupedOpenRouterModels; + isLoadingOpenRouterModels: boolean; + refreshOpenRouterModels: () => Promise; }; const SettingsContext = createContext(null); @@ -15,11 +29,38 @@ type ProviderPropsType = { children: React.ReactNode; }; +async function fetchOpenRouterModels(): Promise { + try { + const response = await fetch('https://openrouter.ai/api/v1/models'); + if (!response.ok) { + throw new Error('Failed to fetch models'); + } + const data = await response.json(); + return data.data || []; + } catch (error) { + console.error('Error fetching OpenRouter models:', error); + return []; + } +} + +function groupModelsByProvider(models: OpenRouterModel[]): GroupedOpenRouterModels { + return models.reduce((grouped, model) => { + const provider = model.provider || 'Unknown'; + if (!grouped[provider]) { + grouped[provider] = []; + } + grouped[provider].push(model); + return grouped; + }, {} as GroupedOpenRouterModels); +} + /** * An interface for working with our config. */ export function SettingsProvider({ config, children }: ProviderPropsType) { const revalidator = useRevalidator(); + const [openRouterModels, setOpenRouterModels] = useState({}); + const [isLoadingOpenRouterModels, setIsLoadingOpenRouterModels] = useState(false); const updateConfig = async (newConfig: Partial) => { // Filter out null values and convert back to an object @@ -31,11 +72,29 @@ export function SettingsProvider({ config, children }: ProviderPropsType) { revalidator.revalidate(); }; + const refreshOpenRouterModels = async () => { + setIsLoadingOpenRouterModels(true); + try { + const models = await fetchOpenRouterModels(); + const grouped = groupModelsByProvider(models); + setOpenRouterModels(grouped); + } finally { + setIsLoadingOpenRouterModels(false); + } + }; + + useEffect(() => { + if (config.aiProvider === 'openrouter') { + refreshOpenRouterModels(); + } + }, [config.aiProvider]); + const aiEnabled = (config.openaiKey && config.aiProvider === 'openai') || (config.anthropicKey && config.aiProvider === 'anthropic') || (config.xaiKey && config.aiProvider === 'Xai') || (config.geminiKey && config.aiProvider === 'Gemini') || + (config.openrouterKey && config.aiProvider === 'openrouter') || (config.aiProvider === 'custom' && !!config.aiBaseUrl) || false; @@ -43,6 +102,9 @@ export function SettingsProvider({ config, children }: ProviderPropsType) { ...config, aiEnabled, updateConfig, + openRouterModels, + isLoadingOpenRouterModels, + refreshOpenRouterModels, }; return {children}; diff --git a/packages/web/src/routes/settings.tsx b/packages/web/src/routes/settings.tsx index c1775557..6277592a 100644 --- a/packages/web/src/routes/settings.tsx +++ b/packages/web/src/routes/settings.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; -import { CircleCheck, Loader2, CircleX } from 'lucide-react'; +import { CircleCheck, Loader2, CircleX, RefreshCw } from 'lucide-react'; import { aiHealthcheck, subscribeToMailingList } from '@/lib/server'; -import { useSettings } from '@/components/use-settings'; +import { useSettings, type OpenRouterModel } from '@/components/use-settings'; import { AiProviderType, getDefaultModel, type CodeLanguageType } from '@srcbook/shared'; import { Select, @@ -9,11 +9,19 @@ import { SelectItem, SelectTrigger, SelectValue, + SelectGroup, + SelectLabel, } from '@srcbook/components/src/components/ui/select'; import { Input } from '@srcbook/components/src/components/ui/input'; import useTheme from '@srcbook/components/src/components/use-theme'; import { Switch } from '@srcbook/components/src/components/ui/switch'; import { Button } from '@srcbook/components/src/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@srcbook/components/src/components/ui/tooltip'; import { toast } from 'sonner'; function Settings() { @@ -158,6 +166,16 @@ function AiInfoBanner() { ); + case 'openrouter': + return ( +
+

API key required

+ + Go to {aiProvider} + +
+ ); + case 'custom': return (
@@ -244,6 +262,7 @@ export function AiSettings({ saveButtonLabel }: AiSettingsProps) { customApiKey: configCustomApiKey, xaiKey: configXaiKey, geminiKey: configGeminiKey, + openrouterKey: configOpenrouterKey, updateConfig: updateConfigContext, } = useSettings(); @@ -251,6 +270,7 @@ export function AiSettings({ saveButtonLabel }: AiSettingsProps) { const [anthropicKey, setAnthropicKey] = useState(configAnthropicKey ?? ''); const [xaiKey, setXaiKey] = useState(configXaiKey ?? ''); const [geminiKey, setGeminiKey] = useState(configGeminiKey ?? ''); + const [openrouterKey, setOpenrouterKey] = useState(configOpenrouterKey ?? ''); const [customApiKey, setCustomApiKey] = useState(configCustomApiKey ?? ''); const [model, setModel] = useState(aiModel); const [baseUrl, setBaseUrl] = useState(aiBaseUrl || ''); @@ -280,10 +300,16 @@ export function AiSettings({ saveButtonLabel }: AiSettingsProps) { model !== aiModel; const geminiKeySaveEnabled = - (typeof configGeminiKey === 'string' && geminiKey !== configXaiKey) || + (typeof configGeminiKey === 'string' && geminiKey !== configGeminiKey) || ((configGeminiKey === null || configGeminiKey === undefined) && geminiKey.length > 0) || model !== aiModel; + const openrouterKeySaveEnabled = + (typeof configOpenrouterKey === 'string' && openrouterKey !== configOpenrouterKey) || + ((configOpenrouterKey === null || configOpenrouterKey === undefined) && + openrouterKey.length > 0) || + model !== aiModel; + const customModelSaveEnabled = (typeof configCustomApiKey === 'string' && customApiKey !== configCustomApiKey) || ((configCustomApiKey === null || configCustomApiKey === undefined) && @@ -305,6 +331,7 @@ export function AiSettings({ saveButtonLabel }: AiSettingsProps) { anthropic Xai Gemini + openrouter custom @@ -395,6 +422,32 @@ export function AiSettings({ saveButtonLabel }: AiSettingsProps) {
)} + {aiProvider === 'openrouter' && ( +
+

+ OpenRouter provides access to models from multiple AI providers including OpenAI, + Anthropic, Mistral, and more through a single API. Enter your OpenRouter API key below. +

+
+ setOpenrouterKey(e.target.value)} + /> + +
+ +
+ )} + {aiProvider === 'custom' && (

@@ -436,4 +489,105 @@ export function AiSettings({ saveButtonLabel }: AiSettingsProps) { ); } +type OpenRouterModelSelectorProps = { + onSelectModel: (model: string) => void; + currentModel: string; +}; + +function OpenRouterModelSelector({ onSelectModel, currentModel }: OpenRouterModelSelectorProps) { + const { openRouterModels, isLoadingOpenRouterModels, refreshOpenRouterModels } = useSettings(); + + // Format model ID for display - show just the model name, not the full provider/model path + function formatModelName(modelId: string): string { + // Validate input to ensure we never return undefined + if (typeof modelId !== 'string' || modelId.length === 0) { + return ''; + } + + // Split by / and take the last part if it exists + const parts = modelId.split('/'); + if (parts.length > 1) { + // @ts-expect-error - We know this is valid based on the length check + return parts[parts.length - 1]; + } + + // Return the original value + return modelId; + } + + // Get model display name with helpful context + const getModelDisplayName = (model: OpenRouterModel): string => { + // model.id is defined as string in OpenRouterModel type + const baseName = model.name || formatModelName(model.id); + const contextLength = model.context_length + ? ` (${Math.floor(model.context_length / 1000)}k ctx)` + : ''; + return `${baseName}${contextLength}`; + }; + + return ( +

+
+ Select an OpenRouter model: + + + + + + +

Refresh model list

+
+
+
+
+ + {isLoadingOpenRouterModels ? ( +
+ + Loading models... +
+ ) : Object.keys(openRouterModels).length === 0 ? ( +
+ No models found. Check your OpenRouter API key and try refreshing. +
+ ) : ( + + )} + + {currentModel && !isLoadingOpenRouterModels && ( +
+ Selected model ID: {currentModel} +
+ )} +
+ ); +} + export default Settings; diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts index 356f9812..60c357c0 100644 --- a/packages/web/src/types.ts +++ b/packages/web/src/types.ts @@ -14,6 +14,7 @@ export type SettingsType = { anthropicKey?: string | null; xaiKey?: string | null; geminiKey?: string | null; + openrouterKey?: string | null; aiProvider: AiProviderType; customApiKey: string | null; aiModel: string;