Skip to content

Commit 15643ae

Browse files
Add provider connection status tracking and API key setup flow
1 parent 614c6eb commit 15643ae

4 files changed

Lines changed: 258 additions & 115 deletions

File tree

frontend/src/api/providers.ts

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export interface Provider {
9898
models: Record<string, Model>;
9999
options?: Record<string, unknown>;
100100
source?: ProviderSource;
101+
isConnected?: boolean;
101102
}
102103

103104
export interface ProviderWithModels {
@@ -108,6 +109,7 @@ export interface ProviderWithModels {
108109
npm?: string;
109110
models: Model[];
110111
source: ProviderSource;
112+
isConnected: boolean;
111113
}
112114

113115
interface ConfigProvider {
@@ -139,23 +141,21 @@ function classifyProviderSource(providerId: string, isFromConfig: boolean): Prov
139141
return "configured";
140142
}
141143

142-
function getProviderPriority(source: ProviderSource): number {
143-
switch (source) {
144-
case "configured": return 1;
145-
case "local": return 2;
146-
case "builtin": return 3;
147-
default: return 4;
148-
}
149-
}
150-
151144

145+
interface OpenCodeProviderResponse {
146+
all: OpenCodeProvider[];
147+
connected: string[];
148+
default: Record<string, string>;
149+
}
152150

153-
async function getProvidersFromOpenCodeServer(): Promise<Provider[]> {
151+
async function getProvidersFromOpenCodeServer(): Promise<{ providers: Provider[]; connected: string[] }> {
154152
try {
155-
const response = await axios.get(`${API_BASE_URL}/api/opencode/provider`);
153+
const response = await axios.get<OpenCodeProviderResponse>(`${API_BASE_URL}/api/opencode/provider`);
156154

157155
if (response?.data?.all && Array.isArray(response.data.all)) {
158-
return response.data.all.map((openCodeProvider: OpenCodeProvider) => {
156+
const connectedSet = new Set(response.data.connected || []);
157+
158+
const providers = response.data.all.map((openCodeProvider: OpenCodeProvider) => {
159159
const models: Record<string, Model> = {};
160160

161161
Object.entries(openCodeProvider.models).forEach(([modelId, openCodeModel]) => {
@@ -196,21 +196,24 @@ async function getProvidersFromOpenCodeServer(): Promise<Provider[]> {
196196
env: openCodeProvider.env,
197197
models,
198198
options: openCodeProvider.options,
199+
isConnected: connectedSet.has(openCodeProvider.id),
199200
};
200201
});
202+
203+
return { providers, connected: response.data.connected || [] };
201204
}
202205
} catch (error) {
203206
console.warn("Failed to load providers from OpenCode server", error);
204207
}
205208

206-
return [];
209+
return { providers: [], connected: [] };
207210
}
208211

209-
export async function getProviders(): Promise<Provider[]> {
212+
export async function getProviders(): Promise<{ providers: Provider[]; connected: string[] }> {
210213
return await getProvidersFromOpenCodeServer();
211214
}
212215

213-
async function getConfiguredProviders(): Promise<ProviderWithModels[]> {
216+
async function getConfiguredProviders(connectedIds: Set<string>): Promise<ProviderWithModels[]> {
214217
try {
215218
const config = await settingsApi.getDefaultOpenCodeConfig();
216219
if (!config?.content?.provider) return [];
@@ -247,6 +250,7 @@ async function getConfiguredProviders(): Promise<ProviderWithModels[]> {
247250
npm: providerConfig.npm,
248251
models,
249252
source,
253+
isConnected: connectedIds.has(providerId),
250254
});
251255
}
252256

@@ -258,11 +262,10 @@ async function getConfiguredProviders(): Promise<ProviderWithModels[]> {
258262
}
259263

260264
export async function getProvidersWithModels(): Promise<ProviderWithModels[]> {
261-
const [builtinProviders, configuredProviders] = await Promise.all([
262-
getProviders(),
263-
getConfiguredProviders(),
264-
]);
265-
265+
const { providers: builtinProviders, connected } = await getProviders();
266+
const connectedIds = new Set(connected);
267+
268+
const configuredProviders = await getConfiguredProviders(connectedIds);
266269
const configuredIds = new Set(configuredProviders.map((p) => p.id));
267270

268271
const builtinResult: ProviderWithModels[] = builtinProviders
@@ -281,15 +284,16 @@ export async function getProvidersWithModels(): Promise<ProviderWithModels[]> {
281284
npm: provider.npm,
282285
models,
283286
source: "builtin" as ProviderSource,
287+
isConnected: provider.isConnected ?? false,
284288
};
285289
});
286290

287291
const allProviders = [...configuredProviders, ...builtinResult];
288292

289293
allProviders.sort((a, b) => {
290-
const priorityA = getProviderPriority(a.source);
291-
const priorityB = getProviderPriority(b.source);
292-
if (priorityA !== priorityB) return priorityA - priorityB;
294+
if (a.isConnected !== b.isConnected) {
295+
return a.isConnected ? -1 : 1;
296+
}
293297
return a.name.localeCompare(b.name);
294298
});
295299

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { useState, useCallback } from "react";
2+
import {
3+
Dialog,
4+
DialogContent,
5+
DialogHeader,
6+
DialogTitle,
7+
DialogDescription,
8+
DialogFooter,
9+
} from "@/components/ui/dialog";
10+
import { Button } from "@/components/ui/button";
11+
import { Input } from "@/components/ui/input";
12+
import { Label } from "@/components/ui/label";
13+
import { Loader2, Key, ExternalLink } from "lucide-react";
14+
import { providerCredentialsApi } from "@/api/providers";
15+
import type { ProviderWithModels } from "@/api/providers";
16+
17+
interface ApiKeyDialogProps {
18+
open: boolean;
19+
onOpenChange: (open: boolean) => void;
20+
provider: ProviderWithModels | null;
21+
onSuccess: () => void;
22+
}
23+
24+
export function ApiKeyDialog({
25+
open,
26+
onOpenChange,
27+
provider,
28+
onSuccess,
29+
}: ApiKeyDialogProps) {
30+
const [apiKey, setApiKey] = useState("");
31+
const [isSubmitting, setIsSubmitting] = useState(false);
32+
const [error, setError] = useState<string | null>(null);
33+
34+
const handleSubmit = useCallback(async () => {
35+
if (!provider || !apiKey.trim()) return;
36+
37+
setIsSubmitting(true);
38+
setError(null);
39+
40+
try {
41+
await providerCredentialsApi.set(provider.id, apiKey.trim());
42+
setApiKey("");
43+
onSuccess();
44+
} catch (err) {
45+
setError("Failed to save API key. Please try again.");
46+
console.error("Failed to set API key:", err);
47+
} finally {
48+
setIsSubmitting(false);
49+
}
50+
}, [provider, apiKey, onSuccess]);
51+
52+
const handleClose = useCallback(() => {
53+
setApiKey("");
54+
setError(null);
55+
onOpenChange(false);
56+
}, [onOpenChange]);
57+
58+
if (!provider) return null;
59+
60+
const envVarName = provider.env?.[0] || `${provider.id.toUpperCase()}_API_KEY`;
61+
62+
return (
63+
<Dialog open={open} onOpenChange={handleClose}>
64+
<DialogContent className="sm:max-w-md">
65+
<DialogHeader>
66+
<DialogTitle className="flex items-center gap-2">
67+
<Key className="h-5 w-5" />
68+
Connect {provider.name}
69+
</DialogTitle>
70+
<DialogDescription>
71+
Enter your API key to use models from {provider.name}.
72+
</DialogDescription>
73+
</DialogHeader>
74+
75+
<div className="space-y-4 py-4">
76+
<div className="space-y-2">
77+
<Label htmlFor="api-key">API Key</Label>
78+
<Input
79+
id="api-key"
80+
type="password"
81+
placeholder={`Enter your ${envVarName}`}
82+
value={apiKey}
83+
onChange={(e) => setApiKey(e.target.value)}
84+
onKeyDown={(e) => {
85+
if (e.key === "Enter" && apiKey.trim()) {
86+
handleSubmit();
87+
}
88+
}}
89+
autoFocus
90+
/>
91+
<p className="text-xs text-muted-foreground">
92+
Environment variable: <code className="bg-muted px-1 py-0.5 rounded">{envVarName}</code>
93+
</p>
94+
</div>
95+
96+
{error && (
97+
<p className="text-sm text-destructive">{error}</p>
98+
)}
99+
100+
{provider.api && (
101+
<a
102+
href={provider.api}
103+
target="_blank"
104+
rel="noopener noreferrer"
105+
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
106+
>
107+
<ExternalLink className="h-3 w-3" />
108+
Get an API key
109+
</a>
110+
)}
111+
</div>
112+
113+
<DialogFooter>
114+
<Button variant="outline" onClick={handleClose} disabled={isSubmitting}>
115+
Cancel
116+
</Button>
117+
<Button onClick={handleSubmit} disabled={!apiKey.trim() || isSubmitting}>
118+
{isSubmitting ? (
119+
<>
120+
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
121+
Connecting...
122+
</>
123+
) : (
124+
"Connect"
125+
)}
126+
</Button>
127+
</DialogFooter>
128+
</DialogContent>
129+
</Dialog>
130+
);
131+
}

0 commit comments

Comments
 (0)