Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 212 additions & 0 deletions app/src/components/settings/panels/AIPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -89,6 +92,8 @@ type Workload = { id: WorkloadId; group: WorkloadGroup; label: string; descripti

type RoutingMap = Record<WorkloadId, ProviderRef>;

type OpenAiOAuthStatus = { connected: boolean; authMethod?: string | null };

// ─────────────────────────────────────────────────────────────────────────────
// Static catalog
// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}) => (
<div className="rounded-lg border border-emerald-200 dark:border-emerald-500/30 bg-emerald-50/60 dark:bg-emerald-500/10 px-3 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-xs font-medium text-emerald-900 dark:text-emerald-100">
ChatGPT OAuth
</div>
<p className="mt-0.5 text-[11px] text-emerald-700 dark:text-emerald-300">
Use your ChatGPT/Codex sign-in for OpenAI routing. No API key required.
</p>
</div>
{connected ? (
<span className="text-xs font-medium text-emerald-800 dark:text-emerald-200">
{OPENAI_OAUTH_CONNECTED_LABEL}
</span>
) : (
<button
type="button"
disabled={busy}
onClick={onStart}
className="rounded-lg border border-emerald-500 bg-white/80 dark:bg-neutral-900/80 px-3 py-1.5 text-xs font-medium text-emerald-800 dark:text-emerald-200 hover:bg-white dark:hover:bg-neutral-900 disabled:cursor-wait disabled:opacity-60">
{busy ? 'Opening sign-in...' : OPENAI_OAUTH_CONNECT_LABEL}
</button>
)}
</div>

{awaitingCallback && !connected ? (
<div className="mt-3 flex flex-col gap-1.5">
<label
htmlFor="openai-oauth-callback-url"
className="text-xs font-medium text-emerald-900 dark:text-emerald-100">
ChatGPT redirect URL
</label>
<input
id="openai-oauth-callback-url"
type="text"
autoComplete="off"
spellCheck={false}
placeholder={OPENAI_OAUTH_CALLBACK_PLACEHOLDER}
value={callbackUrl}
disabled={busy}
onChange={e => 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"
/>
<button
type="button"
disabled={busy}
onClick={onComplete}
className="self-start text-xs font-medium text-emerald-800 dark:text-emerald-200 underline disabled:cursor-wait disabled:opacity-60">
Finish ChatGPT sign-in
</button>
</div>
) : null}

{error ? (
<p className="mt-2 text-xs font-medium text-red-600 dark:text-red-300">{error}</p>
) : null}
</div>
);

// Connect-provider dialog — shown when the user flips a provider toggle ON.
//
// Two modes:
Expand Down Expand Up @@ -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<string | null>(null);
const [openAiOAuthConnected, setOpenAiOAuthConnected] = useState(false);
const [openAiOAuthBusy, setOpenAiOAuthBusy] = useState(false);
const [openAiOAuthAwaitingCallback, setOpenAiOAuthAwaitingCallback] = useState(false);
const [openAiOAuthCallbackUrl, setOpenAiOAuthCallbackUrl] = useState('');
const [openAiOAuthError, setOpenAiOAuthError] = useState<string | null>(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 } });
Expand Down Expand Up @@ -2163,6 +2361,20 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
);
})}
</div>

<OpenAiOAuthControls
connected={openAiOAuthConnected}
busy={openAiOAuthBusy}
awaitingCallback={openAiOAuthAwaitingCallback}
callbackUrl={openAiOAuthCallbackUrl}
error={openAiOAuthError}
onStart={() => void handleOpenAiOAuthStart()}
onComplete={() => void handleOpenAiOAuthComplete()}
onCallbackChange={value => {
setOpenAiOAuthCallbackUrl(value);
setOpenAiOAuthError(null);
}}
/>
</section>
</div>
{/* end of Auth section */}
Expand Down
87 changes: 87 additions & 0 deletions app/src/components/settings/panels/__tests__/AIPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<typeof import('../../../../utils/tauriCommands/common')>(
'../../../../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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(<AIPanel />);

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: [] });
Expand Down
Loading
Loading