diff --git a/AGENTS.md b/AGENTS.md index 5ba13ce0..ecb0cd7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,6 @@ ### General - DRY principles, follow existing patterns -- ./opencode-src/ is reference only, never commit +- ./temp/opencode is reference only, never commit has opencode src - Use shared types from workspace package - OpenCode server runs on port 5551, backend API on port 5001 diff --git a/README.md b/README.md index 3b687c0c..c2b20e38 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ A full-stack web application for running [OpenCode](https://github.com/sst/openc ### AI Model & Provider Configuration - **Model Selection** - Browse and select from available AI models with filtering -- **Provider Management** - Configure multiple AI providers with API keys +- **Provider Management** - Configure multiple AI providers with API keys or OAuth +- **OAuth Authentication** - Secure OAuth login for supported providers (Anthropic, GitHub Copilot) - **Context Usage Indicator** - Visual progress bar showing token usage - **Agent Configuration** - Create custom agents with system prompts and tool permissions @@ -138,4 +139,52 @@ cp .env.example .env npm run dev ``` +## OAuth Provider Setup + +OpenCode WebUI supports OAuth authentication for select providers, offering a more secure and convenient alternative to API keys. + +### Supported Providers + +- **Anthropic (Claude)** - OAuth login with Claude Pro/Max accounts +- **GitHub Copilot** - OAuth device flow authentication + +### OAuth vs API Keys + +| Feature | OAuth | API Keys | +|---------|-------|----------| +| **Security** | High (no manual key handling) | Medium (requires secure storage) | +| **Setup** | One-time authorization flow | Manual key entry | +| **Refresh** | Automatic token refresh | Manual key rotation | +| **Expiration** | Handled automatically | Keys don't expire | + +### Setting Up OAuth + +1. **Navigate to Settings → Provider Credentials** +2. **Select a provider** that shows the "OAuth" badge +3. **Click "Add OAuth"** to start the authorization flow +4. **Choose authentication method:** + - **"Open Authorization Page"** - Opens browser for sign-in + - **"Use Authorization Code"** - Provides code for manual entry +5. **Complete authorization** in the browser or enter the provided code +6. **Connection status** will show as "Configured" when successful + +### OAuth Flow Types + +- **Auto Flow** (GitHub Copilot): Opens browser window, automatic completion +- **Code Flow** (Anthropic): Requires manual code entry from authorization page + +### Troubleshooting OAuth + +- **"Invalid authorization code"**: Start the OAuth flow again +- **"Access denied"**: Check provider permissions and try again +- **"Code expired"**: Authorization codes expire quickly, restart the flow +- **"Server error"**: Check internet connection and try again later + +### Token Management + +- OAuth tokens are stored securely in your workspace +- Tokens automatically refresh when expired +- Use "Update OAuth" to re-authorize if needed +- API keys can still be used alongside OAuth for the same provider + diff --git a/backend/src/index.ts b/backend/src/index.ts index 21b37830..2f387a0e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,6 +9,7 @@ import { createHealthRoutes } from './routes/health' import { createTTSRoutes, cleanupExpiredCache } from './routes/tts' import { createFileRoutes } from './routes/files' import { createProvidersRoutes } from './routes/providers' +import { createOAuthRoutes } from './routes/oauth' import { ensureDirectoryExists, writeFileContent } from './services/file-operations' import { SettingsService } from './services/settings' import { opencodeServerManager } from './services/opencode-single-server' @@ -147,6 +148,7 @@ app.route('/api/settings', createSettingsRoutes(db)) app.route('/api/health', createHealthRoutes(db)) app.route('/api/files', createFileRoutes(db)) app.route('/api/providers', createProvidersRoutes()) +app.route('/api/oauth', createOAuthRoutes()) app.route('/api/tts', createTTSRoutes(db)) app.all('/api/opencode/*', async (c) => { diff --git a/backend/src/routes/oauth.ts b/backend/src/routes/oauth.ts new file mode 100644 index 00000000..c2c10ae1 --- /dev/null +++ b/backend/src/routes/oauth.ts @@ -0,0 +1,120 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import { proxyRequest } from '../services/proxy' +import { logger } from '../utils/logger' +import { ENV } from '@opencode-webui/shared' +import { + OAuthAuthorizeRequestSchema, + OAuthAuthorizeResponseSchema, + OAuthCallbackRequestSchema +} from '../../../shared/src/schemas/auth' + +const OPENCODE_SERVER_URL = `http://${ENV.OPENCODE.HOST}:${ENV.OPENCODE.PORT}` + +export function createOAuthRoutes() { + const app = new Hono() + + app.post('/:id/oauth/authorize', async (c) => { + try { + const providerId = c.req.param('id') + const body = await c.req.json() + const validated = OAuthAuthorizeRequestSchema.parse(body) + + // Proxy to OpenCode server + const response = await proxyRequest( + new Request( + `${OPENCODE_SERVER_URL}/provider/${providerId}/oauth/authorize`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validated) + } + ) + ) + + if (!response.ok) { + const error = await response.text() + logger.error(`OAuth authorize failed for ${providerId}:`, error) + return c.json({ error: 'OAuth authorization failed' }, 500) + } + + const data = await response.json() + const validatedResponse = OAuthAuthorizeResponseSchema.parse(data) + + return c.json(validatedResponse) + } catch (error) { + logger.error('OAuth authorize error:', error) + if (error instanceof z.ZodError) { + return c.json({ error: 'Invalid request data', details: error.issues }, 400) + } + return c.json({ error: 'OAuth authorization failed' }, 500) + } + }) + + app.post('/:id/oauth/callback', async (c) => { + try { + const providerId = c.req.param('id') + const body = await c.req.json() + const validated = OAuthCallbackRequestSchema.parse(body) + + // Proxy to OpenCode server + const response = await proxyRequest( + new Request( + `${OPENCODE_SERVER_URL}/provider/${providerId}/oauth/callback`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validated) + } + ) + ) + + if (!response.ok) { + const error = await response.text() + logger.error(`OAuth callback failed for ${providerId}:`, error) + return c.json({ error: 'OAuth callback failed' }, 500) + } + + const data = await response.json() + + return c.json(data) + } catch (error) { + logger.error('OAuth callback error:', error) + if (error instanceof z.ZodError) { + return c.json({ error: 'Invalid request data', details: error.issues }, 400) + } + return c.json({ error: 'OAuth callback failed' }, 500) + } + }) + + app.get('/auth-methods', async (c) => { + try { + // Proxy to OpenCode server + const response = await proxyRequest( + new Request(`${OPENCODE_SERVER_URL}/provider/auth`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' } + }) + ) + + if (!response.ok) { + const error = await response.text() + logger.error('Failed to get provider auth methods:', error) + return c.json({ error: 'Failed to get provider auth methods' }, 500) + } + + const data = await response.json() + + // The OpenCode server returns the format we need directly + return c.json({ providers: data }) + } catch (error) { + logger.error('Provider auth methods error:', error) + if (error instanceof z.ZodError) { + return c.json({ error: 'Invalid response data', details: error.issues }, 500) + } + return c.json({ error: 'Failed to get provider auth methods' }, 500) + } + }) + + return app +} diff --git a/backend/src/services/auth.ts b/backend/src/services/auth.ts index 33b9ca04..ff44a273 100644 --- a/backend/src/services/auth.ts +++ b/backend/src/services/auth.ts @@ -6,7 +6,6 @@ import { AuthCredentialsSchema } from '../../../shared/src/schemas/auth' import type { z } from 'zod' type AuthCredentials = z.infer -type AuthEntry = AuthCredentials[string] export class AuthService { private authPath = getAuthPath() @@ -42,7 +41,7 @@ export class AuthService { const auth = await this.getAll() delete auth[providerId] - await fs.writeFile(this.authPath, JSON.stringify(auth, null, 2)) + await fs.writeFile(this.authPath, JSON.stringify(auth, null, 2), { mode: 0o600 }) logger.info(`Deleted credentials for provider: ${providerId}`) } @@ -56,8 +55,4 @@ export class AuthService { return !!auth[providerId] } - async get(providerId: string): Promise { - const auth = await this.getAll() - return auth[providerId] || null - } } diff --git a/frontend/src/api/oauth.ts b/frontend/src/api/oauth.ts new file mode 100644 index 00000000..b4e187c3 --- /dev/null +++ b/frontend/src/api/oauth.ts @@ -0,0 +1,61 @@ +import axios from "axios" +import { API_BASE_URL } from "@/config" + +export interface OAuthAuthorizeResponse { + url: string + method: "auto" | "code" + instructions: string +} + +export interface OAuthCallbackRequest { + method: number + code?: string +} + +export interface ProviderAuthMethod { + type: "oauth" | "api" + label: string +} + +export interface ProviderAuthMethods { + [providerId: string]: ProviderAuthMethod[] +} + +function handleApiError(error: unknown, context: string): never { + if (axios.isAxiosError(error)) { + const message = error.response?.data?.error || error.message + throw new Error(`${context}: ${message}`) + } + throw error +} + +export const oauthApi = { + authorize: async (providerId: string, method: number): Promise => { + try { + const { data } = await axios.post(`${API_BASE_URL}/api/oauth/${providerId}/oauth/authorize`, { + method, + }) + return data + } catch (error) { + handleApiError(error, "OAuth authorization failed") + } + }, + + callback: async (providerId: string, request: OAuthCallbackRequest): Promise => { + try { + const { data } = await axios.post(`${API_BASE_URL}/api/oauth/${providerId}/oauth/callback`, request) + return data + } catch (error) { + handleApiError(error, "OAuth callback failed") + } + }, + + getAuthMethods: async (): Promise => { + try { + const { data } = await axios.get(`${API_BASE_URL}/api/oauth/auth-methods`) + return data.providers || data + } catch (error) { + handleApiError(error, "Failed to get provider auth methods") + } + }, +} diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 4f0f6a39..277646c7 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -1,9 +1,10 @@ import { useState, useRef, useEffect, useMemo, type KeyboardEvent } from 'react' -import { useSendPrompt, useAbortSession, useMessages, useSendShell, useConfig, useAgents } from '@/hooks/useOpenCode' +import { useSendPrompt, useAbortSession, useMessages, useSendShell, useAgents } from '@/hooks/useOpenCode' import { useSettings } from '@/hooks/useSettings' import { useCommands } from '@/hooks/useCommands' import { useCommandHandler } from '@/hooks/useCommandHandler' import { useFileSearch } from '@/hooks/useFileSearch' +import { useModelSelection } from '@/hooks/useModelSelection' import { useUserBash } from '@/stores/userBashStore' import { ChevronDown } from 'lucide-react' @@ -11,7 +12,6 @@ import { ChevronDown } from 'lucide-react' import { CommandSuggestions } from '@/components/command/CommandSuggestions' import { MentionSuggestions, type MentionItem } from './MentionSuggestions' import { detectMentionTrigger, parsePromptToParts, getFilename, filterAgentsByQuery } from '@/lib/promptParser' -import { getSessionModel } from '@/lib/model' import { getModel, formatModelName } from '@/api/providers' import type { components } from '@/api/opencode-types' import type { MessageWithParts, FileInfo } from '@/api/types' @@ -69,7 +69,6 @@ export function PromptInput({ const sendShell = useSendShell(opcodeUrl, directory) const abortSession = useAbortSession(opcodeUrl, directory, sessionID) const { data: messages } = useMessages(opcodeUrl, sessionID, directory) - const { data: config } = useConfig(opcodeUrl) const { preferences, updateSettings } = useSettings() const { filterCommands } = useCommands(opcodeUrl) const { executeCommand } = useCommandHandler({ @@ -430,20 +429,16 @@ export function PromptInput({ const modeColor = currentMode === 'plan' ? 'text-yellow-600 dark:text-yellow-500' : 'text-green-600 dark:text-green-500' const modeBg = currentMode === 'plan' ? 'bg-yellow-500/10 border-yellow-500/30' : 'bg-green-500/10 border-green-500/30' - const currentModel = getSessionModel(messages, config?.model) || '' + const { model: selectedModel, modelString } = useModelSelection(opcodeUrl, directory) + const currentModel = modelString || '' useEffect(() => { const loadModelName = async () => { - if (currentModel) { + if (selectedModel) { try { - const [providerId, modelId] = currentModel.split('/') - if (providerId && modelId) { - const model = await getModel(providerId, modelId) - if (model) { - setModelName(formatModelName(model)) - } else { - setModelName(currentModel) - } + const model = await getModel(selectedModel.providerID, selectedModel.modelID) + if (model) { + setModelName(formatModelName(model)) } else { setModelName(currentModel) } @@ -456,7 +451,7 @@ export function PromptInput({ } loadModelName() - }, [currentModel]) + }, [selectedModel, currentModel]) useEffect(() => { if (textareaRef.current && !disabled && !hasActiveStream) { diff --git a/frontend/src/components/model/ModelSelectDialog.tsx b/frontend/src/components/model/ModelSelectDialog.tsx index 2f88d6ff..3b095d68 100644 --- a/frontend/src/components/model/ModelSelectDialog.tsx +++ b/frontend/src/components/model/ModelSelectDialog.tsx @@ -15,9 +15,7 @@ import { formatModelName, formatProviderName, } from "@/api/providers"; -import { useSettings } from "@/hooks/useSettings"; -import { useOpenCodeClient } from "@/hooks/useOpenCode"; -import { useParams } from "react-router-dom"; +import { useModelSelection } from "@/hooks/useModelSelection"; import { useQuery } from "@tanstack/react-query"; import type { Model, ProviderWithModels } from "@/api/providers"; @@ -25,7 +23,7 @@ interface ModelSelectDialogProps { open: boolean; onOpenChange: (open: boolean) => void; opcodeUrl?: string | null; - currentSessionModel?: string | null; + directory?: string; } interface FlatModel { @@ -181,13 +179,17 @@ interface ModelGridProps { currentModel: string; onSelect: (providerId: string, modelId: string) => void; loading: boolean; + recentModels?: FlatModel[]; + showRecent?: boolean; } const ModelGrid = memo(function ModelGrid({ models, currentModel, onSelect, - loading + loading, + recentModels = [], + showRecent = false, }: ModelGridProps) { if (loading) { return ( @@ -197,7 +199,7 @@ const ModelGrid = memo(function ModelGrid({ ); } - if (models.length === 0) { + if (models.length === 0 && recentModels.length === 0) { return (
No models found @@ -206,17 +208,49 @@ const ModelGrid = memo(function ModelGrid({ } return ( -
- {models.map(({ model, provider, modelKey }) => ( - - ))} +
+ {showRecent && recentModels.length > 0 && ( +
+

+ + Recent Models +

+
+ {recentModels.map(({ model, provider, modelKey }) => ( + + ))} +
+
+ )} + + {models.length > 0 && ( +
+ {showRecent && recentModels.length > 0 && ( +

+ All Models +

+ )} +
+ {models.map(({ model, provider, modelKey }) => ( + + ))} +
+
+ )}
); }); @@ -322,15 +356,13 @@ export function ModelSelectDialog({ open, onOpenChange, opcodeUrl, - currentSessionModel, + directory, }: ModelSelectDialogProps) { const [searchQuery, setSearchQuery] = useState(""); const [selectedProvider, setSelectedProvider] = useState(""); - const { preferences, updateSettings } = useSettings(); - const client = useOpenCodeClient(opcodeUrl); - const { sessionID } = useParams<{ sessionID: string }>(); - const currentModel = currentSessionModel || preferences?.defaultModel || ""; + const { modelString, setModel, recentModels } = useModelSelection(opcodeUrl, directory); + const currentModel = modelString || ""; const { data: providers = [], isLoading: loading } = useQuery({ queryKey: ["providers-with-models"], @@ -341,22 +373,35 @@ export function ModelSelectDialog({ }); useEffect(() => { - if (currentModel && providers.length > 0) { - const [providerId] = currentModel.split("/"); - setSelectedProvider(providerId); + if (open) { + setSelectedProvider(""); } - }, [currentModel, providers]); + }, [open]); const flatModels = useMemo((): FlatModel[] => { - return providers.flatMap((provider) => - provider.models.map((model) => ({ - model, - provider, - modelKey: `${provider.id}/${model.id}`, - })) - ); + const sourceOrder = { configured: 0, local: 1, builtin: 2 }; + return providers + .slice() + .sort((a, b) => (sourceOrder[a.source] ?? 2) - (sourceOrder[b.source] ?? 2)) + .flatMap((provider) => + provider.models.map((model) => ({ + model, + provider, + modelKey: `${provider.id}/${model.id}`, + })) + ); }, [providers]); + const recentFlatModels = useMemo((): FlatModel[] => { + return recentModels + .map((recent) => { + const modelKey = `${recent.providerID}/${recent.modelID}`; + return flatModels.find((fm) => fm.modelKey === modelKey); + }) + .filter((fm): fm is FlatModel => fm !== undefined) + .slice(0, 6); + }, [recentModels, flatModels]); + const filteredModels = useMemo(() => { const search = searchQuery.toLowerCase(); return flatModels.filter((item) => { @@ -393,24 +438,10 @@ export function ModelSelectDialog({ setSearchQuery(query); }, []); - const handleModelSelect = useCallback(async (providerId: string, modelId: string) => { - const newModel = `${providerId}/${modelId}`; - updateSettings({ defaultModel: newModel }); - - if (sessionID && client) { - try { - await client.sendCommand(sessionID, { - command: "model", - arguments: newModel, - model: newModel, - }); - } catch { - // Ignore errors - } - } - + const handleModelSelect = useCallback((providerId: string, modelId: string) => { + setModel({ providerID: providerId, modelID: modelId }); onOpenChange(false); - }, [sessionID, client, updateSettings, onOpenChange]); + }, [setModel, onOpenChange]); const searchResetKey = selectedProvider; @@ -491,6 +522,8 @@ export function ModelSelectDialog({ currentModel={currentModel} onSelect={handleModelSelect} loading={loading} + recentModels={recentFlatModels} + showRecent={!selectedProvider && !searchQuery} />
diff --git a/frontend/src/components/settings/AddProviderDialog.tsx b/frontend/src/components/settings/AddProviderDialog.tsx deleted file mode 100644 index b0fb8179..00000000 --- a/frontend/src/components/settings/AddProviderDialog.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { useState } from 'react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' -import { Badge } from '@/components/ui/badge' -import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' -import { Loader2, ExternalLink } from 'lucide-react' -import { PROVIDER_TEMPLATES, type ProviderTemplate } from '@/lib/providerTemplates' -import { settingsApi } from '@/api/settings' -import { useMutation, useQueryClient } from '@tanstack/react-query' - -interface AddProviderDialogProps { - open: boolean - onOpenChange: (open: boolean) => void -} - -export function AddProviderDialog({ open, onOpenChange }: AddProviderDialogProps) { - const [step, setStep] = useState<'select' | 'customize'>('select') - const [selectedTemplate, setSelectedTemplate] = useState(null) - const [providerId, setProviderId] = useState('') - const [providerName, setProviderName] = useState('') - const [baseURL, setBaseURL] = useState('') - const queryClient = useQueryClient() - - const addProviderMutation = useMutation({ - mutationFn: async () => { - const config = await settingsApi.getDefaultOpenCodeConfig() - const currentProvider = config?.content?.provider || {} - - const newProvider = { - npm: selectedTemplate?.npm || '@ai-sdk/openai-compatible', - name: providerName || selectedTemplate?.name, - ...(baseURL && { - options: { - baseURL, - }, - }), - ...(selectedTemplate?.models && { - models: selectedTemplate.models, - }), - } - - const updatedConfig = { - ...config?.content, - provider: { - ...currentProvider, - [providerId]: newProvider, - }, - } - - await settingsApi.updateOpenCodeConfig( - config?.name || 'default', - { content: updatedConfig } - ) - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['opencode-config'] }) - queryClient.invalidateQueries({ queryKey: ['providers'] }) - handleClose() - }, - }) - - const handleSelectTemplate = (template: ProviderTemplate) => { - setSelectedTemplate(template) - setProviderId(template.id) - setProviderName(template.name) - setBaseURL(template.options?.baseURL || '') - setStep('customize') - } - - const handleAdd = () => { - if (providerId && selectedTemplate) { - addProviderMutation.mutate() - } - } - - const handleClose = () => { - setStep('select') - setSelectedTemplate(null) - setProviderId('') - setProviderName('') - setBaseURL('') - onOpenChange(false) - } - - return ( - - - {step === 'select' && ( - <> - - Add Provider - - Choose a provider template to get started quickly - - - -
- {PROVIDER_TEMPLATES.map((template) => ( - handleSelectTemplate(template)} - > - -
-
- - {template.name} - {!template.requiresApiKey && ( - - Local - - )} - - - {template.description} - -

- {template.npm} -

-
-
-
-
- ))} -
- - )} - - {step === 'customize' && selectedTemplate && ( - <> - - Configure {selectedTemplate.name} - - Customize the provider settings before adding - {selectedTemplate.docsUrl && ( - - View docs - - )} - - - -
-
- - setProviderId(e.target.value)} - placeholder="e.g., anthropic, openai, my-provider" - className="bg-background border-border" - /> -

- Unique identifier for this provider (lowercase, no spaces) -

-
- -
- - setProviderName(e.target.value)} - placeholder={selectedTemplate.name} - className="bg-background border-border" - /> -
- - {selectedTemplate.options?.baseURL && ( -
- - setBaseURL(e.target.value)} - placeholder={selectedTemplate.options.baseURL} - className="bg-background border-border" - /> -

- API endpoint for this provider -

-
- )} - -
-

- NPM Package: {selectedTemplate.npm} -

- {selectedTemplate.models && ( -

- Models: {Object.keys(selectedTemplate.models).length} pre-configured -

- )} -
-
- - - - - - - )} -
-
- ) -} diff --git a/frontend/src/components/settings/OAuthAuthorizeDialog.tsx b/frontend/src/components/settings/OAuthAuthorizeDialog.tsx new file mode 100644 index 00000000..2f9db772 --- /dev/null +++ b/frontend/src/components/settings/OAuthAuthorizeDialog.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { ExternalLink, Copy } from 'lucide-react' +import { oauthApi, type OAuthAuthorizeResponse } from '@/api/oauth' +import { mapOAuthError, OAuthMethod } from '@/lib/oauthErrors' + +interface OAuthAuthorizeDialogProps { + providerId: string + providerName: string + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: (response: OAuthAuthorizeResponse) => void +} + +export function OAuthAuthorizeDialog({ + providerId, + providerName, + open, + onOpenChange, + onSuccess +}: OAuthAuthorizeDialogProps) { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const handleAuthorize = async (methodIndex: number) => { + setIsLoading(true) + setError(null) + + try { + const response = await oauthApi.authorize(providerId, methodIndex) + onSuccess(response) + } catch (err) { + setError(mapOAuthError(err, 'authorize')) + console.error('OAuth authorize error:', err) + } finally { + setIsLoading(false) + } + } + + const handleClose = () => { + setError(null) + onOpenChange(false) + } + + return ( + + + + Connect to {providerName} + + Choose an authentication method to connect your {providerName} account. + + + + {error && ( +
+

{error}

+
+ )} + +
+ + + +
+ +
+

• "Open Authorization Page" will open a browser window for you to sign in

+

• "Use Authorization Code" will give you a code to manually enter

+
+
+
+ ) +} diff --git a/frontend/src/components/settings/OAuthCallbackDialog.tsx b/frontend/src/components/settings/OAuthCallbackDialog.tsx new file mode 100644 index 00000000..d25fea94 --- /dev/null +++ b/frontend/src/components/settings/OAuthCallbackDialog.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Loader2, ExternalLink, CheckCircle } from 'lucide-react' +import { oauthApi, type OAuthAuthorizeResponse } from '@/api/oauth' +import { mapOAuthError, OAuthMethod } from '@/lib/oauthErrors' + +interface OAuthCallbackDialogProps { + providerId: string + providerName: string + authResponse: OAuthAuthorizeResponse + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +export function OAuthCallbackDialog({ + providerId, + providerName, + authResponse, + open, + onOpenChange, + onSuccess +}: OAuthCallbackDialogProps) { + const [isLoading, setIsLoading] = useState(false) + const [authCode, setAuthCode] = useState('') + const [error, setError] = useState(null) + + const handleAutoCallback = async () => { + setIsLoading(true) + setError(null) + + try { + await oauthApi.callback(providerId, { method: OAuthMethod.AUTO }) + onSuccess() + } catch (err) { + setError(mapOAuthError(err, 'callback')) + console.error('OAuth callback error:', err) + } finally { + setIsLoading(false) + } + } + + const handleCodeCallback = async () => { + if (!authCode.trim()) { + setError('Please enter the authorization code') + return + } + + setIsLoading(true) + setError(null) + + try { + await oauthApi.callback(providerId, { method: OAuthMethod.CODE, code: authCode.trim() }) + onSuccess() + } catch (err) { + setError(mapOAuthError(err, 'callback')) + console.error('OAuth callback error:', err) + } finally { + setIsLoading(false) + } + } + + const handleOpenAuthUrl = () => { + window.open(authResponse.url, '_blank') + } + + const handleClose = () => { + setError(null) + setAuthCode('') + onOpenChange(false) + } + + return ( + + + + Complete {providerName} Authentication + + {authResponse.method === 'auto' + ? 'Follow the instructions to complete authentication.' + : 'Enter the authorization code from the provider.' + } + + + + {error && ( +
+

{error}

+
+ )} + +
+ {authResponse.method === 'auto' ? ( +
+
+

{authResponse.instructions}

+
+ + + + +
+ ) : ( +
+
+

{authResponse.instructions}

+ +
+ +
+ + setAuthCode(e.target.value)} + placeholder="Enter the authorization code..." + className="bg-background border-border" + disabled={isLoading} + /> +
+ + +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/components/settings/ProviderSettings.tsx b/frontend/src/components/settings/ProviderSettings.tsx index 7b1c9fcb..3b0ba473 100644 --- a/frontend/src/components/settings/ProviderSettings.tsx +++ b/frontend/src/components/settings/ProviderSettings.tsx @@ -1,20 +1,19 @@ -import { useState } from 'react' +import { useState, useMemo, useCallback } from 'react' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Badge } from '@/components/ui/badge' -import { Loader2, Key, Check, X, Plus } from 'lucide-react' +import { Loader2, Check, X, Shield } from 'lucide-react' import { providerCredentialsApi, getProviders, type Provider } from '@/api/providers' +import { oauthApi, type OAuthAuthorizeResponse } from '@/api/oauth' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { AddProviderDialog } from './AddProviderDialog' +import { OAuthAuthorizeDialog } from './OAuthAuthorizeDialog' +import { OAuthCallbackDialog } from './OAuthCallbackDialog' export function ProviderSettings() { const [selectedProvider, setSelectedProvider] = useState(null) - const [apiKey, setApiKey] = useState('') - const [showApiKey, setShowApiKey] = useState(false) - const [addDialogOpen, setAddDialogOpen] = useState(false) + const [oauthDialogOpen, setOauthDialogOpen] = useState(false) + const [oauthCallbackDialogOpen, setOauthCallbackDialogOpen] = useState(false) + const [oauthResponse, setOauthResponse] = useState(null) const queryClient = useQueryClient() const { data: providers, isLoading: providersLoading } = useQuery({ @@ -28,14 +27,9 @@ export function ProviderSettings() { queryFn: () => providerCredentialsApi.list(), }) - const setCredentialMutation = useMutation({ - mutationFn: ({ providerId, apiKey }: { providerId: string; apiKey: string }) => - providerCredentialsApi.set(providerId, apiKey), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['provider-credentials'] }) - setSelectedProvider(null) - setApiKey('') - }, + const { data: authMethods } = useQuery({ + queryKey: ['provider-auth-methods'], + queryFn: () => oauthApi.getAuthMethods(), }) const deleteCredentialMutation = useMutation({ @@ -45,22 +39,49 @@ export function ProviderSettings() { }, }) - const handleSetCredential = () => { - if (selectedProvider && apiKey) { - setCredentialMutation.mutate({ providerId: selectedProvider, apiKey }) - } - } - const handleDeleteCredential = (providerId: string) => { if (confirm(`Remove credentials for ${providerId}?`)) { deleteCredentialMutation.mutate(providerId) } } + const handleOAuthAuthorize = (response: OAuthAuthorizeResponse) => { + setOauthResponse(response) + setOauthDialogOpen(false) + setOauthCallbackDialogOpen(true) + } + + const handleOAuthDialogClose = () => { + setOauthDialogOpen(false) + setSelectedProvider(null) + } + + const handleOAuthSuccess = () => { + queryClient.invalidateQueries({ queryKey: ['provider-credentials'] }) + setOauthCallbackDialogOpen(false) + setOauthResponse(null) + setSelectedProvider(null) + } + + const supportsOAuth = useCallback((providerId: string) => { + const methods = authMethods?.[providerId] || [] + return methods.some(method => method.type === 'oauth') + }, [authMethods]) + const hasCredentials = (providerId: string) => { return credentialsList?.includes(providerId) || false } + const oauthProviders = useMemo(() => { + if (!providers || !authMethods) return [] + return providers.filter(provider => supportsOAuth(provider.id)) + }, [providers, authMethods, supportsOAuth]) + + const selectedProviderName = useMemo(() => { + if (!selectedProvider) return '' + return providers?.find(p => p.id === selectedProvider)?.name || selectedProvider + }, [selectedProvider, providers]) + if (providersLoading || credentialsLoading) { return (
@@ -71,30 +92,24 @@ export function ProviderSettings() { return (
-
-
-

Provider Credentials

-

- Manage API keys for AI providers. Keys are stored securely in your workspace. -

-
- +
+

OAuth Providers

+

+ Connect to AI providers using OAuth. For API keys, configure them in your OpenCode config file. +

- {!providers || providers.length === 0 ? ( + {oauthProviders.length === 0 ? (

- No providers configured. Add providers in your OpenCode config. + No OAuth-capable providers available.

) : (
- {providers.map((provider) => { + {oauthProviders.map((provider) => { const hasKey = hasCredentials(provider.id) const modelCount = Object.keys(provider.models || {}).length @@ -108,12 +123,12 @@ export function ProviderSettings() { {hasKey ? ( - Configured + Connected ) : ( - No Key + Not Connected )} @@ -131,10 +146,13 @@ export function ProviderSettings() { {hasKey && ( )}
@@ -155,56 +173,26 @@ export function ProviderSettings() {
)} - !open && setSelectedProvider(null)}> - - - Set API Key for {selectedProvider} - - Enter your API key. It will be stored securely in your workspace. - - - -
-
- -
- setApiKey(e.target.value)} - placeholder="sk-..." - className="bg-background border-border pr-20" - /> - -
-
-
- - - - - -
-
- - + {selectedProvider && ( + + )} + + {selectedProvider && oauthResponse && ( + + )}
) } diff --git a/frontend/src/hooks/useContextUsage.ts b/frontend/src/hooks/useContextUsage.ts index 32784b7d..08461e5f 100644 --- a/frontend/src/hooks/useContextUsage.ts +++ b/frontend/src/hooks/useContextUsage.ts @@ -1,8 +1,7 @@ import { useMemo } from 'react' import { useMessages } from './useOpenCode' -import { useSettings } from './useSettings' import { useQuery } from '@tanstack/react-query' -import { getSessionModel } from '@/lib/model' +import { useModelSelection } from './useModelSelection' interface ContextUsage { totalTokens: number @@ -43,7 +42,7 @@ async function fetchProviders(opcodeUrl: string): Promise { export const useContextUsage = (opcodeUrl: string | null | undefined, sessionID: string | undefined, directory?: string): ContextUsage => { const { data: messages, isLoading: messagesLoading } = useMessages(opcodeUrl, sessionID, directory) - const { preferences } = useSettings() + const { modelString } = useModelSelection(opcodeUrl, directory) const { data: providersData } = useQuery({ queryKey: ['providers', opcodeUrl], @@ -53,7 +52,7 @@ export const useContextUsage = (opcodeUrl: string | null | undefined, sessionID: }) return useMemo(() => { - const currentModel = getSessionModel(messages, preferences?.defaultModel) + const currentModel = modelString || null const assistantMessages = messages?.filter(msg => msg.info.role === 'assistant') || [] let latestAssistantMessage = assistantMessages[assistantMessages.length - 1] @@ -101,5 +100,5 @@ export const useContextUsage = (opcodeUrl: string | null | undefined, sessionID: currentModel, isLoading: false } - }, [messages, messagesLoading, preferences?.defaultModel, providersData]) + }, [messages, messagesLoading, modelString, providersData]) } diff --git a/frontend/src/hooks/useModelSelection.ts b/frontend/src/hooks/useModelSelection.ts new file mode 100644 index 00000000..d5cd57f7 --- /dev/null +++ b/frontend/src/hooks/useModelSelection.ts @@ -0,0 +1,31 @@ +import { useEffect } from 'react' +import { useConfig } from './useOpenCode' +import { useModelStore, type ModelSelection } from '@/stores/modelStore' + +interface UseModelSelectionResult { + model: ModelSelection | null + modelString: string | null + recentModels: ModelSelection[] + setModel: (model: ModelSelection) => void +} + +export function useModelSelection( + opcodeUrl: string | null | undefined, + directory?: string +): UseModelSelectionResult { + const { data: config } = useConfig(opcodeUrl, directory) + const { model, recentModels, setModel, initializeFromConfig, getModelString } = useModelStore() + + useEffect(() => { + if (config?.model) { + initializeFromConfig(config.model) + } + }, [config?.model, initializeFromConfig]) + + return { + model, + modelString: getModelString(), + recentModels, + setModel, + } +} diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index a7e5a199..cbcf8378 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -440,13 +440,15 @@ export const useSendShell = (opcodeUrl: string | null | undefined, directory?: s }); }; -export const useConfig = (opcodeUrl: string | null | undefined) => { - const client = useOpenCodeClient(opcodeUrl); +export const useConfig = (opcodeUrl: string | null | undefined, directory?: string) => { + const client = useOpenCodeClient(opcodeUrl, directory); return useQuery({ - queryKey: ["opencode", "config", opcodeUrl], + queryKey: ["opencode", "config", opcodeUrl, directory], queryFn: () => client!.getConfig(), enabled: !!client, + staleTime: 0, + refetchOnWindowFocus: true, }); }; diff --git a/frontend/src/lib/model.ts b/frontend/src/lib/model.ts deleted file mode 100644 index 4f632137..00000000 --- a/frontend/src/lib/model.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { MessageWithParts } from '@/api/types' - -export function getSessionModel( - messages: MessageWithParts[] | undefined, - fallbackModel?: string | null -): string | null { - const assistantMessages = messages?.filter(msg => msg.info.role === 'assistant') || [] - let latest = assistantMessages[assistantMessages.length - 1] - - if (latest?.info.role === 'assistant') { - const tokens = latest.info.tokens.input + latest.info.tokens.output + latest.info.tokens.reasoning - if (tokens === 0 && assistantMessages.length > 1) { - latest = assistantMessages[assistantMessages.length - 2] - } - } - - if (latest?.info.role === 'assistant') { - return `${latest.info.providerID}/${latest.info.modelID}` - } - - return fallbackModel || null -} diff --git a/frontend/src/lib/oauthErrors.ts b/frontend/src/lib/oauthErrors.ts new file mode 100644 index 00000000..093cd466 --- /dev/null +++ b/frontend/src/lib/oauthErrors.ts @@ -0,0 +1,31 @@ +export const OAuthMethod = { + AUTO: 0, + CODE: 1, +} as const + +export type OAuthMethodType = (typeof OAuthMethod)[keyof typeof OAuthMethod] + +const ERROR_MAPPINGS: Record = { + 'invalid code': 'Invalid authorization code. Please try the OAuth flow again.', + 'expired': 'Authorization code has expired. Please try the OAuth flow again.', + 'access denied': 'Access was denied. Please check the permissions and try again.', + 'server error': 'Server error occurred. Please try again later.', + 'provider not found': 'Provider is not available or does not support OAuth.', + 'invalid method': 'Invalid authentication method selected.', +} + +export function mapOAuthError(err: unknown, context: 'authorize' | 'callback'): string { + const defaultMessage = context === 'authorize' + ? 'Failed to initiate OAuth authorization' + : 'Failed to complete OAuth callback' + + if (!(err instanceof Error)) return defaultMessage + + for (const [key, message] of Object.entries(ERROR_MAPPINGS)) { + if (err.message.toLowerCase().includes(key)) { + return message + } + } + + return err.message || defaultMessage +} diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 193eb633..2126f9fd 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from "react"; +import { useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { getRepo } from "@/api/repos"; @@ -10,9 +10,8 @@ import { SessionList } from "@/components/session/SessionList"; import { PermissionRequestDialog } from "@/components/session/PermissionRequestDialog"; import { FileBrowserSheet } from "@/components/file-browser/FileBrowserSheet"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; -import { useSession, useAbortSession, useUpdateSession, useOpenCodeClient, useMessages, useConfig } from "@/hooks/useOpenCode"; +import { useSession, useAbortSession, useUpdateSession, useOpenCodeClient, useMessages } from "@/hooks/useOpenCode"; import { OPENCODE_API_ENDPOINT } from "@/config"; -import { getSessionModel } from "@/lib/model"; import { useSSE } from "@/hooks/useSSE"; import { useSettings } from "@/hooks/useSettings"; import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; @@ -65,9 +64,6 @@ export function SessionDetail() { const repoDirectory = repo?.fullPath; const { data: messages, isLoading: messagesLoading } = useMessages(opcodeUrl, sessionId, repoDirectory); - const { data: config } = useConfig(opcodeUrl); - - const currentSessionModel = useMemo(() => getSessionModel(messages, config?.model), [messages, config?.model]); const { scrollToBottom } = useAutoScroll({ containerRef: messageContainerRef, @@ -261,7 +257,7 @@ export function SessionDetail() { open={modelDialogOpen} onOpenChange={setModelDialogOpen} opcodeUrl={opcodeUrl} - currentSessionModel={currentSessionModel} + directory={repoDirectory} /> {/* Sessions Dialog */} diff --git a/frontend/src/stores/modelStore.ts b/frontend/src/stores/modelStore.ts new file mode 100644 index 00000000..bb2647b5 --- /dev/null +++ b/frontend/src/stores/modelStore.ts @@ -0,0 +1,79 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +export interface ModelSelection { + providerID: string + modelID: string +} + +interface ModelStore { + model: ModelSelection | null + recentModels: ModelSelection[] + isInitialized: boolean + + setModel: (model: ModelSelection) => void + initializeFromConfig: (configModel: string | undefined) => void + getModelString: () => string | null +} + +const MAX_RECENT_MODELS = 10 + +function parseModelString(model: string): ModelSelection | null { + const [providerID, ...rest] = model.split('/') + const modelID = rest.join('/') + if (!providerID || !modelID) return null + return { providerID, modelID } +} + +export const useModelStore = create()( + persist( + (set, get) => ({ + model: null, + recentModels: [], + isInitialized: false, + + setModel: (model: ModelSelection) => { + set((state) => { + const newRecent = [ + model, + ...state.recentModels.filter( + (m) => !(m.providerID === model.providerID && m.modelID === model.modelID) + ), + ].slice(0, MAX_RECENT_MODELS) + + return { + model, + recentModels: newRecent, + } + }) + }, + + initializeFromConfig: (configModel: string | undefined) => { + const state = get() + if (state.isInitialized) return + + if (!state.model && configModel) { + const parsed = parseModelString(configModel) + if (parsed) { + set({ model: parsed, isInitialized: true }) + return + } + } + set({ isInitialized: true }) + }, + + getModelString: () => { + const { model } = get() + if (!model) return null + return `${model.providerID}/${model.modelID}` + }, + }), + { + name: 'opencode-model-selection', + partialize: (state) => ({ + model: state.model, + recentModels: state.recentModels, + }), + } + ) +) diff --git a/shared/src/schemas/auth.ts b/shared/src/schemas/auth.ts index f740f986..17e529df 100644 --- a/shared/src/schemas/auth.ts +++ b/shared/src/schemas/auth.ts @@ -21,3 +21,29 @@ export const CredentialStatusResponseSchema = z.object({ export const CredentialListResponseSchema = z.object({ providers: z.array(z.string()), }); + +export const ProviderAuthMethodSchema = z.object({ + type: z.enum(["oauth", "api"]), + label: z.string(), +}); + +export const ProviderAuthMethodsSchema = z.record(z.array(ProviderAuthMethodSchema)); + +export const OAuthAuthorizeRequestSchema = z.object({ + method: z.number(), +}); + +export const OAuthAuthorizeResponseSchema = z.object({ + url: z.string(), + method: z.enum(["auto", "code"]), + instructions: z.string(), +}); + +export const OAuthCallbackRequestSchema = z.object({ + method: z.number(), + code: z.string().optional(), +}); + +export const ProviderAuthMethodsResponseSchema = z.object({ + providers: z.record(z.array(ProviderAuthMethodSchema)), +});