fix: call Anthropic API directly instead of /api/* dev proxy (#155)
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 <test@test.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
157
src/utils/ai-agent.test.ts
Normal file
157
src/utils/ai-agent.test.ts
Normal file
@@ -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<string, string> = {}
|
||||
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<string, string>
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
178
src/utils/ai-chat.test.ts
Normal file
178
src/utils/ai-chat.test.ts
Normal file
@@ -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<string, string> = {}
|
||||
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<string, string>
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user