Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 84 additions & 13 deletions app/api/claude/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions app/api/opencode-models/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
105 changes: 89 additions & 16 deletions components/SettingsModal.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -65,11 +82,41 @@ export default function SettingsModal({
}: SettingsModalProps) {
const [localSettings, setLocalSettings] = useState<Settings>(settings);
const [showApiKey, setShowApiKey] = useState(false);
const [opencodeModels, setOpencodeModels] = useState<BaseModel[]>([]);
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);
Expand Down Expand Up @@ -138,6 +185,32 @@ export default function SettingsModal({
</button>
</div>

{/* Provider Selection */}
<div className="mb-5">
<label
className="block text-sm font-medium mb-2"
style={{ color: "var(--text-alt)", opacity: 0.8 }}
>
Provider
</label>
<select
value={localSettings.provider}
onChange={(e) =>
setLocalSettings((s) => ({ ...s, provider: e.target.value as Provider, model: "" }))
}
className="w-full px-3 py-2.5 rounded-lg text-sm"
style={{
background: "var(--bg)",
color: "var(--text-alt)",
border: "1px solid var(--border-right-panel)",
}}
>
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
<option value="opencode">OpenCode Zen</option>
</select>
</div>

{/* Model Selection */}
<div className="mb-5">
<label
Expand All @@ -148,21 +221,21 @@ export default function SettingsModal({
</label>
<select
value={localSettings.model}
onChange={(e) =>
setLocalSettings((s) => ({ ...s, model: e.target.value as ModelId }))
}
onChange={(e) => setLocalSettings((s) => ({ ...s, model: e.target.value }))}
className="w-full px-3 py-2.5 rounded-lg text-sm"
style={{
background: "var(--bg)",
color: "var(--text-alt)",
border: "1px solid var(--border-right-panel)",
}}
>
{AVAILABLE_MODELS.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
{availableModels
.filter((m) => m.provider === localSettings.provider)
.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</select>
</div>

Expand All @@ -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"
Expand Down