Skip to content

Commit 0d253f7

Browse files
committed
feat: add OpenAI-compatible provider support
- Updated package version to 0.0.23 and adjusted release date. - Enhanced DuckBrainPanel to include OpenAI-compatible provider configuration. - Modified BrainTab to manage OpenAI-compatible provider inputs and connection testing. - Updated OpenAIProvider to handle custom endpoints and connection validation for OpenAI-compatible APIs. - Adjusted types and store management to accommodate the new provider. - Improved UI elements for OpenAI-compatible configuration in the sidebar.
1 parent b08a62e commit 0d253f7

File tree

9 files changed

+375
-102
lines changed

9 files changed

+375
-102
lines changed

bun.lock

Lines changed: 98 additions & 46 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"name": "duck-ui",
33
"private": true,
4-
"version": "0.0.22",
5-
"release_date": "2025-12-31",
4+
"version": "0.0.23",
5+
"release_date": "2026-01-12",
66
"type": "module",
77
"scripts": {
88
"dev": "vite",
@@ -13,7 +13,7 @@
1313
"dependencies": {
1414
"@dnd-kit/core": "^6.3.1",
1515
"@dnd-kit/sortable": "^10.0.0",
16-
"@duckdb/duckdb-wasm": "1.33.1-dev4.0",
16+
"@duckdb/duckdb-wasm": "1.33.1-dev17.0",
1717
"@hookform/resolvers": "^5.2.2",
1818
"@mlc-ai/web-llm": "^0.2.80",
1919
"@radix-ui/react-accordion": "^1.2.12",
@@ -41,7 +41,7 @@
4141
"@radix-ui/react-tooltip": "^1.2.8",
4242
"@tailwindcss/vite": "^4.1.18",
4343
"@tanstack/react-table": "^8.21.3",
44-
"@tanstack/react-virtual": "^3.13.13",
44+
"@tanstack/react-virtual": "^3.13.18",
4545
"buffer": "^6.0.3",
4646
"class-variance-authority": "^0.7.1",
4747
"clsx": "^2.1.1",
@@ -50,37 +50,37 @@
5050
"echarts": "^6.0.0",
5151
"echarts-for-react": "^3.0.5",
5252
"fflate": "^0.8.2",
53-
"framer-motion": "^12.23.26",
53+
"framer-motion": "^12.25.0",
5454
"html2canvas": "^1.4.1",
5555
"lodash": "^4.17.21",
5656
"lucide-react": "^0.546.0",
5757
"monaco-editor": "^0.54.0",
58-
"openai": "^6.15.0",
58+
"openai": "^6.16.0",
5959
"papaparse": "^5.5.3",
6060
"react": "^19.2.3",
6161
"react-dom": "^19.2.3",
6262
"react-dropzone": "^14.3.8",
63-
"react-error-boundary": "^6.0.0",
64-
"react-hook-form": "^7.69.0",
63+
"react-error-boundary": "^6.0.3",
64+
"react-hook-form": "^7.71.0",
6565
"react-markdown": "^10.1.0",
6666
"react-resizable-panels": "^3.0.6",
67-
"react-router": "^7.11.0",
67+
"react-router": "^7.12.0",
6868
"recharts": "^3.6.0",
6969
"sonner": "^2.0.7",
7070
"sql-formatter": "^15.6.12",
7171
"tailwind-merge": "^3.4.0",
7272
"tailwindcss-animate": "^1.0.7",
7373
"vaul": "^1.1.2",
7474
"xlsx": "^0.18.5",
75-
"zod": "^4.2.1",
76-
"zustand": "^5.0.9"
75+
"zod": "^4.3.5",
76+
"zustand": "^5.0.10"
7777
},
7878
"devDependencies": {
7979
"@eslint/js": "^9.39.2",
80-
"@types/lodash": "^4.17.21",
81-
"@types/node": "^24.10.4",
80+
"@types/lodash": "^4.17.23",
81+
"@types/node": "^24.10.7",
8282
"@types/papaparse": "^5.5.2",
83-
"@types/react": "^19.2.7",
83+
"@types/react": "^19.2.8",
8484
"@types/react-dom": "^19.2.3",
8585
"@vitejs/plugin-react": "^5.1.2",
8686
"autoprefixer": "^10.4.23",
@@ -92,8 +92,8 @@
9292
"postcss": "^8.5.6",
9393
"tailwindcss": "^4.1.18",
9494
"typescript": "~5.9.3",
95-
"typescript-eslint": "^8.50.1",
96-
"vite": "^7.3.0"
95+
"typescript-eslint": "^8.52.0",
96+
"vite": "^7.3.1"
9797
},
9898
"description": "Duck-UI is a web-based interface for interacting with DuckDB, a high-performance analytical database system. This project leverages DuckDB's WebAssembly (WASM) capabilities to provide a seamless and efficient user experience directly in the browser.",
9999
"main": "eslint.config.js",

src/components/duck-brain/DuckBrainPanel.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ const DuckBrainPanel: React.FC<DuckBrainPanelProps> = ({ tabId }) => {
7272
const model = ANTHROPIC_MODELS.find((m) => m.id === config.modelId);
7373
return { name: model?.name || "Claude Sonnet 4", isCloud: true };
7474
}
75+
} else if (aiProvider === "openai-compatible") {
76+
const config = providerConfigs["openai-compatible"];
77+
if (config?.baseUrl && config?.modelId) {
78+
return { name: config.modelId, isCloud: true };
79+
}
7580
}
7681
// Default to local model
7782
const localModel = AVAILABLE_MODELS.find((m) => m.id === duckBrain.currentModel);
@@ -109,6 +114,14 @@ const DuckBrainPanel: React.FC<DuckBrainPanelProps> = ({ tabId }) => {
109114
});
110115
}
111116

