diff --git a/.env.example b/.env.example index f700a2cb..be2fd10e 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,10 @@ GEMINI_API_KEY=your_gemini_api_key_here # Get your API key from: https://platform.openai.com/api-keys OPENAI_API_KEY=your_openai_api_key_here +# Anthropic API Key (Optional - only needed if using Anthropic Claude LLM provider) +# Get your API key from: https://console.anthropic.com/settings/keys +ANTHROPIC_API_KEY=your_anthropic_api_key_here + # Replicate API Key (Optional - only needed if using Replicate models) # Get your API key from: https://replicate.com/account/api-tokens REPLICATE_API_KEY=your_replicate_api_key_here diff --git a/src/app/api/env-status/route.ts b/src/app/api/env-status/route.ts index 038c878c..798328cc 100644 --- a/src/app/api/env-status/route.ts +++ b/src/app/api/env-status/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; export interface EnvStatusResponse { gemini: boolean; openai: boolean; + anthropic: boolean; replicate: boolean; fal: boolean; kie: boolean; @@ -14,6 +15,7 @@ export async function GET() { const status: EnvStatusResponse = { gemini: !!process.env.GEMINI_API_KEY, openai: !!process.env.OPENAI_API_KEY, + anthropic: !!process.env.ANTHROPIC_API_KEY, replicate: !!process.env.REPLICATE_API_KEY, fal: !!process.env.FAL_API_KEY, kie: !!process.env.KIE_API_KEY, diff --git a/src/app/api/llm/__tests__/route.test.ts b/src/app/api/llm/__tests__/route.test.ts index fcb6903f..c942b99b 100644 --- a/src/app/api/llm/__tests__/route.test.ts +++ b/src/app/api/llm/__tests__/route.test.ts @@ -583,4 +583,290 @@ describe("/api/llm route", () => { expect(data.error).toBe("No text in OpenAI response"); }); }); + + describe("Anthropic provider", () => { + beforeEach(() => { + global.fetch = mockFetch; + }); + + it("should generate text successfully with Anthropic", async () => { + process.env.ANTHROPIC_API_KEY = "test-anthropic-key"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + content: [{ type: "text", text: "Anthropic response text" }], + }), + }); + + const request = createMockPostRequest({ + prompt: "Test prompt", + provider: "anthropic", + model: "claude-sonnet-4.5", + temperature: 0.7, + maxTokens: 1024, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.text).toBe("Anthropic response text"); + + // Verify fetch was called with correct parameters + expect(mockFetch).toHaveBeenCalledWith( + "https://api.anthropic.com/v1/messages", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": "test-anthropic-key", + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: "claude-sonnet-4-5-20250929", + messages: [{ role: "user", content: [{ type: "text", text: "Test prompt" }] }], + temperature: 0.7, + max_tokens: 1024, + }), + }) + ); + }); + + it("should handle multimodal input with Anthropic content block structure", async () => { + process.env.ANTHROPIC_API_KEY = "test-anthropic-key"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + content: [{ type: "text", text: "Image description from Claude" }], + }), + }); + + const request = createMockPostRequest({ + prompt: "Describe this image", + images: ["data:image/png;base64,iVBORw0KGgo="], + provider: "anthropic", + model: "claude-sonnet-4.5", + temperature: 0.7, + maxTokens: 1024, + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.text).toBe("Image description from Claude"); + + // Verify Anthropic content block structure + expect(mockFetch).toHaveBeenCalledWith( + "https://api.anthropic.com/v1/messages", + expect.objectContaining({ + body: JSON.stringify({ + model: "claude-sonnet-4-5-20250929", + messages: [ + { + role: "user", + content: [ + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgo=" }, + }, + { type: "text", text: "Describe this image" }, + ], + }, + ], + temperature: 0.7, + max_tokens: 1024, + }), + }) + ); + }); + + it("should reject missing Anthropic API key", async () => { + delete process.env.ANTHROPIC_API_KEY; + + const request = createMockPostRequest({ + prompt: "Test prompt", + provider: "anthropic", + model: "claude-sonnet-4.5", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toContain("ANTHROPIC_API_KEY not configured"); + }); + + it("should use X-Anthropic-API-Key header over env var", async () => { + process.env.ANTHROPIC_API_KEY = "env-anthropic-key"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + content: [{ type: "text", text: "Response with header key" }], + }), + }); + + const request = createMockPostRequest( + { + prompt: "Test prompt", + provider: "anthropic", + model: "claude-sonnet-4.5", + }, + { "X-Anthropic-API-Key": "header-anthropic-key" } + ); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + + // Verify fetch was called with header key (takes precedence) + expect(mockFetch).toHaveBeenCalledWith( + "https://api.anthropic.com/v1/messages", + expect.objectContaining({ + headers: { + "Content-Type": "application/json", + "x-api-key": "header-anthropic-key", + "anthropic-version": "2023-06-01", + }, + }) + ); + }); + + it("should return 429 on rate limit errors", async () => { + process.env.ANTHROPIC_API_KEY = "test-anthropic-key"; + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 429, + json: () => + Promise.resolve({ + error: { message: "429 Rate limit exceeded" }, + }), + }); + + const request = createMockPostRequest({ + prompt: "Test prompt", + provider: "anthropic", + model: "claude-sonnet-4.5", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(429); + expect(data.success).toBe(false); + expect(data.error).toBe("Rate limit reached. Please wait and try again."); + }); + + it("should handle Anthropic API error responses", async () => { + process.env.ANTHROPIC_API_KEY = "test-anthropic-key"; + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: () => + Promise.resolve({ + error: { message: "Invalid API key" }, + }), + }); + + const request = createMockPostRequest({ + prompt: "Test prompt", + provider: "anthropic", + model: "claude-sonnet-4.5", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toBe("Invalid API key"); + }); + + it("should handle no text in Anthropic response", async () => { + process.env.ANTHROPIC_API_KEY = "test-anthropic-key"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + content: [], + }), + }); + + const request = createMockPostRequest({ + prompt: "Test prompt", + provider: "anthropic", + model: "claude-sonnet-4.5", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toBe("No text in Anthropic response"); + }); + + it("should handle image without data URL prefix", async () => { + process.env.ANTHROPIC_API_KEY = "test-anthropic-key"; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + content: [{ type: "text", text: "Image description" }], + }), + }); + + const request = createMockPostRequest({ + prompt: "Describe this", + images: ["iVBORw0KGgoAAAANSUhEUgAAAAUA"], + provider: "anthropic", + model: "claude-sonnet-4.5", + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + + // Verify fallback to PNG mime type + expect(mockFetch).toHaveBeenCalledWith( + "https://api.anthropic.com/v1/messages", + expect.objectContaining({ + body: JSON.stringify({ + model: "claude-sonnet-4-5-20250929", + messages: [ + { + role: "user", + content: [ + { + type: "image", + source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgoAAAANSUhEUgAAAAUA" }, + }, + { type: "text", text: "Describe this" }, + ], + }, + ], + temperature: 0.7, + max_tokens: 1024, + }), + }) + ); + }); + }); }); diff --git a/src/app/api/llm/route.ts b/src/app/api/llm/route.ts index 411495a8..260614ef 100644 --- a/src/app/api/llm/route.ts +++ b/src/app/api/llm/route.ts @@ -15,6 +15,7 @@ const GOOGLE_MODEL_MAP: Record = { "gemini-2.5-flash": "gemini-2.5-flash", "gemini-3-flash-preview": "gemini-3-flash-preview", "gemini-3-pro-preview": "gemini-3-pro-preview", + "gemini-3.1-pro-preview": "gemini-3.1-pro-preview", }; const OPENAI_MODEL_MAP: Record = { @@ -22,6 +23,12 @@ const OPENAI_MODEL_MAP: Record = { "gpt-4.1-nano": "gpt-4.1-nano", }; +const ANTHROPIC_MODEL_MAP: Record = { + "claude-sonnet-4.5": "claude-sonnet-4-5-20250929", + "claude-haiku-4.5": "claude-haiku-4-5-20251001", + "claude-opus-4.6": "claude-opus-4-6", +}; + async function generateWithGoogle( prompt: string, model: LLMModelType, @@ -190,6 +197,98 @@ async function generateWithOpenAI( return text; } +async function generateWithAnthropic( + prompt: string, + model: LLMModelType, + temperature: number, + maxTokens: number, + images?: string[], + requestId?: string, + userApiKey?: string | null +): Promise { + const apiKey = userApiKey || process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + logger.error('api.error', 'ANTHROPIC_API_KEY not configured', { requestId }); + throw new Error("ANTHROPIC_API_KEY not configured. Add it to .env.local or configure in Settings."); + } + + const modelId = ANTHROPIC_MODEL_MAP[model]; + + logger.info('api.llm', 'Calling Anthropic API', { + requestId, + model: modelId, + temperature, + maxTokens, + imageCount: images?.length || 0, + promptLength: prompt.length, + }); + + // Build content blocks + const content: Array<{ type: string; text?: string; source?: { type: string; media_type: string; data: string } }> = []; + + if (images && images.length > 0) { + for (const img of images) { + const matches = img.match(/^data:(.+?);base64,(.+)$/); + if (matches) { + content.push({ + type: "image", + source: { type: "base64", media_type: matches[1], data: matches[2] }, + }); + } else { + content.push({ + type: "image", + source: { type: "base64", media_type: "image/png", data: img }, + }); + } + } + } + + content.push({ type: "text", text: prompt }); + + const startTime = Date.now(); + const response = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: modelId, + messages: [{ role: "user", content }], + temperature, + max_tokens: maxTokens, + }), + }); + const duration = Date.now() - startTime; + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + logger.error('api.error', 'Anthropic API request failed', { + requestId, + status: response.status, + error: error.error?.message, + }); + throw new Error(error.error?.message || `Anthropic API error: ${response.status}`); + } + + const data = await response.json(); + const text = data.content?.[0]?.text; + + if (!text) { + logger.error('api.error', 'No text in Anthropic response', { requestId }); + throw new Error("No text in Anthropic response"); + } + + logger.info('api.llm', 'Anthropic API response received', { + requestId, + duration, + responseLength: text.length, + }); + + return text; +} + export async function POST(request: NextRequest) { const requestId = generateRequestId(); @@ -197,6 +296,7 @@ export async function POST(request: NextRequest) { // Get user-provided API keys from headers (override env variables) const geminiApiKey = request.headers.get("X-Gemini-API-Key"); const openaiApiKey = request.headers.get("X-OpenAI-API-Key"); + const anthropicApiKey = request.headers.get("X-Anthropic-API-Key"); const body: LLMGenerateRequest = await request.json(); const { @@ -233,6 +333,8 @@ export async function POST(request: NextRequest) { text = await generateWithGoogle(prompt, model, temperature, maxTokens, images, requestId, geminiApiKey); } else if (provider === "openai") { text = await generateWithOpenAI(prompt, model, temperature, maxTokens, images, requestId, openaiApiKey); + } else if (provider === "anthropic") { + text = await generateWithAnthropic(prompt, model, temperature, maxTokens, images, requestId, anthropicApiKey); } else { logger.warn('api.llm', 'Unknown provider requested', { requestId, provider }); return NextResponse.json( diff --git a/src/components/CostDialog.tsx b/src/components/CostDialog.tsx index 5716df77..28a84d77 100644 --- a/src/components/CostDialog.tsx +++ b/src/components/CostDialog.tsx @@ -20,6 +20,7 @@ function ProviderIcon({ provider }: { provider: ProviderType }) { fal: { bg: "bg-purple-500/20", text: "text-purple-300" }, replicate: { bg: "bg-blue-500/20", text: "text-blue-300" }, openai: { bg: "bg-teal-500/20", text: "text-teal-300" }, + anthropic: { bg: "bg-amber-500/20", text: "text-amber-300" }, kie: { bg: "bg-orange-500/20", text: "text-orange-300" }, wavespeed: { bg: "bg-purple-500/20", text: "text-purple-300" }, }; @@ -29,6 +30,7 @@ function ProviderIcon({ provider }: { provider: ProviderType }) { fal: "f", replicate: "R", openai: "O", + anthropic: "A", kie: "K", wavespeed: "W", }; @@ -51,6 +53,7 @@ function getProviderDisplayName(provider: ProviderType): string { fal: "fal.ai", replicate: "Replicate", openai: "OpenAI", + anthropic: "Anthropic", kie: "Kie.ai", wavespeed: "WaveSpeed", }; diff --git a/src/components/ProjectSetupModal.tsx b/src/components/ProjectSetupModal.tsx index 3e680d04..bf081d99 100644 --- a/src/components/ProjectSetupModal.tsx +++ b/src/components/ProjectSetupModal.tsx @@ -13,6 +13,7 @@ import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog"; const LLM_PROVIDERS: { value: LLMProvider; label: string }[] = [ { value: "google", label: "Google" }, { value: "openai", label: "OpenAI" }, + { value: "anthropic", label: "Anthropic" }, ]; const LLM_MODELS: Record = { @@ -20,11 +21,17 @@ const LLM_MODELS: Record { value: "gemini-3-flash-preview", label: "Gemini 3 Flash" }, { value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, { value: "gemini-3-pro-preview", label: "Gemini 3.0 Pro" }, + { value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro" }, ], openai: [ { value: "gpt-4.1-mini", label: "GPT-4.1 Mini" }, { value: "gpt-4.1-nano", label: "GPT-4.1 Nano" }, ], + anthropic: [ + { value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5" }, + { value: "claude-haiku-4.5", label: "Claude Haiku 4.5" }, + { value: "claude-opus-4.6", label: "Claude Opus 4.6" }, + ], }; // Provider icons @@ -149,6 +156,7 @@ export function ProjectSetupModal({ const [showApiKey, setShowApiKey] = useState>({ gemini: false, openai: false, + anthropic: false, replicate: false, fal: false, kie: false, @@ -157,6 +165,7 @@ export function ProjectSetupModal({ const [overrideActive, setOverrideActive] = useState>({ gemini: false, openai: false, + anthropic: false, replicate: false, fal: false, kie: false, @@ -192,11 +201,12 @@ export function ProjectSetupModal({ // Sync local providers state setLocalProviders(providerSettings); - setShowApiKey({ gemini: false, openai: false, replicate: false, fal: false, kie: false, wavespeed: false }); + setShowApiKey({ gemini: false, openai: false, anthropic: false, replicate: false, fal: false, kie: false, wavespeed: false }); // Initialize override as active if user already has a key set setOverrideActive({ gemini: !!providerSettings.providers.gemini?.apiKey, openai: !!providerSettings.providers.openai?.apiKey, + anthropic: !!providerSettings.providers.anthropic?.apiKey, replicate: !!providerSettings.providers.replicate?.apiKey, fal: !!providerSettings.providers.fal?.apiKey, kie: !!providerSettings.providers.kie?.apiKey, @@ -298,7 +308,7 @@ export function ProjectSetupModal({ const handleSaveProviders = () => { // Save each provider's settings - const providerIds: ProviderType[] = ["gemini", "openai", "replicate", "fal", "kie", "wavespeed"]; + const providerIds: ProviderType[] = ["gemini", "openai", "anthropic", "replicate", "fal", "kie", "wavespeed"]; for (const providerId of providerIds) { const local = localProviders.providers[providerId]; const current = providerSettings.providers[providerId]; @@ -572,6 +582,54 @@ export function ProjectSetupModal({ + {/* Anthropic Provider */} +
+
+ Anthropic + {envStatus?.anthropic && !overrideActive.anthropic ? ( +
+ Configured via .env + +
+ ) : ( +
+ updateLocalProvider("anthropic", { apiKey: e.target.value || null })} + placeholder="sk-ant-..." + className="w-48 px-2 py-1 bg-neutral-800 border border-neutral-600 rounded text-neutral-100 text-xs focus:outline-none focus:border-neutral-500" + /> + + {envStatus?.anthropic && ( + + )} +
+ )} +
+
+ {/* Replicate Provider */}
@@ -904,12 +962,15 @@ export function ProjectSetupModal({ onChange={(e) => { const newProvider = e.target.value as LLMProvider; const firstModelForProvider = LLM_MODELS[newProvider][0].value; + const currentTemp = localNodeDefaults.llm?.temperature ?? 0.7; setLocalNodeDefaults(prev => ({ ...prev, llm: { ...prev.llm, provider: newProvider, model: firstModelForProvider, + // Clamp temperature for Anthropic (max 1.0) + ...(newProvider === "anthropic" && currentTemp > 1 ? { temperature: 1 } : {}), } })); }} @@ -948,7 +1009,7 @@ export function ProjectSetupModal({ { diff --git a/src/components/nodes/LLMGenerateNode.tsx b/src/components/nodes/LLMGenerateNode.tsx index 523cb5fe..fb9ad6d2 100644 --- a/src/components/nodes/LLMGenerateNode.tsx +++ b/src/components/nodes/LLMGenerateNode.tsx @@ -10,6 +10,7 @@ import { LLMGenerateNodeData, LLMProvider, LLMModelType } from "@/types"; const PROVIDERS: { value: LLMProvider; label: string }[] = [ { value: "google", label: "Google" }, { value: "openai", label: "OpenAI" }, + { value: "anthropic", label: "Anthropic" }, ]; const MODELS: Record = { @@ -17,11 +18,17 @@ const MODELS: Record = { { value: "gemini-3-flash-preview", label: "Gemini 3 Flash" }, { value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, { value: "gemini-3-pro-preview", label: "Gemini 3.0 Pro" }, + { value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro" }, ], openai: [ { value: "gpt-4.1-mini", label: "GPT-4.1 Mini" }, { value: "gpt-4.1-nano", label: "GPT-4.1 Nano" }, ], + anthropic: [ + { value: "claude-sonnet-4.5", label: "Claude Sonnet 4.5" }, + { value: "claude-haiku-4.5", label: "Claude Haiku 4.5" }, + { value: "claude-opus-4.6", label: "Claude Opus 4.6" }, + ], }; type LLMGenerateNodeType = Node; @@ -35,12 +42,17 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps) => { const newProvider = e.target.value as LLMProvider; const firstModelForProvider = MODELS[newProvider][0].value; - updateNodeData(id, { + const updates: Partial = { provider: newProvider, - model: firstModelForProvider - }); + model: firstModelForProvider, + }; + // Anthropic limits temperature to 0-1 + if (newProvider === "anthropic" && nodeData.temperature > 1) { + updates.temperature = 1; + } + updateNodeData(id, updates); }, - [id, updateNodeData] + [id, updateNodeData, nodeData.temperature] ); const handleModelChange = useCallback( @@ -266,7 +278,7 @@ export function LLMGenerateNode({ id, data, selected }: NodeProps = { kie: "X-Kie-Key", wavespeed: "X-WaveSpeed-Key", openai: "X-OpenAI-API-Key", + anthropic: "X-Anthropic-API-Key", }; /** @@ -65,6 +66,11 @@ export function buildLlmHeaders( if (openaiConfig?.apiKey) { headers["X-OpenAI-API-Key"] = openaiConfig.apiKey; } + } else if (llmProvider === "anthropic") { + const anthropicConfig = providerSettings.providers.anthropic; + if (anthropicConfig?.apiKey) { + headers["X-Anthropic-API-Key"] = anthropicConfig.apiKey; + } } return headers; diff --git a/src/store/utils/localStorage.ts b/src/store/utils/localStorage.ts index 41165034..fb5ed9b9 100644 --- a/src/store/utils/localStorage.ts +++ b/src/store/utils/localStorage.ts @@ -45,6 +45,7 @@ export const defaultProviderSettings: ProviderSettings = { providers: { gemini: { id: "gemini", name: "Google Gemini", enabled: true, apiKey: null, apiKeyEnvVar: "GEMINI_API_KEY" }, openai: { id: "openai", name: "OpenAI", enabled: true, apiKey: null, apiKeyEnvVar: "OPENAI_API_KEY" }, + anthropic: { id: "anthropic", name: "Anthropic", enabled: true, apiKey: null, apiKeyEnvVar: "ANTHROPIC_API_KEY" }, replicate: { id: "replicate", name: "Replicate", enabled: false, apiKey: null, apiKeyEnvVar: "REPLICATE_API_KEY" }, fal: { id: "fal", name: "fal.ai", enabled: false, apiKey: null, apiKeyEnvVar: "FAL_API_KEY" }, kie: { id: "kie", name: "Kie.ai", enabled: false, apiKey: null, apiKeyEnvVar: "KIE_API_KEY" }, diff --git a/src/types/providers.ts b/src/types/providers.ts index bfc1d536..a249b5a8 100644 --- a/src/types/providers.ts +++ b/src/types/providers.ts @@ -6,7 +6,7 @@ */ // Provider Types for multi-provider support (image/video generation) -export type ProviderType = "gemini" | "openai" | "replicate" | "fal" | "kie" | "wavespeed"; +export type ProviderType = "gemini" | "openai" | "anthropic" | "replicate" | "fal" | "kie" | "wavespeed"; // Model pricing info (stored when model is selected) export interface SelectedModelPricing { @@ -36,15 +36,19 @@ export interface ProviderSettings { } // LLM Provider Options -export type LLMProvider = "google" | "openai"; +export type LLMProvider = "google" | "openai" | "anthropic"; // LLM Model Options export type LLMModelType = | "gemini-2.5-flash" | "gemini-3-flash-preview" | "gemini-3-pro-preview" + | "gemini-3.1-pro-preview" | "gpt-4.1-mini" - | "gpt-4.1-nano"; + | "gpt-4.1-nano" + | "claude-opus-4.6" + | "claude-sonnet-4.5" + | "claude-haiku-4.5"; // Recently used models tracking export interface RecentModel {