From 264d4af5fcefd2b51f3429eb207d5cbf27da3e9c Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Sun, 1 Mar 2026 00:32:18 +0100 Subject: [PATCH] fix: call Anthropic API directly instead of /api/* dev proxy (#155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AI panel called /api/ai/agent and /api/ai/chat — relative HTTP endpoints that only exist in the Vite dev server. In the Tauri app there is no local HTTP server, so requests failed with "The string did not match the expected pattern". Replace with direct calls to https://api.anthropic.com/v1/messages with proper headers (x-api-key, anthropic-version). Tauri's webview allows fetch() to external URLs without CORS issues. Co-authored-by: Test Co-authored-by: Claude Opus 4.6 --- src/utils/ai-agent.test.ts | 157 ++++++++++++++++++++++++++++++++ src/utils/ai-agent.ts | 16 +++- src/utils/ai-chat.test.ts | 178 +++++++++++++++++++++++++++++++++++++ src/utils/ai-chat.ts | 14 ++- 4 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 src/utils/ai-agent.test.ts create mode 100644 src/utils/ai-chat.test.ts diff --git a/src/utils/ai-agent.test.ts b/src/utils/ai-agent.test.ts new file mode 100644 index 00000000..5238abeb --- /dev/null +++ b/src/utils/ai-agent.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// localStorage mock (jsdom doesn't provide a full implementation) +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + } +})() +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) + +import { buildAgentSystemPrompt, executeToolViaWs, runAgentLoop, type AgentStepCallback } from './ai-agent' + +// --- buildAgentSystemPrompt --- + +describe('buildAgentSystemPrompt', () => { + it('returns preamble when no vault context', () => { + const prompt = buildAgentSystemPrompt() + expect(prompt).toContain('AI assistant integrated into Laputa') + expect(prompt).not.toContain('Vault context') + }) + + it('appends vault context when provided', () => { + const prompt = buildAgentSystemPrompt('Recent notes: foo, bar') + expect(prompt).toContain('AI assistant integrated into Laputa') + expect(prompt).toContain('Vault context:') + expect(prompt).toContain('Recent notes: foo, bar') + }) +}) + +// --- runAgentLoop calls Anthropic API directly --- + +describe('runAgentLoop', () => { + const mockCallbacks: AgentStepCallback = { + onThinking: vi.fn(), + onToolStart: vi.fn(), + onToolDone: vi.fn(), + onText: vi.fn(), + onError: vi.fn(), + onDone: vi.fn(), + } + + beforeEach(() => { + vi.restoreAllMocks() + localStorageMock.clear() + localStorageMock.setItem('laputa:anthropic-api-key', 'test-key-123') + }) + + it('calls https://api.anthropic.com/v1/messages directly', async () => { + const mockResponse = { + id: 'msg_123', + role: 'assistant', + content: [{ type: 'text', text: 'Hello!' }], + stop_reason: 'end_turn', + } + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(mockResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + await runAgentLoop('test message', 'claude-3-5-haiku-20241022', 'system prompt', mockCallbacks) + + expect(fetchSpy).toHaveBeenCalledOnce() + const [url, options] = fetchSpy.mock.calls[0] + expect(url).toBe('https://api.anthropic.com/v1/messages') + expect(options?.method).toBe('POST') + + const headers = options?.headers as Record + expect(headers['x-api-key']).toBe('test-key-123') + expect(headers['anthropic-version']).toBe('2023-06-01') + expect(headers['Content-Type']).toBe('application/json') + + const body = JSON.parse(options?.body as string) + expect(body.model).toBe('claude-3-5-haiku-20241022') + expect(body.max_tokens).toBe(4096) + expect(body.system).toBe('system prompt') + expect(body.messages).toEqual([{ role: 'user', content: 'test message' }]) + expect(body.tools).toBeDefined() + // API key must NOT be in the body + expect(body.apiKey).toBeUndefined() + }) + + it('calls onText and onDone for a text-only response', async () => { + const mockResponse = { + id: 'msg_123', + role: 'assistant', + content: [{ type: 'text', text: 'Here is your answer.' }], + stop_reason: 'end_turn', + } + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(mockResponse), { status: 200 }), + ) + + await runAgentLoop('question', 'claude-3-5-haiku-20241022', 'sys', mockCallbacks) + + expect(mockCallbacks.onThinking).toHaveBeenCalled() + expect(mockCallbacks.onText).toHaveBeenCalledWith('Here is your answer.') + expect(mockCallbacks.onDone).toHaveBeenCalled() + expect(mockCallbacks.onError).not.toHaveBeenCalled() + }) + + it('calls onError when API key is missing', async () => { + localStorageMock.clear() + + await runAgentLoop('test', 'claude-3-5-haiku-20241022', 'sys', mockCallbacks) + + expect(mockCallbacks.onError).toHaveBeenCalledWith( + expect.stringContaining('No API key configured'), + ) + }) + + it('calls onError with API error message on non-ok response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ error: { message: 'Invalid API key' } }), { status: 401 }), + ) + + await runAgentLoop('test', 'claude-3-5-haiku-20241022', 'sys', mockCallbacks) + + expect(mockCallbacks.onError).toHaveBeenCalledWith('Invalid API key') + }) + + it('does not send apiKey in the request body', async () => { + const mockResponse = { + id: 'msg_123', + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + stop_reason: 'end_turn', + } + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(mockResponse), { status: 200 }), + ) + + await runAgentLoop('test', 'claude-3-5-haiku-20241022', 'sys', mockCallbacks) + + const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string) + expect(body).not.toHaveProperty('apiKey') + expect(body).not.toHaveProperty('maxTokens') + }) +}) + +// --- executeToolViaWs --- + +describe('executeToolViaWs', () => { + it('resolves with error when WebSocket is unavailable', async () => { + // jsdom WebSocket will fail to connect + const result = await executeToolViaWs('read_note', { path: 'test.md' }) + expect(result.isError).toBe(true) + }) +}) diff --git a/src/utils/ai-agent.ts b/src/utils/ai-agent.ts index e661addf..d8dd25f9 100644 --- a/src/utils/ai-agent.ts +++ b/src/utils/ai-agent.ts @@ -272,10 +272,20 @@ async function callAnthropicAgent( const apiKey = getApiKey() if (!apiKey) throw new Error('No API key configured. Open Settings (⌘,) to add your Anthropic key.') - const response = await fetch('/api/ai/agent', { + const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ apiKey, model, messages, system, maxTokens: 4096, tools }), + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model, + max_tokens: 4096, + system: system || undefined, + messages, + tools, + }), }) if (!response.ok) { diff --git a/src/utils/ai-chat.test.ts b/src/utils/ai-chat.test.ts new file mode 100644 index 00000000..93743951 --- /dev/null +++ b/src/utils/ai-chat.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// localStorage mock (jsdom doesn't provide a full implementation) +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + } +})() +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) + +import { + getApiKey, setApiKey, estimateTokens, buildSystemPrompt, + nextMessageId, streamChat, +} from './ai-chat' +import type { VaultEntry } from '../types' + +// --- getApiKey / setApiKey --- + +describe('getApiKey / setApiKey', () => { + beforeEach(() => localStorageMock.clear()) + + it('returns empty string when no key set', () => { + expect(getApiKey()).toBe('') + }) + + it('round-trips an API key', () => { + setApiKey('sk-test-abc') + expect(getApiKey()).toBe('sk-test-abc') + }) +}) + +// --- estimateTokens --- + +describe('estimateTokens', () => { + it('estimates tokens from string length', () => { + expect(estimateTokens('abcd')).toBe(1) + expect(estimateTokens('abcdefgh')).toBe(2) + }) + + it('accepts a number (char count)', () => { + expect(estimateTokens(100)).toBe(25) + }) +}) + +// --- buildSystemPrompt --- + +describe('buildSystemPrompt', () => { + const makeEntry = (path: string, title: string): VaultEntry => ({ + path, title, filename: `${title}.md`, isA: 'Note', + aliases: [], belongsTo: [], relatedTo: [], + status: null, owner: null, cadence: null, + modifiedAt: null, createdAt: null, fileSize: 100, + snippet: '', relationships: {}, + }) + + it('returns empty prompt for no notes', () => { + const result = buildSystemPrompt([], {}) + expect(result.prompt).toBe('') + expect(result.totalTokens).toBe(0) + expect(result.truncated).toBe(false) + }) + + it('includes note content in the prompt', () => { + const notes = [makeEntry('/test.md', 'Test Note')] + const content = { '/test.md': '# Test Note\nHello world' } + const result = buildSystemPrompt(notes, content) + expect(result.prompt).toContain('Test Note') + expect(result.prompt).toContain('Hello world') + expect(result.totalTokens).toBeGreaterThan(0) + }) +}) + +// --- nextMessageId --- + +describe('nextMessageId', () => { + it('returns unique IDs', () => { + const id1 = nextMessageId() + const id2 = nextMessageId() + expect(id1).not.toBe(id2) + expect(id1).toMatch(/^msg-/) + }) +}) + +// --- streamChat calls Anthropic API directly --- + +describe('streamChat', () => { + const onChunk = vi.fn() + const onDone = vi.fn() + const onError = vi.fn() + + beforeEach(() => { + vi.restoreAllMocks() + onChunk.mockClear() + onDone.mockClear() + onError.mockClear() + localStorageMock.clear() + localStorageMock.setItem('laputa:anthropic-api-key', 'test-key-456') + }) + + it('calls https://api.anthropic.com/v1/messages with correct headers', async () => { + const sseData = [ + 'data: {"type":"content_block_delta","delta":{"text":"Hi"}}\n\n', + 'data: {"type":"message_stop"}\n\n', + ].join('') + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(sseData, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }), + ) + + await streamChat( + [{ role: 'user', content: 'hello' }], 'system', 'claude-3-5-haiku-20241022', + onChunk, onDone, onError, + ) + + expect(fetchSpy).toHaveBeenCalledOnce() + const [url, options] = fetchSpy.mock.calls[0] + expect(url).toBe('https://api.anthropic.com/v1/messages') + + const headers = options?.headers as Record + expect(headers['x-api-key']).toBe('test-key-456') + expect(headers['anthropic-version']).toBe('2023-06-01') + + const body = JSON.parse(options?.body as string) + expect(body.stream).toBe(true) + expect(body.model).toBe('claude-3-5-haiku-20241022') + expect(body.messages).toEqual([{ role: 'user', content: 'hello' }]) + // API key must NOT be in the body + expect(body.apiKey).toBeUndefined() + }) + + it('calls onError when API key is missing', async () => { + localStorageMock.clear() + + await streamChat([], '', 'model', onChunk, onDone, onError) + + expect(onError).toHaveBeenCalledWith( + expect.stringContaining('No API key configured'), + ) + expect(onChunk).not.toHaveBeenCalled() + }) + + it('calls onError with parsed error on non-ok response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ error: { message: 'Invalid x-api-key' } }), { status: 401 }), + ) + + await streamChat( + [{ role: 'user', content: 'hi' }], '', 'model', + onChunk, onDone, onError, + ) + + expect(onError).toHaveBeenCalledWith('Invalid x-api-key') + }) + + it('does not send apiKey in the request body', async () => { + const sseData = 'data: {"type":"message_stop"}\n\n' + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(sseData, { status: 200 }), + ) + + await streamChat( + [{ role: 'user', content: 'hi' }], '', 'model', + onChunk, onDone, onError, + ) + + const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string) + expect(body).not.toHaveProperty('apiKey') + expect(body).not.toHaveProperty('maxTokens') + expect(body.max_tokens).toBe(4096) + }) +}) diff --git a/src/utils/ai-chat.ts b/src/utils/ai-chat.ts index 2c0535f3..d5bf698e 100644 --- a/src/utils/ai-chat.ts +++ b/src/utils/ai-chat.ts @@ -153,13 +153,19 @@ export async function streamChat( } try { - const response = await fetch('/api/ai/chat', { + const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, body: JSON.stringify({ - apiKey, model, messages, + model, + max_tokens: 4096, system: systemPrompt || undefined, - maxTokens: 4096, + messages, + stream: true, }), })