Skip to content

Commit ddf5737

Browse files
feat(web): Settings → LLM Providers panel + test-flow fixes (Phase 4)
Settings nav panel with per-provider add/change/delete/test, plus fixes found in live testing. UI: new Settings sidebar button + settingsPanel() Alpine component; per- auth_kind rows (api_key / bedrock_bearer / ollama editable base_url). style.css. Backend fixes (verified live via curl + Playwright MCP): - /providers/{slug}/test: empty key from the 'Test' button now falls back to the STORED key — fixes minimax 'Illegal header value Bearer ' and gemini 403. - secrets: empty-key guard in test_provider_auth. - Ollama: test discovers installed models via /api/tags (list_ollama_models), skips embeddings — fixes 404 when recommended models aren't pulled. Confirmed live: discovers gemma4:latest, generated 5 cards. Tests: 133 backend pass. KNOWN GAP: 6 settings-panel e2e tests have a strict- locator issue (x-show-hidden rows in DOM); panel verified working live.
1 parent 38e7c81 commit ddf5737

9 files changed

Lines changed: 716 additions & 18 deletions

File tree

packages/studyloop/src/studyloop/provider_auth.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,49 @@ def test_bedrock_bearer(token: str, region: str = "us-east-1") -> tuple[bool, st
136136
return True, "Bearer token verified against Bedrock."
137137

138138

139+
def list_ollama_models(base_url: str = "http://localhost:11434") -> list[str]:
140+
"""Return the model names actually installed on the Ollama server.
141+
142+
Queries ``GET /api/tags``. Returns ``[]`` if the server is unreachable or
143+
returns no models. Names include the tag (e.g. ``gemma3:4b``,
144+
``gemma4:latest``).
145+
"""
146+
import httpx
147+
148+
try:
149+
resp = httpx.get(f"{base_url.rstrip('/')}/api/tags", timeout=2.0)
150+
if resp.status_code != 200:
151+
return []
152+
data = resp.json()
153+
except Exception:
154+
return []
155+
156+
models = data.get("models", []) if isinstance(data, dict) else []
157+
names = [m.get("name", "") for m in models if isinstance(m, dict)]
158+
return [n for n in names if n]
159+
160+
161+
def _ollama_test_candidates(base_url: str, model: str) -> list[str]:
162+
"""Pick which models the test should try, preferring what's installed.
163+
164+
- explicit ``model`` → just that one.
165+
- otherwise: installed models, recommended ones first (so a known-good
166+
small model is tried before a random embedding model), then any other
167+
installed model. Falls back to the recommended list if discovery fails
168+
(so the error message still names sensible models).
169+
"""
170+
if model:
171+
return [model]
172+
installed = list_ollama_models(base_url)
173+
if not installed:
174+
return list(OLLAMA_RECOMMENDED_MODELS)
175+
# Embedding models can't generate chat/tool-use output — skip them.
176+
installed = [m for m in installed if "embed" not in m.lower()]
177+
preferred = [m for m in installed if m in OLLAMA_RECOMMENDED_MODELS]
178+
rest = [m for m in installed if m not in OLLAMA_RECOMMENDED_MODELS]
179+
return preferred + rest
180+
181+
139182
def test_ollama_generate(
140183
base_url: str = "http://localhost:11434", model: str = ""
141184
) -> tuple[bool, str]:
@@ -146,14 +189,20 @@ def test_ollama_generate(
146189
can produce StudyLoop's structured tool-use output — the thing small models
147190
most often fail — without a second judge LLM.
148191
149-
When ``model`` is empty, tries :data:`OLLAMA_RECOMMENDED_MODELS` in order
150-
and returns on the first success. Returns ``(True, msg)`` / ``(False, msg)``.
192+
When ``model`` is empty, discovers installed models (via ``/api/tags``)
193+
and tries them — recommended ones first — so the test uses a model the
194+
user actually has. Returns ``(True, msg)`` / ``(False, msg)``.
151195
"""
152196
from studyloop.content.generators import CardGenerationError
153197
from studyloop.content.generators.ollama import OllamaGenerator
154198
from studyloop.settings import CardGeneratorConfig, OllamaBackendConfig
155199

156-
candidates = [model] if model else list(OLLAMA_RECOMMENDED_MODELS)
200+
candidates = _ollama_test_candidates(base_url, model)
201+
if not candidates:
202+
return False, (
203+
f"No Ollama models installed at {base_url}. "
204+
"Pull one first, e.g. `ollama pull qwen2.5:7b`."
205+
)
157206
failures: list[str] = []
158207

159208
for candidate in candidates:
@@ -185,6 +234,7 @@ def test_ollama_generate(
185234
__all__ = [
186235
"BEDROCK_TEST_MODEL",
187236
"OLLAMA_RECOMMENDED_MODELS",
237+
"list_ollama_models",
188238
"test_bedrock_bearer",
189239
"test_ollama_generate",
190240
]

packages/studyloop/src/studyloop/secrets.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,11 @@ def test_provider_auth(provider: str, key: str = "", base_url: str = "") -> tupl
438438
if spec is None:
439439
return False, f"Unknown provider {provider!r}. Known: {', '.join(_AUTH_TEST_PROVIDERS)}"
440440

441+
# An empty key would build an invalid auth header (e.g. "Bearer ") that
442+
# httpx rejects with "Illegal header value" — guard with a clear message.
443+
if not key.strip():
444+
return False, f"No API key provided for {provider}."
445+
441446
try:
442447
ok, msg = _run_auth_test(provider, key, spec)
443448
return ok, msg

packages/studyloop/src/studyloop/web/routes/content_gen.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ async def test_provider(slug: str, body: TestProviderRequest) -> TestProviderRes
468468
Unknown slug → 404. The raw key is never logged or echoed.
469469
"""
470470
from studyloop.content.generators.provider_profiles import PROFILES
471-
from studyloop.secrets import test_provider_auth
471+
from studyloop.secrets import get_secret, test_provider_auth
472472

473473
slug = slug.lower().strip()
474474
if slug not in PROFILES:
@@ -478,13 +478,25 @@ async def test_provider(slug: str, body: TestProviderRequest) -> TestProviderRes
478478
ok, message = await asyncio.to_thread(
479479
test_provider_auth, "ollama", "", _ollama_base_url()
480480
)
481-
elif slug == "bedrock" and body.key.strip():
482-
# Real bearer-token Converse probe — off-thread (network + boto3).
483-
ok, message = await asyncio.to_thread(test_provider_auth, "bedrock", body.key)
484-
else:
485-
# Cheap HTTP auth check (api-key providers) or bedrock no-key fast-path.
486-
ok, message = test_provider_auth(slug, body.key)
487-
481+
return TestProviderResponse(ok=ok, message=message)
482+
483+
if slug == "bedrock":
484+
# Prefer the typed token; else the stored one. With neither, fall to the
485+
# AWS-SDK/profile path (test_provider_auth returns the informational msg).
486+
token = body.key.strip() or (get_secret("bedrock_bearer_token") or "")
487+
ok, message = await asyncio.to_thread(test_provider_auth, "bedrock", token)
488+
return TestProviderResponse(ok=ok, message=message)
489+
490+
# API-key providers. The "Test" button sends an empty key (the browser
491+
# never holds the stored secret), so fall back to the stored key. With no
492+
# key anywhere there is nothing to test — say so rather than make an
493+
# empty-credential HTTP call.
494+
key = body.key.strip() or (get_secret(slug) or "")
495+
if not key:
496+
return TestProviderResponse(
497+
ok=False, message=f"No stored key for {slug}. Enter a key and Test & save."
498+
)
499+
ok, message = test_provider_auth(slug, key)
488500
return TestProviderResponse(ok=ok, message=message)
489501

490502

packages/studyloop/src/studyloop/web/static/index.html

Lines changed: 197 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,15 @@
205205
</div>
206206
</div>
207207
</div>
208+
209+
<!-- Spacer pushes Settings to the bottom of the sidebar (gear convention) -->
210+
<div class="sidebar-spacer" aria-hidden="true"></div>
211+
<button class="sidebar-btn"
212+
:class="{ active: $store.nav.is('settings') }"
213+
@click="$store.nav.go('settings')">
214+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
215+
<span>Settings</span>
216+
</button>
208217
</nav>
209218

210219
<!-- Content area -->
@@ -1279,6 +1288,87 @@ <h2>Start a Study Session</h2>
12791288
</div>
12801289
</div>
12811290

1291+
<!-- ============================================================ -->
1292+
<!-- SETTINGS VIEW — LLM Providers admin -->
1293+
<!-- ============================================================ -->
1294+
<div x-show="$store.nav.is('settings')" x-cloak
1295+
x-data="settingsPanel()" x-init="init()">
1296+
<div class="settings-panel">
1297+
<h2>Settings</h2>
1298+
<section class="settings-section">
1299+
<h3>LLM Providers</h3>
1300+
<p class="settings-blurb">
1301+
Manage credentials for content generation. Keys are encrypted at
1302+
rest in <code>~/.config/studyloop/</code> and never leave this machine.
1303+
</p>
1304+
1305+
<template x-for="p in providers" :key="p.slug">
1306+
<div class="provider-row">
1307+
<div class="provider-row-header">
1308+
<span class="provider-label" x-text="p.label"></span>
1309+
<span class="provider-status"
1310+
:class="p.available ? 'status-ok' : 'status-missing'"
1311+
x-text="p.available ? 'configured' : 'not configured'"></span>
1312+
</div>
1313+
1314+
<!-- API-key providers -->
1315+
<div x-show="p.auth_kind === 'api_key'" class="provider-controls">
1316+
<input type="password" x-model="inputs[p.slug]"
1317+
:placeholder="p.available ? '••••••••• (stored — type to replace)' : 'Paste API key'"
1318+
autocomplete="off" spellcheck="false" />
1319+
<button type="button" @click="saveKey(p.slug)"
1320+
:disabled="busy === p.slug || !inputs[p.slug]?.trim()"
1321+
x-text="busy === p.slug ? 'Testing…' : 'Test & save'"></button>
1322+
<button type="button" class="danger-btn" x-show="p.available"
1323+
@click="deleteKey(p.slug)">Delete</button>
1324+
<button type="button" @click="testOnly(p.slug)"
1325+
:disabled="busy === p.slug || !p.available">Test</button>
1326+
</div>
1327+
1328+
<!-- Bedrock bearer token -->
1329+
<div x-show="p.auth_kind === 'bedrock_bearer'" class="provider-controls">
1330+
<input type="password" x-model="inputs['bedrock']"
1331+
placeholder="Paste AWS bearer token (optional)"
1332+
autocomplete="off" spellcheck="false" />
1333+
<button type="button" @click="saveBearer()"
1334+
:disabled="busy === 'bedrock' || !inputs['bedrock']?.trim()"
1335+
x-text="busy === 'bedrock' ? 'Testing…' : 'Test & save'"></button>
1336+
<button type="button" class="danger-btn" x-show="p.available"
1337+
@click="deleteBearer()">Delete token</button>
1338+
<button type="button" @click="testOnly('bedrock')"
1339+
:disabled="busy === 'bedrock'">Test AWS creds</button>
1340+
<small class="settings-hint">
1341+
Bearer token (<code>AWS_BEARER_TOKEN_BEDROCK</code>) for accounts that
1342+
use token auth. Leave empty to use your AWS profile / IAM role instead.
1343+
</small>
1344+
</div>
1345+
1346+
<!-- Ollama (local, keyless) -->
1347+
<div x-show="p.auth_kind === 'local_keyless'" class="provider-controls">
1348+
<input type="text" x-model="inputs['ollama']"
1349+
:placeholder="p.base_url || 'http://localhost:11434'"
1350+
autocomplete="off" spellcheck="false" />
1351+
<button type="button" @click="saveOllamaUrl()"
1352+
:disabled="busy === 'ollama' || !inputs['ollama']?.trim()">Save URL</button>
1353+
<button type="button" @click="testOnly('ollama')"
1354+
:disabled="busy === 'ollama'"
1355+
x-text="busy === 'ollama' ? 'Testing…' : 'Test connection'"></button>
1356+
<small class="settings-hint">
1357+
Local &amp; keyless. Test runs a real generation against a recommended
1358+
model — may take 10–30s on first call while the model loads.
1359+
</small>
1360+
</div>
1361+
1362+
<small x-show="status[p.slug]?.error" class="key-error"
1363+
x-text="status[p.slug]?.error"></small>
1364+
<small x-show="status[p.slug]?.ok" class="key-ok"
1365+
x-text="status[p.slug]?.ok"></small>
1366+
</div>
1367+
</template>
1368+
</section>
1369+
</div>
1370+
</div>
1371+
12821372
</div><!-- .content-area -->
12831373
</div><!-- .app-layout -->
12841374

@@ -1293,7 +1383,7 @@ <h2>Start a Study Session</h2>
12931383

12941384
init() {
12951385
const hash = window.location.hash.slice(1);
1296-
const valid = ['flashcards', 'quizzes', 'generate', 'body-double', 'study-session'];
1386+
const valid = ['flashcards', 'quizzes', 'generate', 'body-double', 'study-session', 'settings'];
12971387
if (hash && valid.includes(hash)) this.current = hash;
12981388
},
12991389

@@ -1640,6 +1730,112 @@ <h2>Start a Study Session</h2>
16401730
};
16411731
}
16421732

