Skip to content
Draft
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
138 changes: 130 additions & 8 deletions src/components/TopTabsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ import { useProjectContext } from "../lib/project/ProjectContext";
import { getActiveProviderId, setActiveProviderId } from "../lib/ai/settings";
import type { AiProviderId } from "../lib/ai/types";

const AI_PROVIDERS: Array<{ id: string; name: string; providerId?: AiProviderId }> = [
{ id: "google", name: "Google Gemini", providerId: "gemini" },
{ id: "anthropic", name: "Anthropic Claude", providerId: "anthropic" },
{ id: "xai", name: "xAI" },
{ id: "openai", name: "OpenAI" },
const AI_PROVIDERS: Array<{ id: string; name: string; providerId?: AiProviderId; hasCustomUrl?: boolean }> = [
{ id: "google", name: "Google Gemini", providerId: "gemini" },
{ id: "anthropic", name: "Anthropic Claude", providerId: "anthropic" },
{ id: "xai", name: "xAI" },
{ id: "openai", name: "OpenAI Compatible", providerId: "openai", hasCustomUrl: true },
{ id: "anthropic-compatible", name: "Anthropic Compatible", providerId: "anthropic-compatible", hasCustomUrl: true },
];

const SEARCH_PROVIDERS: Array<{ id: string; name: string; hint: string }> = [
Expand Down Expand Up @@ -43,6 +44,8 @@ function ApiKeyModal({ onClose }: { onClose: () => void }) {
const [visibleId, setVisibleId] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [activeProvider, setActiveProvider] = useState<AiProviderId>(getActiveProviderId);
const [models, setModels] = useState<Record<string, string[]>>({});
const [loadingModels, setLoadingModels] = useState<Record<string, boolean>>({});

useEffect(() => {
function handleClick(e: MouseEvent) {
Expand All @@ -58,6 +61,43 @@ function ApiKeyModal({ onClose }: { onClose: () => void }) {
localStorage.setItem("raincast-api-keys", JSON.stringify(next));
}

async function loadModels(providerId: string) {
const baseUrl = (keys[`${providerId}-url`] || "").trim();
const apiKey = keys[providerId] || "";
if (!apiKey || !baseUrl) return;

setLoadingModels((prev) => ({ ...prev, [providerId]: true }));
try {
const res = await fetch(`${baseUrl}/models`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const modelList: string[] = Array.isArray(data.data)
? data.data.map((m: { id: string }) => m.id)
: Array.isArray(data)
? data.map((m: string) => m)
: [];
setModels((prev) => ({ ...prev, [providerId]: modelList }));
} catch (err) {
console.warn("[loadModels]", err);
} finally {
setLoadingModels((prev) => ({ ...prev, [providerId]: false }));
}
}

function updateCustomUrl(providerId: string, value: string) {
const next = { ...keys, [`${providerId}-url`]: value };
setKeys(next);
localStorage.setItem("raincast-api-keys", JSON.stringify(next));
}

function updateCustomModel(providerId: string, value: string) {
const next = { ...keys, [`${providerId}-model`]: value };
setKeys(next);
localStorage.setItem("raincast-api-keys", JSON.stringify(next));
}

return (
<div
ref={ref}
Expand Down Expand Up @@ -170,9 +210,7 @@ function ApiKeyModal({ onClose }: { onClose: () => void }) {
</button>

{isOpen && (
<div style={{
padding: "6px 8px 10px 23px",
}}>
<div style={{ padding: "6px 8px 10px 23px" }}>
<label style={{
fontSize: 10,
fontWeight: 600,
Expand Down Expand Up @@ -228,6 +266,90 @@ function ApiKeyModal({ onClose }: { onClose: () => void }) {
</button>
</div>

{p.hasCustomUrl && (
<>
<label style={{
fontSize: 10,
fontWeight: 600,
color: "var(--text-tertiary)",
marginBottom: 4,
marginTop: 8,
display: "block",
letterSpacing: "0.03em",
textTransform: "uppercase",
}}>
Base URL
</label>
<input
type="text"
value={keys[`${p.id}-url`] || ""}
onChange={(e) => updateCustomUrl(p.id, e.target.value)}
placeholder={p.id === "openai" ? "https://api.openai.com/v1" : "https://api.anthropic.com/v1"}
style={{
width: "100%",
padding: "6px 10px",
fontSize: 12,
borderRadius: 8,
border: "1px solid var(--input-border)",
background: "var(--input-bg)",
color: "var(--text-input)",
outline: "none",
boxSizing: "border-box",
}}
onFocus={(e) => { e.currentTarget.style.borderColor = "var(--slider-thumb)"; }}
onBlur={(e) => { e.currentTarget.style.borderColor = "var(--input-border)"; }}
/>

<button
type="button"
onClick={() => loadModels(p.id)}
disabled={loadingModels[p.id] || !keys[p.id] || !keys[`${p.id}-url`]}
style={{
marginTop: 8,
width: "100%",
padding: "5px 0",
fontSize: 11,
fontWeight: 600,
borderRadius: 6,
border: "1px solid var(--input-border)",
background: "var(--subtle-bg)",
color: "var(--text-primary)",
cursor: "pointer",
opacity: loadingModels[p.id] || !keys[p.id] || !keys[`${p.id}-url`] ? 0.5 : 1,
transition: "background 150ms ease",
}}
onMouseEnter={(e) => { if (!loadingModels[p.id] && keys[p.id] && keys[`${p.id}-url`]) e.currentTarget.style.background = "var(--input-border)"; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "var(--subtle-bg)"; }}
>
{loadingModels[p.id] ? "Loading..." : "Load Models"}
</button>

{(models[p.id] || []).length > 0 && (
<select
value={keys[`${p.id}-model`] || ""}
onChange={(e) => updateCustomModel(p.id, e.target.value)}
style={{
marginTop: 6,
width: "100%",
padding: "5px 8px",
fontSize: 12,
borderRadius: 6,
border: "1px solid var(--input-border)",
background: "var(--input-bg)",
color: "var(--text-input)",
outline: "none",
boxSizing: "border-box",
}}
>
<option value="">Select model...</option>
{models[p.id].map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
)}
</>
)}

{p.providerId && hasKey && !isActive && (
<button
type="button"
Expand Down
6 changes: 4 additions & 2 deletions src/components/chat/ProviderSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { Popover } from "../common";
import type { AiProviderId } from "../../lib/ai/types";

const PROVIDERS: Array<{ id: AiProviderId; name: string; keyId: string }> = [
{ id: "gemini", name: "Gemini", keyId: "google" },
{ id: "anthropic", name: "Anthropic", keyId: "anthropic" },
{ id: "gemini", name: "Gemini", keyId: "google" },
{ id: "anthropic", name: "Anthropic", keyId: "anthropic" },
{ id: "openai", name: "OpenAI Compatible", keyId: "openai-compatible-key" },
{ id: "anthropic-compatible", name: "Anthropic Compatible", keyId: "anthropic-compatible-key" },
];

function hasApiKey(keyId: string): boolean {
Expand Down
4 changes: 4 additions & 0 deletions src/lib/ai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ export * from "./settings";
// Register providers on import
import { geminiProvider } from "./providers/gemini";
import { anthropicProvider } from "./providers/anthropic";
import { openaiCompatibleProvider } from "./providers/openai-compatible";
import { anthropicCompatibleProvider } from "./providers/anthropic-compatible";
import { registerProvider } from "./registry";

registerProvider(geminiProvider);
registerProvider(anthropicProvider);
registerProvider(openaiCompatibleProvider);
registerProvider(anthropicCompatibleProvider);
113 changes: 113 additions & 0 deletions src/lib/ai/providers/anthropic-compatible.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { ModelAdapter } from "../baseProvider";
import { createProvider } from "../baseProvider";
import {
getAnthropicCompatibleApiKey,
getAnthropicCompatibleBaseUrl,
getAnthropicCompatibleModel,
} from "../settings";

const DEFAULT_BASE_URL = "https://api.anthropic.com/v1";
const DEFAULT_MODEL_PRO = "claude-sonnet-4-6";
const DEFAULT_MODEL_FAST = "claude-haiku-4-5-20251001";

function buildClient() {
const key = getAnthropicCompatibleApiKey();
if (!key) throw new Error("No Anthropic Compatible API key configured. Add it in Settings (key icon in the top bar).");
const baseUrl = getAnthropicCompatibleBaseUrl() || DEFAULT_BASE_URL;
return { key, baseUrl };
}

function getModel(tier?: "fast" | "pro"): string {
const saved = getAnthropicCompatibleModel();
if (saved) return saved;
return tier === "fast" ? DEFAULT_MODEL_FAST : DEFAULT_MODEL_PRO;
}

const adapter: ModelAdapter = {
async generate(system, user, opts) {
const { key, baseUrl } = buildClient();
const model = getModel(opts?.model);
const maxTokens = opts?.model === "pro" ? 65536 : 4096;

const response = await fetch(`${baseUrl}/messages`, {
method: "POST",
headers: {
"x-api-key": key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model,
max_tokens: maxTokens,
system,
messages: [{ role: "user", content: user }],
}),
});

if (!response.ok) {
const err = await response.text();
throw new Error(`Anthropic Compatible API error ${response.status}: ${err}`);
}

const data = await response.json();
return data.content?.[0]?.text ?? "";
},

async generateStream(system, user, onChunk, opts) {
const { key, baseUrl } = buildClient();
const model = getModel(opts?.model);
const maxTokens = opts?.model === "pro" ? 65536 : 4096;

const response = await fetch(`${baseUrl}/messages`, {
method: "POST",
headers: {
"x-api-key": key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model,
max_tokens: maxTokens,
system,
messages: [{ role: "user", content: user }],
stream: true,
}),
});

if (!response.ok) {
const err = await response.text();
throw new Error(`Anthropic Compatible API error ${response.status}: ${err}`);
}

const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");

const decoder = new TextDecoder();

while (true) {
const { done, value } = await reader.read();
if (done) break;

const text = decoder.decode(value, { stream: true });
for (const line of text.split("\n")) {
if (line.startsWith("data: ")) {
const json = line.slice(6).trim();
if (json === "[DONE]") return;
try {
const parsed = JSON.parse(json);
if (parsed.type === "content_block_delta") {
onChunk(parsed.delta?.text ?? "");
}
} catch { /* skip malformed lines */ }
}
}
}
},
};

export const anthropicCompatibleProvider = createProvider(
"anthropic-compatible",
"Anthropic Compatible",
false,
adapter,
);
Loading