Skip to content

Commit e669fdb

Browse files
fix(web): boto3 in dev deps + Bedrock AWS-credential labelling
Two Generate-panel fixes: - boto3>=1.40 added to root dev dependency-group so 'uv run studyloop web' has a working Bedrock backend (workspace members sync without their optional-deps, so the repo .venv lacked boto3 → generation died with 'No module named boto3'). - Bedrock provider relabelled: shows 'needs AWS credentials' (not 'needs API key') and an AWS-cred hint instead of the key box; Generate is blocked until creds resolve. Fixed a latent bug where x-show on a nested <span> inside <option> is ignored by browsers (option text is flattened) — the unavailable-suffix rendered on every provider. Now built via providerOptionLabel(p). Verified live in browser.
1 parent 875bdb0 commit e669fdb

4 files changed

Lines changed: 158 additions & 4 deletions

File tree

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

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -802,10 +802,11 @@ <h2>Generate</h2>
802802
<select x-model="form.provider" @change="onProviderChange()">
803803
<option value="">— pick a provider —</option>
804804
<template x-for="p in providers" :key="p.slug">
805-
<option :value="p.slug">
806-
<span x-text="p.label"></span>
807-
<span x-show="!p.available"> — needs API key</span>
808-
</option>
805+
<!-- The unavailable-suffix must be part of the option's text,
806+
not a nested <span x-show>: browsers flatten <option>
807+
content and ignore display:none on children, so a hidden
808+
span still renders. Build the whole label via x-text. -->
809+
<option :value="p.slug" x-text="providerOptionLabel(p)"></option>
809810
</template>
810811
</select>
811812
</label>
@@ -833,6 +834,26 @@ <h2>Generate</h2>
833834
</div>
834835
</div>
835836

837+
<!-- Bedrock uses AWS SigV4 credentials, not a typed API key, so it
838+
gets a guidance hint rather than the key-entry box above. -->
839+
<div class="form-row bedrock-creds-hint"
840+
x-show="needsBedrockCreds"
841+
x-transition>
842+
<span>AWS credentials</span>
843+
<div style="display:flex; flex-direction:column; gap:0.35rem;">
844+
<small class="key-hint">
845+
Bedrock authenticates with your AWS credentials, not a typed key.
846+
Set <code>AWS_PROFILE</code> (or <code>AWS_ACCESS_KEY_ID</code>)
847+
before launching StudyLoop, then reload this page.
848+
</small>
849+
<small class="key-hint">
850+
No credentials yet? Run your Bedrock credential setup
851+
(e.g. <code>aictl claude bedrock</code>) or pick an API-key
852+
provider above.
853+
</small>
854+
</div>
855+
</div>
856+
836857
<label class="form-row" x-show="form.provider">
837858
<span>Model</span>
838859
<select x-model="form.model">
@@ -1373,6 +1394,24 @@ <h2>Start a Study Session</h2>
13731394
return !!p && !p.available && p.adapter !== 'bedrock';
13741395
},
13751396

1397+
get needsBedrockCreds() {
1398+
// Bedrock authenticates with AWS SigV4 credentials (IAM role /
1399+
// AWS_PROFILE / access keys), not a typed API key. When it is chosen
1400+
// but credentials don't resolve, show an AWS-specific hint instead of
1401+
// the key-entry box.
1402+
const p = this.selectedProvider;
1403+
return !!p && !p.available && p.adapter === 'bedrock';
1404+
},
1405+
1406+
providerOptionLabel(p) {
1407+
// Build the full <option> text here — x-show/display:none does NOT
1408+
// work on elements nested inside <option> (browsers render the
1409+
// flattened text content). Bedrock uses AWS creds, not a typed key.
1410+
if (p.available) return p.label;
1411+
const suffix = p.adapter === 'bedrock' ? ' — needs AWS credentials' : ' — needs API key';
1412+
return p.label + suffix;
1413+
},
1414+
13761415
async init() {
13771416
// The tree is 3-level: publisher → course → lesson file. Load the
13781417
// publishers (study-tree top level) and the LLM providers up front;
@@ -1481,6 +1520,9 @@ <h2>Start a Study Session</h2>
14811520
// submission until the key is entered, else the job fails async on the
14821521
// backend (CardGenerationError) instead of a clean disabled button.
14831522
if (this.needsKey) return false;
1523+
// Same reasoning for Bedrock: block until AWS credentials resolve,
1524+
// else the job fails async with CardGenerationError.
1525+
if (this.needsBedrockCreds) return false;
14841526
if (!this.form.publisher) return false;
14851527
if (!this.form.course) return false;
14861528
if (this.form.kinds.length === 0) return false;

packages/studyloop/tests/test_web_key_entry_e2e.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,24 @@ def server(stub_config: Path) -> Generator[subprocess.Popen, None, None]:
104104
}
105105
],
106106
},
107+
{
108+
# Bedrock uses AWS SigV4 creds, not a typed key. Unavailable → it must
109+
# show the AWS-creds hint (not the key box) and block Generate.
110+
"slug": "bedrock",
111+
"label": "AWS Bedrock",
112+
"adapter": "bedrock",
113+
"auth_env": "AWS_PROFILE",
114+
"available": False,
115+
"models": [
116+
{
117+
"id": "us.anthropic.claude-haiku-4-5-20251001-v1:0",
118+
"label": "Claude Haiku 4.5 (Bedrock)",
119+
"cost_tier": "cheap",
120+
"thinking": False,
121+
"notes": "",
122+
}
123+
],
124+
},
107125
]
108126

