tftsr-devops_investigation/src/pages/Settings/AIProviders.tsx
Shaun Arman 190084888c feat: add Custom REST provider support
- Extended ProviderConfig with optional custom fields for non-OpenAI APIs
- Added custom_endpoint_path, custom_auth_header, custom_auth_prefix fields
- Added api_format field to distinguish between OpenAI and Custom REST provider formats
- Added session_id field for stateful conversation APIs
- Implemented chat_custom_rest() method in OpenAI provider
- Custom REST provider uses different request format (prompt+sessionId) and response (msg field)
- Updated TypeScript types to match Rust schema
- Added UI controls in Settings/AIProviders for custom provider configuration
- API format selector auto-populates appropriate defaults (OpenAI vs Custom REST provider)
- Backward compatible: existing providers default to OpenAI format
2026-04-03 15:45:42 -05:00

393 lines
14 KiB
TypeScript

import React, { useState } from "react";
import { Plus, Pencil, Trash2, CheckCircle, XCircle, Zap } from "lucide-react";
import {
Card,
CardHeader,
CardTitle,
CardContent,
Button,
Input,
Label,
Badge,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
Separator,
} from "@/components/ui";
import { useSettingsStore } from "@/stores/settingsStore";
import { testProviderConnectionCmd, type ProviderConfig } from "@/lib/tauriCommands";
const emptyProvider: ProviderConfig = {
name: "",
provider_type: "openai",
api_url: "",
api_key: "",
model: "",
max_tokens: 4096,
temperature: 0.7,
custom_endpoint_path: undefined,
custom_auth_header: undefined,
custom_auth_prefix: undefined,
api_format: undefined,
session_id: undefined,
};
export default function AIProviders() {
const {
ai_providers,
active_provider,
addProvider,
updateProvider,
removeProvider,
setActiveProvider,
} = useSettingsStore();
const [editIndex, setEditIndex] = useState<number | null>(null);
const [isAdding, setIsAdding] = useState(false);
const [form, setForm] = useState<ProviderConfig>({ ...emptyProvider });
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [isTesting, setIsTesting] = useState(false);
const startAdd = () => {
setForm({ ...emptyProvider });
setEditIndex(null);
setIsAdding(true);
setTestResult(null);
};
const startEdit = (index: number) => {
setForm({ ...ai_providers[index] });
setEditIndex(index);
setIsAdding(true);
setTestResult(null);
};
const handleSave = () => {
if (!form.name || !form.api_url || !form.model) return;
if (editIndex != null) {
updateProvider(editIndex, form);
} else {
addProvider(form);
}
setIsAdding(false);
setEditIndex(null);
setForm({ ...emptyProvider });
};
const handleCancel = () => {
setIsAdding(false);
setEditIndex(null);
setForm({ ...emptyProvider });
setTestResult(null);
};
const handleTest = async () => {
setIsTesting(true);
setTestResult(null);
try {
const response = await testProviderConnectionCmd(form);
setTestResult({ success: true, message: `OK: ${response.content.slice(0, 100)}` });
} catch (err) {
setTestResult({ success: false, message: String(err) });
} finally {
setIsTesting(false);
}
};
const maskApiKey = (key?: string) => {
if (!key) return "Not set";
if (key.length <= 8) return "****";
return key.slice(0, 4) + "..." + key.slice(-4);
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">AI Providers</h1>
<p className="text-muted-foreground mt-1">
Configure AI model providers for triage and document generation.
</p>
</div>
{!isAdding && (
<Button onClick={startAdd}>
<Plus className="w-4 h-4 mr-2" />
Add Provider
</Button>
)}
</div>
{/* Provider list */}
{ai_providers.length === 0 && !isAdding && (
<Card>
<CardContent className="p-8 text-center">
<p className="text-muted-foreground">No providers configured yet.</p>
<Button className="mt-3" onClick={startAdd}>
<Plus className="w-4 h-4 mr-2" />
Add your first provider
</Button>
</CardContent>
</Card>
)}
{ai_providers.map((provider, idx) => (
<Card key={`${provider.name}-${idx}`}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{provider.name}</span>
<Badge variant="secondary">{provider.provider_type}</Badge>
{active_provider === provider.name && (
<Badge variant="success">Active</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{provider.api_url} | Model: {provider.model} | Key: {maskApiKey(provider.api_key)}
</p>
</div>
<div className="flex items-center gap-1">
{active_provider !== provider.name && (
<Button
variant="ghost"
size="sm"
onClick={() => setActiveProvider(provider.name)}
>
<Zap className="w-3 h-3 mr-1" />
Set Active
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => startEdit(idx)}>
<Pencil className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeProvider(idx)}
>
<Trash2 className="w-3 h-3 text-destructive" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
{/* Add/Edit form */}
{isAdding && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
{editIndex != null ? "Edit Provider" : "Add Provider"}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="My OpenAI"
/>
</div>
<div className="space-y-2">
<Label>Type</Label>
<Select
value={form.provider_type}
onValueChange={(v) => {
const type = v as ProviderConfig["provider_type"];
const defaults: Partial<ProviderConfig> =
type === "ollama"
? { api_url: "http://localhost:11434", api_key: "", model: "llama3.2:3b" }
: type === "openai"
? { api_url: "https://api.openai.com/v1" }
: type === "anthropic"
? { api_url: "https://api.anthropic.com" }
: {};
setForm({ ...form, provider_type: type, ...defaults });
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="ollama">Ollama</SelectItem>
<SelectItem value="azure">Azure OpenAI</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>API URL</Label>
<Input
value={form.api_url}
onChange={(e) => setForm({ ...form, api_url: e.target.value })}
placeholder="https://api.openai.com/v1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>API Key</Label>
<Input
type="password"
value={form.api_key ?? ""}
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
placeholder="sk-..."
/>
</div>
<div className="space-y-2">
<Label>Model</Label>
<Input
value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })}
placeholder="gpt-4o"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Max Tokens</Label>
<Input
type="number"
value={form.max_tokens ?? 4096}
onChange={(e) => setForm({ ...form, max_tokens: Number(e.target.value) })}
/>
</div>
<div className="space-y-2">
<Label>Temperature</Label>
<Input
type="number"
step="0.1"
min="0"
max="2"
value={form.temperature ?? 0.7}
onChange={(e) => setForm({ ...form, temperature: Number(e.target.value) })}
/>
</div>
</div>
{/* Custom provider format options */}
{form.provider_type === "custom" && (
<>
<Separator />
<div className="space-y-4">
<div className="space-y-2">
<Label>API Format</Label>
<Select
value={form.api_format ?? "openai"}
onValueChange={(v) => {
const format = v;
const defaults =
format === "msi_genai"
? {
custom_endpoint_path: "",
custom_auth_header: "x-msi-genai-api-key",
custom_auth_prefix: "",
}
: {
custom_endpoint_path: "/chat/completions",
custom_auth_header: "Authorization",
custom_auth_prefix: "Bearer ",
};
setForm({ ...form, api_format: format, ...defaults });
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI Compatible</SelectItem>
<SelectItem value="msi_genai">MSI GenAI</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Select the API format. MSI GenAI uses a different request/response structure.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Endpoint Path</Label>
<Input
value={form.custom_endpoint_path ?? ""}
onChange={(e) =>
setForm({ ...form, custom_endpoint_path: e.target.value })
}
placeholder="/chat/completions"
/>
<p className="text-xs text-muted-foreground">
Path appended to API URL. Leave empty if URL includes full path.
</p>
</div>
<div className="space-y-2">
<Label>Auth Header Name</Label>
<Input
value={form.custom_auth_header ?? ""}
onChange={(e) =>
setForm({ ...form, custom_auth_header: e.target.value })
}
placeholder="Authorization"
/>
<p className="text-xs text-muted-foreground">
Header name for authentication (e.g., "Authorization" or "x-msi-genai-api-key")
</p>
</div>
</div>
<div className="space-y-2">
<Label>Auth Prefix</Label>
<Input
value={form.custom_auth_prefix ?? ""}
onChange={(e) => setForm({ ...form, custom_auth_prefix: e.target.value })}
placeholder="Bearer "
/>
<p className="text-xs text-muted-foreground">
Prefix added before API key (e.g., "Bearer " for OpenAI, empty for MSI GenAI)
</p>
</div>
</div>
</>
)}
{/* Test result */}
{testResult && (
<div
className={`flex items-center gap-2 rounded-md p-3 text-sm ${
testResult.success
? "bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300"
: "bg-destructive/10 text-destructive"
}`}
>
{testResult.success ? (
<CheckCircle className="w-4 h-4" />
) : (
<XCircle className="w-4 h-4" />
)}
{testResult.message}
</div>
)}
<Separator />
<div className="flex items-center gap-2">
<Button onClick={handleSave}>Save</Button>
<Button variant="outline" onClick={handleTest} disabled={isTesting}>
{isTesting ? "Testing..." : "Test Connection"}
</Button>
<Button variant="ghost" onClick={handleCancel}>
Cancel
</Button>
</div>
</CardContent>
</Card>
)}
</div>
);
}