fix(ai): add OpenRouter OAuth provider flow (#2571)

This commit is contained in:
Steven Enamakel
2026-05-24 10:01:58 -07:00
committed by GitHub
parent e9ca97c48c
commit d52abe5524
4 changed files with 464 additions and 111 deletions
+169 -111
View File
@@ -36,6 +36,7 @@ import {
type CreditTransaction,
type TeamUsage,
} from '../../../services/api/creditsApi';
import { connectOpenRouterViaOAuth } from '../../../utils/openrouterOAuth';
import {
type AuthStyle,
openhumanUpdateLocalAiSettings,
@@ -530,6 +531,7 @@ const ProviderKeyDialog = ({
slug,
label,
isLocalRuntime,
oauthAction,
onCancel,
onSubmit,
}: {
@@ -537,6 +539,7 @@ const ProviderKeyDialog = ({
label: string;
/** When true, render an "Endpoint URL" field instead of API key. */
isLocalRuntime: boolean;
oauthAction?: { label: string; onClick: () => Promise<void> | void } | null;
onCancel: () => void;
/** Returns the entered value. For local runtimes this is the endpoint URL;
* for cloud providers it's the API key. */
@@ -544,7 +547,7 @@ const ProviderKeyDialog = ({
}) => {
const { t } = useT();
const [value, setValue] = useState<string>(isLocalRuntime ? defaultEndpointFor(slug) : '');
const [phase, setPhase] = useState<'idle' | 'saving'>('idle');
const [phase, setPhase] = useState<'idle' | 'saving' | 'oauth'>('idle');
const [error, setError] = useState<string | null>(null);
const busy = phase !== 'idle';
@@ -592,6 +595,23 @@ const ProviderKeyDialog = ({
}
};
const handleOAuth = async () => {
if (!oauthAction) return;
setError(null);
setPhase('oauth');
try {
await oauthAction.onClick();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn('[ai-settings] provider oauth failed', {
slug,
summary: presentProviderSetupError(message).summary,
});
setError(message);
setPhase('idle');
}
};
return (
<div
role="dialog"
@@ -632,6 +652,24 @@ const ProviderKeyDialog = ({
{error ? <ProviderSetupErrorNotice error={error} /> : null}
</div>
{oauthAction ? (
<div className="mt-4 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/50 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
Or
</div>
<p className="mt-1 text-xs text-stone-500 dark:text-neutral-400">
Sign in with OpenRouter and import a user-controlled API key using PKCE.
</p>
<button
type="button"
onClick={() => void handleOAuth()}
disabled={busy}
className="mt-3 inline-flex items-center justify-center rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm font-medium text-stone-900 dark:text-neutral-100 hover:bg-stone-100 dark:hover:bg-neutral-800 disabled:cursor-not-allowed disabled:opacity-50">
{phase === 'oauth' ? 'Connecting...' : oauthAction.label}
</button>
</div>
) : null}
<div className="mt-6 flex justify-end gap-2">
<button
type="button"
@@ -2041,10 +2079,107 @@ 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 openRouterOauthAbortRef = useRef<AbortController | null>(null);
const updateRouting = (id: WorkloadId, next: ProviderRef) =>
setDraft({ ...draft, routing: { ...draft.routing, [id]: next } });
const connectProvider = useCallback(
async ({
slug,
localLabel = null,
value,
credentialMode,
}: {
slug: string;
localLabel?: string | null;
value: string;
credentialMode: 'api_key' | 'oauth' | 'endpoint';
}) => {
const isLocalRuntime = credentialMode === 'endpoint';
setBusyAction(`toggle-${localLabel ? localLabel.toLowerCase().replace(/\s/g, '') : slug}`);
try {
const trimmed = value.trim();
const endpoint = isLocalRuntime
? (() => {
const url = new URL(trimmed);
if (!/^https?:$/.test(url.protocol)) {
throw new Error('Endpoint must start with http:// or https://');
}
if (url.pathname === '' || url.pathname === '/') {
url.pathname = '/v1';
}
return url.toString().replace(/\/$/, '');
})()
: defaultEndpointFor(slug);
const upserted: CloudProvider = {
id: `p_${slug}_${Math.random().toString(36).slice(2, 7)}`,
slug,
label: localLabel ?? BUILTIN_PROVIDER_META[slug]?.label ?? slug,
endpoint,
authStyle: authStyleForSlug(slug),
maskedKey: maskKeyLabel(true),
};
const priorWireProviders = saved.cloudProviders.map(p => ({
id: p.id,
slug: p.slug,
label: p.label,
endpoint: p.endpoint,
auth_style: p.authStyle,
}));
if (!isLocalRuntime && slug !== 'openhuman') {
await setCloudProviderKey(slug, trimmed);
} else if (isLocalRuntime && slug === 'ollama') {
const baseUrl = endpoint.replace(/\/v1\/?$/, '');
await openhumanUpdateLocalAiSettings({
base_url: baseUrl,
provider: 'ollama',
runtime_enabled: true,
opt_in_confirmed: true,
});
}
if (slug !== 'openhuman') {
const nextWireProviders = [
...priorWireProviders.filter(p => p.slug !== slug),
{
id: upserted.id,
slug: upserted.slug,
label: upserted.label,
endpoint: upserted.endpoint,
auth_style: upserted.authStyle,
},
];
await flushCloudProviders(nextWireProviders);
try {
await listProviderModels(slug);
} catch (probeErr) {
await flushCloudProviders(priorWireProviders).catch(() => {});
if (!isLocalRuntime && slug !== 'openhuman') {
await clearCloudProviderKey(slug).catch(() => {});
}
const msg = probeErr instanceof Error ? probeErr.message : String(probeErr);
throw new Error(`Could not reach ${upserted.label}: ${msg}`);
}
}
setDraft({
...draft,
cloudProviders: [...draft.cloudProviders.filter(p => p.slug !== slug), upserted],
});
setKeyDialogFor(null);
setPendingLocalLabel(null);
} finally {
setBusyAction(null);
}
},
[draft, saved.cloudProviders, setDraft]
);
useEffect(() => {
let active = true;
loadOpenAICompatEndpointStatus()
@@ -2532,120 +2667,43 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
slug={keyDialogFor}
label={pendingLocalLabel ?? BUILTIN_PROVIDER_META[keyDialogFor]?.label ?? keyDialogFor}
isLocalRuntime={Boolean(pendingLocalLabel)}
oauthAction={
keyDialogFor === 'openrouter' && !pendingLocalLabel
? {
label: 'Sign in with OpenRouter',
onClick: async () => {
const controller = new AbortController();
openRouterOauthAbortRef.current = controller;
try {
const apiKey = await connectOpenRouterViaOAuth({ signal: controller.signal });
await connectProvider({
slug: 'openrouter',
value: apiKey,
credentialMode: 'oauth',
});
} finally {
if (openRouterOauthAbortRef.current === controller) {
openRouterOauthAbortRef.current = null;
}
}
},
}
: null
}
onCancel={() => {
openRouterOauthAbortRef.current?.abort();
openRouterOauthAbortRef.current = null;
setKeyDialogFor(null);
setPendingLocalLabel(null);
}}
onSubmit={async value => {
const slug = keyDialogFor;
const localLabel = pendingLocalLabel;
const isLocalRuntime = Boolean(localLabel);
setBusyAction(
`toggle-${localLabel ? localLabel.toLowerCase().replace(/\s/g, '') : slug}`
);
try {
const trimmed = value.trim();
// Normalize local-runtime endpoints so the cloud_providers entry
// always carries the OpenAI-compatible `/v1` path that the
// `list_configured_models` probe expects. Without this,
// `http://host:11434` would pass validation, be stored verbatim,
// and silently fail model discovery until the user manually
// appended `/v1` — confusing because the UI would still mark the
// provider connected (caught in review).
const endpoint = isLocalRuntime
? (() => {
const url = new URL(trimmed); // throws on malformed → caught above
if (!/^https?:$/.test(url.protocol)) {
throw new Error('Endpoint must start with http:// or https://');
}
if (url.pathname === '' || url.pathname === '/') {
url.pathname = '/v1';
}
return url.toString().replace(/\/$/, '');
})()
: defaultEndpointFor(slug);
const upserted: CloudProvider = {
id: `p_${slug}_${Math.random().toString(36).slice(2, 7)}`,
slug,
label: localLabel ?? BUILTIN_PROVIDER_META[slug]?.label ?? slug,
endpoint,
authStyle: authStyleForSlug(slug),
maskedKey: maskKeyLabel(true),
};
// Snapshot the prior persisted cloud_providers list so we can
// roll back to it if the live probe fails. `saved` reflects what
// is currently on disk (the eager-flush effect keeps it in sync
// with `draft`), so this is the right baseline to restore to.
const priorWireProviders = saved.cloudProviders.map(p => ({
id: p.id,
slug: p.slug,
label: p.label,
endpoint: p.endpoint,
auth_style: p.authStyle,
}));
// Persist the credential / endpoint BEFORE the probe, so the
// factory has everything it needs to actually answer it. Each
// step short-circuits and surfaces its own error via throw —
// ProviderKeyDialog.handleSave catches and keeps the dialog open
// so the user can fix the value and retry.
if (!isLocalRuntime && slug !== 'openhuman') {
await setCloudProviderKey(slug, trimmed);
} else if (isLocalRuntime && slug === 'ollama') {
// The Rust Ollama branch reads `config.local_ai.base_url`
// (not `cloud_providers[].endpoint`) when building the chat
// provider — persist it eagerly so chat routing actually hits
// the user-chosen host. Strip a trailing `/v1` since
// `make_ollama_provider` appends `/v1` itself.
const baseUrl = endpoint.replace(/\/v1\/?$/, '');
await openhumanUpdateLocalAiSettings({
base_url: baseUrl,
provider: 'ollama',
runtime_enabled: true,
opt_in_confirmed: true,
});
}
// Live verification: flush the new cloud_providers list to disk
// and call `/models` through the Rust controller. A reachable
// endpoint + valid auth header is the strongest check we can
// make without burning tokens. Skip the probe for the
// OpenHuman backend (session JWT, no /models endpoint to hit).
if (slug !== 'openhuman') {
const nextWireProviders = [
...priorWireProviders.filter(p => p.slug !== slug),
{
id: upserted.id,
slug: upserted.slug,
label: upserted.label,
endpoint: upserted.endpoint,
auth_style: upserted.authStyle,
},
];
await flushCloudProviders(nextWireProviders);
try {
await listProviderModels(slug);
} catch (probeErr) {
// Roll back so the UI / on-disk state never reflects a
// provider we couldn't actually reach. The user sees the
// error in the dialog and the chip stays in the OFF state.
await flushCloudProviders(priorWireProviders).catch(() => {});
if (!isLocalRuntime && slug !== 'openhuman') {
await clearCloudProviderKey(slug).catch(() => {});
}
const msg = probeErr instanceof Error ? probeErr.message : String(probeErr);
throw new Error(`Could not reach ${upserted.label}: ${msg}`);
}
}
setDraft({ ...draft, cloudProviders: [...draft.cloudProviders, upserted] });
setKeyDialogFor(null);
setPendingLocalLabel(null);
} finally {
setBusyAction(null);
}
}}
onSubmit={async value =>
await connectProvider({
slug: keyDialogFor,
localLabel: pendingLocalLabel,
value,
credentialMode: pendingLocalLabel ? 'endpoint' : 'api_key',
})
}
/>
)}
</div>
@@ -14,6 +14,7 @@ import {
} from '../../../../services/api/aiSettingsApi';
import { creditsApi } from '../../../../services/api/creditsApi';
import { renderWithProviders } from '../../../../test/test-utils';
import { connectOpenRouterViaOAuth } from '../../../../utils/openrouterOAuth';
// Lazy import so the typed mock is available to individual tests.
import { openhumanUpdateLocalAiSettings as openhumanUpdateLocalAiSettingsMock } from '../../../../utils/tauriCommands/config';
import {
@@ -89,6 +90,8 @@ vi.mock('../../../../utils/tauriCommands/config', async () => {
};
});
vi.mock('../../../../utils/openrouterOAuth', () => ({ connectOpenRouterViaOAuth: vi.fn() }));
const baseSettings = {
cloudProviders: [
{
@@ -204,6 +207,7 @@ describe('AIPanel', () => {
vi.mocked(clearOpenAICompatEndpointKey).mockResolvedValue(undefined);
vi.mocked(setCloudProviderKey).mockResolvedValue(undefined);
vi.mocked(listProviderModels).mockResolvedValue([]);
vi.mocked(connectOpenRouterViaOAuth).mockResolvedValue('sk-or-oauth');
vi.mocked(openhumanHeartbeatSettingsGet).mockResolvedValue({
result: { settings: baseHeartbeatSettings },
logs: [],
@@ -418,6 +422,45 @@ describe('AIPanel', () => {
expect(screen.getByLabelText(/API key/i)).toBeInTheDocument();
});
it('clicking the OpenRouter chip shows both API key entry and the OAuth button', async () => {
vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] });
renderWithProviders(<AIPanel />);
await waitFor(() =>
expect(screen.getByRole('switch', { name: /Connect OpenRouter/i })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('switch', { name: /Connect OpenRouter/i }));
const dialog = await screen.findByRole('dialog', { name: /Connect OpenRouter/i });
expect(within(dialog).getByLabelText(/API key/i)).toBeInTheDocument();
expect(
within(dialog).getByRole('button', { name: /Sign in with OpenRouter/i })
).toBeInTheDocument();
});
it('stores the OpenRouter OAuth key and enables the provider chip', async () => {
vi.mocked(loadAISettings).mockResolvedValue({ ...baseSettings, cloudProviders: [] });
vi.mocked(connectOpenRouterViaOAuth).mockResolvedValue('sk-or-from-oauth');
renderWithProviders(<AIPanel />);
await waitFor(() =>
expect(screen.getByRole('switch', { name: /Connect OpenRouter/i })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('switch', { name: /Connect OpenRouter/i }));
const dialog = await screen.findByRole('dialog', { name: /Connect OpenRouter/i });
fireEvent.click(within(dialog).getByRole('button', { name: /Sign in with OpenRouter/i }));
await waitFor(() => expect(connectOpenRouterViaOAuth).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(setCloudProviderKey).toHaveBeenCalledWith('openrouter', 'sk-or-from-oauth')
);
await waitFor(() =>
expect(screen.getByRole('switch', { name: /Disconnect OpenRouter/i })).toBeInTheDocument()
);
});
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: [] });
@@ -0,0 +1,98 @@
import { describe, expect, it, vi } from 'vitest';
import { connectOpenRouterViaOAuth } from '../openrouterOAuth';
describe('connectOpenRouterViaOAuth', () => {
it('opens the OpenRouter auth URL and exchanges the callback code for an API key', async () => {
const openExternalUrl = vi.fn().mockResolvedValue(undefined);
const cancel = vi.fn().mockResolvedValue(undefined);
const startLoopbackListener = vi
.fn()
.mockResolvedValue({
redirectUri: 'http://127.0.0.1:53824/auth?state=expected-state',
state: 'expected-state',
awaitCallback: vi
.fn()
.mockResolvedValue('http://127.0.0.1:53824/auth?state=expected-state&code=abc123'),
cancel,
});
const fetchImpl = vi
.fn()
.mockResolvedValue({ ok: true, json: async () => ({ key: 'sk-or-via-oauth' }) });
const key = await connectOpenRouterViaOAuth({
startLoopbackListener,
openExternalUrl,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(key).toBe('sk-or-via-oauth');
expect(startLoopbackListener).toHaveBeenCalledWith({ port: 3000 });
expect(openExternalUrl).toHaveBeenCalledTimes(1);
const authUrl = new URL(openExternalUrl.mock.calls[0][0]);
expect(authUrl.origin + authUrl.pathname).toBe('https://openrouter.ai/auth');
expect(authUrl.searchParams.get('callback_url')).toBe(
'http://localhost:3000/auth?state=expected-state'
);
expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256');
expect(authUrl.searchParams.get('code_challenge')).toBeTruthy();
expect(fetchImpl).toHaveBeenCalledWith(
'https://openrouter.ai/api/v1/auth/keys',
expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' } })
);
expect(cancel).toHaveBeenCalledTimes(1);
});
it('rejects when the loopback listener is unavailable', async () => {
await expect(
connectOpenRouterViaOAuth({ startLoopbackListener: vi.fn().mockResolvedValue(null) })
).rejects.toThrow('OpenRouter OAuth requires the desktop app');
});
it('rejects when the callback state does not match the request', async () => {
const cancel = vi.fn().mockResolvedValue(undefined);
await expect(
connectOpenRouterViaOAuth({
startLoopbackListener: vi
.fn()
.mockResolvedValue({
redirectUri: 'http://127.0.0.1:53824/auth?state=expected-state',
state: 'expected-state',
awaitCallback: vi
.fn()
.mockResolvedValue('http://127.0.0.1:53824/auth?state=wrong-state&code=abc123'),
cancel,
}),
openExternalUrl: vi.fn().mockResolvedValue(undefined),
fetchImpl: vi.fn() as unknown as typeof fetch,
})
).rejects.toThrow('OpenRouter OAuth callback state did not match the request.');
expect(cancel).toHaveBeenCalledTimes(1);
});
it('cancels the loopback listener when the OAuth flow is aborted', async () => {
const cancel = vi.fn().mockResolvedValue(undefined);
const controller = new AbortController();
const promise = connectOpenRouterViaOAuth({
signal: controller.signal,
startLoopbackListener: vi
.fn()
.mockResolvedValue({
redirectUri: 'http://127.0.0.1:3000/auth?state=expected-state',
state: 'expected-state',
awaitCallback: vi.fn().mockImplementation(() => new Promise(() => {})),
cancel,
}),
openExternalUrl: vi.fn().mockResolvedValue(undefined),
fetchImpl: vi.fn() as unknown as typeof fetch,
});
controller.abort();
await expect(promise).rejects.toThrow('OpenRouter OAuth was cancelled.');
expect(cancel).toHaveBeenCalledTimes(1);
});
});
+154
View File
@@ -0,0 +1,154 @@
import {
type LoopbackHandle,
startLoopbackOauthListener,
type StartLoopbackOptions,
} from './loopbackOauthListener';
import { openUrl } from './openUrl';
const OPENROUTER_AUTH_URL = 'https://openrouter.ai/auth';
const OPENROUTER_TOKEN_URL = 'https://openrouter.ai/api/v1/auth/keys';
const PKCE_METHOD = 'S256';
const OPENROUTER_LOOPBACK_PORT = 3000;
const VERIFIER_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
interface OpenRouterExchangeResponse {
key?: string;
error?: { message?: string } | string;
}
export interface OpenRouterOAuthDeps {
startLoopbackListener?: (options?: StartLoopbackOptions) => Promise<LoopbackHandle | null>;
openExternalUrl?: (url: string) => Promise<void>;
fetchImpl?: typeof fetch;
signal?: AbortSignal;
}
function randomVerifier(length = 64): string {
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return Array.from(bytes, value => VERIFIER_ALPHABET[value % VERIFIER_ALPHABET.length]).join('');
}
function base64UrlEncode(bytes: Uint8Array): string {
let binary = '';
for (const value of bytes) {
binary += String.fromCharCode(value);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
async function createCodeChallenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
return base64UrlEncode(new Uint8Array(digest));
}
function extractOAuthCode(callbackUrl: string, expectedState: string): string {
let parsed: URL;
try {
parsed = new URL(callbackUrl);
} catch {
throw new Error('OpenRouter OAuth returned an invalid callback URL.');
}
const actualState = parsed.searchParams.get('state');
if (actualState !== expectedState) {
throw new Error('OpenRouter OAuth callback state did not match the request.');
}
const code = parsed.searchParams.get('code');
if (!code) {
throw new Error('OpenRouter OAuth did not return an authorization code.');
}
return code;
}
async function exchangeCodeForKey(
code: string,
verifier: string,
fetchImpl: typeof fetch
): Promise<string> {
const response = await fetchImpl(OPENROUTER_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: PKCE_METHOD }),
});
let body: OpenRouterExchangeResponse | null = null;
try {
body = (await response.json()) as OpenRouterExchangeResponse;
} catch {
body = null;
}
if (!response.ok) {
const detail =
typeof body?.error === 'string'
? body.error
: body?.error && typeof body.error === 'object'
? body.error.message
: null;
throw new Error(detail || `OpenRouter key exchange failed (${response.status}).`);
}
if (!body?.key || typeof body.key !== 'string') {
throw new Error('OpenRouter key exchange succeeded but no API key was returned.');
}
return body.key;
}
function toOpenRouterCallbackUrl(redirectUri: string): string {
let parsed: URL;
try {
parsed = new URL(redirectUri);
} catch {
throw new Error('OpenRouter OAuth listener returned an invalid redirect URL.');
}
parsed.hostname = 'localhost';
parsed.port = String(OPENROUTER_LOOPBACK_PORT);
return parsed.toString();
}
export async function connectOpenRouterViaOAuth(deps: OpenRouterOAuthDeps = {}): Promise<string> {
const startLoopbackListener = deps.startLoopbackListener ?? startLoopbackOauthListener;
const openExternalUrl = deps.openExternalUrl ?? openUrl;
const fetchImpl = deps.fetchImpl ?? fetch;
const signal = deps.signal;
const loopback = await startLoopbackListener({ port: OPENROUTER_LOOPBACK_PORT });
if (!loopback) {
throw new Error('OpenRouter OAuth requires the desktop app. Use an API key instead.');
}
if (signal?.aborted) {
await loopback.cancel();
throw new Error('OpenRouter OAuth was cancelled.');
}
const verifier = randomVerifier();
const challenge = await createCodeChallenge(verifier);
const authUrl = new URL(OPENROUTER_AUTH_URL);
authUrl.searchParams.set('callback_url', toOpenRouterCallbackUrl(loopback.redirectUri));
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', PKCE_METHOD);
try {
await openExternalUrl(authUrl.toString());
const callbackUrl = await Promise.race([
loopback.awaitCallback(),
new Promise<string>((_, reject) => {
if (!signal) return;
const onAbort = () => {
signal.removeEventListener('abort', onAbort);
reject(new Error('OpenRouter OAuth was cancelled.'));
};
signal.addEventListener('abort', onAbort, { once: true });
}),
]);
const code = extractOAuthCode(callbackUrl, loopback.state);
return await exchangeCodeForKey(code, verifier, fetchImpl);
} finally {
await loopback.cancel();
}
}