tinyhumansai/openhuman · error · Error
OpenRouter OAuth requires the desktop app. Use an API key in
Error message
OpenRouter OAuth requires the desktop app. Use an API key instead.
What it means
connectOpenRouterViaOAuth() runs a PKCE OAuth flow that needs a loopback HTTP listener (startLoopbackOauthListener on OPENROUTER_LOOPBACK_PORT) to receive OpenRouter's callback. Only the Tauri desktop shell can bind that listener; when startLoopbackListener resolves falsy the flow has no way to receive the auth code, so the function aborts immediately and points you at API-key auth instead.
Source
Thrown at app/src/utils/openrouterOAuth.ts:126
// Preserve the port the loopback listener actually bound to (carried in
// redirectUri): when the requested port is busy, the Tauri command falls back
// to an OS-assigned ephemeral port, so hardcoding OPENROUTER_LOOPBACK_PORT here
// sent OpenRouter a callback_url pointing at the wrong port. The PKCE
// callback_url is per-request, so the dynamic port is valid (this matches the
// sibling OAuthProviderButton flow, which trusts the bound port).
parsed.hostname = 'localhost';
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(),View on GitHub (pinned to a221052e0d)
Solutions
- Run the UI inside the Tauri desktop shell (`pnpm dev:app` or the packaged app) so the loopback listener can bind.
- If you are in a browser context by design, use the API-key entry path instead — OAuth is desktop-only by construction.
- In tests, inject a stub listener: connectOpenRouterViaOAuth({ startLoopbackListener: async () => ({ redirectUri: 'http://localhost:1/cb', cancel: async () => {} }) }).
- If it throws inside the desktop app, check OPENROUTER_LOOPBACK_PORT availability (firewall rules, another instance holding the port).
Example fix
// before
const apiKey = await connectOpenRouterViaOAuth();
// after
import { isTauri } from '@/utils/tauriCommands/common';
if (!isTauri()) {
showApiKeyEntry(); // OAuth needs the desktop loopback listener
} else {
const apiKey = await connectOpenRouterViaOAuth();
} Defensive patterns
Strategy: validation
Validate before calling
import { isTauri } from '@/utils/tauriCommands/common';
// Only offer OAuth where the loopback listener can exist
if (!isTauri()) {
// render API-key entry instead of the OAuth button
} Try / catch
try {
const key = await connectOpenRouterViaOAuth({ signal });
} catch (err) {
if (err instanceof Error && err.message.includes('requires the desktop app')) {
showApiKeyEntry(); // graceful fallback path
} else {
throw err;
}
} Prevention
- Gate the Connect-with-OAuth button on isTauri() so browser users never see a dead action.
- Inject deps.startLoopbackListener in tests instead of relying on the environment.
- Always keep an API-key path available wherever OAuth is offered.
When it happens
Trigger: Calling connectOpenRouterViaOAuth() with default deps outside the desktop shell: UI served by `pnpm dev` (Vite-only) and opened in a plain browser tab, a Vitest run importing the real module, or any context where the loopback listener returns null/undefined. Can also fire inside the desktop app if the listener genuinely fails to start.
Common situations: Developer runs `pnpm dev` instead of `pnpm dev:app` and clicks Connect OpenRouter in settings; a component test exercises the OAuth path without injecting deps.startLoopbackListener; the loopback port is blocked or already bound by another process so even the desktop listener fails.
Related errors
- OpenRouter OAuth returned an invalid callback URL.
- OpenRouter OAuth listener returned an invalid redirect URL.
- OpenRouter OAuth callback state did not match the request.
- OpenRouter OAuth did not return an authorization code.
- OpenRouter key exchange failed (${response.status}).
AI-assisted analysis of tinyhumansai/openhuman@a221052e0d (2026-08-16).
Data as JSON: /api/errors/e1b4c4a1566926d2.
Report an issue: GitHub.