Skip to content

Commit fcc741e

Browse files
authored
Merge pull request #1947 from Open-Source-Legal/claude/eager-johnson-ghc1N
Cache resolved LLM provider credentials per agent build
2 parents 3dc8258 + 87ada9a commit fcc741e

5 files changed

Lines changed: 188 additions & 16 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
- **Memoized the per-agent-build LLM credential read (#1921).**
2+
`opencontractserver/llms/model_factory.py::_get_db_credentials` ran on every
3+
agent build — every chat message, structured-output call, and
4+
memory-curation task — and resolved the provider's secret through
5+
`PipelineSettings.get_full_component_settings`, which reads the encrypted
6+
secret store twice and so derived the Fernet key with the deliberately
7+
expensive PBKDF2 KDF (480k HMAC iterations) twice per build whenever any
8+
provider key was configured. The resolved per-provider credentials are now
9+
cached in-process keyed on `(class_path, PipelineSettings.modified)` — the
10+
same key the reranker/embedder instance caches use
11+
(`opencontractserver/pipeline/utils.py`). Repeat builds skip the decryption,
12+
while live rotation is preserved: a superuser key change calls
13+
`PipelineSettings.save()`, which bumps `modified` (auto_now) and clears the
14+
singleton cache, so the next build misses the memo and re-decrypts — no
15+
redeploy, and no staleness beyond the existing 5-minute `PipelineSettings`
16+
cache TTL (the live-configurability guarantee of #1897 is intact, as is the
17+
DB-wins / env-fallback precedence). New `invalidate_credential_cache()`
18+
mirrors the existing `invalidate_embedder_cache` / `invalidate_reranker_cache`
19+
helpers (wired into `conftest.py` for test isolation; call it after any
20+
out-of-band singleton write that bypasses `save()`). Tests:
21+
`opencontractserver/tests/test_llm_model_factory.py::TestCredentialCache`.

docs/architecture/llms/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2868,6 +2868,8 @@ Resolution is **DB-wins / env-fallback**, applied by [`opencontractserver/llms/m
28682868

28692869
Any failure to build a credentialed model degrades to the env-fallback string, so a misconfiguration can never take the chat path down. The factory is invoked at every `make_pydantic_ai_agent` call site (document, corpus, and structured-output agents, plus the memory-curation tasks); it performs ORM access, so async call sites use the `abuild_agent_model()` wrapper.
28702870

2871+
Because the factory runs on every agent build, the resolved per-provider credentials are memoized in-process keyed on `(class_path, PipelineSettings.modified)` — the same cache key the reranker/embedder instance caches use. This skips the Fernet/PBKDF2 secret decryption on repeat builds while keeping rotation live: a superuser key change calls `PipelineSettings.save()`, which bumps `modified` and clears the singleton cache, so the next build misses the memo and re-decrypts (no redeploy, no staleness beyond the existing 5-minute `PipelineSettings` cache TTL). An out-of-band write that bypasses `save()` (e.g. `QuerySet.update`) should call `invalidate_credential_cache()`.
2872+
28712873
### Validation
28722874

28732875
`Corpus.save()` and `AgentConfiguration.save()` both run the resolver's `validate_model_spec()` — a malformed string or a provider with no registered `BaseLLMProvider` subclass raises `ValidationError({"preferred_llm": ...})`. The validator does not gate against `supported_models` so users aren't blocked from passing newly-released model names. Specs are normalised to canonical `"{provider}:{model}"` form on the way into the database.

opencontractserver/conftest.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,18 @@ def _invalidate_pipeline_settings_singleton_cache():
4343
migration-seeded state on cache miss.
4444
4545
We also drop the process-local embedder/reranker *instance* caches
46-
(``opencontractserver/pipeline/utils.py``). Those are keyed by
47-
``(class_path, PipelineSettings.modified)``; because ``modified`` is the
48-
stable migration-seeded value across every ``TestCase`` test that doesn't
49-
write the singleton, two tests that mock ``get_component_by_name`` to
50-
return *different* classes for the *same* path would otherwise collide on
51-
the cached instance from whichever test ran first. Clearing before each
52-
test restores per-test isolation without every test needing its own
53-
teardown.
46+
(``opencontractserver/pipeline/utils.py``) and the LLM-provider
47+
*credential* cache (``opencontractserver/llms/model_factory.py``). All are
48+
keyed by ``(class_path, PipelineSettings.modified)``; because ``modified``
49+
is the stable migration-seeded value across every ``TestCase`` test that
50+
doesn't write the singleton, two tests that mock ``get_component_by_name``
51+
to return *different* classes for the *same* path (or configure *different*
52+
provider credentials) would otherwise collide on the cached value from
53+
whichever test ran first. Clearing before each test restores per-test
54+
isolation without every test needing its own teardown.
5455
"""
5556
from opencontractserver.documents.models import PipelineSettings
57+
from opencontractserver.llms.model_factory import invalidate_credential_cache
5658
from opencontractserver.pipeline.utils import (
5759
invalidate_embedder_cache,
5860
invalidate_reranker_cache,
@@ -61,6 +63,7 @@ def _invalidate_pipeline_settings_singleton_cache():
6163
PipelineSettings._invalidate_cache()
6264
invalidate_embedder_cache()
6365
invalidate_reranker_cache()
66+
invalidate_credential_cache()
6467
yield
6568

6669

opencontractserver/llms/model_factory.py

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from __future__ import annotations
3232

3333
import logging
34+
import threading
3435
from typing import Any
3536

3637
from asgiref.sync import sync_to_async
@@ -76,13 +77,60 @@ def _provider_class_path(provider_key: str) -> str | None:
7677
return definition.class_name if definition else None
7778

7879

80+
# Process-local cache of resolved per-provider credentials keyed on
81+
# ``(class_path, PipelineSettings.modified)`` — the same key the reranker and
82+
# embedder instance caches use (``opencontractserver/pipeline/utils.py``).
83+
#
84+
# ``_get_db_credentials`` runs on *every* agent build — every chat message,
85+
# every structured-output call, every memory-curation task. ``PipelineSettings``
86+
# already caches the singleton row (Django cache, 5-min TTL), so this is not a
87+
# DB round-trip per call, but resolving a provider's secret still decrypts the
88+
# Fernet store, and that decryption derives its key with a deliberately
89+
# expensive PBKDF2 KDF (hundreds of thousands of HMAC iterations — see
90+
# ``PipelineSettings._derive_key``). ``get_full_component_settings`` reads the
91+
# secret store twice (merged settings + secrets overlay), so two KDF passes ran
92+
# on every build once any provider key was configured (issue #1921).
93+
#
94+
# Caching the *resolved* creds amortises that across calls. Live-rotation is
95+
# preserved: every credential write in production flows through
96+
# ``PipelineSettings.save()``, which bumps ``modified`` (auto_now) and clears
97+
# the singleton cache, so the next lookup misses on the new key and re-decrypts
98+
# — a rotated or cleared key takes effect within the existing cache-TTL window
99+
# with no redeploy (the live-configurability guarantee of issue #1897), and the
100+
# cache adds no staleness beyond ``get_instance``'s own TTL. The same issue
101+
# #1410 caveat applies: code paths that bypass ``save()`` (``QuerySet.update``,
102+
# ``bulk_update``, raw migrations) won't bump ``modified``; call
103+
# :func:`invalidate_credential_cache` explicitly in those cases.
104+
_CREDENTIAL_CACHE: dict[tuple[str, Any], dict[str, str]] = {}
105+
# Guards against two concurrent builds paying the decryption cost twice.
106+
# Lookups after warm-up are read-only, so no lock is held on the hot path.
107+
_CREDENTIAL_CACHE_LOCK = threading.Lock()
108+
109+
110+
def invalidate_credential_cache() -> None:
111+
"""Drop cached per-provider credentials.
112+
113+
In normal operation this is unnecessary — the cache key includes
114+
``PipelineSettings.modified`` so any settings write naturally invalidates
115+
the cache on the next lookup across all workers. Kept for test isolation
116+
and for callers that mutate the singleton out-of-band (a path that bypasses
117+
``save()`` and so leaves ``modified`` unchanged).
118+
"""
119+
with _CREDENTIAL_CACHE_LOCK:
120+
_CREDENTIAL_CACHE.clear()
121+
122+
79123
def _get_db_credentials(provider_key: str) -> dict[str, str]:
80124
"""Read DB-configured credentials for a provider from ``PipelineSettings``.
81125
82126
Returns a dict with optional ``api_key`` / ``base_url`` keys (only
83127
present when non-empty). Empty when the provider is unknown or has no
84128
credentials configured. Performs ORM access — invoke from a sync
85129
context or via :func:`abuild_agent_model`.
130+
131+
The resolved creds are memoized per ``(class_path, PipelineSettings.modified)``
132+
so repeat builds skip the Fernet/PBKDF2 decryption; see the
133+
``_CREDENTIAL_CACHE`` comment for the invalidation contract.
86134
"""
87135
class_path = _provider_class_path(provider_key)
88136
if not class_path:
@@ -91,7 +139,29 @@ def _get_db_credentials(provider_key: str) -> dict[str, str]:
91139
try:
92140
from opencontractserver.documents.models import PipelineSettings
93141

94-
stored = PipelineSettings.get_instance().get_full_component_settings(class_path)
142+
instance = PipelineSettings.get_instance()
143+
cache_key = (class_path, instance.modified)
144+
145+
# Fast path: a copy so callers can never mutate the cached dict.
146+
cached = _CREDENTIAL_CACHE.get(cache_key)
147+
if cached is not None:
148+
return dict(cached)
149+
150+
with _CREDENTIAL_CACHE_LOCK:
151+
# Double-check — another thread may have populated it while we waited.
152+
cached = _CREDENTIAL_CACHE.get(cache_key)
153+
if cached is not None:
154+
return dict(cached)
155+
156+
stored = instance.get_full_component_settings(class_path)
157+
creds: dict[str, str] = {}
158+
for key in ("api_key", "base_url"):
159+
value = (stored or {}).get(key)
160+
if isinstance(value, str) and value.strip():
161+
creds[key] = value.strip()
162+
163+
_CREDENTIAL_CACHE[cache_key] = creds
164+
return dict(creds)
95165
except _DB_READ_RECOVERABLE_ERRORS:
96166
# DB unavailable (migrations / early startup) — fall back to env.
97167
logger.debug(
@@ -101,13 +171,6 @@ def _get_db_credentials(provider_key: str) -> dict[str, str]:
101171
)
102172
return {}
103173

104-
creds: dict[str, str] = {}
105-
for key in ("api_key", "base_url"):
106-
value = (stored or {}).get(key)
107-
if isinstance(value, str) and value.strip():
108-
creds[key] = value.strip()
109-
return creds
110-
111174

112175
async def aget_provider_credentials(provider_key: str) -> dict[str, str]:
113176
"""DB-configured ``api_key`` / ``base_url`` for a provider (empty when unset).

opencontractserver/tests/test_llm_model_factory.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from opencontractserver.llms.model_factory import (
2525
abuild_agent_model,
2626
build_agent_model,
27+
invalidate_credential_cache,
2728
)
2829
from opencontractserver.pipeline.base.settings_schema import (
2930
get_secret_settings,
@@ -209,3 +210,85 @@ def test_has_value_flips_after_setting_secret(self):
209210
self.assertTrue(schema["api_key"]["has_value"])
210211
# The value itself is never exposed.
211212
self.assertIsNone(schema["api_key"]["current_value"])
213+
214+
215+
class TestCredentialCache(TestCase):
216+
"""Resolved provider creds are memoized per ``PipelineSettings.modified``.
217+
218+
Regression coverage for issue #1921: the per-build credential read must
219+
skip repeated Fernet/PBKDF2 decryption, yet a live key rotation (which
220+
bumps ``modified`` via ``save()``) must still take effect without a
221+
redeploy — preserving the live-configurability guarantee of issue #1897.
222+
"""
223+
224+
def setUp(self):
225+
reset_registry()
226+
self.addCleanup(reset_registry)
227+
PipelineSettings.clear_cache()
228+
self.addCleanup(PipelineSettings.clear_cache)
229+
# The memo is process-local and survives TestCase rollback; clear it
230+
# explicitly so the suite is isolated under the unittest runner too
231+
# (the conftest autouse fixture only applies under pytest).
232+
invalidate_credential_cache()
233+
self.addCleanup(invalidate_credential_cache)
234+
openai_defn = get_llm_provider_by_key_cached("openai")
235+
assert openai_defn is not None
236+
self.openai_path = openai_defn.class_name
237+
238+
def _configure_openai_creds(self, *, api_key):
239+
instance = PipelineSettings.get_instance()
240+
instance.set_secrets({self.openai_path: {"api_key": api_key}})
241+
# save() bumps ``modified`` (auto_now) and clears the singleton cache.
242+
instance.save()
243+
244+
def test_resolution_memoized_across_builds(self):
245+
"""The second build for a provider reuses creds without re-decrypting."""
246+
self._configure_openai_creds(api_key="sk-db-key")
247+
with mock.patch.object(
248+
PipelineSettings,
249+
"get_secrets",
250+
autospec=True,
251+
side_effect=PipelineSettings.get_secrets,
252+
) as get_secrets_spy:
253+
first = build_agent_model("openai:gpt-4o")
254+
second = build_agent_model("openai:gpt-4o")
255+
# The first build decrypts once — get_full_component_settings reads the
256+
# secret store twice (merged settings + secrets overlay) — and the
257+
# second build is served from the memo without touching the store.
258+
self.assertEqual(get_secrets_spy.call_count, 2)
259+
self.assertIsInstance(first, Model)
260+
self.assertIsInstance(second, Model)
261+
262+
def test_rotated_key_takes_effect_without_redeploy(self):
263+
"""A key rotation via save() busts the memo (preserves #1897)."""
264+
self._configure_openai_creds(api_key="sk-old")
265+
seen_keys: list[str | None] = []
266+
267+
def _capture(provider_key, model_name, creds):
268+
seen_keys.append(creds.get("api_key"))
269+
return object()
270+
271+
with mock.patch(
272+
"opencontractserver.llms.model_factory._construct_model",
273+
side_effect=_capture,
274+
):
275+
build_agent_model("openai:gpt-4o") # warms the memo with sk-old
276+
# Rotate exactly as a superuser would: System Settings → save().
277+
self._configure_openai_creds(api_key="sk-new")
278+
build_agent_model("openai:gpt-4o")
279+
280+
self.assertEqual(seen_keys, ["sk-old", "sk-new"])
281+
282+
def test_invalidate_credential_cache_forces_redecrypt(self):
283+
"""The explicit purge re-decrypts on the next build (out-of-band path)."""
284+
self._configure_openai_creds(api_key="sk-db-key")
285+
with mock.patch.object(
286+
PipelineSettings,
287+
"get_secrets",
288+
autospec=True,
289+
side_effect=PipelineSettings.get_secrets,
290+
) as get_secrets_spy:
291+
build_agent_model("openai:gpt-4o") # 2 decrypt reads, then cached
292+
invalidate_credential_cache()
293+
build_agent_model("openai:gpt-4o") # memo purged → 2 more reads
294+
self.assertEqual(get_secrets_spy.call_count, 4)

0 commit comments

Comments
 (0)