diff --git a/app/api/claude/route.ts b/app/api/claude/route.ts index 93c1fba..46cf766 100644 --- a/app/api/claude/route.ts +++ b/app/api/claude/route.ts @@ -116,6 +116,42 @@ async function streamClaude( return fullText; } +// streams OpenCode Zen Claude response (uses Anthropic SDK with custom baseURL) +async function streamOpenCodeClaude( + client: Anthropic, + systemPrompt: string, + userPrompt: string, + maxTokens: number, + controller: ReadableStreamDefaultController, + encoder: TextEncoder, + model: string +): Promise { + const stream = await client.messages.stream({ + model: model, + max_tokens: maxTokens, + system: systemPrompt, + messages: [{ role: "user", content: userPrompt }], + }); + + let fullText = ""; + + for await (const event of stream) { + if (event.type === "content_block_delta") { + const delta = event.delta as { type: string; text?: string }; + if (delta.type === "text_delta" && delta.text) { + fullText += delta.text; + const data = JSON.stringify({ + type: "content_block_delta", + delta: { text: delta.text }, + }); + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + } + } + } + + return fullText; +} + // streams OpenAI response async function streamOpenAI( client: OpenAI, @@ -154,22 +190,25 @@ async function streamOpenAI( } // Helper to determine provider from model ID -function getProviderFromModel(model: string): "anthropic" | "openai" { +function getProviderFromModel(model: string): "anthropic" | "openai" | "opencode" { if (model.startsWith("gpt-") || model.startsWith("o1") || model.startsWith("o3")) { return "openai"; } - return "anthropic"; + if (model.startsWith("claude-")) { + return "anthropic"; + } + return "opencode"; } // Helper to detect API key type -function detectApiKeyProvider(apiKey: string): "anthropic" | "openai" | "unknown" { +function detectApiKeyProvider(apiKey: string): "anthropic" | "openai" | "opencode" | "unknown" { if (apiKey.startsWith("sk-ant-")) { return "anthropic"; } if (apiKey.startsWith("sk-") || apiKey.startsWith("sk-proj-")) { return "openai"; } - return "unknown"; + return "opencode"; } export async function POST(req: NextRequest) { @@ -207,12 +246,12 @@ export async function POST(req: NextRequest) { // Detect API key type and validate it matches the selected provider const keyProvider = detectApiKeyProvider(apiKey); - if (keyProvider !== "unknown" && keyProvider !== provider) { - const providerName = provider === "openai" ? "OpenAI" : "Anthropic"; - const keyProviderName = keyProvider === "openai" ? "OpenAI" : "Anthropic"; + const providers = { anthropic: "Anthropic", openai: "OpenAI", opencode: "OpenCode Zen" }; + const isOpencodeProvider = provider === "opencode"; + if (!isOpencodeProvider && keyProvider !== "unknown" && keyProvider !== provider) { return new Response( JSON.stringify({ - error: `You selected an ${providerName} model but provided an ${keyProviderName} API key. Please update your API key in Settings to match the selected model.`, + error: `You selected an ${providers[provider]} model but provided an ${providers[keyProvider]} API key. Please update your API key in Settings to match the selected model.`, }), { status: 400, @@ -228,9 +267,20 @@ export async function POST(req: NextRequest) { }); } + // Create appropriate client based on provider // Create appropriate client based on provider const anthropicClient = provider === "anthropic" ? new Anthropic({ apiKey }) : null; const openaiClient = provider === "openai" ? new OpenAI({ apiKey }) : null; + const opencodeAnthropicClient = provider === "opencode" + ? new Anthropic({ apiKey, baseURL: "https://opencode.ai/zen/v1" }) + : null; + const opencodeOpenAIClient = provider === "opencode" + ? new OpenAI({ apiKey, baseURL: "https://opencode.ai/zen/v1" }) + : null; + + // OpenCode Claude models use Anthropic API, others use OpenAI API + const isOpencodeClaude = provider === "opencode" && model.startsWith("claude-"); + const opencodeClient = isOpencodeClaude ? opencodeAnthropicClient : opencodeOpenAIClient; // get balanced config values const config = getConfigValues(); @@ -258,11 +308,21 @@ export async function POST(req: NextRequest) { const readable = new ReadableStream({ async start(controller) { try { - // first attempt - streaming let fullText: string; - if (provider === "openai" && openaiClient) { + if (isOpencodeClaude && opencodeAnthropicClient) { + fullText = await streamOpenCodeClaude( + opencodeAnthropicClient, + systemPrompt, + userPrompt, + maxTokens, + controller, + encoder, + model + ); + } else if ((provider === "openai" || (provider === "opencode" && !isOpencodeClaude)) && (openaiClient || opencodeOpenAIClient)) { + const client = provider === "opencode" ? opencodeOpenAIClient! : openaiClient!; fullText = await streamOpenAI( - openaiClient, + client, systemPrompt, userPrompt, maxTokens, @@ -319,9 +379,20 @@ export async function POST(req: NextRequest) { controller.enqueue(encoder.encode(`data: ${clearMsg}\n\n`)); // second attempt - also streaming - if (provider === "openai" && openaiClient) { + if (isOpencodeClaude && opencodeAnthropicClient) { + await streamOpenCodeClaude( + opencodeAnthropicClient, + systemPrompt, + retryUserPrompt, + maxTokens, + controller, + encoder, + model + ); + } else if ((provider === "openai" || (provider === "opencode" && !isOpencodeClaude)) && (openaiClient || opencodeOpenAIClient)) { + const client = provider === "opencode" ? opencodeOpenAIClient! : openaiClient!; await streamOpenAI( - openaiClient, + client, systemPrompt, retryUserPrompt, maxTokens, diff --git a/app/api/opencode-models/route.ts b/app/api/opencode-models/route.ts new file mode 100644 index 0000000..d32b043 --- /dev/null +++ b/app/api/opencode-models/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + try { + const res = await fetch("https://opencode.ai/zen/v1/models"); + if (!res.ok) { + return NextResponse.json({ error: "Failed to fetch models" }, { status: res.status }); + } + const data = await res.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 }); + } +} diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx index 956f639..90a768f 100644 --- a/components/SettingsModal.tsx +++ b/components/SettingsModal.tsx @@ -1,29 +1,45 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; -// Available models -export const AVAILABLE_MODELS = [ +export type Provider = "anthropic" | "openai" | "opencode"; + +interface BaseModel { + id: string; + name: string; + provider: Provider; +} + +const ANTHROPIC_MODELS: BaseModel[] = [ { id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4", provider: "anthropic" }, { id: "claude-opus-4-20250514", name: "Claude Opus 4", provider: "anthropic" }, +]; + +const OPENAI_MODELS: BaseModel[] = [ { id: "gpt-4o", name: "GPT-4o", provider: "openai" }, { id: "gpt-4o-mini", name: "GPT-4o Mini", provider: "openai" }, -] as const; - -export type ModelId = typeof AVAILABLE_MODELS[number]["id"]; +]; export interface Settings { - model: ModelId; + model: string; apiKey: string; + provider: Provider; } const DEFAULT_SETTINGS: Settings = { model: "claude-sonnet-4-20250514", apiKey: "", + provider: "anthropic", }; const STORAGE_KEY = "audial-settings"; +function inferProvider(model: string): Provider { + if (model.startsWith("gpt-")) return "openai"; + if (model.startsWith("claude-")) return "anthropic"; + return "opencode"; +} + export function loadSettings(): Settings { if (typeof window === "undefined") return DEFAULT_SETTINGS; try { @@ -33,6 +49,7 @@ export function loadSettings(): Settings { return { model: parsed.model || DEFAULT_SETTINGS.model, apiKey: parsed.apiKey || DEFAULT_SETTINGS.apiKey, + provider: parsed.provider || inferProvider(parsed.model || DEFAULT_SETTINGS.model), }; } } catch { @@ -65,11 +82,41 @@ export default function SettingsModal({ }: SettingsModalProps) { const [localSettings, setLocalSettings] = useState(settings); const [showApiKey, setShowApiKey] = useState(false); + const [opencodeModels, setOpencodeModels] = useState([]); + const loadAttemptedRef = useRef(false); useEffect(() => { setLocalSettings(settings); }, [settings]); + useEffect(() => { + if (localSettings.provider === "opencode" && opencodeModels.length === 0 && !loadAttemptedRef.current) { + loadAttemptedRef.current = true; + fetch("/api/opencode-models") + .then((res) => res.json()) + .then((data) => { + const models = data.data.map((m: { id: string }) => ({ + id: m.id, + name: m.id, + provider: "opencode" as const, + })); + setOpencodeModels(models); + }) + .catch(() => {}); + } + }, [localSettings.provider, opencodeModels.length]); + + const availableModels = [...ANTHROPIC_MODELS, ...OPENAI_MODELS, ...opencodeModels]; + + const getPlaceholder = () => { + switch (localSettings.provider) { + case "anthropic": return "sk-ant-..."; + case "openai": return "sk-..."; + case "opencode": return "OpenCode Zen API key"; + default: return "API key"; + } + }; + const handleSave = useCallback(() => { onSettingsChange(localSettings); saveSettings(localSettings); @@ -138,6 +185,32 @@ export default function SettingsModal({ + {/* Provider Selection */} +
+ + +
+ {/* Model Selection */}
@@ -181,7 +254,7 @@ export default function SettingsModal({ onChange={(e) => setLocalSettings((s) => ({ ...s, apiKey: e.target.value })) } - placeholder="sk-ant-..." + placeholder={getPlaceholder()} autoComplete="off" data-1p-ignore data-lpignore="true"