109127

@@ -261,3 +279,89 @@ def test_save_key_shows_error_on_rejection(self, page: Page) -> None:
261279
page.click(".api-key-entry button")
262280
page.wait_for_selector(".api-key-entry .key-error", state="visible", timeout=3000)
263281
assert "rejected" in page.inner_text(".api-key-entry .key-error").lower()
282+
283+
284+
class TestBedrockCredsHint:
285+
"""Bedrock authenticates with AWS creds, not a typed key.
286+
287+
When Bedrock is selected but unavailable it must NOT show the API-key
288+
box (you can't type a SigV4 credential), must show the AWS-creds hint
289+
instead, and must block Generate so the job never reaches the backend
290+
only to die with a CardGenerationError.
291+
"""
292+
293+
def test_bedrock_shows_aws_hint_not_key_box(self, page: Page) -> None:
294+
_route_providers(page, anthropic_available=False)
295+
_goto_generate(page)
296+
page.wait_for_function(
297+
"() => window.Alpine.$data("
298+
"document.querySelector('[x-data=\"generatePanel()\"]')"
299+
").providers.length > 0",
300+
timeout=3000,
301+
)
302+
page.select_option('select[x-model="form.provider"]', "bedrock")
303+
304+
# AWS-creds hint visible; API-key input NOT present.
305+
page.wait_for_selector(".bedrock-creds-hint", state="visible", timeout=3000)
306+
assert page.is_visible(".bedrock-creds-hint")
307+
assert not page.is_visible(".api-key-entry input[type='password']")
308+
309+
# needsBedrockCreds true, needsKey false.
310+
state = page.evaluate(
311+
"() => { const d = window.Alpine.$data("
312+
"document.querySelector('[x-data=\"generatePanel()\"]'));"
313+
" return { bedrock: d.needsBedrockCreds, key: d.needsKey }; }"
314+
)
315+
assert state["bedrock"] is True
316+
assert state["key"] is False
317+
318+
# The rendered <option>.label must say AWS credentials, NOT "API key".
319+
# x-show on a nested <span> is silently ignored inside <option> (the
320+
# browser flattens option text), so the suffix is built in
321+
# providerOptionLabel() and asserted here on the real .label property.
322+
label = page.eval_on_selector(
323+
"select[x-model='form.provider'] option[value='bedrock']",
324+
"el => el.label",
325+
)
326+
assert "needs AWS credentials" in label, label
327+
assert "API key" not in label, label
328+
329+
def test_available_provider_option_has_no_suffix(self, page: Page) -> None:
330+
# Regression: the suffix must NOT appear on available providers. The
331+
# original nested-span x-show rendered it on every option regardless.
332+
_route_providers(page, anthropic_available=True)
333+
_goto_generate(page)
334+
page.wait_for_function(
335+
"() => window.Alpine.$data("
336+
"document.querySelector('[x-data=\"generatePanel()\"]')"
337+
").providers.length > 0",
338+
timeout=3000,
339+
)
340+
label = page.eval_on_selector(
341+
"select[x-model='form.provider'] option[value='anthropic']",
342+
"el => el.label",
343+
)
344+
assert label == "Anthropic", f"available provider should have no suffix, got: {label!r}"
345+
346+
def test_bedrock_unavailable_blocks_generate(self, page: Page) -> None:
347+
_route_providers(page, anthropic_available=False)
348+
_goto_generate(page)
349+
page.wait_for_function(
350+
"() => window.Alpine.$data("
351+
"document.querySelector('[x-data=\"generatePanel()\"]')"
352+
").providers.length > 0",
353+
timeout=3000,
354+
)
355+
# Satisfy every OTHER submit precondition so the bedrock-creds guard is
356+
# the only thing that could block submission.
357+
page.evaluate(
358+
"() => { const d = window.Alpine.$data("
359+
"document.querySelector('[x-data=\"generatePanel()\"]'));"
360+
" d.form.publisher = 'p'; d.form.course = 'c'; }"
361+
)
362+
page.select_option('select[x-model="form.provider"]', "bedrock")
363+
page.wait_for_selector(".bedrock-creds-hint", state="visible", timeout=3000)
364+
365+
assert page.is_disabled('button[type="submit"]'), (
366+
"Generate enabled despite needsBedrockCreds=true — canSubmit() lacks the guard"
367+
)

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ dev = [
3030
"pytest-asyncio>=1.3.0",
3131
"mcp[cli]>=1.0.0",
3232
"bandit>=1.8",
33+
# boto3 powers the Bedrock content generator (studyloop[bedrock]). It is
34+
# in the dev group so a plain `uv sync` gives `uv run studyloop web` a
35+
# working Bedrock backend — otherwise generation dies with
36+
# "No module named 'boto3'" because workspace members are synced without
37+
# their optional-dependencies.
38+
"boto3>=1.40",
3339
]
3440

3541
[build-system]

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)