117+
// Add OpenAI-Compatible if configured
118+
if (providerConfigs["openai-compatible"]?.baseUrl && providerConfigs["openai-compatible"]?.modelId) {
119+
providers.push({
120+
value: "openai-compatible",
121+
label: providerConfigs["openai-compatible"].modelId,
122+
});
123+
}
124+
112125
return providers;
113126
}, [modelStatus, duckBrain.currentModel, providerConfigs]);
114127

@@ -169,7 +182,8 @@ const DuckBrainPanel: React.FC<DuckBrainPanelProps> = ({ tabId }) => {
169182
// Check if external provider is configured
170183
const hasExternalProvider =
171184
(aiProvider === "openai" && providerConfigs.openai?.apiKey) ||
172-
(aiProvider === "anthropic" && providerConfigs.anthropic?.apiKey);
185+
(aiProvider === "anthropic" && providerConfigs.anthropic?.apiKey) ||
186+
(aiProvider === "openai-compatible" && providerConfigs["openai-compatible"]?.baseUrl && providerConfigs["openai-compatible"]?.modelId);
173187

174188
// Render idle state (not initialized) - only for WebLLM without external provider
175189
if ((modelStatus === "idle" || modelStatus === "checking") && !hasExternalProvider) {

src/components/layout/Sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export default function Sidebar({ isExplorerOpen, onToggleExplorer }: SidebarPro
129129
<img
130130
src={theme === "dark" ? Logo : LogoLight}
131131
alt="Duck-UI"
132-
className="h-6 w-6"
132+
className="h-6"
133133
/>
134134
</button>
135135
</TooltipTrigger>

src/components/workspace/BrainTab.tsx

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import {
3333
Cloud,
3434
Eye,
3535
EyeOff,
36+
Server,
37+
Link,
3638
} from "lucide-react";
3739
import { toast } from "sonner";
3840
import { AVAILABLE_MODELS, type ModelConfig } from "@/lib/duckBrain";
@@ -49,6 +51,11 @@ const BrainTab = () => {
4951
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
5052
const [isTesting, setIsTesting] = useState(false);
5153

54+
// OpenAI-compatible provider inputs
55+
const [compatibleBaseUrl, setCompatibleBaseUrl] = useState("");
56+
const [compatibleModelId, setCompatibleModelId] = useState("");
57+
const [compatibleApiKey, setCompatibleApiKey] = useState("");
58+
5259
useEffect(() => {
5360
checkCacheSize();
5461
const inputs: Record<string, string> = {};
@@ -59,6 +66,14 @@ const BrainTab = () => {
5966
inputs.anthropic = duckBrain.providerConfigs.anthropic.apiKey;
6067
}
6168
setApiKeyInputs(inputs);
69+
70+
// Initialize openai-compatible inputs
71+
const compatibleConfig = duckBrain.providerConfigs["openai-compatible"];
72+
if (compatibleConfig) {
73+
setCompatibleBaseUrl(compatibleConfig.baseUrl || "");
74+
setCompatibleModelId(compatibleConfig.modelId || "");
75+
setCompatibleApiKey(compatibleConfig.apiKey || "");
76+
}
6277
}, []);
6378

6479
const checkCacheSize = async () => {
@@ -173,6 +188,44 @@ const BrainTab = () => {
173188
});
174189
};
175190

191+
const handleSaveCompatibleConfig = async () => {
192+
if (!compatibleBaseUrl) {
193+
toast.error("Please enter a Base URL");
194+
return;
195+
}
196+
if (!compatibleModelId) {
197+
toast.error("Please enter a Model ID");
198+
return;
199+
}
200+
201+
setIsTesting(true);
202+
try {
203+
const { testProviderConnection } = await import("@/lib/duckBrain/providers");
204+
const result = await testProviderConnection("openai-compatible", {
205+
baseUrl: compatibleBaseUrl,
206+
modelId: compatibleModelId,
207+
apiKey: compatibleApiKey || undefined,
208+
});
209+
210+
if (result.success) {
211+
// Only save config if connection test succeeds
212+
updateProviderConfig("openai-compatible", {
213+
baseUrl: compatibleBaseUrl,
214+
modelId: compatibleModelId,
215+
apiKey: compatibleApiKey || undefined,
216+
});
217+
toast.success("Connected successfully!");
218+
} else {
219+
toast.error(`Connection failed: ${result.error || "Unknown error"}`);
220+
}
221+
} catch (err) {
222+
const errorMessage = err instanceof Error ? err.message : "Connection failed";
223+
toast.error(errorMessage);
224+
} finally {
225+
setIsTesting(false);
226+
}
227+
};
228+
176229
const {
177230
modelStatus,
178231
currentModel,
@@ -252,6 +305,14 @@ const BrainTab = () => {
252305
<Cloud className="h-4 w-4" />
253306
Anthropic
254307
</Button>
308+
<Button
309+
variant={aiProvider === "openai-compatible" ? "default" : "outline"}
310+
onClick={() => handleProviderChange("openai-compatible")}
311+
className="flex items-center gap-2"
312+
>
313+
<Server className="h-4 w-4" />
314+
OpenAI-Compatible
315+
</Button>
255316
</div>
256317

257318
{aiProvider === "webllm" && (
@@ -382,6 +443,91 @@ const BrainTab = () => {
382443
)}
383444
</div>
384445
)}
446+
447+
{aiProvider === "openai-compatible" && (
448+
<div className="space-y-4 pt-2">
449+
<Alert>
450+
<Server className="h-4 w-4" />
451+
<AlertDescription>
452+
<strong>OpenAI-Compatible API</strong> - Connect to Ollama, LocalAI, vLLM, DeepSeek, and other services that implement the OpenAI chat completions API.
453+
</AlertDescription>
454+
</Alert>
455+
456+
<div className="space-y-2">
457+
<Label htmlFor="compatible-base-url" className="flex items-center gap-2">
458+
<Link className="h-4 w-4" />
459+
Base URL <span className="text-destructive">*</span>
460+
</Label>
461+
<Input
462+
id="compatible-base-url"
463+
type="url"
464+
placeholder="http://localhost:11434/v1"
465+
value={compatibleBaseUrl}
466+
onChange={(e) => setCompatibleBaseUrl(e.target.value)}
467+
/>
468+
<p className="text-xs text-muted-foreground">
469+
The API endpoint URL (e.g., http://localhost:11434/v1 for Ollama)
470+
</p>
471+
</div>
472+
473+
<div className="space-y-2">
474+
<Label htmlFor="compatible-model-id" className="flex items-center gap-2">
475+
<Cpu className="h-4 w-4" />
476+
Model ID <span className="text-destructive">*</span>
477+
</Label>
478+
<Input
479+
id="compatible-model-id"
480+
type="text"
481+
placeholder="llama3.2"
482+
value={compatibleModelId}
483+
onChange={(e) => setCompatibleModelId(e.target.value)}
484+
/>
485+
<p className="text-xs text-muted-foreground">
486+
The model name as recognized by your API (e.g., llama3.2, deepseek-coder)
487+
</p>
488+
</div>
489+
490+
<div className="space-y-2">
491+
<Label htmlFor="compatible-api-key" className="flex items-center gap-2">
492+
<Key className="h-4 w-4" />
493+
API Key <span className="text-muted-foreground text-xs">(optional)</span>
494+
</Label>
495+
<div className="relative">
496+
<Input
497+
id="compatible-api-key"
498+
type={showApiKey["openai-compatible"] ? "text" : "password"}
499+
placeholder="Optional - only if your server requires authentication"
500+
value={compatibleApiKey}
501+
onChange={(e) => setCompatibleApiKey(e.target.value)}
502+
/>
503+
<Button
504+
variant="ghost"
505+
size="icon"
506+
className="absolute right-0 top-0 h-full"
507+
onClick={() => setShowApiKey((prev) => ({ ...prev, "openai-compatible": !prev["openai-compatible"] }))}
508+
>
509+
{showApiKey["openai-compatible"] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
510+
</Button>
511+
</div>
512+
</div>
513+
514+
<Button
515+
onClick={handleSaveCompatibleConfig}
516+
disabled={isTesting || !compatibleBaseUrl || !compatibleModelId}
517+
className="w-full"
518+
>
519+
{isTesting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
520+
{isTesting ? "Testing Connection..." : "Test & Save"}
521+
</Button>
522+
523+
{providerConfigs["openai-compatible"]?.baseUrl && providerConfigs["openai-compatible"]?.modelId && (
524+
<Badge variant="secondary" className="bg-green-500/10 text-green-600">
525+
<Check className="h-3 w-3 mr-1" />
526+
Configured: {providerConfigs["openai-compatible"].modelId}
527+
</Badge>
528+
)}
529+
</div>
530+
)}
385531
</CardContent>
386532
</Card>
387533

src/lib/duckBrain/providers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { AnthropicProvider } from "./anthropic.provider";
1212
export function createProvider(type: AIProviderType): AIProvider {
1313
switch (type) {
1414
case "openai":
15+
case "openai-compatible":
1516
return new OpenAIProvider();
1617
case "anthropic":
1718
return new AnthropicProvider();

0 commit comments

Comments
 (0)