Files
openmaic/OpenMAIC/tests/web-search/claude.test.ts
2026-08-16 14:58:47 +08:00

369 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── AI SDK mocks ──────────────────────────────────────────────────────────────
const { mockGenerateText, mockProvider, mockTool, mockCreateAnthropic } = vi.hoisted(() => {
const mockTool = {};
const mockModel = {};
const mockProvider = Object.assign(
vi.fn(() => mockModel),
{
tools: {
webSearch_20260209: vi.fn(() => mockTool),
webSearch_20250305: vi.fn(() => mockTool),
},
},
);
const mockCreateAnthropic = vi.fn(() => mockProvider);
const mockGenerateText = vi.fn();
return { mockGenerateText, mockProvider, mockTool, mockCreateAnthropic };
});
vi.mock('@/lib/ai/llm', () => ({ callLLM: mockGenerateText }));
vi.mock('@ai-sdk/anthropic', () => ({ createAnthropic: mockCreateAnthropic }));
vi.mock('@/lib/server/proxy-fetch', () => ({ proxyFetch: vi.fn() }));
vi.mock('@/lib/logger', () => ({
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}));
import { proxyFetch } from '@/lib/server/proxy-fetch';
import { searchWithClaude } from '@/lib/web-search/claude';
import { CLAUDE_WEB_SEARCH_DEFAULT_MODEL } from '@/lib/web-search/constants';
const mockProxyFetch = proxyFetch as ReturnType<typeof vi.fn>;
type UrlSource = { sourceType: 'url'; type: 'source'; id: string; url: string; title?: string };
function urlSource(url: string, title?: string, id = 'src-1'): UrlSource {
return { sourceType: 'url', type: 'source', id, url, title };
}
function mockAIResponse(text = 'Search result', sources: UrlSource[] = []) {
mockGenerateText.mockResolvedValueOnce({ text, sources });
}
function getWrappedFetch(): (url: string, init?: RequestInit) => Promise<Response> {
const calls = mockCreateAnthropic.mock.calls as unknown as [options: Record<string, unknown>][];
return calls[0]![0]!['fetch'] as (url: string, init?: RequestInit) => Promise<Response>;
}
describe('searchWithClaude', () => {
beforeEach(() => {
mockProxyFetch.mockReset();
mockGenerateText.mockReset();
mockCreateAnthropic.mockClear();
mockProvider.mockClear();
mockProvider.tools.webSearch_20260209.mockClear();
mockProvider.tools.webSearch_20250305.mockClear();
});
// ── fetch interceptor: allowed_callers injection ──────────────────────────
it('injects allowed_callers=["direct"] on the basic tool (Haiku 4.5 request body)', async () => {
mockAIResponse();
await searchWithClaude({ query: 'test', apiKey: 'sk-test', modelId: 'claude-haiku-4-5' });
const wrappedFetch = getWrappedFetch();
const body = JSON.stringify({
model: 'claude-haiku-4-5',
tools: [{ type: 'web_search_20250305', name: 'web_search' }],
});
mockProxyFetch.mockResolvedValueOnce(new Response('{}', { status: 200 }));
await wrappedFetch('https://api.anthropic.com/v1/messages', { method: 'POST', body });
const proxyCalls = mockProxyFetch.mock.calls as unknown as [string, RequestInit][];
const sentBody = JSON.parse(proxyCalls[0]![1]!.body as string);
expect(sentBody.tools[0].allowed_callers).toEqual(['direct']);
});
it('leaves the dynamic-filtering tool unpinned (Sonnet 5 request body)', async () => {
mockAIResponse();
await searchWithClaude({ query: 'test', apiKey: 'sk-test', modelId: 'claude-sonnet-5' });
const wrappedFetch = getWrappedFetch();
const body = JSON.stringify({
model: 'claude-sonnet-5',
tools: [{ type: 'web_search_20260209', name: 'web_search' }],
});
mockProxyFetch.mockResolvedValueOnce(new Response('{}', { status: 200 }));
await wrappedFetch('https://api.anthropic.com/v1/messages', { method: 'POST', body });
const proxyCalls = mockProxyFetch.mock.calls as unknown as [string, RequestInit][];
// Untouched body: pinning to "direct" would bypass the code-execution
// caller that provides dynamic filtering.
expect(proxyCalls[0]![1]!.body).toBe(body);
const sentBody = JSON.parse(proxyCalls[0]![1]!.body as string);
expect(sentBody.tools[0].allowed_callers).toBeUndefined();
});
it('does not overwrite allowed_callers when already set', async () => {
mockAIResponse();
await searchWithClaude({ query: 'test', apiKey: 'sk-test', modelId: 'claude-haiku-4-5' });
const wrappedFetch = getWrappedFetch();
const body = JSON.stringify({
tools: [{ type: 'web_search_20250305', name: 'web_search', allowed_callers: ['code'] }],
});
mockProxyFetch.mockResolvedValueOnce(new Response('{}', { status: 200 }));
await wrappedFetch('https://api.anthropic.com/v1/messages', { method: 'POST', body });
const proxyCalls = mockProxyFetch.mock.calls as unknown as [string, RequestInit][];
const sentBody = JSON.parse(proxyCalls[0]![1]!.body as string);
expect(sentBody.tools[0].allowed_callers).toEqual(['code']);
});
it('leaves requests without a tools array untouched', async () => {
mockAIResponse();
await searchWithClaude({ query: 'test', apiKey: 'sk-test' });
const wrappedFetch = getWrappedFetch();
const body = JSON.stringify({ model: 'claude-sonnet-5' });
mockProxyFetch.mockResolvedValueOnce(new Response('{}', { status: 200 }));
await wrappedFetch('https://api.anthropic.com/v1/messages', { method: 'POST', body });
const proxyCalls = mockProxyFetch.mock.calls as unknown as [string, RequestInit][];
expect(proxyCalls[0]![1]!.body).toBe(body);
});
it('leaves non-JSON POST bodies unchanged', async () => {
mockAIResponse();
await searchWithClaude({ query: 'test', apiKey: 'sk-test' });
const wrappedFetch = getWrappedFetch();
mockProxyFetch.mockResolvedValueOnce(new Response('{}', { status: 200 }));
await wrappedFetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
body: 'not json',
});
const proxyCalls = mockProxyFetch.mock.calls as unknown as [string, RequestInit][];
expect(proxyCalls[0]![1]!.body).toBe('not json');
});
// ── model + tool selection ─────────────────────────────────────────────────
it('uses the default model when none is given', async () => {
mockAIResponse();
await searchWithClaude({ query: 'test', apiKey: 'sk-test' });
expect(mockProvider).toHaveBeenCalledWith(CLAUDE_WEB_SEARCH_DEFAULT_MODEL);
});
it('uses the provided model, trimmed', async () => {
mockAIResponse();
await searchWithClaude({ query: 'test', apiKey: 'sk-test', modelId: ' claude-opus-5 ' });
expect(mockProvider).toHaveBeenCalledWith('claude-opus-5');
});
it('falls back to the default model for a blank modelId', async () => {
mockAIResponse();
await searchWithClaude({ query: 'test', apiKey: 'sk-test', modelId: ' ' });
expect(mockProvider).toHaveBeenCalledWith(CLAUDE_WEB_SEARCH_DEFAULT_MODEL);
});
it.each([
'claude-sonnet-5',
'claude-opus-5',
'claude-fable-5',
'claude-opus-4-8',
'claude-opus-4-7',
'claude-opus-4-6',
'claude-sonnet-4-6',
])('uses web_search_20260209 for %s', async (modelId) => {
mockAIResponse();
await searchWithClaude({ query: 'test', apiKey: 'sk-test', modelId });
expect(mockProvider.tools.webSearch_20260209).toHaveBeenCalled();
expect(mockProvider.tools.webSearch_20250305).not.toHaveBeenCalled();
});
it.each([
'claude-haiku-4-5',
'claude-haiku-4-5-20251001',
'claude-sonnet-4-5',
'claude-sonnet-4-5-20250929',
'claude-opus-4-5-20251101',
'claude-opus-4-1',
// Canonical dated Claude 4.0 ids — pre-4.6, so basic search only.
'claude-sonnet-4-20250514',
'claude-opus-4-20250514',
'claude-3-5-sonnet-20241022',
'not-a-claude-model',
])('uses the basic web_search_20250305 for %s', async (modelId) => {
mockAIResponse();
await searchWithClaude({ query: 'test', apiKey: 'sk-test', modelId });
expect(mockProvider.tools.webSearch_20250305).toHaveBeenCalled();
expect(mockProvider.tools.webSearch_20260209).not.toHaveBeenCalled();
});
it('maps maxResults to the tool maxUses argument', async () => {
mockAIResponse();
await searchWithClaude({ query: 'test', apiKey: 'sk-test', maxResults: 3 });
expect(mockProvider.tools.webSearch_20260209).toHaveBeenCalledWith({ maxUses: 3 });
});
it('omits maxUses when maxResults is not given', async () => {
mockAIResponse();
await searchWithClaude({ query: 'test', apiKey: 'sk-test' });
expect(mockProvider.tools.webSearch_20260209).toHaveBeenCalledWith({});
});
it('passes the web search tool through callLLM with a usage source label', async () => {
mockAIResponse();
await searchWithClaude({ query: 'test', apiKey: 'sk-test' });
const [args, source] = mockGenerateText.mock.calls[0]! as [
{ tools: Record<string, unknown> },
string,
];
expect(args.tools).toEqual({ web_search: mockTool });
expect(source).toBe('web-search-claude');
});
// ── provider configuration ────────────────────────────────────────────────
it('defaults the base URL to the Anthropic API', async () => {
mockAIResponse();
await searchWithClaude({ query: 'test', apiKey: 'sk-test' });
expect(mockCreateAnthropic).toHaveBeenCalledWith(
expect.objectContaining({ apiKey: 'sk-test', baseURL: 'https://api.anthropic.com/v1' }),
);
});
it('trims whitespace around a pasted API key and base URL', async () => {
mockAIResponse();
await searchWithClaude({
query: 'test',
apiKey: ' sk-test\n',
baseUrl: ' https://api.anthropic.com/v1 ',
});
expect(mockCreateAnthropic).toHaveBeenCalledWith(
expect.objectContaining({ apiKey: 'sk-test', baseURL: 'https://api.anthropic.com/v1' }),
);
});
it('strips trailing slashes from a custom base URL', async () => {
mockAIResponse();
await searchWithClaude({
query: 'test',
apiKey: 'sk-test',
baseUrl: 'https://api.anthropic.com/v1/',
});
expect(mockCreateAnthropic).toHaveBeenCalledWith(
expect.objectContaining({ baseURL: 'https://api.anthropic.com/v1' }),
);
});
// The allowlist and Settings both accept the bare Anthropic root, but the AI
// SDK appends "/messages" verbatim — without /v1 the request 404s.
it.each([
'https://api.anthropic.com',
'https://api.anthropic.com/',
' https://api.anthropic.com ',
])('normalizes the bare Anthropic root (%s) so the request URL keeps /v1', async (baseUrl) => {
mockAIResponse();
await searchWithClaude({ query: 'test', apiKey: 'sk-test', baseUrl });
const calls = mockCreateAnthropic.mock.calls as unknown as [{ baseURL: string }][];
const { baseURL } = calls[0]![0]!;
expect(baseURL).toBe('https://api.anthropic.com/v1');
expect(`${baseURL}/messages`).toBe('https://api.anthropic.com/v1/messages');
});
it('leaves a self-hosted gateway base URL untouched', async () => {
mockAIResponse();
await searchWithClaude({
query: 'test',
apiKey: 'sk-test',
baseUrl: 'https://gateway.internal.example.com/anthropic',
});
expect(mockCreateAnthropic).toHaveBeenCalledWith(
expect.objectContaining({ baseURL: 'https://gateway.internal.example.com/anthropic' }),
);
});
// ── result mapping ─────────────────────────────────────────────────────────
it('returns the answer and cited sources without fetching pages', async () => {
mockAIResponse('Node.js 22 is the current LTS.', [
urlSource('https://nodejs.org/en', 'Node.js', 'a'),
urlSource('https://endoflife.date/nodejs', 'endoflife.date', 'b'),
]);
const result = await searchWithClaude({ query: 'node lts', apiKey: 'sk-test' });
expect(result.answer).toBe('Node.js 22 is the current LTS.');
expect(result.query).toBe('node lts');
expect(typeof result.responseTime).toBe('number');
expect(result.sources).toEqual([
{
title: 'Node.js',
url: 'https://nodejs.org/en',
content: 'Referenced by the Claude web-search answer.',
score: 1,
},
{
title: 'endoflife.date',
url: 'https://endoflife.date/nodejs',
content: 'Referenced by the Claude web-search answer.',
score: 1,
},
]);
// No page-content enrichment: the only proxyFetch usage is the wrapped
// provider fetch, which the mocked generateText never invokes.
expect(mockProxyFetch).not.toHaveBeenCalled();
});
it('reports responseTime in seconds, like every sibling adapter', async () => {
const nowSpy = vi.spyOn(Date, 'now').mockReturnValueOnce(1_000).mockReturnValueOnce(3_500);
mockAIResponse('answer');
const result = await searchWithClaude({ query: 'q', apiKey: 'sk-test' });
expect(result.responseTime).toBe(2.5);
nowSpy.mockRestore();
});
it('deduplicates sources by URL', async () => {
mockAIResponse('answer', [
urlSource('https://example.com/a', 'First', 'a'),
urlSource('https://example.com/a', 'Duplicate', 'b'),
]);
const result = await searchWithClaude({ query: 'q', apiKey: 'sk-test' });
expect(result.sources).toHaveLength(1);
expect(result.sources[0]!.title).toBe('First');
});
it('falls back to the hostname when a source has no title', async () => {
mockAIResponse('answer', [urlSource('https://docs.example.com/page')]);
const result = await searchWithClaude({ query: 'q', apiKey: 'sk-test' });
expect(result.sources[0]!.title).toBe('docs.example.com');
});
it('drops non-URL and non-HTTP sources', async () => {
mockAIResponse('answer', [
{ sourceType: 'document', type: 'source', id: 'd' } as unknown as UrlSource,
urlSource('ftp://example.com/file', 'FTP'),
urlSource('not a url', 'Bad'),
urlSource('https://example.com/ok', 'OK'),
]);
const result = await searchWithClaude({ query: 'q', apiKey: 'sk-test' });
expect(result.sources.map((s) => s.url)).toEqual(['https://example.com/ok']);
});
it('returns an empty source list when the answer cites nothing', async () => {
mockAIResponse('answer with no citations', []);
const result = await searchWithClaude({ query: 'q', apiKey: 'sk-test' });
expect(result.answer).toBe('answer with no citations');
expect(result.sources).toEqual([]);
});
// ── errors ─────────────────────────────────────────────────────────────────
it('propagates model call failures', async () => {
mockGenerateText.mockRejectedValueOnce(new Error('invalid x-api-key'));
await expect(searchWithClaude({ query: 'q', apiKey: 'bad' })).rejects.toThrow(
'invalid x-api-key',
);
});
});