feat(settings): System tab — LLM backends, BYOK gate, store + view

This commit is contained in:
pyr0ball 2026-03-22 07:26:07 -07:00
parent 91874a176c
commit 0d17b20831
4 changed files with 336 additions and 0 deletions

View file

@ -1142,3 +1142,50 @@ def suggest_search(body: dict):
return {"suggestions": []}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── Settings: System — LLM Backends + BYOK endpoints ─────────────────────────
class ByokAckPayload(BaseModel):
backends: List[str] = []
LLM_CONFIG_PATH = Path("config/llm.yaml")
@app.get("/api/settings/system/llm")
def get_llm_config():
try:
user = load_user_profile(_user_yaml_path())
backends = []
if LLM_CONFIG_PATH.exists():
with open(LLM_CONFIG_PATH) as f:
data = yaml.safe_load(f) or {}
backends = data.get("backends", [])
return {"backends": backends, "byok_acknowledged": user.get("byok_acknowledged_backends", [])}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.put("/api/settings/system/llm")
def save_llm_config(payload: dict):
try:
data = {}
if LLM_CONFIG_PATH.exists():
with open(LLM_CONFIG_PATH) as f:
data = yaml.safe_load(f) or {}
data["backends"] = payload.get("backends", [])
LLM_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(LLM_CONFIG_PATH, "w") as f:
yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
return {"ok": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/settings/system/llm/byok-ack")
def byok_ack(payload: ByokAckPayload):
try:
user = load_user_profile(_user_yaml_path())
existing = user.get("byok_acknowledged_backends", [])
user["byok_acknowledged_backends"] = list(set(existing + payload.backends))
save_user_profile(_user_yaml_path(), user)
return {"ok": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View file

@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useSystemStore } from './system'
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
import { useApiFetch } from '../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('useSystemStore — BYOK gate', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
it('save() proceeds without modal when no cloud backends enabled', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useSystemStore()
store.backends = [{ id: 'ollama', enabled: true, priority: 1 }]
store.byokAcknowledged = []
await store.trySave()
expect(store.byokPending).toHaveLength(0)
expect(mockFetch).toHaveBeenCalledWith('/api/settings/system/llm', expect.anything())
})
it('save() sets byokPending when new cloud backend enabled', async () => {
const store = useSystemStore()
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
store.byokAcknowledged = []
await store.trySave()
expect(store.byokPending).toContain('anthropic')
expect(mockFetch).not.toHaveBeenCalledWith('/api/settings/system/llm', expect.anything())
})
it('save() skips modal for already-acknowledged backends', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useSystemStore()
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
store.byokAcknowledged = ['anthropic']
await store.trySave()
expect(store.byokPending).toHaveLength(0)
})
it('confirmByok() saves acknowledgment then commits LLM config', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useSystemStore()
store.byokPending = ['anthropic']
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
await store.confirmByok()
expect(mockFetch).toHaveBeenCalledWith('/api/settings/system/llm/byok-ack', expect.anything())
expect(mockFetch).toHaveBeenCalledWith('/api/settings/system/llm', expect.anything())
})
it('cancelByok() clears pending and restores backends to pre-save state', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useSystemStore()
const original = [{ id: 'ollama', enabled: true, priority: 1 }]
store.backends = [...original]
await store.trySave() // captures snapshot, commits (no cloud backends)
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
store.byokPending = ['anthropic']
store.cancelByok()
expect(store.byokPending).toHaveLength(0)
expect(store.backends).toEqual(original)
})
})

View file

@ -0,0 +1,73 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
const CLOUD_BACKEND_IDS = ['anthropic', 'openai']
export interface Backend { id: string; enabled: boolean; priority: number }
export const useSystemStore = defineStore('settings/system', () => {
const backends = ref<Backend[]>([])
const byokAcknowledged = ref<string[]>([])
const byokPending = ref<string[]>([])
// Private snapshot — NOT in return(). Closure-level only.
let _preSaveSnapshot: Backend[] = []
const saving = ref(false)
const saveError = ref<string | null>(null)
const loadError = ref<string | null>(null)
async function loadLlm() {
loadError.value = null
const { data, error } = await useApiFetch<{ backends: Backend[]; byok_acknowledged: string[] }>('/api/settings/system/llm')
if (error) { loadError.value = 'Failed to load LLM config'; return }
if (!data) return
backends.value = data.backends ?? []
byokAcknowledged.value = data.byok_acknowledged ?? []
}
async function trySave() {
_preSaveSnapshot = JSON.parse(JSON.stringify(backends.value))
const newlyEnabled = backends.value
.filter(b => CLOUD_BACKEND_IDS.includes(b.id) && b.enabled)
.map(b => b.id)
.filter(id => !byokAcknowledged.value.includes(id))
if (newlyEnabled.length > 0) {
byokPending.value = newlyEnabled
return // modal takes over
}
await _commitSave()
}
async function confirmByok() {
saving.value = true
const { error } = await useApiFetch('/api/settings/system/llm/byok-ack', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ backends: byokPending.value }),
})
if (!error) byokAcknowledged.value = [...byokAcknowledged.value, ...byokPending.value]
byokPending.value = []
await _commitSave()
}
function cancelByok() {
backends.value = JSON.parse(JSON.stringify(_preSaveSnapshot))
byokPending.value = []
_preSaveSnapshot = []
}
async function _commitSave() {
saving.value = true
saveError.value = null
const { error } = await useApiFetch('/api/settings/system/llm', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ backends: backends.value }),
})
saving.value = false
if (error) saveError.value = 'Save failed — please try again.'
}
// Services, email, integrations added in Task 6
return { backends, byokAcknowledged, byokPending, saving, saveError, loadError, loadLlm, trySave, confirmByok, cancelByok }
})

