diff --git a/backend/migrations/000_one_shot_schema.sql b/backend/migrations/000_one_shot_schema.sql index 80d563af..07d0d0b6 100644 --- a/backend/migrations/000_one_shot_schema.sql +++ b/backend/migrations/000_one_shot_schema.sql @@ -20,6 +20,7 @@ create table if not exists public.user_profiles ( tabular_model text not null default 'gemini-3-flash-preview', claude_api_key text, gemini_api_key text, + openrouter_api_key text, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); diff --git a/backend/migrations/001_add_openrouter_api_key.sql b/backend/migrations/001_add_openrouter_api_key.sql new file mode 100644 index 00000000..dcc33ef5 --- /dev/null +++ b/backend/migrations/001_add_openrouter_api_key.sql @@ -0,0 +1,5 @@ +-- Add OpenRouter API key column to user_profiles +-- Run this migration in your Supabase SQL Editor + +alter table public.user_profiles + add column if not exists openrouter_api_key text; diff --git a/backend/src/lib/llm/index.ts b/backend/src/lib/llm/index.ts index 518ddc01..06e1bee6 100644 --- a/backend/src/lib/llm/index.ts +++ b/backend/src/lib/llm/index.ts @@ -1,5 +1,6 @@ import { streamClaude, completeClaudeText } from "./claude"; import { streamGemini, completeGeminiText } from "./gemini"; +import { streamOpenRouter, completeOpenRouterText } from "./openrouter"; import { providerForModel } from "./models"; import type { StreamChatParams, StreamChatResult, UserApiKeys } from "./types"; @@ -11,6 +12,7 @@ export async function streamChatWithTools( ): Promise { const provider = providerForModel(params.model); if (provider === "claude") return streamClaude(params); + if (provider === "openrouter") return streamOpenRouter(params); return streamGemini(params); } @@ -23,5 +25,6 @@ export async function completeText(params: { }): Promise { const provider = providerForModel(params.model); if (provider === "claude") return completeClaudeText(params); + if (provider === "openrouter") return completeOpenRouterText(params); return completeGeminiText(params); } diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts index 52314007..0afb5a74 100644 --- a/backend/src/lib/llm/models.ts +++ b/backend/src/lib/llm/models.ts @@ -10,14 +10,25 @@ export const GEMINI_MAIN_MODELS = [ "gemini-3-flash-preview", ] as const; +// OpenRouter main-chat tier +export const OPENROUTER_MAIN_MODELS = [ + "openrouter/openai/gpt-5.3-chat", + "openrouter/anthropic/claude-sonnet-4.6", + "openrouter/anthropic/claude-opus-4.7", + "openrouter/x-ai/grok-4.3", + "openrouter/openai/gpt-4o-mini", +] as const; + // Mid-tier (used for tabular review) — user picks one in account settings. export const CLAUDE_MID_MODELS = ["claude-sonnet-4-6"] as const; export const GEMINI_MID_MODELS = ["gemini-3-flash-preview"] as const; +export const OPENROUTER_MID_MODELS = ["openrouter/openai/gpt-4o-mini"] as const; // Low-tier (used for title generation, lightweight extractions) — user picks // one in account settings. export const CLAUDE_LOW_MODELS = ["claude-haiku-4-5"] as const; export const GEMINI_LOW_MODELS = ["gemini-3.1-flash-lite-preview"] as const; +export const OPENROUTER_LOW_MODELS = ["openrouter/openai/gpt-4o-mini"] as const; export const DEFAULT_MAIN_MODEL = "gemini-3-flash-preview"; export const DEFAULT_TITLE_MODEL = "gemini-3.1-flash-lite-preview"; @@ -26,10 +37,13 @@ export const DEFAULT_TABULAR_MODEL = "gemini-3-flash-preview"; const ALL_MODELS = new Set([ ...CLAUDE_MAIN_MODELS, ...GEMINI_MAIN_MODELS, + ...OPENROUTER_MAIN_MODELS, ...CLAUDE_MID_MODELS, ...GEMINI_MID_MODELS, + ...OPENROUTER_MID_MODELS, ...CLAUDE_LOW_MODELS, ...GEMINI_LOW_MODELS, + ...OPENROUTER_LOW_MODELS, ]); // --------------------------------------------------------------------------- @@ -37,6 +51,7 @@ const ALL_MODELS = new Set([ // --------------------------------------------------------------------------- export function providerForModel(model: string): Provider { + if (model.startsWith("openrouter/")) return "openrouter"; if (model.startsWith("claude")) return "claude"; if (model.startsWith("gemini")) return "gemini"; throw new Error(`Unknown model id: ${model}`); diff --git a/backend/src/lib/llm/openrouter.ts b/backend/src/lib/llm/openrouter.ts new file mode 100644 index 00000000..16f1e2d5 --- /dev/null +++ b/backend/src/lib/llm/openrouter.ts @@ -0,0 +1,272 @@ +import type { + StreamChatParams, + StreamChatResult, + NormalizedToolCall, +} from "./types"; + +const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"; +const MAX_TOKENS = 16384; + +type OpenRouterMessage = { + role: "system" | "user" | "assistant" | "tool"; + content: string | null; + tool_calls?: { + id: string; + type: "function"; + function: { name: string; arguments: string }; + }[]; + tool_call_id?: string; +}; + +type OpenRouterChoice = { + delta?: { + content?: string | null; + tool_calls?: { + index: number; + id?: string; + type?: "function"; + function?: { name?: string; arguments?: string }; + }[]; + }; + finish_reason?: string | null; +}; + +type OpenRouterStreamChunk = { + choices: OpenRouterChoice[]; +}; + +function getApiKey(override?: string | null): string { + return override?.trim() || process.env.OPENROUTER_API_KEY || ""; +} + +/** + * Strip the "openrouter/" prefix from model IDs. + * e.g., "openrouter/openai/gpt-4o" -> "openai/gpt-4o" + */ +function toOpenRouterModelId(model: string): string { + return model.startsWith("openrouter/") ? model.slice("openrouter/".length) : model; +} + +function toOpenRouterMessages( + systemPrompt: string, + messages: StreamChatParams["messages"], +): OpenRouterMessage[] { + const result: OpenRouterMessage[] = []; + if (systemPrompt) { + result.push({ role: "system", content: systemPrompt }); + } + for (const m of messages) { + result.push({ role: m.role, content: m.content }); + } + return result; +} + +export async function streamOpenRouter( + params: StreamChatParams, +): Promise { + const { + model, + systemPrompt, + tools = [], + callbacks = {}, + runTools, + apiKeys, + } = params; + const maxIter = params.maxIterations ?? 10; + const apiKey = getApiKey(apiKeys?.openrouter); + const openRouterModel = toOpenRouterModelId(model); + + const messages: OpenRouterMessage[] = toOpenRouterMessages(systemPrompt, params.messages); + let fullText = ""; + + for (let iter = 0; iter < maxIter; iter++) { + const body: Record = { + model: openRouterModel, + messages, + max_tokens: MAX_TOKENS, + stream: true, + }; + + if (tools.length > 0) { + body.tools = tools; + body.tool_choice = "auto"; + } + + const response = await fetch(OPENROUTER_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "HTTP-Referer": process.env.APP_URL || "http://localhost:3000", + "X-Title": "Mike", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`OpenRouter API error: ${response.status} - ${errorText}`); + } + + if (!response.body) { + throw new Error("OpenRouter response body is null"); + } + + // Parse SSE stream + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + // Per-iteration accumulators + const textParts: string[] = []; + const toolCalls: Map = new Map(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === "data: [DONE]") continue; + if (!trimmed.startsWith("data: ")) continue; + + const jsonStr = trimmed.slice(6); + let chunk: OpenRouterStreamChunk; + try { + chunk = JSON.parse(jsonStr); + } catch { + continue; + } + + console.log("[openrouter stream chunk]", JSON.stringify(chunk, null, 2)); + + const choice = chunk.choices?.[0]; + if (!choice?.delta) continue; + + // Handle text content + if (choice.delta.content) { + textParts.push(choice.delta.content); + callbacks.onContentDelta?.(choice.delta.content); + } + + // Handle tool calls + if (choice.delta.tool_calls) { + for (const tc of choice.delta.tool_calls) { + const existing = toolCalls.get(tc.index); + if (existing) { + // Accumulate function arguments + if (tc.function?.arguments) { + existing.arguments += tc.function.arguments; + } + } else { + // New tool call + toolCalls.set(tc.index, { + id: tc.id || `tool-${tc.index}`, + name: tc.function?.name || "", + arguments: tc.function?.arguments || "", + }); + } + } + } + } + } + + fullText += textParts.join(""); + + // Convert accumulated tool calls to normalized format + const normalizedCalls: NormalizedToolCall[] = []; + for (const [, tc] of toolCalls) { + if (!tc.name) continue; + let input: Record = {}; + try { + input = JSON.parse(tc.arguments || "{}"); + } catch { + input = {}; + } + const call: NormalizedToolCall = { + id: tc.id, + name: tc.name, + input, + }; + callbacks.onToolCallStart?.(call); + normalizedCalls.push(call); + } + + // If no tool calls or no runTools handler, we're done + if (!normalizedCalls.length || !runTools) { + break; + } + + // Execute tools and continue the loop + const results = await runTools(normalizedCalls); + + // Add assistant message with tool calls + messages.push({ + role: "assistant", + content: textParts.join("") || null, + tool_calls: normalizedCalls.map((c) => ({ + id: c.id, + type: "function" as const, + function: { + name: c.name, + arguments: JSON.stringify(c.input), + }, + })), + }); + + // Add tool results + for (const r of results) { + messages.push({ + role: "tool", + tool_call_id: r.tool_use_id, + content: r.content, + }); + } + } + + return { fullText }; +} + +export async function completeOpenRouterText(params: { + model: string; + systemPrompt?: string; + user: string; + maxTokens?: number; + apiKeys?: { openrouter?: string | null }; +}): Promise { + const apiKey = getApiKey(params.apiKeys?.openrouter); + const openRouterModel = toOpenRouterModelId(params.model); + + const messages: OpenRouterMessage[] = []; + if (params.systemPrompt) { + messages.push({ role: "system", content: params.systemPrompt }); + } + messages.push({ role: "user", content: params.user }); + + const response = await fetch(OPENROUTER_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "HTTP-Referer": process.env.APP_URL || "http://localhost:3000", + "X-Title": "Mike", + }, + body: JSON.stringify({ + model: openRouterModel, + messages, + max_tokens: params.maxTokens ?? 512, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`OpenRouter API error: ${response.status} - ${errorText}`); + } + + const data = await response.json(); + return data.choices?.[0]?.message?.content ?? ""; +} diff --git a/backend/src/lib/llm/types.ts b/backend/src/lib/llm/types.ts index 8cc411a7..4858972b 100644 --- a/backend/src/lib/llm/types.ts +++ b/backend/src/lib/llm/types.ts @@ -2,7 +2,7 @@ // Callers always speak OpenAI-style tools + { role, content } messages; each // provider translates internally. -export type Provider = "claude" | "gemini"; +export type Provider = "claude" | "gemini" | "openrouter"; export type OpenAIToolSchema = { type: "function"; @@ -39,6 +39,7 @@ export type StreamCallbacks = { export type UserApiKeys = { claude?: string | null; gemini?: string | null; + openrouter?: string | null; }; export type StreamChatParams = { diff --git a/backend/src/lib/userSettings.ts b/backend/src/lib/userSettings.ts index c798b636..201da3d4 100644 --- a/backend/src/lib/userSettings.ts +++ b/backend/src/lib/userSettings.ts @@ -29,13 +29,14 @@ export async function getUserModelSettings( const client = db ?? createServerSupabase(); const { data } = await client .from("user_profiles") - .select("tabular_model, claude_api_key, gemini_api_key") + .select("tabular_model, claude_api_key, gemini_api_key, openrouter_api_key") .eq("user_id", userId) .single(); const api_keys: UserApiKeys = { claude: data?.claude_api_key ?? null, gemini: data?.gemini_api_key ?? null, + openrouter: data?.openrouter_api_key ?? null, }; return { @@ -52,11 +53,12 @@ export async function getUserApiKeys( const client = db ?? createServerSupabase(); const { data } = await client .from("user_profiles") - .select("claude_api_key, gemini_api_key") + .select("claude_api_key, gemini_api_key, openrouter_api_key") .eq("user_id", userId) .single(); return { claude: data?.claude_api_key ?? null, gemini: data?.gemini_api_key ?? null, + openrouter: data?.openrouter_api_key ?? null, }; } diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index cf3720ea..f10619fc 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -44,6 +44,7 @@ export default function ModelsAndApiKeysPage() { apiKeys={{ claudeApiKey: profile?.claudeApiKey ?? null, geminiApiKey: profile?.geminiApiKey ?? null, + openrouterApiKey: profile?.openrouterApiKey ?? null, }} onChange={(id) => updateModelPreference("tabularModel", id) @@ -87,6 +88,14 @@ export default function ModelsAndApiKeysPage() { updateApiKey("gemini", value.trim() || null) } /> + + updateApiKey("openrouter", value.trim() || null) + } + /> @@ -100,12 +109,12 @@ function TabularModelDropdown({ }: { value: string; onChange: (id: string) => void; - apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null }; + apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null; openrouterApiKey: string | null }; }) { const [isOpen, setIsOpen] = useState(false); const selected = MODELS.find((m) => m.id === value); const selectedAvailable = isModelAvailable(value, apiKeys); - const groups: ("Anthropic" | "Google")[] = ["Anthropic", "Google"]; + const groups: ("Anthropic" | "Google" | "OpenRouter")[] = ["Anthropic", "Google", "OpenRouter"]; return ( diff --git a/frontend/src/app/components/assistant/ChatInput.tsx b/frontend/src/app/components/assistant/ChatInput.tsx index 7f56192b..df0a38f4 100644 --- a/frontend/src/app/components/assistant/ChatInput.tsx +++ b/frontend/src/app/components/assistant/ChatInput.tsx @@ -70,6 +70,7 @@ export const ChatInput = forwardRef(function ChatInput( const apiKeys = { claudeApiKey: profile?.claudeApiKey ?? null, geminiApiKey: profile?.geminiApiKey ?? null, + openrouterApiKey: profile?.openrouterApiKey ?? null, }; const textareaRef = useRef(null); const [docSelectorOpen, setDocSelectorOpen] = useState(false); diff --git a/frontend/src/app/components/assistant/ModelToggle.tsx b/frontend/src/app/components/assistant/ModelToggle.tsx index cc10d518..f9ac42da 100644 --- a/frontend/src/app/components/assistant/ModelToggle.tsx +++ b/frontend/src/app/components/assistant/ModelToggle.tsx @@ -1,115 +1,85 @@ -"use client"; +'use client'; -import { useState } from "react"; -import { ChevronDown, Check, AlertCircle } from "lucide-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { isModelAvailable } from "@/app/lib/modelAvailability"; +import { useState } from 'react'; +import { ChevronDown, Check, AlertCircle } from 'lucide-react'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { isModelAvailable } from '@/app/lib/modelAvailability'; export interface ModelOption { - id: string; - label: string; - group: "Anthropic" | "Google"; + id: string; + label: string; + group: 'Anthropic' | 'Google' | 'OpenRouter'; } export const MODELS: ModelOption[] = [ - { id: "claude-opus-4-7", label: "Claude Opus 4.7", group: "Anthropic" }, - { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", group: "Anthropic" }, - { id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro", group: "Google" }, - { id: "gemini-3-flash-preview", label: "Gemini 3 Flash", group: "Google" }, + { id: 'claude-opus-4-7', label: 'Claude Opus 4.7', group: 'Anthropic' }, + { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', group: 'Anthropic' }, + { id: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro', group: 'Google' }, + { id: 'gemini-3-flash-preview', label: 'Gemini 3 Flash', group: 'Google' }, + { id: 'openrouter/openai/gpt-5.3-chat', label: 'GPT-5.3', group: 'OpenRouter' }, + { id: 'openrouter/openai/gpt-4o-mini', label: 'GPT-4o Mini', group: 'OpenRouter' }, + { id: 'openrouter/anthropic/claude-sonnet-4.6', label: 'Claude Sonnet 4.6', group: 'OpenRouter' }, + { id: 'openrouter/anthropic/claude-opus-4.7', label: 'Claude Opus 4.7', group: 'OpenRouter' }, + { id: 'openrouter/x-ai/grok-4.3', label: 'Grok 4.3', group: 'OpenRouter' } ]; -export const DEFAULT_MODEL_ID = "gemini-3-flash-preview"; +export const DEFAULT_MODEL_ID = 'gemini-3-flash-preview'; export const ALLOWED_MODEL_IDS = new Set(MODELS.map((m) => m.id)); -const GROUP_ORDER: ModelOption["group"][] = ["Anthropic", "Google"]; +const GROUP_ORDER: ModelOption['group'][] = ['Anthropic', 'Google', 'OpenRouter']; interface Props { - value: string; - onChange: (id: string) => void; - apiKeys?: { - claudeApiKey: string | null; - geminiApiKey: string | null; - }; + value: string; + onChange: (id: string) => void; + apiKeys?: { + claudeApiKey: string | null; + geminiApiKey: string | null; + openrouterApiKey: string | null; + }; } export function ModelToggle({ value, onChange, apiKeys }: Props) { - const [isOpen, setIsOpen] = useState(false); - const selected = MODELS.find((m) => m.id === value); - const selectedLabel = selected?.label ?? "Model"; - const selectedAvailable = apiKeys - ? isModelAvailable(value, apiKeys) - : true; + const [isOpen, setIsOpen] = useState(false); + const selected = MODELS.find((m) => m.id === value); + const selectedLabel = selected?.label ?? 'Model'; + const selectedAvailable = apiKeys ? isModelAvailable(value, apiKeys) : true; - return ( - - - - - - {GROUP_ORDER.map((group, gi) => { - const items = MODELS.filter((m) => m.group === group); - if (items.length === 0) return null; - return ( -
- {gi > 0 && } - - {group} - - {items.map((m) => { - const available = apiKeys - ? isModelAvailable(m.id, apiKeys) - : true; - return ( - onChange(m.id)} - > - - {m.label} - - {!available && ( - - )} - {m.id === value && available && ( - - )} - - ); - })} -
- ); - })} -
-
- ); + return ( + + + + + + {GROUP_ORDER.map((group, gi) => { + const items = MODELS.filter((m) => m.group === group); + if (items.length === 0) return null; + return ( +
+ {gi > 0 && } + {group} + {items.map((m) => { + const available = apiKeys ? isModelAvailable(m.id, apiKeys) : true; + return ( + onChange(m.id)}> + {m.label} + {!available && } + {m.id === value && available && } + + ); + })} +
+ ); + })} +
+
+ ); } diff --git a/frontend/src/app/components/tabular/TRChatPanel.tsx b/frontend/src/app/components/tabular/TRChatPanel.tsx index 3522df3a..fa4e4755 100644 --- a/frontend/src/app/components/tabular/TRChatPanel.tsx +++ b/frontend/src/app/components/tabular/TRChatPanel.tsx @@ -453,7 +453,7 @@ function TRChatInput({ onCancel: () => void; model: string; onModelChange: (id: string) => void; - apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null }; + apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null; openrouterApiKey: string | null }; }) { const [value, setValue] = useState(""); const textareaRef = useRef(null); @@ -610,6 +610,7 @@ export function TRChatPanel({ const apiKeys = { claudeApiKey: profile?.claudeApiKey ?? null, geminiApiKey: profile?.geminiApiKey ?? null, + openrouterApiKey: profile?.openrouterApiKey ?? null, }; const currentModel = profile?.tabularModel ?? "gemini-3-flash-preview"; const [apiKeyModalProvider, setApiKeyModalProvider] = diff --git a/frontend/src/app/lib/modelAvailability.ts b/frontend/src/app/lib/modelAvailability.ts index 933a8c2d..285d8fb5 100644 --- a/frontend/src/app/lib/modelAvailability.ts +++ b/frontend/src/app/lib/modelAvailability.ts @@ -1,39 +1,49 @@ import { MODELS, type ModelOption } from "../components/assistant/ModelToggle"; -export type ModelProvider = "claude" | "gemini"; +export type ModelProvider = "claude" | "gemini" | "openrouter"; export function getModelProvider(modelId: string): ModelProvider | null { const model = MODELS.find((m) => m.id === modelId); if (!model) return null; - return model.group === "Anthropic" ? "claude" : "gemini"; + if (model.group === "Anthropic") return "claude"; + if (model.group === "Google") return "gemini"; + if (model.group === "OpenRouter") return "openrouter"; + return null; } export function isModelAvailable( modelId: string, - apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null }, + apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null; openrouterApiKey?: string | null }, ): boolean { const provider = getModelProvider(modelId); if (!provider) return false; - return provider === "claude" - ? !!apiKeys.claudeApiKey?.trim() - : !!apiKeys.geminiApiKey?.trim(); + if (provider === "claude") return !!apiKeys.claudeApiKey?.trim(); + if (provider === "gemini") return !!apiKeys.geminiApiKey?.trim(); + if (provider === "openrouter") return !!apiKeys.openrouterApiKey?.trim(); + return false; } export function isProviderAvailable( provider: ModelProvider, - apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null }, + apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null; openrouterApiKey?: string | null }, ): boolean { - return provider === "claude" - ? !!apiKeys.claudeApiKey?.trim() - : !!apiKeys.geminiApiKey?.trim(); + if (provider === "claude") return !!apiKeys.claudeApiKey?.trim(); + if (provider === "gemini") return !!apiKeys.geminiApiKey?.trim(); + if (provider === "openrouter") return !!apiKeys.openrouterApiKey?.trim(); + return false; } export function providerLabel(provider: ModelProvider): string { - return provider === "claude" ? "Anthropic (Claude)" : "Google (Gemini)"; + if (provider === "claude") return "Anthropic (Claude)"; + if (provider === "gemini") return "Google (Gemini)"; + if (provider === "openrouter") return "OpenRouter"; + return ""; } export function modelGroupToProvider( group: ModelOption["group"], ): ModelProvider { - return group === "Anthropic" ? "claude" : "gemini"; + if (group === "Anthropic") return "claude"; + if (group === "Google") return "gemini"; + return "openrouter"; } diff --git a/frontend/src/contexts/UserProfileContext.tsx b/frontend/src/contexts/UserProfileContext.tsx index 12061076..c9bc2a60 100644 --- a/frontend/src/contexts/UserProfileContext.tsx +++ b/frontend/src/contexts/UserProfileContext.tsx @@ -21,6 +21,7 @@ interface UserProfile { tabularModel: string; claudeApiKey: string | null; geminiApiKey: string | null; + openrouterApiKey: string | null; } interface UserProfileContextType { @@ -33,7 +34,7 @@ interface UserProfileContextType { value: string, ) => Promise; updateApiKey: ( - provider: "claude" | "gemini", + provider: "claude" | "gemini" | "openrouter", value: string | null, ) => Promise; reloadProfile: () => Promise; @@ -77,6 +78,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { tabularModel: "gemini-3-flash-preview", claudeApiKey: null, geminiApiKey: null, + openrouterApiKey: null, }); return; } @@ -111,6 +113,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { data.tabular_model || "gemini-3-flash-preview", claudeApiKey: data.claude_api_key ?? null, geminiApiKey: data.gemini_api_key ?? null, + openrouterApiKey: data.openrouter_api_key ?? null, }); // 2. Update database in background if needed @@ -148,6 +151,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { tabularModel: "gemini-3-flash-preview", claudeApiKey: null, geminiApiKey: null, + openrouterApiKey: null, }); } finally { setLoading(false); @@ -245,14 +249,22 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { const updateApiKey = useCallback( async ( - provider: "claude" | "gemini", + provider: "claude" | "gemini" | "openrouter", value: string | null, ): Promise => { if (!user) return false; - const dbField = - provider === "claude" ? "claude_api_key" : "gemini_api_key"; - const stateField = - provider === "claude" ? "claudeApiKey" : "geminiApiKey"; + const dbFieldMap: Record = { + claude: "claude_api_key", + gemini: "gemini_api_key", + openrouter: "openrouter_api_key", + }; + const stateFieldMap: Record = { + claude: "claudeApiKey", + gemini: "geminiApiKey", + openrouter: "openrouterApiKey", + }; + const dbField = dbFieldMap[provider]; + const stateField = stateFieldMap[provider]; const normalized = value?.trim() ? value.trim() : null; try { const { error } = await supabase