Skip to content

Commit a3a00a3

Browse files
fix(content): adapters + availability honour the encrypted key store
The encrypted secrets store (secrets.py, commit fd294f4) was a sealed vault: nothing read from it at generation time and the provider dropdown ignored it. A key added via the web store had zero effect. This wires both ends. - anthropic_compat / openai_compat _read_api_key: call secrets.get_secret(slug) (resolves store -> env -> None) instead of os.environ only. A key stored via the Generate panel is now actually consumed. Error message points users at the panel as well as the env var. Dropped the now-unused os import. - web /content/providers: 'available' flag is now get_secret(slug) OR env var, so a provider with a stored-but-not-exported key shows as enabled, not greyed out. Bedrock's separate boto3-credential path is untouched. TDD: 3 new tests (stored-key consumed at construction; missing-everywhere error names both paths; provider available from store). Red confirmed before fix. 36/36 adapter+providers suites green; 66 secrets/route/content-gen regression tests pass. Remaining for the full feature: the Generate-panel key-entry UI (#2) — backend secrets routes exist + tested, frontend does not call them yet.
1 parent f2a46ed commit a3a00a3

5 files changed

Lines changed: 93 additions & 10 deletions

File tree

packages/studyloop/src/studyloop/content/generators/anthropic_compat.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
from __future__ import annotations
3030

3131
import json
32-
import os
3332
from typing import TYPE_CHECKING, Any
3433

3534
import httpx
@@ -153,11 +152,17 @@ def generate_quiz(self, source: str, title: str) -> QuizDeck:
153152
# ------------------------------------------------------------------
154153

155154
def _read_api_key(self) -> str:
156-
key = os.environ.get(self._profile.auth_env, "").strip()
155+
# Resolution order (encrypted store -> env var -> None) lives in
156+
# secrets.get_secret; call it so a key added via the Generate panel
157+
# (encrypted store) is honoured, not just one exported in the shell.
158+
from studyloop.secrets import get_secret
159+
160+
key = (get_secret(self._profile.slug) or "").strip()
157161
if not key:
158162
raise CardGenerationError(
159-
f"{self._profile.label} requires {self._profile.auth_env} to be set "
160-
f"(in shell env or in the project-root .env file)."
163+
f"{self._profile.label} requires an API key. Add it in the web "
164+
f"Generate panel (stored encrypted), or set {self._profile.auth_env} "
165+
f"in your shell env / project-root .env file."
161166
)
162167
return key
163168

packages/studyloop/src/studyloop/content/generators/openai_compat.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
from __future__ import annotations
3636

3737
import json
38-
import os
3938
from typing import TYPE_CHECKING, Any
4039

4140
import httpx
@@ -146,11 +145,17 @@ def generate_quiz(self, source: str, title: str) -> QuizDeck:
146145
# ------------------------------------------------------------------
147146

148147
def _read_api_key(self) -> str:
149-
key = os.environ.get(self._profile.auth_env, "").strip()
148+
# Resolution order (encrypted store -> env var -> None) lives in
149+
# secrets.get_secret; call it so a key added via the Generate panel
150+
# (encrypted store) is honoured, not just one exported in the shell.
151+
from studyloop.secrets import get_secret
152+
153+
key = (get_secret(self._profile.slug) or "").strip()
150154
if not key:
151155
raise CardGenerationError(
152-
f"{self._profile.label} requires {self._profile.auth_env} to be set "
153-
f"(in shell env or in the project-root .env file)."
156+
f"{self._profile.label} requires an API key. Add it in the web "
157+
f"Generate panel (stored encrypted), or set {self._profile.auth_env} "
158+
f"in your shell env / project-root .env file."
154159
)
155160
return key
156161

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@
4141

