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

  1. Run the UI inside the Tauri desktop shell (`pnpm dev:app` or the packaged app) so the loopback listener can bind.
  2. If you are in a browser context by design, use the API-key entry path instead — OAuth is desktop-only by construction.
  3. In tests, inject a stub listener: connectOpenRouterViaOAuth({ startLoopbackListener: async () => ({ redirectUri: 'http://localhost:1/cb', cancel: async () => {} }) }).
  4. 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

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


AI-assisted analysis of tinyhumansai/openhuman@a221052e0d (2026-08-16). Data as JSON: /api/errors/e1b4c4a1566926d2. Report an issue: GitHub.