diff --git a/app/src/components/settings/panels/AIPanel.tsx b/app/src/components/settings/panels/AIPanel.tsx index 05fb6d91a6..fba97450e6 100644 --- a/app/src/components/settings/panels/AIPanel.tsx +++ b/app/src/components/settings/panels/AIPanel.tsx @@ -33,6 +33,9 @@ import { type CreditTransaction, type TeamUsage, } from '../../../services/api/creditsApi'; +import { callCoreRpc } from '../../../services/coreRpcClient'; +import { openUrl } from '../../../utils/openUrl'; +import { isTauri } from '../../../utils/tauriCommands/common'; import { type AuthStyle, openhumanUpdateLocalAiSettings, @@ -89,6 +92,8 @@ type Workload = { id: WorkloadId; group: WorkloadGroup; label: string; descripti type RoutingMap = Record; +type OpenAiOAuthStatus = { connected: boolean; authMethod?: string | null }; + // ───────────────────────────────────────────────────────────────────────────── // Static catalog // ───────────────────────────────────────────────────────────────────────────── @@ -199,11 +204,42 @@ const EMPTY_ROUTING: RoutingMap = { }; const EMPTY_SETTINGS: AISettings = { cloudProviders: [], routing: EMPTY_ROUTING }; +const OPENAI_OAUTH_CONNECTED_LABEL = 'Connected with ChatGPT'; +const OPENAI_OAUTH_CONNECT_LABEL = 'Sign in with ChatGPT'; +const OPENAI_OAUTH_CALLBACK_PLACEHOLDER = 'http://127.0.0.1:1455/auth/callback?code=...&state=...'; function maskKeyLabel(hasKey: boolean): string { return hasKey ? '•••• configured' : 'Not configured'; } +function toFlushableProviders(providers: CloudProvider[]) { + return providers + .filter(p => !['', 'cloud', 'openhuman', 'pid'].includes(p.slug)) + .map(p => ({ + id: p.id, + slug: p.slug, + label: p.label, + endpoint: p.endpoint, + auth_style: p.authStyle, + })); +} + +function withOpenAiOAuthProvider(settings: AISettings): AISettings { + const existing = settings.cloudProviders.find(p => p.slug === 'openai'); + const openai: CloudProvider = { + id: existing?.id ?? 'p_openai_oauth', + slug: 'openai', + label: existing?.label ?? BUILTIN_PROVIDER_META.openai.label, + endpoint: defaultEndpointFor('openai'), + authStyle: authStyleForSlug('openai'), + maskedKey: OPENAI_OAUTH_CONNECTED_LABEL, + }; + const cloudProviders = existing + ? settings.cloudProviders.map(p => (p.id === existing.id ? openai : p)) + : [...settings.cloudProviders, openai]; + return { ...settings, cloudProviders }; +} + /** * Default auth style for a slug. Built-in slugs map to their known styles; * everything else (custom + third-party slugs the user types in) defaults @@ -512,6 +548,84 @@ const ProviderToggleChip = ({ ); }; +const OpenAiOAuthControls = ({ + connected, + busy, + awaitingCallback, + callbackUrl, + error, + onStart, + onComplete, + onCallbackChange, +}: { + connected: boolean; + busy: boolean; + awaitingCallback: boolean; + callbackUrl: string; + error: string | null; + onStart: () => void; + onComplete: () => void; + onCallbackChange: (value: string) => void; +}) => ( +
+
+
+
+ ChatGPT OAuth +
+

+ Use your ChatGPT/Codex sign-in for OpenAI routing. No API key required. +

+
+ {connected ? ( + + {OPENAI_OAUTH_CONNECTED_LABEL} + + ) : ( + + )} +
+ + {awaitingCallback && !connected ? ( +
+ + onCallbackChange(e.target.value)} + className="rounded-lg border border-emerald-300 dark:border-emerald-500/40 bg-white dark:bg-neutral-900 px-3 py-2 text-xs text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500 disabled:opacity-60" + /> + +
+ ) : null} + + {error ? ( +

{error}

+ ) : null} +
+); + // Connect-provider dialog — shown when the user flips a provider toggle ON. // // Two modes: @@ -2001,6 +2115,90 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => { // need to remember which label to attach to the upserted provider so the // chip can find it again. Cleared when the dialog closes. const [pendingLocalLabel, setPendingLocalLabel] = useState(null); + const [openAiOAuthConnected, setOpenAiOAuthConnected] = useState(false); + const [openAiOAuthBusy, setOpenAiOAuthBusy] = useState(false); + const [openAiOAuthAwaitingCallback, setOpenAiOAuthAwaitingCallback] = useState(false); + const [openAiOAuthCallbackUrl, setOpenAiOAuthCallbackUrl] = useState(''); + const [openAiOAuthError, setOpenAiOAuthError] = useState(null); + + const refreshOpenAiOAuthStatus = useCallback(async () => { + if (!isTauri()) { + return; + } + try { + const res = await callCoreRpc<{ result: OpenAiOAuthStatus }>({ + method: 'openhuman.inference_openai_oauth_status', + params: {}, + }); + const connected = Boolean(res?.result?.connected); + setOpenAiOAuthConnected(connected); + if (connected) { + setDraft(current => withOpenAiOAuthProvider(current)); + } + } catch (err) { + console.debug('[ai-settings] OpenAI OAuth status check failed', err); + } + }, [setDraft]); + + useEffect(() => { + void refreshOpenAiOAuthStatus(); + }, [refreshOpenAiOAuthStatus]); + + const handleOpenAiOAuthStart = async () => { + if (!isTauri()) { + setOpenAiOAuthError('ChatGPT sign-in is only available in the desktop app.'); + return; + } + setOpenAiOAuthBusy(true); + setOpenAiOAuthError(null); + try { + const res = await callCoreRpc<{ result: { authUrl: string } }>({ + method: 'openhuman.inference_openai_oauth_start', + params: {}, + }); + const authUrl = res?.result?.authUrl?.trim(); + if (!authUrl) { + throw new Error('missing authUrl'); + } + setOpenAiOAuthAwaitingCallback(true); + await openUrl(authUrl); + } catch (err) { + console.warn('[ai-settings] OpenAI OAuth start failed', err); + setOpenAiOAuthError('Could not start ChatGPT sign-in. Try again from the desktop app.'); + } finally { + setOpenAiOAuthBusy(false); + } + }; + + const handleOpenAiOAuthComplete = async () => { + const callback = openAiOAuthCallbackUrl.trim(); + if (!callback) { + setOpenAiOAuthError('Paste the redirect URL from your browser after signing in.'); + return; + } + + setOpenAiOAuthBusy(true); + setOpenAiOAuthError(null); + try { + await callCoreRpc({ + method: 'openhuman.inference_openai_oauth_complete', + params: { callback_url: callback }, + }); + const nextDraft = withOpenAiOAuthProvider(draft); + await flushCloudProviders(toFlushableProviders(nextDraft.cloudProviders)); + await persist(nextDraft); + setOpenAiOAuthCallbackUrl(''); + setOpenAiOAuthAwaitingCallback(false); + setOpenAiOAuthConnected(true); + } catch (err) { + console.warn('[ai-settings] OpenAI OAuth complete failed', err); + setOpenAiOAuthError( + 'ChatGPT sign-in did not complete. Check the redirect URL and try again.' + ); + } finally { + setOpenAiOAuthBusy(false); + } + }; const updateRouting = (id: WorkloadId, next: ProviderRef) => setDraft({ ...draft, routing: { ...draft.routing, [id]: next } }); @@ -2163,6 +2361,20 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => { ); })} + + void handleOpenAiOAuthStart()} + onComplete={() => void handleOpenAiOAuthComplete()} + onCallbackChange={value => { + setOpenAiOAuthCallbackUrl(value); + setOpenAiOAuthError(null); + }} + /> {/* end of Auth section */} diff --git a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx index 6479aa2d6a..fb38d2fc53 100644 --- a/app/src/components/settings/panels/__tests__/AIPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/AIPanel.test.tsx @@ -3,13 +3,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { listConnections as listComposioConnections } from '../../../../lib/composio/composioApi'; import { + flushCloudProviders, loadAISettings, loadLocalProviderSnapshot, saveAISettings, setCloudProviderKey, } from '../../../../services/api/aiSettingsApi'; import { creditsApi } from '../../../../services/api/creditsApi'; +import { callCoreRpc } from '../../../../services/coreRpcClient'; import { renderWithProviders } from '../../../../test/test-utils'; +import { openUrl } from '../../../../utils/openUrl'; +import { isTauri } from '../../../../utils/tauriCommands/common'; // Lazy import so the typed mock is available to individual tests. import { openhumanUpdateLocalAiSettings as openhumanUpdateLocalAiSettingsMock } from '../../../../utils/tauriCommands/config'; import { @@ -68,6 +72,16 @@ vi.mock('../../../../services/api/creditsApi', () => ({ vi.mock('../../../../lib/composio/composioApi', () => ({ listConnections: vi.fn() })); +vi.mock('../../../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); +vi.mock('../../../../utils/openUrl', () => ({ openUrl: vi.fn() })); + +vi.mock('../../../../utils/tauriCommands/common', async () => { + const actual = await vi.importActual( + '../../../../utils/tauriCommands/common' + ); + return { ...actual, isTauri: vi.fn(() => true) }; +}); + // The Ollama / LM Studio toggle persists `local_ai.base_url` via this command. // Mock it so tests can assert the call shape without crossing into Tauri IPC. vi.mock('../../../../utils/tauriCommands/config', async () => { @@ -214,6 +228,22 @@ describe('AIPanel', () => { total: baseTransactions.length, }); vi.mocked(listComposioConnections).mockResolvedValue({ connections: baseConnections }); + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(openUrl).mockResolvedValue(undefined); + vi.mocked(callCoreRpc).mockImplementation( + async ({ method }: { method: string; params?: unknown }) => { + if (method === 'openhuman.inference_openai_oauth_status') { + return { result: { connected: false, authMethod: null } }; + } + if (method === 'openhuman.inference_openai_oauth_start') { + return { result: { authUrl: 'https://auth.openai.test/authorize' } }; + } + if (method === 'openhuman.inference_openai_oauth_complete') { + return { result: { connected: true, authMethod: 'oauth' } }; + } + return { result: {} }; + } + ); }); it('renders the LLM Providers + Routing top-level section headers', async () => { @@ -340,6 +370,63 @@ describe('AIPanel', () => { expect(screen.getByLabelText(/API key/i)).toBeInTheDocument(); }); + it('connects OpenAI through ChatGPT OAuth without storing an API key', async () => { + vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] }); + + renderWithProviders(); + + const oauthButton = await screen.findByRole('button', { name: /Sign in with ChatGPT/i }); + fireEvent.click(oauthButton); + + await waitFor(() => + expect(vi.mocked(callCoreRpc)).toHaveBeenCalledWith({ + method: 'openhuman.inference_openai_oauth_start', + params: {}, + }) + ); + expect(vi.mocked(openUrl)).toHaveBeenCalledWith('https://auth.openai.test/authorize'); + + fireEvent.change(screen.getByLabelText(/ChatGPT redirect URL/i), { + target: { value: 'http://127.0.0.1:1455/auth/callback?code=abc&state=xyz' }, + }); + fireEvent.click(screen.getByRole('button', { name: /Finish ChatGPT sign-in/i })); + + await waitFor(() => + expect(vi.mocked(callCoreRpc)).toHaveBeenCalledWith({ + method: 'openhuman.inference_openai_oauth_complete', + params: { callback_url: 'http://127.0.0.1:1455/auth/callback?code=abc&state=xyz' }, + }) + ); + + await waitFor(() => + expect(screen.getByRole('switch', { name: /Disconnect OpenAI/i })).toBeInTheDocument() + ); + expect(screen.getByText(/Connected with ChatGPT/i)).toBeInTheDocument(); + await waitFor(() => expect(vi.mocked(saveAISettings)).toHaveBeenCalled()); + const [, nextSettings] = vi.mocked(saveAISettings).mock.calls[0]; + expect(nextSettings.cloudProviders).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + slug: 'openai', + endpoint: 'https://api.openai.com/v1', + auth_style: 'bearer', + has_api_key: false, + }), + ]) + ); + expect(screen.queryByText(/unsaved change/i)).not.toBeInTheDocument(); + expect(vi.mocked(setCloudProviderKey)).not.toHaveBeenCalledWith('openai', expect.any(String)); + expect(vi.mocked(flushCloudProviders)).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + slug: 'openai', + endpoint: 'https://api.openai.com/v1', + auth_style: 'bearer', + }), + ]) + ); + }); + it('clicking the Custom chip (when disabled) opens the CloudProviderEditor, not the key dialog', async () => { // Load with no custom provider → chip is off. vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] }); diff --git a/app/src/services/api/__tests__/aiSettingsApi.test.ts b/app/src/services/api/__tests__/aiSettingsApi.test.ts index 0253ea9c41..9b81977a44 100644 --- a/app/src/services/api/__tests__/aiSettingsApi.test.ts +++ b/app/src/services/api/__tests__/aiSettingsApi.test.ts @@ -91,8 +91,12 @@ function makeClientConfigResult(overrides: Record = {}) { }; } -function makeAuthProfileResult(profiles: Array<{ id: string; provider: string }> = []) { - return { result: profiles.map(p => ({ ...p, profile_name: 'default', kind: 'token' })) }; +function makeAuthProfileResult( + profiles: Array<{ id: string; provider: string; kind?: 'token' | 'oauth' }> = [] +) { + return { + result: profiles.map(p => ({ ...p, profile_name: 'default', kind: p.kind ?? 'token' })), + }; } // ─── parseProviderString ───────────────────────────────────────────────────── @@ -345,6 +349,28 @@ describe('loadAISettings', () => { expect(settings.cloudProviders[0].has_api_key).toBe(true); }); + it('does not report OAuth profiles as API keys', async () => { + mockOpenhumanGetClientConfig.mockResolvedValue( + makeClientConfigResult({ + cloud_providers: [ + { + id: 'p_openai_oauth', + slug: 'openai', + label: 'OpenAI', + endpoint: 'https://api.openai.com/v1', + auth_style: 'bearer', + }, + ], + }) + ); + mockAuthListProviderCredentials.mockResolvedValue( + makeAuthProfileResult([{ id: 'prof-oauth', provider: 'provider:openai', kind: 'oauth' }]) + ); + + const settings = await loadAISettings(); + expect(settings.cloudProviders[0].has_api_key).toBe(false); + }); + it('parses non-default per-workload routing strings correctly', async () => { mockOpenhumanGetClientConfig.mockResolvedValue( makeClientConfigResult({ diff --git a/app/src/services/api/aiSettingsApi.ts b/app/src/services/api/aiSettingsApi.ts index e801641c2d..2cbbe72e82 100644 --- a/app/src/services/api/aiSettingsApi.ts +++ b/app/src/services/api/aiSettingsApi.ts @@ -193,7 +193,9 @@ export async function loadAISettings(): Promise { // Build a set of stored provider keys for has_api_key derivation. // Supports both new-style `provider:` and legacy bare ``. const profileProviders = new Set( - profilesRes.result.map((p: AuthProfileSummary) => p.provider.toLowerCase()) + profilesRes.result + .filter((p: AuthProfileSummary) => p.kind === 'token') + .map((p: AuthProfileSummary) => p.provider.toLowerCase()) ); const cloudProviders: CloudProviderView[] = config.cloud_providers