{"record":{"id":"1530842394a69bce","repo":"tinyhumansai/openhuman","slug":"openrouter-key-exchange-succeeded-but-no-api-key-w","errorCode":null,"errorMessage":"OpenRouter key exchange succeeded but no API key was returned.","messagePattern":"OpenRouter key exchange succeeded but no API key was returned\\.","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"app/src/utils/openrouterOAuth.ts","lineNumber":94,"sourceCode":"  let body: OpenRouterExchangeResponse | null = null;\n  try {\n    body = (await response.json()) as OpenRouterExchangeResponse;\n  } catch {\n    body = null;\n  }\n\n  if (!response.ok) {\n    const detail =\n      typeof body?.error === 'string'\n        ? body.error\n        : body?.error && typeof body.error === 'object'\n          ? body.error.message\n          : null;\n    throw new Error(detail || `OpenRouter key exchange failed (${response.status}).`);\n  }\n\n  if (!body?.key || typeof body.key !== 'string') {\n    throw new Error('OpenRouter key exchange succeeded but no API key was returned.');\n  }\n\n  return body.key;\n}\n\nfunction toOpenRouterCallbackUrl(redirectUri: string): string {\n  let parsed: URL;\n  try {\n    parsed = new URL(redirectUri);\n  } catch {\n    throw new Error('OpenRouter OAuth listener returned an invalid redirect URL.');\n  }\n\n  // Preserve the port the loopback listener actually bound to (carried in\n  // redirectUri): when the requested port is busy, the Tauri command falls back\n  // to an OS-assigned ephemeral port, so hardcoding OPENROUTER_LOOPBACK_PORT here\n  // sent OpenRouter a callback_url pointing at the wrong port. The PKCE\n  // callback_url is per-request, so the dynamic port is valid (this matches the","sourceCodeStart":76,"sourceCodeEnd":112,"githubUrl":"https://github.com/tinyhumansai/openhuman/blob/749120085864ce16e0f273c7b86fac7740b39c5b/app/src/utils/openrouterOAuth.ts#L76-L112","documentation":"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.","triggerScenarios":"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 () => ({}) }`.","commonSituations":"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.","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)"],"exampleFix":"// before\nif (!body?.key || typeof body.key !== 'string') {\n  throw new Error('OpenRouter key exchange succeeded but no API key was returned.');\n}\n\n// after (diagnose which 200-body actually came back)\nif (!body?.key || typeof body.key !== 'string') {\n  const shape = body && typeof body === 'object' ? Object.keys(body).join(',') : String(body);\n  throw new Error(\n    `OpenRouter key exchange returned 200 but no API key (body keys: ${shape || 'none'}).`\n  );\n}","handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"try {\n  const key = await connectOpenRouterViaOAuth({ signal });\n} catch (err) {\n  if (err instanceof Error && err.message.includes('no API key was returned')) {\n    // 200-without-key: surface retry + manual key entry, don't dead-end\n    showRetryOrManualKeyEntry();\n  } else {\n    throw err;\n  }\n}","preventionTips":["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"],"tags":["oauth","openrouter","pkce","network","api-key"],"backgroundTag":"oauth-token-exchange-failed","analyzedSha":"749120085864ce16e0f273c7b86fac7740b39c5b","analyzedAt":"2026-08-17T21:21:45.363Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}