feat(cloud): manage LLM providers in database
Tests / Test passed: 664

This commit is contained in:
2026-07-14 00:31:47 +08:00
parent b613a315ff
commit a166ffd8a4
35 changed files with 2432 additions and 204 deletions
+11 -1
View File
@@ -7,6 +7,7 @@ import {
LogOut,
MonitorSmartphone,
Puzzle,
SlidersHorizontal,
UsersRound,
} from "@lucide/vue";
import {
@@ -14,6 +15,7 @@ import {
getCurrentUser,
logout,
} from "./api";
import { hasScope } from "./permissions";
import type { CloudUser } from "./types";
import LoginScreen from "./views/LoginScreen.vue";
import PasswordChangeScreen from "./views/PasswordChangeScreen.vue";
@@ -21,8 +23,9 @@ import TasksView from "./views/TasksView.vue";
import DevicesView from "./views/DevicesView.vue";
import PluginsView from "./views/PluginsView.vue";
import UsersView from "./views/UsersView.vue";
import LlmProvidersView from "./views/LlmProvidersView.vue";
type ViewId = "tasks" | "devices" | "plugins" | "users";
type ViewId = "tasks" | "devices" | "plugins" | "users" | "providers";
const activeView = ref<ViewId>("tasks");
const currentUser = ref<CloudUser | null>(null);
@@ -49,6 +52,7 @@ const canAdminGovernance = computed(
currentUser.value?.scopes.includes("*") ||
currentUser.value?.scopes.includes("governance:admin")),
);
const canAdminProviders = computed(() => hasScope(currentUser.value, "llm-providers:admin"));
const isAuthenticated = computed(() => currentUser.value !== null);
const currentUserLabel = computed(() =>
currentUser.value ? `${currentUser.value.display_name} (${currentUser.value.role})` : "",
@@ -63,6 +67,9 @@ const navItems = computed<{ id: ViewId; label: string; icon: Component }[]>(() =
if (canAdminUsers.value || canAdminGovernance.value) {
items.push({ id: "users", label: "Users & limits", icon: UsersRound });
}
if (canAdminProviders.value) {
items.push({ id: "providers", label: "LLM providers", icon: SlidersHorizontal });
}
return items;
});
@@ -119,6 +126,8 @@ const activeComponent = computed(() => {
return PluginsView;
case "users":
return UsersView;
case "providers":
return LlmProvidersView;
default:
return TasksView;
}
@@ -155,6 +164,7 @@ const activeComponent = computed(() => {
:can-admin-users="canAdminUsers"
:can-admin-governance="canAdminGovernance"
/>
<LlmProvidersView v-else-if="activeView === 'providers'" :can-admin="canAdminProviders" />
<component v-else :is="activeComponent" :can-submit="canSubmitTasks" />
</main>
</div>
+39
View File
@@ -3,6 +3,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
AUTH_INVALID_EVENT,
createLlmProviderProfile,
listDevices,
login,
registerPlugin,
@@ -74,6 +75,44 @@ describe("Cloud Console API authentication", () => {
);
});
it("sends a Provider profile write through the CSRF-protected management API", async () => {
document.cookie = "amcp_csrf=csrf-value; path=/";
vi.mocked(fetch).mockResolvedValueOnce(
response({
id: "provider-a",
name: "OpenAI compatible",
provider_type: "openai-compatible",
model: "model-a",
base_url: "https://compat.example/v1",
timeout_seconds: 30,
enabled: true,
revision: 1,
has_api_key: true,
key_last_rotated_at: "2026-01-01T00:00:00+00:00",
created_at: "2026-01-01T00:00:00+00:00",
updated_at: "2026-01-01T00:00:00+00:00",
active: false,
}),
);
await createLlmProviderProfile({
name: "OpenAI compatible",
provider_type: "openai-compatible",
model: "model-a",
base_url: "https://compat.example/v1",
timeout_seconds: 30,
api_key: "secret-value",
});
expect(fetch).toHaveBeenCalledWith(
expect.stringMatching(/\/v1\/planner\/providers$/),
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({ "X-CSRF-Token": "csrf-value" }),
}),
);
});
it("invalidates the session only on 401", async () => {
const invalidated = vi.fn();
window.addEventListener(AUTH_INVALID_EVENT, invalidated);
+64
View File
@@ -3,6 +3,10 @@ import type {
DeviceRecord,
HostGovernancePolicy,
HostTokenUsageSummary,
LlmProviderProfile,
LlmProviderProfileListResponse,
LlmProviderSettings,
LlmProviderType,
HostRecord,
PluginRecord,
PluginRegistrationPayload,
@@ -253,3 +257,63 @@ export function registerPlugin(payload: PluginRegistrationPayload): Promise<Plug
body: JSON.stringify(payload),
});
}
export function listLlmProviderProfiles(): Promise<LlmProviderProfileListResponse> {
return request<LlmProviderProfileListResponse>("/v1/planner/providers");
}
export function createLlmProviderProfile(payload: {
name: string;
provider_type: LlmProviderType;
model: string;
base_url?: string | null;
timeout_seconds: number;
api_key: string;
}): Promise<LlmProviderProfile> {
return request<LlmProviderProfile>("/v1/planner/providers", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateLlmProviderProfile(
profileId: string,
payload: {
name?: string;
provider_type?: LlmProviderType;
model?: string;
base_url?: string | null;
timeout_seconds?: number;
enabled?: boolean;
api_key?: string;
expected_revision?: number;
},
): Promise<LlmProviderProfile> {
return request<LlmProviderProfile>(`/v1/planner/providers/${encodeURIComponent(profileId)}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export function activateLlmProviderProfile(
profileId: string,
expectedSettingsRevision: number,
): Promise<LlmProviderSettings> {
return request<LlmProviderSettings>(
`/v1/planner/providers/${encodeURIComponent(profileId)}/activate`,
{
method: "POST",
body: JSON.stringify({ expected_settings_revision: expectedSettingsRevision }),
},
);
}
export function deleteLlmProviderProfile(
profileId: string,
expectedRevision: number,
): Promise<void> {
return request<void>(
`/v1/planner/providers/${encodeURIComponent(profileId)}?expected_revision=${expectedRevision}`,
{ method: "DELETE" },
);
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { hasScope } from "./permissions";
import type { CloudUser } from "./types";
const baseUser: Omit<CloudUser, "scopes"> = {
id: "user-a",
username: "operator",
display_name: "Operator",
role: "operator",
enabled: true,
must_change_password: false,
created_at: "2026-01-01T00:00:00+00:00",
updated_at: "2026-01-01T00:00:00+00:00",
last_login_at: null,
};
describe("Provider navigation permission", () => {
it("requires the Provider administration scope", () => {
expect(hasScope({ ...baseUser, scopes: ["tasks:read"] }, "llm-providers:admin")).toBe(false);
expect(hasScope({ ...baseUser, scopes: ["llm-providers:admin"] }, "llm-providers:admin")).toBe(true);
expect(hasScope({ ...baseUser, scopes: ["*"] }, "llm-providers:admin")).toBe(true);
});
});
+5
View File
@@ -0,0 +1,5 @@
import type { CloudUser } from "./types";
export function hasScope(user: CloudUser | null, scope: string): boolean {
return Boolean(user?.scopes.includes("*") || user?.scopes.includes(scope));
}
+21
View File
@@ -65,6 +65,20 @@ button.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
button.icon-button {
width: 30px;
height: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
button.danger:hover:not(:disabled) {
background: var(--danger-bg);
border-color: var(--danger);
}
input,
select,
textarea {
@@ -356,6 +370,13 @@ tr.row-selected {
color: var(--text-muted);
}
.provider-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
white-space: nowrap;
}
.loader {
display: inline-block;
animation: spin 1.2s linear infinite;
+29
View File
@@ -149,3 +149,32 @@ export interface TokenUsageEvent {
total_tokens: number;
occurred_at: string;
}
export type LlmProviderType = "anthropic" | "openai-compatible";
export interface LlmProviderProfile {
id: string;
name: string;
provider_type: LlmProviderType;
model: string;
base_url: string | null;
timeout_seconds: number;
enabled: boolean;
revision: number;
has_api_key: boolean;
key_last_rotated_at: string;
created_at: string;
updated_at: string;
active: boolean;
}
export interface LlmProviderSettings {
active_profile_id: string | null;
revision: number;
updated_at: string | null;
}
export interface LlmProviderProfileListResponse {
settings: LlmProviderSettings;
items: LlmProviderProfile[];
}
@@ -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>