tinyhumansai/openhuman · error
OpenRouter key exchange succeeded but no API key was…
Error message
OpenRouter key exchange succeeded but no API key was returned.
What it means
Thrown by exchangeCodeForKey after POSTing the OAuth authorization code plus PKCE code_verifier to https://openrouter.ai/api/v1/auth/keys. The HTTP status was 2xx (the earlier `!response.ok` branch did not fire), but the parsed body has no string `key` field — the response type is `{ key?: string; error?: ... }`, so `key` is optional and OpenRouter answered 'success' without returning a credential. Typically a 200-status error body, an intercepted/rewritten response, or an upstream contract change.
Solutions
- Log the exchange response status and body keys where the error is thrown, then retry the whole flow once with a fresh verifier — authorization codes are single-use and a stale/replayed code is the most common cause
- Check the network path from the desktop app (proxy, VPN, captive portal) — a 200 HTML interception page parses as `body.key === undefined`
- Verify OpenRouter's /api/v1/auth/keys contract still returns `{ key: string }`; if it changed, update OpenRouterExchangeResponse and this check together
- If it persists, fall back to the manual API-key entry path the UI already offers (the error message itself suggests this)
Example fix
// before
if (!body?.key || typeof body.key !== 'string') {
throw new Error('OpenRouter key exchange succeeded but no API key was returned.');
}
// after (diagnose which 200-body actually came back)
if (!body?.key || typeof body.key !== 'string') {
const shape = body && typeof body === 'object' ? Object.keys(body).join(',') : String(body);
throw new Error(
`OpenRouter key exchange returned 200 but no API key (body keys: ${shape || 'none'}).`
);
} Defensive patterns
Strategy: try-catch
Try / catch
try {
const key = await connectOpenRouterViaOAuth({ signal });
} catch (err) {
if (err instanceof Error && err.message.includes('no API key was returned')) {
// 200-without-key: surface retry + manual key entry, don't dead-end
showRetryOrManualKeyEntry();
} else {
throw err;
}
} Prevention
- Treat the exchange step as fallible even on HTTP 200 — always provide a manual API-key entry path next to the OAuth button
- Capture the exchange response body keys in telemetry (never the key itself) so 200-without-key incidents are diagnosable
- Retry the full flow with a fresh verifier when it fires — authorization codes are single-use
When it happens
Trigger: Calling connectOpenRouterViaOAuth, the user completes consent, the loopback callback delivers `code`, and the POST to /api/v1/auth/keys returns response.ok with a body where `key` is absent, empty, or non-string — e.g. `{}`, `{"error":"..."}` served with 200, or a proxy page that happens to decode as JSON. Also triggered by test doubles: `fetchImpl` stubbed as `{ ok: true, json: async () => ({}) }`.
Common situations: Corporate TLS-inspecting proxy or captive portal rewriting the exchange response; reusing an already-consumed single-use authorization code (OpenRouter 200s an error payload); OpenRouter incident or API change to /api/v1/auth/keys; unit tests whose fake fetch never returns a key.
Related errors
- OpenRouter key exchange failed
- OpenRouter OAuth callback state did not match the request.
- OpenRouter OAuth did not return an authorization code.
- OpenRouter OAuth returned an invalid callback URL.
- Composio action listing failed on v3
AI-assisted analysis of tinyhumansai/openhuman@7491200858 (2026-08-17).
Data as JSON: /api/errors/1530842394a69bce.
Report an issue: GitHub.
Appendix: source
Thrown at app/src/utils/openrouterOAuth.ts:94
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.');
}
// 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 theView on GitHub (pinned to 7491200858)