Skip to content

Commit c227fff

Browse files
committed
fix(anthropic): reveal real auth-rejection reason; match Claude Code OAuth shape
A 401/403 from the Messages API was flattened to a generic "credential rejected / regenerate your API key" message that hid Anthropic's actual reason and wrongly assumed an API key even on the OAuth subscription path. - callAnthropic now keeps the HTTP status and Anthropic's own message on the thrown error, so categorizeError classifies by status (not keyword-guessing) and the real reason can surface. - errors.js surfaces the provider's reason for auth failures instead of the canned line; output-tab's hint now covers both subscription sign-in and API keys. - Send the OAuth system prompt as the structured Claude Code identity block the real CLI uses, so subscription tokens are more likely to be honored. Adds tests covering the surfaced-reason and bare-status auth cases.
1 parent 705baf5 commit c227fff

4 files changed

Lines changed: 38 additions & 5 deletions

File tree

src/background.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1829,7 +1829,10 @@ async function callAnthropic(model = 'claude-sonnet-4-6', prompt) {
18291829
messages: [{ role: 'user', content: prompt }],
18301830
};
18311831
if (oauth) {
1832-
body.system = "You are Claude Code, Anthropic's official CLI for Claude.";
1832+
// Claude's OAuth (subscription) tokens are only honored when the request looks
1833+
// like the real CLI: the system prompt must lead with the exact Claude Code
1834+
// identity, sent as a structured text-block array (the form the CLI itself uses).
1835+
body.system = [{ type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." }];
18331836
}
18341837

18351838
const res = await fetchWithTimeout(
@@ -1844,7 +1847,13 @@ async function callAnthropic(model = 'claude-sonnet-4-6', prompt) {
18441847
if (!res.ok) {
18451848
const err = await res.json().catch(() => ({}));
18461849
if (oauth && (res.status === 401 || res.status === 403)) await clearAnthropicOAuthTokens();
1847-
throw new Error(err.error?.message ?? `Anthropic API error ${res.status}`);
1850+
// Keep both the HTTP status and Anthropic's own reason: the status lets
1851+
// categorizeError classify reliably (not by keyword-guessing the text), and the
1852+
// reason is what actually explains an OAuth token refused at inference time.
1853+
const detail = err.error?.message || `Anthropic API error ${res.status}`;
1854+
const e = new Error(detail);
1855+
e.status = res.status;
1856+
throw e;
18481857
}
18491858
const data = await res.json();
18501859
const text = data.content?.[0]?.text;

src/errors.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,17 @@ function humanize(kind, provider, fallback) {
3737
switch (kind) {
3838
case 'none':
3939
return 'No AI provider connected — open Settings to add a key.';
40-
case 'auth':
41-
return `${who}’s credential was rejected — check or reconnect it in Settings.`;
40+
case 'auth': {
41+
// Surface the provider's actual reason when we have one — the generic line
42+
// hides *why* (e.g. an OAuth subscription token refused at inference time,
43+
// which reads very differently from a mistyped API key).
44+
const detail = (fallback || '').trim();
45+
const showDetail =
46+
detail && !/credential was rejected/i.test(detail) && !/^[\w ]+ API error \d+$/i.test(detail);
47+
return showDetail
48+
? `${who}’s credential was rejected: ${detail}. Reconnect it in Settings.`
49+
: `${who}’s credential was rejected — check or reconnect it in Settings.`;
50+
}
4251
case 'rate_limit':
4352
return `${who} is rate-limited — wait a moment, or route this part to another provider.`;
4453
case 'not_found':

src/output-tab.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -654,7 +654,7 @@ async function init() {
654654
const actions = errorActions(kind, canRetry);
655655
const HINTS = {
656656
none: 'Add an API key in Settings → it only takes 30 seconds.',
657-
auth: "Your API key was rejected. Regenerate it from the provider's console, then paste it in Settings.",
657+
auth: "The provider rejected your credential. For a Claude/ChatGPT subscription sign-in, reconnect it in Settings; for an API key, regenerate it from the provider's console.",
658658
rate_limit:
659659
"You've hit the rate limit. Wait a minute and retry — or switch to a different provider in Settings.",
660660
not_found: 'The model name is unrecognised. Open Settings and pick a valid model from the dropdown.',

tests/errors.test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,21 @@ describe('categorizeError', () => {
1414
expect(categorizeError({ status: 401 }).kind).toBe('auth');
1515
expect(categorizeError('invalid x-api-key').kind).toBe('auth');
1616
});
17+
it('surfaces the provider’s real reason for auth failures instead of a generic line', () => {
18+
// Mirrors how callAnthropic throws: a clean reason string plus the HTTP status.
19+
const r = categorizeError(
20+
Object.assign(new Error('OAuth authentication is currently not supported'), { status: 401 }),
21+
'Anthropic'
22+
);
23+
expect(r.kind).toBe('auth');
24+
expect(r.userMessage).toMatch(/OAuth authentication is currently not supported/);
25+
});
26+
it('keeps the generic auth line when there is no specific reason (bare status)', () => {
27+
const r = categorizeError({ status: 401 }, 'Anthropic');
28+
expect(r.kind).toBe('auth');
29+
expect(r.userMessage).toMatch(/reconnect it in Settings/i);
30+
expect(r.userMessage).not.toMatch(/API error/i);
31+
});
1732
it('classifies rate limits as retryable', () => {
1833
expect(categorizeError(new Error('429 Too Many Requests')).kind).toBe('rate_limit');
1934
expect(categorizeError('rate limit exceeded').retryable).toBe(true);

0 commit comments

Comments
 (0)