Skip to content

Commit c6f072f

Browse files
committed
feat(data): migrate to litellm embedding contract with deploy guardrails
Unify PromptHandler onto LiteLLM-backed model/env contracts and enforce embedding consistency by persisting model+dimension metadata with startup/write-time checks. Add alembic revision guardrails in release flow and operational DB scripts so deploys fail fast on migration lineage drift.
1 parent c53570d commit c6f072f

17 files changed

Lines changed: 934 additions & 34 deletions

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ migrate-upgrade:
118118
export FLASK_APP="$(CURDIR)/cre.py"
119119
flask db upgrade
120120

121+
alembic-guardrail:
122+
[ -d "./venv" ] && . ./venv/bin/activate &&\
123+
python scripts/check_alembic_revision_guardrail.py
124+
121125
migrate-downgrade:
122126
[ -d "./venv" ] && . ./venv/bin/activate &&\
123127
export FLASK_APP="$(CURDIR)/cre.py"

Procfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
release: python scripts/check_alembic_revision_guardrail.py
12
web: gunicorn cre:app
23
worker: FLASK_APP=`pwd`/cre.py python cre.py --start_worker

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,13 +268,45 @@ Then edit `.env` and provide values appropriate for your environment.
268268
* Neo4j: `NEO4J_URL`
269269
* Redis: `REDIS_HOST`, `REDIS_PORT`, `REDIS_URL`, `REDIS_NO_SSL`
270270
* Flask: `FLASK_CONFIG`, `INSECURE_REQUESTS`
271-
* Embeddings: `NO_GEN_EMBEDDINGS`
271+
* Embeddings: `NO_GEN_EMBEDDINGS`, `CRE_EMBED_MODEL`, `CRE_EMBED_EXPECTED_DIM`, `CRE_VALIDATE_EMBED_DIM_ON_INIT`
272+
* LLM models/retries: `CRE_LLM_CHAT_MODEL`, `CRE_EMBED_ALIGN_MODEL`, `CRE_LLM_MAX_RETRIES`, `CRE_LLM_RETRY_SLEEP_SECONDS`
273+
* Provider credentials: `OPENAI_API_KEY`, `GEMINI_API_KEY`, `GCP_NATIVE`
272274
* Google Auth: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_SECRET_JSON`, `LOGIN_ALLOWED_DOMAINS`
273275
* GCP: `GCP_NATIVE`
274276
* Spreadsheet Auth: `OpenCRE_gspread_Auth`
275277

276278
See `.env.example` for full list and defaults.
277279

280+
### LiteLLM backend (optional)
281+
282+
OpenCRE uses LiteLLM for LLM calls. Configure models and provider credentials via environment variables.
283+
284+
Recommended minimal example:
285+
286+
```bash
287+
# Chat / completion models (LiteLLM model strings)
288+
CRE_LLM_CHAT_MODEL=openai/gpt-4o-mini
289+
CRE_EMBED_ALIGN_MODEL=openai/gpt-4o-mini
290+
291+
# Embedding model used for persisted vectors
292+
CRE_EMBED_MODEL=openai/text-embedding-3-small
293+
CRE_EMBED_EXPECTED_DIM=1536
294+
CRE_VALIDATE_EMBED_DIM_ON_INIT=1
295+
296+
# Retry policy
297+
CRE_LLM_MAX_RETRIES=2
298+
CRE_LLM_RETRY_SLEEP_SECONDS=15
299+
300+
# Provider credential (example for OpenAI)
301+
OPENAI_API_KEY=your-key
302+
```
303+
304+
Notes:
305+
306+
* Treat changes to `CRE_EMBED_MODEL` or `CRE_EMBED_EXPECTED_DIM` as a data migration event (usually requires re-embedding).
307+
* `CRE_EMBED_EXPECTED_DIM` is a safety guard: writes fail fast on dimension mismatch.
308+
* Keep chat/alignment models and embedding model independently configurable; only embeddings must remain dimension-compatible with stored vectors.
309+
278310
You can run the containers with:
279311

280312
```bash

application/database/db.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@ class Embeddings(BaseModel): # type: ignore
188188

189189
embeddings_url = sqla.Column(sqla.String, nullable=True, default=None)
190190
embeddings_content = sqla.Column(sqla.String, nullable=True, default=None)
191+
embedding_model_id = sqla.Column(sqla.String, nullable=True, default=None)
192+
embedding_dim = sqla.Column(sqla.Integer, nullable=True, default=None)
191193

192194