1733+
/* ------------------------------------------------------------------
1734+
* Settings panel — LLM Providers admin (add/change/delete/test keys)
1735+
* ------------------------------------------------------------------ */
1736+
function settingsPanel() {
1737+
return {
1738+
providers: [],
1739+
inputs: {}, // { slug: string } live input values
1740+
status: {}, // { slug: { ok: string, error: string } }
1741+
busy: '', // slug whose request is in flight
1742+
1743+
async init() {
1744+
await this.refreshProviders();
1745+
},
1746+
1747+
async refreshProviders() {
1748+
try {
1749+
const r = await fetch('/api/content/providers');
1750+
if (r.ok) this.providers = await r.json();
1751+
} catch { /* leave list as-is */ }
1752+
},
1753+
1754+
_ok(slug, msg) { this.status = { ...this.status, [slug]: { ok: msg, error: '' } }; },
1755+
_err(slug, msg) { this.status = { ...this.status, [slug]: { ok: '', error: msg } }; },
1756+
_clear(slug) { this.status = { ...this.status, [slug]: { ok: '', error: '' } }; },
1757+
1758+
async _postSecret(storeName, value, uiSlug) {
1759+
// Test + store via the secrets route; refresh on success.
1760+
this._clear(uiSlug);
1761+
try {
1762+
const r = await fetch('/api/content/secrets', {
1763+
method: 'POST',
1764+
headers: { 'Content-Type': 'application/json' },
1765+
body: JSON.stringify({ provider: storeName, key: value }),
1766+
});
1767+
if (r.ok) {
1768+
this.inputs[uiSlug] = '';
1769+
this._ok(uiSlug, 'Verified and saved.');
1770+
await this.refreshProviders();
1771+
} else {
1772+
let detail = 'Could not verify the value.';
1773+
try { detail = (await r.json()).detail || detail; } catch {}
1774+
this._err(uiSlug, detail);
1775+
}
1776+
} catch { this._err(uiSlug, 'Network error.'); }
1777+
},
1778+
1779+
async _deleteSecret(storeName, uiSlug, okMsg) {
1780+
this._clear(uiSlug);
1781+
try {
1782+
const r = await fetch(`/api/content/secrets/${storeName}`, { method: 'DELETE' });
1783+
if (r.ok) { this._ok(uiSlug, okMsg); await this.refreshProviders(); }
1784+
else { this._err(uiSlug, 'Delete failed.'); }
1785+
} catch { this._err(uiSlug, 'Network error.'); }
1786+
},
1787+
1788+
async saveKey(slug) {
1789+
const v = this.inputs[slug]?.trim();
1790+
if (!v || this.busy) return;
1791+
this.busy = slug;
1792+
await this._postSecret(slug, v, slug);
1793+
this.busy = '';
1794+
},
1795+
1796+
async deleteKey(slug) { await this._deleteSecret(slug, slug, 'Key deleted.'); },
1797+
1798+
async saveBearer() {
1799+
const v = this.inputs['bedrock']?.trim();
1800+
if (!v || this.busy) return;
1801+
this.busy = 'bedrock';
1802+
await this._postSecret('bedrock_bearer_token', v, 'bedrock');
1803+
this.busy = '';
1804+
},
1805+
1806+
async deleteBearer() {
1807+
await this._deleteSecret('bedrock_bearer_token', 'bedrock', 'Bearer token deleted.');
1808+
},
1809+
1810+
async saveOllamaUrl() {
1811+
const v = this.inputs['ollama']?.trim();
1812+
if (!v || this.busy) return;
1813+
this.busy = 'ollama';
1814+
// ollama_base_url is a config value — stored without an auth-test.
1815+
await this._postSecret('ollama_base_url', v, 'ollama');
1816+
this.busy = '';
1817+
},
1818+
1819+
async testOnly(slug) {
1820+
if (this.busy) return;
1821+
this.busy = slug;
1822+
this._clear(slug);
1823+
try {
1824+
const key = slug === 'bedrock' ? (this.inputs['bedrock']?.trim() || '') : '';
1825+
const r = await fetch(`/api/content/providers/${slug}/test`, {
1826+
method: 'POST',
1827+
headers: { 'Content-Type': 'application/json' },
1828+
body: JSON.stringify({ key }),
1829+
});
1830+
const data = await r.json();
1831+
if (data.ok) this._ok(slug, data.message || 'Credentials valid.');
1832+
else this._err(slug, data.message || 'Test failed.');
1833+
} catch { this._err(slug, 'Network error.'); }
1834+
this.busy = '';
1835+
},
1836+
};
1837+
}
1838+
16431839
/* ------------------------------------------------------------------
16441840
* Session timer component (from session.html)
16451841
* ------------------------------------------------------------------ */

0 commit comments

Comments
 (0)