@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { Check, LoaderCircle, Pencil, Plus, RefreshCw, Trash2, X } from "@lucide/vue";
|
||||
import {
|
||||
activateLlmProviderProfile,
|
||||
createLlmProviderProfile,
|
||||
deleteLlmProviderProfile,
|
||||
listLlmProviderProfiles,
|
||||
updateLlmProviderProfile,
|
||||
} from "../api";
|
||||
import type { LlmProviderProfile, LlmProviderSettings, LlmProviderType } from "../types";
|
||||
|
||||
defineProps<{ canAdmin: boolean }>();
|
||||
|
||||
const profiles = ref<LlmProviderProfile[]>([]);
|
||||
const settings = ref<LlmProviderSettings>({ active_profile_id: null, revision: 0, updated_at: null });
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const editingId = ref<string | null>(null);
|
||||
const formError = ref("");
|
||||
const successMessage = ref("");
|
||||
const errorMessage = ref("");
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
provider_type: "openai-compatible" as LlmProviderType,
|
||||
model: "",
|
||||
base_url: "",
|
||||
timeout_seconds: "30",
|
||||
api_key: "",
|
||||
enabled: true,
|
||||
});
|
||||
const editingProfile = computed(
|
||||
() => profiles.value.find((profile) => profile.id === editingId.value) ?? null,
|
||||
);
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null;
|
||||
form.name = "";
|
||||
form.provider_type = "openai-compatible";
|
||||
form.model = "";
|
||||
form.base_url = "";
|
||||
form.timeout_seconds = "30";
|
||||
form.api_key = "";
|
||||
form.enabled = true;
|
||||
formError.value = "";
|
||||
}
|
||||
|
||||
function showError(error: unknown, fallback: string) {
|
||||
successMessage.value = "";
|
||||
errorMessage.value = error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const response = await listLlmProviderProfiles();
|
||||
profiles.value = response.items;
|
||||
settings.value = response.settings;
|
||||
} catch (error) {
|
||||
showError(error, "failed to load LLM providers");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function editProfile(profile: LlmProviderProfile) {
|
||||
editingId.value = profile.id;
|
||||
form.name = profile.name;
|
||||
form.provider_type = profile.provider_type;
|
||||
form.model = profile.model;
|
||||
form.base_url = profile.base_url ?? "";
|
||||
form.timeout_seconds = String(profile.timeout_seconds);
|
||||
form.api_key = "";
|
||||
form.enabled = profile.enabled;
|
||||
formError.value = "";
|
||||
successMessage.value = "";
|
||||
}
|
||||
|
||||
function parseTimeout(): number | null {
|
||||
const timeout = Number(form.timeout_seconds);
|
||||
return Number.isFinite(timeout) && timeout > 0 && timeout <= 120 ? timeout : null;
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
formError.value = "";
|
||||
successMessage.value = "";
|
||||
const timeout = parseTimeout();
|
||||
if (!form.name.trim() || !form.model.trim() || timeout === null) {
|
||||
formError.value = "name, model, and a timeout between 0 and 120 are required";
|
||||
return;
|
||||
}
|
||||
if (!editingId.value && !form.api_key) {
|
||||
formError.value = "an API key is required for a new Provider profile";
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const baseUrl = form.provider_type === "anthropic" ? null : form.base_url.trim() || null;
|
||||
if (editingId.value) {
|
||||
const existing = profiles.value.find((profile) => profile.id === editingId.value);
|
||||
if (!existing) throw new Error("Provider profile no longer exists");
|
||||
const payload: Parameters<typeof updateLlmProviderProfile>[1] = {
|
||||
name: form.name.trim(),
|
||||
provider_type: form.provider_type,
|
||||
model: form.model.trim(),
|
||||
base_url: baseUrl,
|
||||
timeout_seconds: timeout,
|
||||
enabled: form.enabled,
|
||||
expected_revision: existing.revision,
|
||||
};
|
||||
if (form.api_key) payload.api_key = form.api_key;
|
||||
await updateLlmProviderProfile(editingId.value, payload);
|
||||
successMessage.value = "Provider profile saved";
|
||||
} else {
|
||||
await createLlmProviderProfile({
|
||||
name: form.name.trim(),
|
||||
provider_type: form.provider_type,
|
||||
model: form.model.trim(),
|
||||
base_url: baseUrl,
|
||||
timeout_seconds: timeout,
|
||||
api_key: form.api_key,
|
||||
});
|
||||
successMessage.value = "Provider profile created";
|
||||
}
|
||||
resetForm();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
formError.value = error instanceof Error ? error.message : "failed to save Provider profile";
|
||||
} finally {
|
||||
form.api_key = "";
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(profile: LlmProviderProfile) {
|
||||
saving.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
settings.value = await activateLlmProviderProfile(profile.id, settings.value.revision);
|
||||
successMessage.value = `${profile.name} is active for Cloud planner requests`;
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(error, "failed to activate Provider profile");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(profile: LlmProviderProfile) {
|
||||
if (!window.confirm(`Delete ${profile.name}?`)) return;
|
||||
saving.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await deleteLlmProviderProfile(profile.id, profile.revision);
|
||||
successMessage.value = "Provider profile deleted";
|
||||
if (editingId.value === profile.id) resetForm();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(error, "failed to delete Provider profile");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="toolbar">
|
||||
<h2>LLM providers</h2>
|
||||
<button title="Refresh providers" aria-label="Refresh providers" :disabled="loading" @click="refresh">
|
||||
<RefreshCw :size="14" />
|
||||
</button>
|
||||
<button class="primary" @click="resetForm"><Plus :size="14" /> New provider</button>
|
||||
<span v-if="loading" class="muted"><LoaderCircle :size="14" class="loader" /> loading…</span>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
|
||||
<div v-if="successMessage" class="notice success">{{ successMessage }}</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>{{ editingId ? "Edit Provider" : "New Provider" }}</h3>
|
||||
<form @submit.prevent="saveProfile">
|
||||
<div class="form-grid">
|
||||
<label>Name <input v-model="form.name" autocomplete="off" /></label>
|
||||
<label>Protocol
|
||||
<select v-model="form.provider_type">
|
||||
<option value="openai-compatible">OpenAI-compatible</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Model <input v-model="form.model" autocomplete="off" /></label>
|
||||
<label>Timeout seconds <input v-model="form.timeout_seconds" inputmode="decimal" /></label>
|
||||
<label v-if="form.provider_type === 'openai-compatible'" class="field-full">Base URL
|
||||
<input v-model="form.base_url" placeholder="Official OpenAI endpoint when blank" autocomplete="url" />
|
||||
</label>
|
||||
<label class="field-full">{{ editingId ? "Rotate API key" : "API key" }}
|
||||
<input v-model="form.api_key" type="password" autocomplete="new-password" />
|
||||
</label>
|
||||
<label v-if="editingId"><input v-model="form.enabled" type="checkbox" :disabled="editingProfile?.active" /> Enabled</label>
|
||||
</div>
|
||||
<div v-if="formError" class="notice error" style="margin-top: 12px">{{ formError }}</div>
|
||||
<div class="toolbar" style="margin-top: 12px">
|
||||
<button type="submit" class="primary" :disabled="saving">{{ editingId ? "Save Provider" : "Create Provider" }}</button>
|
||||
<button v-if="editingId" type="button" title="Cancel edit" aria-label="Cancel edit" @click="resetForm"><X :size="14" /></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<table v-if="profiles.length">
|
||||
<thead><tr><th>Name</th><th>Protocol / model</th><th>Endpoint</th><th>State</th><th>Key rotation</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="profile in profiles" :key="profile.id">
|
||||
<td><strong>{{ profile.name }}</strong></td>
|
||||
<td><span class="status-badge queued">{{ profile.provider_type }}</span><br /><span class="dim">{{ profile.model }}</span></td>
|
||||
<td class="dim">{{ profile.base_url ?? "Official endpoint" }}</td>
|
||||
<td><span :class="profile.active ? 'status-badge done' : profile.enabled ? 'status-badge idle' : 'status-badge failed'">{{ profile.active ? "active" : profile.enabled ? "ready" : "disabled" }}</span></td>
|
||||
<td class="dim">{{ new Date(profile.key_last_rotated_at).toLocaleString() }}</td>
|
||||
<td class="provider-actions">
|
||||
<button v-if="!profile.active" class="icon-button" title="Activate Provider" :aria-label="`Activate ${profile.name}`" :disabled="saving || !profile.enabled" @click="activate(profile)"><Check :size="14" /></button>
|
||||
<button class="icon-button" title="Edit Provider" :aria-label="`Edit ${profile.name}`" :disabled="saving" @click="editProfile(profile)"><Pencil :size="14" /></button>
|
||||
<button class="icon-button danger" title="Delete Provider" :aria-label="`Delete ${profile.name}`" :disabled="saving || profile.active" @click="remove(profile)"><Trash2 :size="14" /></button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted">No Provider profiles configured.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user