OpenAI Integration
Three modules construct OpenAI clients; two env vars select the models; one guard pattern makes missing keys explicit. This page is the integration contract.
Client setup (current reality)
Three modules each build their own client at import time:
| Module | Client | Models used |
|---|---|---|
services/openai_service.py | OpenAI(api_key=os.getenv("OPENAI_API_KEY")) | OPENAI_MODEL for chat/analysis/RAG/eval |
services/multi_agent_email_service.py | own client, same pattern | OPENAI_MODEL for reply/review |
services/document_service.py | own client | OPENAI_EMBEDDING_MODEL for embeddings |
All three call load_dotenv() first, so backend/app/.env works regardless of which module loads first. The duplication is acknowledged debt — a single shared client factory is the refactor (Roadmap).
Model selection
| Variable | Default | Notes |
|---|---|---|
OPENAI_MODEL | gpt-4o-mini | one model for all completion roles; swap globally by env var |
OPENAI_EMBEDDING_MODEL | text-embedding-3-small | 1536 dims; ingestion and querying must use the same model |
Vectors embedded with one model are not comparable to queries embedded with another. If you change OPENAI_EMBEDDING_MODEL, delete backend/app/chroma_db/ and re-upload documents. There is no re-indexing command yet.
Also note: the cost estimator prices everything at gpt-4o-mini rates regardless of OPENAI_MODEL — swap to a pricier model and the dashboard will undercount cost.
The guard pattern
Every completion/embedding function opens with:
if not os.getenv("OPENAI_API_KEY"):
raise ValueError("OPENAI_API_KEY is missing. Please add it to your .env file.")This converts the SDK's opaque auth failure into an actionable message. Downstream handling varies by caller — chat degrades to a fallback answer, tools and workflows return 500 with the message (Error Handling).
Call anatomy
All completion calls share one shape — messages array with a system + user turn, explicit temperature, no streaming, no tools/functions:
response = client.chat.completions.create(
model=OPENAI_MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
temperature=T, # 0 / 0.2 / 0.3 by role
)
text = response.choices[0].message.contentFor JSON-producing roles the system prompt enforces structure and json.loads validates it — the platform does not use response_format or function calling yet (Design Decisions).
Conventions for adding a new LLM call
- Put it in a service, never a router — routers orchestrate and log; services prompt and call.
- Guard the key with the standard
ValueError. - Pick a temperature from the ladder: 0 for judging, 0.2 for extraction/grounded generation, 0.3 for user-facing prose.
- If you need structure, demand strict JSON in the system prompt, parse with
json.loads, raiseValueErrorwith the raw text on failure, and normalize fields defensively (bool(...),float(result.get(..., 0))). - Log a
ToolCallin the calling router — with cost data fromestimate_openai_costif it is an LLM step (Observability). - Document the prompt in the Prompt Catalog.
Failure modes at the SDK boundary
| Failure | Raised as | Seen by user as |
|---|---|---|
| Missing key | ValueError (guard) | chat fallback / 500 detail |
| Invalid key, quota, rate limit | openai SDK exceptions | same paths |
| Model returns prose instead of JSON | ValueError("... did not return valid JSON: <raw>") | 500 with raw output in detail |
| Network timeout | SDK exception (no custom timeout configured) | same paths |
No retries, no backoff, no circuit breaker anywhere — a deliberate simplicity that becomes the first hardening task under real traffic.
Data-privacy note
Everything the platform processes — chat messages, full email bodies, uploaded document chunks, questions — is sent to the OpenAI API. Apply your organization's data policy accordingly (Security).
Related pages
- Service: OpenAI — the four core functions in detail
- Cost Monitoring — pricing the calls
- Prompt Catalog — what is actually sent