diff --git a/src/mcplocal/src/providers/anthropic.ts b/src/mcplocal/src/providers/anthropic.ts index 84f5db9..7cadc83 100644 --- a/src/mcplocal/src/providers/anthropic.ts +++ b/src/mcplocal/src/providers/anthropic.ts @@ -101,6 +101,15 @@ export class AnthropicProvider implements LlmProvider { res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => { 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 { resolve(JSON.parse(raw)); } catch { diff --git a/src/mcplocal/tests/anthropic-provider.test.ts b/src/mcplocal/tests/anthropic-provider.test.ts new file mode 100644 index 0000000..8afd7f5 --- /dev/null +++ b/src/mcplocal/tests/anthropic-provider.test.ts @@ -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'); + }); +});