@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user