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
18 changes: 17 additions & 1 deletion frontend/src/lib/api/providers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { apiClient } from './client';
import type { ProviderCreateRequest, ProviderResponse, ProviderUpdateRequest } from './types';
import type {
ProviderCreateRequest,
ProviderResponse,
ProviderTestResult,
ProviderUpdateRequest
} from './types';

export async function listProviders(): Promise<ProviderResponse[]> {
return apiClient.request<ProviderResponse[]>({
Expand All @@ -15,3 +20,14 @@ export async function createProvider(data: ProviderCreateRequest): Promise<Provi
export async function updateProvider(id: string, data: ProviderUpdateRequest): Promise<ProviderResponse> {
return apiClient.request<ProviderResponse>({ method: 'PUT', path: `/providers/${id}`, body: data });
}

export async function testProvider(id: string): Promise<ProviderTestResult> {
return apiClient.request<ProviderTestResult>({
method: 'POST',
path: `/providers/${id}/test`
});
}

export async function deleteProvider(id: string): Promise<void> {
return apiClient.request<void>({ method: 'DELETE', path: `/providers/${id}` });
}
6 changes: 6 additions & 0 deletions frontend/src/lib/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,12 @@ export interface SystemHealthResponse {
}

// --- Providers ---
export interface ProviderTestResult {
response_text: string;
model: string;
duration_ms: number;
}

export interface ProviderResponse {
id: string;
name: string;
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/lib/components/providers/ProviderCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { Globe, Key, Pencil } from 'lucide-svelte';
import TestButton from './TestButton.svelte';

let {
provider,
Expand Down Expand Up @@ -44,5 +45,8 @@
</Button>
{/if}
</div>
<div class="pt-2 border-t">
<TestButton providerId={provider.id} />
</div>
</CardContent>
</Card>
65 changes: 53 additions & 12 deletions frontend/src/lib/components/providers/ProviderForm.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import { Label } from '$lib/components/ui/label';
import { Select, SelectOption } from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import type { ProviderCreateRequest, ProviderResponse, ProviderUpdateRequest } from '$lib/api/types';
import { CircleX } from 'lucide-svelte';
import type { ProviderCreateRequest, ProviderResponse, ProviderTestResult, ProviderUpdateRequest } from '$lib/api/types';
import { testProvider, deleteProvider } from '$lib/api/providers';

const PROVIDER_TYPES = [
'openai', 'anthropic', 'google', 'azure', 'bedrock',
Expand Down Expand Up @@ -52,7 +54,7 @@
}: {
open?: boolean;
provider?: ProviderResponse;
onsave: (data: ProviderCreateRequest | ProviderUpdateRequest) => Promise<void>;
onsave: (data: ProviderCreateRequest | ProviderUpdateRequest) => Promise<ProviderResponse>;
} = $props();

let name = $state('');
Expand All @@ -62,6 +64,11 @@
let isSubmitting = $state(false);
let errors = $state<Record<string, string>>({});

// Test state (create mode only)
let testResult = $state<ProviderTestResult | null>(null);
let testError = $state<string | null>(null);
let isTesting = $state(false);

// Config field state
let timeoutMs = $state('');
let extraHeaders = $state<{ name: string; value: string }[]>([]);
Expand Down Expand Up @@ -89,6 +96,9 @@
region = '';
errors = {};
isSubmitting = false;
testResult = null;
testError = null;
isTesting = false;
}

function populateFromProvider(p: ProviderResponse) {
Expand Down Expand Up @@ -193,33 +203,53 @@
e.preventDefault();
if (!validate()) return;

testError = null;
isSubmitting = true;
try {
const newConfig = buildConfigJson();
const configJson = buildConfigJson();
if (isEdit && provider) {
const updateData: ProviderUpdateRequest = {};
if (name.trim() !== provider.name) updateData.name = name.trim();
if (baseUrl.trim() !== provider.base_url) updateData.base_url = baseUrl.trim();
if (apiKey.trim()) updateData.api_key = apiKey.trim();
if (Object.keys(newConfig).length > 0) {
updateData.config_json = newConfig;
if (Object.keys(configJson).length > 0) {
updateData.config_json = configJson;
} else if (Object.keys(provider.config_json).length > 0) {
updateData.config_json = {};
}
await onsave(updateData);
open = false;
} else {
const createData: ProviderCreateRequest = {
name: name.trim(),
provider_type: providerType,
base_url: baseUrl.trim(),
api_key: apiKey.trim()
};
if (Object.keys(newConfig).length > 0) {
createData.config_json = newConfig;
if (Object.keys(configJson).length > 0) {
createData.config_json = configJson;
}
const created = await onsave(createData);
isSubmitting = false;
isTesting = true;
try {
testResult = await testProvider(created.id);
// Test passed — close dialog
open = false;
} catch (err) {
const msg = err instanceof Error ? err.message : 'Test failed';
testError = msg;
// Rollback: delete the provider
try {
await deleteProvider(created.id);
} catch {
// Silently ignore delete failure
}
} finally {
isTesting = false;
}
await onsave(createData);
return;
}
open = false;
} catch (err) {
errors.form = err instanceof Error ? err.message : 'Save failed';
} finally {
Expand Down Expand Up @@ -249,6 +279,13 @@
<p class="text-sm text-destructive">{errors.form}</p>
{/if}

{#if testError}
<div class="flex items-center gap-2 text-destructive border border-destructive rounded-md p-3">
<CircleX class="h-4 w-4 flex-shrink-0" />
<span class="text-sm">Connection failed: {testError}. The provider has been removed.</span>
</div>
{/if}

<div class="flex flex-col gap-1.5">
<Label for="provider-name">Name</Label>
<Input
Expand Down Expand Up @@ -399,11 +436,15 @@

<div class="flex items-center justify-end gap-2 pt-2">
<Button variant="outline" type="button" onclick={handleCancel}>Cancel</Button>
<Button type="submit" disabled={isSubmitting}>
{#if isSubmitting}
<Button type="submit" disabled={isSubmitting || isTesting}>
{#if isTesting}
Testing...
{:else if isSubmitting}
Saving...
{:else}
{:else if isEdit}
Save
{:else}
Test & Save
{/if}
</Button>
</div>
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/lib/components/providers/TestButton.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Loader2, CircleCheck, CircleX } from 'lucide-svelte';
import { createTestButtonState } from './TestButtonState.svelte';

let { providerId }: { providerId: string } = $props();

const testState = createTestButtonState(() => providerId);
</script>

<div class="flex items-center gap-2">
{#if testState.state === 'idle'}
<Button variant="outline" size="sm" onclick={() => testState.handleTest()}>Test</Button>
{:else if testState.state === 'testing'}
<Loader2 class="h-4 w-4 animate-spin" />
<span class="text-sm text-muted-foreground">Testing...</span>
{:else if testState.state === 'success' && testState.result}
<CircleCheck class="h-4 w-4 text-green-500" />
<span class="text-sm font-medium">Working</span>
<span class="text-sm text-muted-foreground">{testState.result.model}</span>
<span class="text-sm text-muted-foreground">{testState.result.duration_ms}ms</span>
<Button variant="link" size="sm" onclick={() => testState.handleTest()}>Test Again</Button>
{:else if testState.state === 'error'}
<CircleX class="h-4 w-4 text-destructive" />
<span class="text-sm text-destructive">{testState.errorMessage}</span>
<Button variant="link" size="sm" onclick={() => testState.handleTest()}>Retry</Button>
{/if}
</div>
36 changes: 36 additions & 0 deletions frontend/src/lib/components/providers/TestButtonState.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { testProvider } from '$lib/api/providers';
import type { ProviderTestResult } from '$lib/api/types';

export type TestState = 'idle' | 'testing' | 'success' | 'error';

export function createTestButtonState(getProviderId: () => string) {
let state = $state<TestState>('idle');
let result = $state<ProviderTestResult | null>(null);
let errorMessage = $state('');

async function handleTest() {
state = 'testing';
result = null;
errorMessage = '';
try {
result = await testProvider(getProviderId());
state = 'success';
} catch (err) {
errorMessage = err instanceof Error ? err.message : 'Test failed';
state = 'error';
}
}

return {
get state() {
return state;
},
get result() {
return result;
},
get errorMessage() {
return errorMessage;
},
handleTest
};
}
12 changes: 8 additions & 4 deletions frontend/src/routes/(app)/providers/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,17 @@
showForm = true;
}

async function handleSave(data: ProviderCreateRequest | ProviderUpdateRequest) {
async function handleSave(data: ProviderCreateRequest | ProviderUpdateRequest): Promise<ProviderResponse> {
if (editingProvider) {
await updateProvider(editingProvider.id, data as ProviderUpdateRequest);
const updated = await updateProvider(editingProvider.id, data as ProviderUpdateRequest);
showForm = false;
await loadProviders();
return updated;
} else {
await createProvider(data as ProviderCreateRequest);
const created = await createProvider(data as ProviderCreateRequest);
await loadProviders();
return created;
}
await loadProviders();
}

onMount(() => {
Expand Down