Skip to content

Commit 45c258c

Browse files
committed
fix: ChatGPT/Claude subscription auth — use Codex Responses API, add rate-limit retry
ChatGPT sign-in was failing because the OAuth callback tried to mint an API key via token-exchange, which requires API platform access that most ChatGPT subscriptions don't include. Now uses the Codex Responses API (chatgpt.com/backend-api/codex/responses) with the OAuth access token directly — same approach the Codex CLI and Aside browser use. Claude token exchange was failing on 429 rate limits with no retry. Added exponential backoff with retry-after header support. Changes: - Rewrite callOpenAIOAuth to use Codex Responses API with SSE streaming - Remove mintOpenAIApiKey call from OAuth callback handler - Add getOpenAIAccessToken helper for direct token access - Add parseCodexResponsesSSE for streaming response parsing - Fix tokenErrorMessage [object Object] bug on nested error objects - Add fetchWithRetry to oauth-anthropic.js for 429 backoff - Update OpenAI model catalog: add GPT-5.4/5.5, make GPT-5.4 recommended - Add chatgpt.com to manifest host_permissions - Fix waitForOpenAIOAuthResult to poll for credentials not minted key - 945/945 tests pass
1 parent c227fff commit 45c258c

9 files changed

Lines changed: 200 additions & 54 deletions

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@ This project follows [Semantic Versioning](https://semver.org/) and groups chang
77
by theme. Dates are when the release landed on `main`. 1.1.0 through 1.6.0 shipped
88
the same day, as a rapid burst of improvements, so they share a date.
99

10+
## [Unreleased] — 2026-06-25 · _Subscription Auth Fix · Codex Responses API_
11+
12+
### Fixed
13+
14+
- **ChatGPT subscription sign-in now works without API platform access.** The previous flow tried to mint an OpenAI API key (`sk-…`) from the OAuth `id_token` via a token-exchange grant — which only works if your ChatGPT plan includes API platform access. Most ChatGPT Plus/Pro subscriptions don't, so every sign-in failed with "Couldn't enable API access for this ChatGPT account." RepoLens now uses the **Codex Responses API** at `chatgpt.com/backend-api/codex/responses` with the OAuth access token directly — the same endpoint the Codex CLI and Aside browser use. No API key minting required; the subscription itself authorizes the request.
15+
- **ChatGPT OAuth error messages are now readable.** The token-exchange error handler was stringifying nested error objects as `[object Object]`. It now drills into `error.message`, `error_description`, and stringifies objects properly.
16+
- **ChatGPT OAuth callback no longer clears credentials on success.** The callback handler was still calling `mintOpenAIApiKey()` after the code exchange, which failed and then wiped the just-stored OAuth credentials — making the provider look disconnected even though the sign-in itself succeeded.
17+
- **Claude sign-in rate-limit handling.** Anthropic's token endpoint rate-limits aggressively on repeated sign-in attempts or concurrent refreshes, surfacing as "Claude token exchange failed: Rate limited." Both the token exchange and refresh paths now retry with exponential backoff (up to 2 retries, reads `retry-after` header, 30s max delay) before giving up.
18+
19+
### Changed
20+
21+
- **OpenAI model catalog updated.** Added GPT-5.4 (recommended), GPT-5.4 mini, and GPT-5.5 to the model picker — matching what ChatGPT subscriptions expose via the Codex Responses API. GPT-4.1, GPT-4o, and o4-mini remain as fallbacks.
22+
- **Default OpenAI model is now GPT-5.4** (was GPT-4.1), matching the current generation available through ChatGPT subscriptions.
23+
- **`waitForOpenAIOAuthResult` now polls for OAuth credentials** instead of a minted API key, since the Codex Responses API path uses the access token directly.
24+
- **Removed unused `mintOpenAIApiKey` import** from background.js — the minting step is no longer part of the inference or callback path.
25+
26+
---
27+
1028
## [Unreleased] — 2026-06-19 · _Actionable Scans · Smooth Loading · Provider Refresh · Stability_
1129

1230
### Added

manifest.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"manifest_version": 3,
33
"name": "RepoLens",
4-
"version": "3.1.0",
4+
"version": "3.1.1",
55
"description": "Click any repo. Get a straight answer on whether to use it.",
66
"content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" },
77
"permissions": [
@@ -34,6 +34,7 @@
3434
"https://cli-chat-proxy.grok.com/*",
3535
"https://api.openai.com/*",
3636
"https://auth.openai.com/*",
37+
"https://chatgpt.com/*",
3738
"https://api.deepseek.com/*",
3839
"https://api.groq.com/*",
3940
"https://integrate.api.nvidia.com/*",

src/background.js

Lines changed: 113 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ import {
5454
OPENAI_CREDENTIALS_KEY,
5555
clearOpenAICredentials,
5656
exchangeOpenAICode,
57+
getOpenAIAccessToken,
5758
isOpenAIOAuthCallbackUrl,
58-
mintOpenAIApiKey,
5959
refreshOpenAIToken,
6060
} from './oauth-openai.js';
6161
import {
@@ -603,8 +603,9 @@ const _handledOAuthCodes = new Set();
603603
//
604604
// The redirect lands on http://localhost:1455/auth/callback — the loopback server
605605
// the CLI runs doesn't exist in a browser, so the navigation can't complete. We
606-
// intercept it (onBeforeNavigate fires first, with the ?code=), exchange the code,
607-
// then mint an API key so inference uses the standard OpenAI engine.
606+
// intercept it (onBeforeNavigate fires first, with the ?code=), exchange the code
607+
// for OAuth credentials. Inference uses the Codex Responses API with the access
608+
// token directly — no API key minting needed.
608609

609610
async function handleOpenAIOAuthCallback(rawUrl, tabId) {
610611
if (!rawUrl || !isOpenAIOAuthCallbackUrl(rawUrl)) return;
@@ -652,15 +653,12 @@ async function handleOpenAIOAuthCallback(rawUrl, tabId) {
652653

653654
try {
654655
const creds = await exchangeOpenAICode({ code, state, verifier, storedState });
655-
// Mint a usable API key so scans run through the ordinary OpenAI engine.
656-
const apiKey = await mintOpenAIApiKey(creds.id_token);
657-
await chrome.storage.local.set({ openaiKey: apiKey });
656+
// Store the OAuth credentials. Inference uses the Codex Responses API
657+
// with the access token directly — no API key minting needed.
658658
await cleanupFlowMarkers();
659659
if (tabId) chrome.tabs.remove(tabId).catch(() => {});
660660
} catch (err) {
661661
console.error('[RepoLens OAuth] OpenAI exchange error:', err.message);
662-
// No usable key ⇒ don't leave half-finished OAuth state that reads as "connected".
663-
await clearOpenAICredentials().catch(() => {});
664662
await chrome.storage.local.set({ [OPENAI_OAUTH_ERROR_KEY]: err.message });
665663
await cleanupFlowMarkers();
666664
if (tabId) chrome.tabs.remove(tabId).catch(() => {});
@@ -1657,48 +1655,124 @@ async function callEmbeddings(keys, texts) {
16571655
}
16581656
}
16591657

1660-
// OpenAI via "Sign in with ChatGPT" (the Codex CLI OAuth flow). The OAuth session is
1661-
// exchanged for a normal OpenAI API key; on a 401 we refresh the session, re-mint, and
1662-
// retry once. Inference itself is the standard api.openai.com chat-completions engine.
1663-
async function callOpenAIOAuth(model = 'gpt-4.1', prompt) {
1664-
let { openaiKey } = await chrome.storage.local.get('openaiKey');
1665-
if (!openaiKey) openaiKey = await mintAndStoreOpenAIKey();
1658+
// OpenAI via "Sign in with ChatGPT" (Codex CLI OAuth flow).
1659+
//
1660+
// Instead of trying to mint an API key (which requires API platform access that
1661+
// most ChatGPT subscriptions don't include), we use the Codex Responses API at
1662+
// chatgpt.com/backend-api/codex/responses — the same endpoint Aside and the
1663+
// Codex CLI use. The OAuth access token works directly; no sk- key needed.
1664+
//
1665+
// The endpoint returns SSE. We stream it and accumulate the text.
1666+
async function callOpenAIOAuth(model = 'gpt-5.4', prompt) {
1667+
const accessToken = await getOpenAIAccessToken();
1668+
1669+
const body = {
1670+
model,
1671+
instructions: 'You are a helpful assistant.',
1672+
input: [{ role: 'user', content: [{ type: 'input_text', text: prompt }] }],
1673+
stream: true,
1674+
store: false,
1675+
};
16661676

1667-
let res = await openaiChat(openaiKey, model, prompt);
1677+
let res = await fetchWithTimeout(
1678+
'https://chatgpt.com/backend-api/codex/responses',
1679+
{
1680+
method: 'POST',
1681+
headers: {
1682+
Authorization: `Bearer ${accessToken}`,
1683+
'Content-Type': 'application/json',
1684+
},
1685+
body: JSON.stringify(body),
1686+
},
1687+
'OpenAI (Codex)'
1688+
);
1689+
1690+
// 401 on first try → force-refresh the access token and retry once.
16681691
if (res.status === 401) {
1669-
openaiKey = await mintAndStoreOpenAIKey(); // the minted key may have been revoked — re-mint once
1670-
res = await openaiChat(openaiKey, model, prompt);
1692+
const fresh = await refreshOpenAIToken({ force: true });
1693+
res = await fetchWithTimeout(
1694+
'https://chatgpt.com/backend-api/codex/responses',
1695+
{
1696+
method: 'POST',
1697+
headers: {
1698+
Authorization: `Bearer ${fresh.access_token}`,
1699+
'Content-Type': 'application/json',
1700+
},
1701+
body: JSON.stringify(body),
1702+
},
1703+
'OpenAI (Codex)'
1704+
);
16711705
}
1706+
16721707
if (!res.ok) {
16731708
if (res.status === 401) {
16741709
await clearOpenAICredentials();
16751710
throw new Error('OpenAI session expired — please reconnect in Settings');
16761711
}
1677-
const err = await res.json().catch(() => ({}));
1678-
throw new Error(err.error?.message ?? `OpenAI API error ${res.status}`);
1712+
const detail = await res.text().catch(() => '');
1713+
let msg;
1714+
try { msg = JSON.parse(detail)?.error?.message; } catch { msg = ''; }
1715+
throw new Error(msg || `OpenAI Codex API error ${res.status}`);
16791716
}
1680-
return parseOpenAiText(await res.json());
1681-
}
16821717

1683-
// Refresh the ChatGPT OAuth session and mint a fresh OpenAI API key into the shared slot.
1684-
async function mintAndStoreOpenAIKey() {
1685-
const creds = await refreshOpenAIToken({ force: true }); // guarantee a fresh id_token to exchange
1686-
const key = await mintOpenAIApiKey(creds?.id_token);
1687-
await chrome.storage.local.set({ openaiKey: key });
1688-
return key;
1718+
return parseCodexResponsesSSE(res, AI_FETCH_TIMEOUT_MS);
16891719
}
16901720

1691-
// Bare OpenAI chat request returning the raw Response, so callers can branch on 401.
1692-
function openaiChat(key, model, prompt) {
1693-
return fetchWithTimeout(
1694-
'https://api.openai.com/v1/chat/completions',
1695-
{
1696-
method: 'POST',
1697-
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
1698-
body: JSON.stringify(openaiBody(model, prompt, 4096)),
1699-
},
1700-
'OpenAI'
1701-
);
1721+
// Parse the SSE stream from the Codex Responses API. Events include:
1722+
// response.output_text.delta → incremental text chunks
1723+
// response.completed → final response with full output
1724+
// response.done / .incomplete → terminal signal
1725+
// error / response.failed → error
1726+
// We accumulate delta text and, if a completed event arrives with full text,
1727+
// prefer that over the accumulated deltas.
1728+
async function parseCodexResponsesSSE(response, timeoutMs = 60_000) {
1729+
const reader = response.body.getReader();
1730+
const decoder = new TextDecoder();
1731+
let buffer = '';
1732+
let fullText = '';
1733+
const deadline = Date.now() + timeoutMs;
1734+
1735+
for (;;) {
1736+
if (Date.now() > deadline) throw new Error('OpenAI Codex stream timed out');
1737+
const { done, value } = await reader.read();
1738+
if (done) break;
1739+
buffer += decoder.decode(value, { stream: true });
1740+
1741+
let sep;
1742+
while ((sep = buffer.indexOf('\n\n')) !== -1) {
1743+
const block = buffer.slice(0, sep);
1744+
buffer = buffer.slice(sep + 2);
1745+
const dataLines = block
1746+
.split('\n')
1747+
.filter((l) => l.startsWith('data:'))
1748+
.map((l) => l.slice(5).trim())
1749+
.filter(Boolean);
1750+
for (const data of dataLines) {
1751+
if (data === '[DONE]') continue;
1752+
let evt;
1753+
try { evt = JSON.parse(data); } catch { continue; }
1754+
if (evt.type === 'response.output_text.delta' && typeof evt.delta === 'string') {
1755+
fullText += evt.delta;
1756+
}
1757+
if ((evt.type === 'response.completed' || evt.type === 'response.done') && evt.response?.output) {
1758+
for (const item of evt.response.output) {
1759+
if (item.type === 'message' && Array.isArray(item.content)) {
1760+
for (const blk of item.content) {
1761+
if (blk.type === 'output_text' && blk.text) fullText = blk.text;
1762+
}
1763+
}
1764+
}
1765+
}
1766+
if (evt.type === 'error' || evt.type === 'response.failed') {
1767+
const msg = evt.error?.message || evt.message || 'Codex API error';
1768+
throw new Error(msg);
1769+
}
1770+
}
1771+
}
1772+
}
1773+
1774+
if (!fullText) throw new Error('OpenAI Codex returned no text content');
1775+
return fullText;
17021776
}
17031777

17041778
// Anthropic-compatible Messages API (x-api-key + anthropic-version).
@@ -1736,7 +1810,7 @@ async function callAnthropicCompatible({
17361810
async function testProvider(provider, keys) {
17371811
const p = compatProviderById(provider);
17381812
if (!p) return { ok: false, connection: false, function: false, detail: 'Unknown provider' };
1739-
// OpenAI connected via "Sign in with ChatGPT" exercises the OAuth → mint → call path.
1813+
// OpenAI connected via "Sign in with ChatGPT" exercises the Codex Responses API path.
17401814
const isOpenAiOAuth = provider === 'openai' && !!keys[OPENAI_CREDENTIALS_KEY]?.refresh_token;
17411815
if (!isCompatConnected(provider, keys) && !isOpenAiOAuth) {
17421816
return {

src/oauth-anthropic.js

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,45 @@ function expiresAt(expiresInSec) {
5959
return Date.now() + sec * 1000 - 5 * 60 * 1000;
6060
}
6161

62+
const _sleep = (ms) => new Promise((r) => setTimeout(r, ms));
63+
64+
/** Fetch with retry on 429 rate-limit. Anthropic's token endpoint throttles
65+
* aggressively — a single retry with backoff prevents false failures. */
66+
async function fetchWithRetry(url, opts, { maxRetries = 2, baseDelayMs = 2000 } = {}) {
67+
let lastErr;
68+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
69+
try {
70+
const res = await fetch(url, opts);
71+
if (res.status !== 429) return res;
72+
// Rate limited — read retry hint and back off
73+
const retryAfter = res.headers.get('retry-after');
74+
const delay = retryAfter ? Math.max(1000, Number(retryAfter) * 1000) : baseDelayMs * (2 ** attempt);
75+
if (attempt < maxRetries) {
76+
await _sleep(Math.min(delay, 30000));
77+
continue;
78+
}
79+
return res; // last attempt — let the caller handle the 429
80+
} catch (err) {
81+
lastErr = err;
82+
if (attempt < maxRetries) {
83+
await _sleep(baseDelayMs * (2 ** attempt));
84+
continue;
85+
}
86+
throw err;
87+
}
88+
}
89+
throw lastErr || new Error('Token request failed after retries');
90+
}
91+
6292
async function parseTokenResponse(res, context) {
6393
const json = await res.json().catch(() => ({}));
6494
if (!res.ok) {
95+
if (res.status === 429) {
96+
throw new Error(`${context} failed: Rate limited. Please try again in a minute.`);
97+
}
6598
const detail =
6699
json.error_description || json.error?.message || json.error || json.message || `${res.status}`;
67-
throw new Error(`${context} failed: ${detail}`);
100+
throw new Error(`${context} failed: ${typeof detail === 'object' ? JSON.stringify(detail) : detail}`);
68101
}
69102
if (!json.access_token) throw new Error(`${context} failed: Anthropic returned no access token`);
70103
return {
@@ -79,7 +112,7 @@ export async function exchangeAnthropicCode({ authCode, verifier }) {
79112
if (!code) throw new Error('Paste the Claude authorization code first.');
80113
if (state && state !== verifier) throw new Error('Claude sign-in state mismatch. Start the sign-in again.');
81114

82-
const res = await fetch(ANTHROPIC_TOKEN_URL, {
115+
const res = await fetchWithRetry(ANTHROPIC_TOKEN_URL, {
83116
method: 'POST',
84117
headers: { 'Content-Type': 'application/json' },
85118
body: JSON.stringify({
@@ -136,7 +169,7 @@ export async function refreshAnthropicAccessToken() {
136169
throw new Error('Claude sign-in expired — reconnect Anthropic in Settings.');
137170
}
138171

139-
const res = await fetch(ANTHROPIC_TOKEN_URL, {
172+
const res = await fetchWithRetry(ANTHROPIC_TOKEN_URL, {
140173
method: 'POST',
141174
headers: { 'Content-Type': 'application/json' },
142175
body: JSON.stringify({

src/oauth-openai.js

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -224,18 +224,34 @@ async function tokenErrorMessage(res, fallback) {
224224
const body = await res.text().catch(() => '');
225225
try {
226226
const e = JSON.parse(body);
227-
return e.error_description || e.error || fallback || `Token request failed (${res.status})`;
227+
return e.error_description || e.error?.message || e.error || e.message || fallback || `Token request failed (${res.status})`;
228228
} catch {
229+
// body isn't JSON — if it's an object stringified somehow, stringify it properly
230+
if (body && typeof body === 'object') return JSON.stringify(body).slice(0, 160);
229231
return (body && body.slice(0, 160)) || fallback || `Token request failed (${res.status})`;
230232
}
231233
}
232234

233-
/** Poll storage until background.js finishes the callback exchange + key mint. */
235+
/** Get a valid (refreshed if needed) OAuth access_token for the ChatGPT session.
236+
* Used by the Codex Responses API path — no API-key minting required. */
237+
export async function getOpenAIAccessToken() {
238+
let creds = await getOpenAICredentials();
239+
if (!creds?.refresh_token) {
240+
await clearOpenAICredentials();
241+
throw new Error('OpenAI session expired — please reconnect in Settings');
242+
}
243+
if (isOpenAITokenExpired(creds)) {
244+
creds = await refreshOpenAIToken();
245+
}
246+
return creds.access_token;
247+
}
248+
249+
/** Poll storage until background.js finishes the callback exchange. */
234250
export async function waitForOpenAIOAuthResult({ timeoutMs = 300_000, intervalMs = 500 } = {}) {
235251
const deadline = Date.now() + timeoutMs;
236252
while (Date.now() < deadline) {
237-
const s = await chrome.storage.local.get([OPENAI_API_KEY_NAME, OPENAI_OAUTH_ERROR_KEY]);
238-
if (s[OPENAI_API_KEY_NAME]) return { key: s[OPENAI_API_KEY_NAME] };
253+
const s = await chrome.storage.local.get([OPENAI_CREDENTIALS_KEY, OPENAI_OAUTH_ERROR_KEY]);
254+
if (s[OPENAI_CREDENTIALS_KEY]?.access_token) return { ok: true };
239255
if (s[OPENAI_OAUTH_ERROR_KEY]) return { error: s[OPENAI_OAUTH_ERROR_KEY] };
240256
await new Promise((r) => setTimeout(r, intervalMs));
241257
}

src/options-providers.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,8 @@ function buildCard(p, snapshot) {
258258
}
259259

260260
// "Sign in with ChatGPT" — PKCE authorize in a new tab; background.js intercepts the
261-
// loopback redirect, exchanges the code, and mints an OpenAI API key into openaiKey.
261+
// loopback redirect, exchanges the code, and stores the OAuth credentials.
262+
// Inference uses the Codex Responses API with the access token directly.
262263
async function connectOpenAiOAuth() {
263264
const restore = () => {
264265
openAiOAuthBtn.disabled = false;

src/providers.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,11 @@ export const COMPAT_PROVIDERS = [
2828
host: 'https://api.openai.com/*',
2929
docsUrl: 'https://platform.openai.com/api-keys',
3030
models: [
31-
{ value: 'gpt-4.1', label: 'GPT-4.1', recommended: true },
32-
{ value: 'gpt-4.1-mini', label: 'GPT-4.1 mini — fast' },
31+
{ value: 'gpt-5.4', label: 'GPT-5.4', recommended: true },
32+
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 mini — fast' },
33+
{ value: 'gpt-5.5', label: 'GPT-5.5 — max quality' },
34+
{ value: 'gpt-4.1', label: 'GPT-4.1' },
35+
{ value: 'gpt-4.1-mini', label: 'GPT-4.1 mini' },
3336
{ value: 'gpt-4o', label: 'GPT-4o' },
3437
{ value: 'o4-mini', label: 'o4-mini — reasoning' },
3538
],

tests/oauth-openai.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,10 +253,10 @@ describe('mintOpenAIApiKey', () => {
253253
});
254254

255255
describe('waitForOpenAIOAuthResult', () => {
256-
it('resolves with the minted key once it appears', async () => {
257-
store.openaiKey = 'sk-final';
256+
it('resolves with ok once OAuth credentials appear', async () => {
257+
store[OPENAI_CREDENTIALS_KEY] = { access_token: 'acc', refresh_token: 'ref', id_token: 'id', expires_at: Date.now() + 3600000 };
258258
const r = await waitForOpenAIOAuthResult({ timeoutMs: 1000, intervalMs: 10 });
259-
expect(r).toEqual({ key: 'sk-final' });
259+
expect(r).toEqual({ ok: true });
260260
});
261261

262262
it('resolves with an error when the callback recorded one', async () => {

tests/routing.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ describe('buildAttemptPlan', () => {
9696
it('treats ChatGPT-login OAuth as connected before the OpenAI key is minted', () => {
9797
expect(isConnected('openai', { openaiOauthCredentials: { refresh_token: 'refresh' } })).toBe(true);
9898
const plan = buildAttemptPlan({ keys: { openaiOauthCredentials: { refresh_token: 'refresh' } } });
99-
expect(plan).toEqual([{ provider: 'openai', model: 'gpt-4.1' }]);
99+
expect(plan).toEqual([{ provider: 'openai', model: 'gpt-5.4' }]);
100100
});
101101

102102
it('a registry provider can be the per-part override and is tried first', () => {

0 commit comments

Comments
 (0)