193195
class GapAnalysisResults(BaseModel):
@@ -2286,6 +2288,18 @@ def add_embedding(
22862288
For nodes, ``embeddings_url`` is the resolved URL used for fetch/embed alignment
22872289
(may include a fragment). When ``None``, defaults to ``db_object.link`` (importer hyperlink).
22882290
"""
2291+
expected_dim_raw = (os.environ.get("CRE_EMBED_EXPECTED_DIM", "") or "").strip()
2292+
embedding_model_id = (
2293+
os.environ.get("CRE_EMBED_MODEL", "") or ""
2294+
).strip() or "openai/text-embedding-3-small"
2295+
embedding_dim = len(embeddings)
2296+
if expected_dim_raw:
2297+
expected_dim = int(expected_dim_raw)
2298+
if len(embeddings) != expected_dim:
2299+
raise ValueError(
2300+
f"embedding dimension mismatch for {db_object.id}: "
2301+
f"expected {expected_dim}, got {len(embeddings)}"
2302+
)
22892303
existing = self.get_embedding(db_object.id)
22902304
embeddings_str = ",".join([str(e) for e in embeddings])
22912305
resolved_node_url: Optional[str] = None
@@ -2302,6 +2316,8 @@ def add_embedding(
23022316
cre_id=db_object.id,
23032317
doc_type=cre_defs.Credoctypes.CRE.value,
23042318
embeddings_content=embedding_text,
2319+
embedding_model_id=embedding_model_id,
2320+
embedding_dim=embedding_dim,
23052321
)
23062322
else:
23072323
emb = Embeddings(
@@ -2310,6 +2326,8 @@ def add_embedding(
23102326
doc_type=db_object.ntype,
23112327
embeddings_content=embedding_text,
23122328
embeddings_url=resolved_node_url,
2329+
embedding_model_id=embedding_model_id,
2330+
embedding_dim=embedding_dim,
23132331
)
23142332
self.session.add(emb)
23152333
self.session.commit()
@@ -2318,6 +2336,8 @@ def add_embedding(
23182336
logger.debug(f"knew of embedding for object {db_object.id} ,updating")
23192337
existing[0].embeddings = embeddings_str
23202338
existing[0].embeddings_content = embedding_text
2339+
existing[0].embedding_model_id = embedding_model_id
2340+
existing[0].embedding_dim = embedding_dim
23212341
if doctype != cre_defs.Credoctypes.CRE:
23222342
if embeddings_url is not None:
23232343
existing[0].embeddings_url = embeddings_url
@@ -2327,6 +2347,57 @@ def add_embedding(
23272347

23282348
return existing
23292349

2350+
def assert_embedding_contract(
2351+
self,
2352+
*,
2353+
expected_model_id: Optional[str],
2354+
expected_dim: Optional[int],
2355+
) -> None:
2356+
"""
2357+
Validate persisted embedding metadata consistency.
2358+
2359+
- Fails when multiple dimensions are stored.
2360+
- Fails when metadata is missing or mismatched against expected model/dimension.
2361+
"""
2362+
rows = self.session.query(
2363+
Embeddings.embedding_dim, Embeddings.embedding_model_id
2364+
).all()
2365+
if not rows:
2366+
return
2367+
2368+
dims = {int(r[0]) for r in rows if r[0] is not None}
2369+
model_ids = {str(r[1]) for r in rows if r[1]}
2370+
has_missing_dim = any(r[0] is None for r in rows)
2371+
has_missing_model = any(not r[1] for r in rows)
2372+
2373+
if len(dims) > 1:
2374+
raise RuntimeError(
2375+
f"multiple embedding dimensions detected in DB: {sorted(dims)}"
2376+
)
2377+
if len(model_ids) > 1:
2378+
raise RuntimeError(
2379+
f"multiple embedding models detected in DB: {sorted(model_ids)}"
2380+
)
2381+
2382+
if has_missing_dim or has_missing_model:
2383+
raise RuntimeError(
2384+
"embedding metadata missing in DB; run metadata migration/backfill"
2385+
)
2386+
2387+
if expected_dim is not None and dims:
2388+
db_dim = next(iter(dims))
2389+
if db_dim != expected_dim:
2390+
raise RuntimeError(
2391+
f"DB embedding dim {db_dim} does not match expected dim {expected_dim}"
2392+
)
2393+
2394+
if expected_model_id and model_ids:
2395+
db_model = next(iter(model_ids))
2396+
if db_model != expected_model_id:
2397+
raise RuntimeError(
2398+
f"DB embedding model {db_model} does not match expected model {expected_model_id}"
2399+
)
2400+
23302401
def gap_analysis_exists(self, cache_key) -> bool:
23312402
row = (
23322403
self.session.query(GapAnalysisResults)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from typing import Any
2+
3+
4+
def is_rate_limit_error(err: BaseException) -> bool:
5+
msg = str(err).lower()
6+
if "rate limit" in msg or "too many requests" in msg:
7+
return True
8+
if "resource exhausted" in msg or "quota" in msg or "exceeded quota" in msg:
9+
return True
10+
if "429" in msg:
11+
return True
12+
13+
status = (
14+
getattr(err, "status", None)
15+
or getattr(err, "status_code", None)
16+
or getattr(err, "http_status", None)
17+
or getattr(err, "code", None)
18+
)
19+
if status == 429:
20+
return True
21+
22+
if isinstance(getattr(err, "args", None), tuple):
23+
# Some SDKs nest details in args[0]/args[1].
24+
nested: Any = err.args[0] if err.args else None
25+
if isinstance(nested, dict):
26+
code = nested.get("code") or nested.get("status_code")
27+
if code == 429:
28+
return True
29+
return False

0 commit comments

Comments
 (0)