View file

@ -0,0 +1,154 @@
<template>
<div class="system-settings">
<h2>System Settings</h2>
<p class="tab-note">This tab is only available in self-hosted mode.</p>
<p v-if="store.loadError" class="error-banner">{{ store.loadError }}</p>
<!-- LLM Backends -->
<section class="form-section">
<h3>LLM Backends</h3>
<p class="section-note">Drag to reorder. Higher position = higher priority in the fallback chain.</p>
<div class="backend-list">
<div
v-for="(backend, idx) in visibleBackends"
:key="backend.id"
class="backend-card"
draggable="true"
@dragstart="dragStart(idx)"
@dragover.prevent="dragOver(idx)"
@drop="drop"
>
<span class="drag-handle" aria-hidden="true"></span>
<span class="priority-badge">{{ idx + 1 }}</span>
<span class="backend-id">{{ backend.id }}</span>
<label class="toggle-label">
<input
type="checkbox"
:checked="backend.enabled"
@change="store.backends = store.backends.map(b =>
b.id === backend.id ? { ...b, enabled: !b.enabled } : b
)"
/>
<span class="toggle-text">{{ backend.enabled ? 'Enabled' : 'Disabled' }}</span>
</label>
</div>
</div>
<div class="form-actions">
<button @click="store.trySave()" :disabled="store.saving" class="btn-primary">
{{ store.saving ? 'Saving…' : 'Save Backends' }}
</button>
<p v-if="store.saveError" class="error">{{ store.saveError }}</p>
</div>
</section>
<!-- BYOK Modal -->
<Teleport to="body">
<div v-if="store.byokPending.length > 0" class="modal-overlay" @click.self="store.cancelByok()">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="byok-title">
<h3 id="byok-title"> Cloud LLM Key Required</h3>
<p>You are enabling the following cloud backends:</p>
<ul>
<li v-for="b in store.byokPending" :key="b">{{ b }}</li>
</ul>
<p class="byok-warning">
These services require your own API key. Your requests and data will be
sent to these third-party providers. Costs will be charged to your account.
</p>
<label class="checkbox-row">
<input type="checkbox" v-model="byokConfirmed" />
I understand and have configured my API key in <code>config/llm.yaml</code>
</label>
<div class="modal-actions">
<button @click="store.cancelByok()" class="btn-cancel">Cancel</button>
<button
@click="handleConfirmByok"
:disabled="!byokConfirmed || store.saving"
class="btn-primary"
>{{ store.saving ? 'Saving…' : 'Save with Cloud LLM' }}</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useSystemStore } from '../../stores/settings/system'
import { useAppConfigStore } from '../../stores/appConfig'
const store = useSystemStore()
const config = useAppConfigStore()
const byokConfirmed = ref(false)
const dragIdx = ref<number | null>(null)
const CONTRACTED_ONLY = ['claude-code', 'copilot']
const visibleBackends = computed(() =>
store.backends.filter(b =>
!CONTRACTED_ONLY.includes(b.id) || config.contractedClient
)
)
function dragStart(idx: number) {
dragIdx.value = idx
}
function dragOver(idx: number) {
if (dragIdx.value === null || dragIdx.value === idx) return
// Reorder store.backends (immutable swap)
const arr = [...store.backends]
const [moved] = arr.splice(dragIdx.value, 1)
arr.splice(idx, 0, moved)
store.backends = arr
// Update priorities
store.backends = store.backends.map((b, i) => ({ ...b, priority: i + 1 }))
dragIdx.value = idx
}
function drop() {
dragIdx.value = null
}
async function handleConfirmByok() {
await store.confirmByok()
byokConfirmed.value = false
}
onMounted(() => store.loadLlm())
</script>
<style scoped>
.system-settings { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: 6px; color: var(--color-text-primary, #e2e8f0); }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); color: var(--color-text-primary, #e2e8f0); }
.tab-note { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: var(--space-6, 32px); }
.form-section { margin-bottom: var(--space-8, 48px); padding-bottom: var(--space-6, 32px); border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.08)); }
.section-note { font-size: 0.78rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 14px; }
.backend-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
.backend-card { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; cursor: grab; user-select: none; }
.backend-card:active { cursor: grabbing; }
.drag-handle { font-size: 1.1rem; color: var(--color-text-secondary, #64748b); }
.priority-badge { width: 22px; height: 22px; border-radius: 50%; background: rgba(124,58,237,0.2); color: var(--color-accent, #a78bfa); font-size: 0.72rem; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.backend-id { flex: 1; font-size: 0.9rem; font-family: monospace; color: var(--color-text-primary, #e2e8f0); }
.toggle-label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
.form-actions { display: flex; align-items: center; gap: var(--space-4, 24px); }
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-cancel { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.2)); border-radius: 7px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.9rem; }
.error-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #ef4444; padding: 10px 14px; margin-bottom: 20px; font-size: 0.85rem; }
.error { color: #ef4444; font-size: 0.82rem; }
/* BYOK Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
.modal-card { background: var(--color-surface-1, #1e293b); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 12px; padding: 28px; max-width: 480px; width: 90%; }
.modal-card h3 { font-size: 1.1rem; margin-bottom: 12px; color: var(--color-text-primary, #e2e8f0); }
.modal-card p { font-size: 0.88rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 12px; }
.modal-card ul { margin: 8px 0 16px 20px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); }
.byok-warning { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); border-radius: 6px; padding: 10px 12px; color: #fbbf24 !important; }
.checkbox-row { display: flex; align-items: flex-start; gap: 8px; font-size: 0.85rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; margin: 16px 0; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
</style>