fix(providers): anthropic surfaces HTTP errors too (gate parity with openai)
Some checks failed
CI/CD / lint (pull_request) Successful in 1m6s
CI/CD / typecheck (pull_request) Successful in 1m5s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / build (pull_request) Successful in 2m14s
CI/CD / smoke (pull_request) Failing after 3m0s
CI/CD / publish (pull_request) Has been skipped

The local mcplocal gate resolves heavy=[anthropic]; its OAuth-gated key 404s the
configured opus-4 model, and anthropic.ts (like openai.ts before this series)
JSON.parsed the error body as a completion → empty content → the gate's
misleading "did not contain valid selection JSON". Reject on status >= 400 with
"Anthropic HTTP <status>: <body>" so the true reason (model-not-found / auth)
surfaces as the degradedReason. + parity test.

Note: this makes the error honest; it does not make the local gate SUCCEED — that
needs a working heavy provider (the anthropic OAuth token is fundamentally gated).
The qwen3-thinking gate path (k8s mcplocal) is addressed by the budget+reasoning
fix in the previous commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-07-23 22:23:37 +01:00
parent c9364a2f2a
commit 366b505c00
2 changed files with 60 additions and 0 deletions

View File

@@ -101,6 +101,15 @@ export class AnthropicProvider implements LlmProvider {
res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => { res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8'); const raw = Buffer.concat(chunks).toString('utf-8');
// Surface transport failures (e.g. a 404 "model not found" or a 401
// for an OAuth-gated key) instead of parsing the error body as a
// completion — otherwise it slips through as an empty completion and
// the gate reports the misleading "did not contain valid JSON".
const status = res.statusCode ?? 0;
if (status >= 400) {
reject(new Error(`Anthropic HTTP ${status}: ${raw.slice(0, 300)}`));
return;
}
try { try {
resolve(JSON.parse(raw)); resolve(JSON.parse(raw));
} catch { } catch {

View File

@@ -0,0 +1,51 @@
/**
* AnthropicProvider — transport-error surfacing (parity with OpenAiProvider).
*
* A 404 "model not found" (OAuth-gated key on an unavailable model) or a 401
* must reject with the status + body, not parse the error body as an empty
* completion (which the gate then misreports as "did not contain valid JSON").
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
let mockStatus = 200;
let mockBody = JSON.stringify({ content: [{ type: 'text', text: 'ok' }], usage: {}, stop_reason: 'end_turn' });
vi.mock('node:https', () => {
const request = vi.fn((_opts: unknown, cb?: unknown) => {
const res = {
statusCode: mockStatus,
on: (event: string, handler: (d?: unknown) => void) => {
if (event === 'data') handler(Buffer.from(mockBody));
if (event === 'end') handler();
return res;
},
};
if (typeof cb === 'function') (cb as (r: unknown) => void)(res);
return { on: vi.fn().mockReturnThis(), write: vi.fn(), end: vi.fn() };
});
return { default: { request }, request };
});
const { AnthropicProvider } = await import('../src/providers/anthropic.js');
describe('AnthropicProvider transport handling', () => {
beforeEach(() => {
mockStatus = 200;
mockBody = JSON.stringify({ content: [{ type: 'text', text: 'ok' }], usage: {}, stop_reason: 'end_turn' });
});
it('rejects on HTTP 404 (model not found) instead of masking it as bad JSON', async () => {
mockStatus = 404;
mockBody = JSON.stringify({ type: 'error', error: { type: 'not_found_error', message: 'model: claude-opus-4' } });
const p = new AnthropicProvider({ apiKey: 'sk-ant-oat01-x' });
await expect(
p.complete({ messages: [{ role: 'user', content: 'hi' }], maxTokens: 8 }),
).rejects.toThrow(/Anthropic HTTP 404/);
});
it('still returns the completion on 200', async () => {
const p = new AnthropicProvider({ apiKey: 'sk-ant-oat01-x' });
const r = await p.complete({ messages: [{ role: 'user', content: 'hi' }], maxTokens: 8 });
expect(r.content).toBe('ok');
});
});