4242
from studyloop.content import active_gen
4343
from studyloop.content.job import JobRequest, run_job
44+
# Module-level so the providers route can OR the encrypted store into its
45+
# availability flag. Named import (not `import studyloop.secrets`) avoids the
46+
# clash with the stdlib `secrets` module already imported above for token_hex.
47+
from studyloop.secrets import get_secret
4448
from studyloop.content.scope import (
4549
ResolvedSource,
4650
ScopeRequest,
@@ -397,7 +401,12 @@ async def list_providers() -> list[dict[str, Any]]:
397401
"label": profile.label,
398402
"adapter": profile.adapter,
399403
"auth_env": profile.auth_env,
400-
"available": bool(os.environ.get(profile.auth_env, "").strip()),
404+
# Available if a key is in the encrypted store OR the env var.
405+
# get_secret already resolves store -> env, so it covers both;
406+
# the explicit env check is kept as a defensive belt-and-braces.
407+
"available": bool(
408+
get_secret(slug) or os.environ.get(profile.auth_env, "").strip()
409+
),
401410
"models": [
402411
{
403412
"id": m.id,

packages/studyloop/tests/test_anthropic_compat_generator.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,47 @@ def test_missing_env_var_raises_with_actionable_message(
7373
self, monkeypatch: pytest.MonkeyPatch
7474
) -> None:
7575
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
76+
# Ensure the encrypted store also has nothing (else the store would
77+
# satisfy the key and no error would be raised).
78+
monkeypatch.setattr("studyloop.secrets.get_secret", lambda name: None)
7679
with pytest.raises(CardGenerationError) as exc:
7780
_make_generator("anthropic", "claude-haiku-4-5")
7881
msg = str(exc.value)
7982
assert "ANTHROPIC_API_KEY" in msg
80-
assert ".env" in msg
83+
84+
def test_reads_key_from_encrypted_store_when_env_unset(
85+
self, monkeypatch: pytest.MonkeyPatch
86+
) -> None:
87+
"""A key in the encrypted store must be consumed even with no env var.
88+
89+
Regression for the audit gap: adapters read os.environ only and ignored
90+
the encrypted store, so a key added via the Generate panel had zero
91+
effect on generation.
92+
"""
93+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
94+
# Simulate a key present in the encrypted store (get_secret resolves
95+
# store -> env -> None; here the store wins).
96+
monkeypatch.setattr(
97+
"studyloop.secrets.get_secret",
98+
lambda name: "stored-key-xyz" if name == "anthropic" else None,
99+
)
100+
gen = _make_generator("anthropic", "claude-haiku-4-5")
101+
try:
102+
assert gen._client.headers["x-api-key"] == "stored-key-xyz"
103+
finally:
104+
gen.close()
105+
106+
def test_missing_everywhere_raises_actionable_message(
107+
self, monkeypatch: pytest.MonkeyPatch
108+
) -> None:
109+
"""No env var AND no stored key -> actionable error naming both paths."""
110+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
111+
monkeypatch.setattr("studyloop.secrets.get_secret", lambda name: None)
112+
with pytest.raises(CardGenerationError) as exc:
113+
_make_generator("anthropic", "claude-haiku-4-5")
114+
msg = str(exc.value)
115+
assert "ANTHROPIC_API_KEY" in msg # names the env var
116+
assert "Generate panel" in msg # points at the new UI affordance
81117

82118
@pytest.mark.usefixtures("anthropic_key")
83119
def test_x_api_key_header_set(self) -> None:

packages/studyloop/tests/test_web_content_providers.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,34 @@ def test_stub_not_in_provider_list(self, client: TestClient) -> None:
6666
"stub is a CI-only backend and must be hidden from the user-facing dropdown."
6767
)
6868

69+
def test_provider_available_from_encrypted_store(
70+
self, monkeypatch: MonkeyPatch
71+
) -> None:
72+
"""A key in the encrypted store (no env var) makes the provider available.
73+
74+
Regression for the audit gap: the availability flag checked os.environ
75+
only, so a key added via the Generate panel still showed the provider
76+
as disabled in the dropdown.
77+
"""
78+
for var in _PROVIDER_ENV_VARS:
79+
monkeypatch.delenv(var, raising=False)
80+
# openai has a stored key; everything else has nothing.
81+
with (
82+
patch(
83+
"studyloop.web.routes.content_gen._bedrock_credentials_available",
84+
return_value=False,
85+
),
86+
patch(
87+
"studyloop.web.routes.content_gen.get_secret",
88+
side_effect=lambda name: "stored" if name == "openai" else None,
89+
),
90+
):
91+
cl = TestClient(create_app(study_dirs=[]))
92+
data = cl.get("/api/content/providers").json()
93+
by_slug = {e["slug"]: e for e in data}
94+
assert by_slug["openai"]["available"] is True, "stored key should enable openai"
95+
assert by_slug["anthropic"]["available"] is False, "no key -> still disabled"
96+
6997
def test_bedrock_in_provider_list(self, monkeypatch: MonkeyPatch) -> None:
7098
"""Bug D: Bedrock (boto3 path) must appear as a selectable provider."""
7199
for var in _PROVIDER_ENV_VARS:

0 commit comments

Comments
 (0)