Skip to content

Commit ed71df0

Browse files
authored
Merge pull request #7 from UABGH-Emerging-Technologies/feature/responses-api-migration
feat: add GPT-5 Responses API support
2 parents fe8df75 + f8933bc commit ed71df0

8 files changed

Lines changed: 292 additions & 63 deletions

File tree

UMLBot/config/config.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import logging
44
import logging.config
55
import os
6+
import re
67
from pathlib import Path
8+
from re import Pattern
79

810

911
def _load_env_file(path: Path) -> None:
@@ -34,6 +36,14 @@ def _load_env_file(path: Path) -> None:
3436
_load_env_file(Path(__file__).resolve().parents[2] / ".env")
3537

3638

39+
_GPT5_PATTERN: Pattern[str] = re.compile(r"^gpt-?5", re.IGNORECASE)
40+
41+
42+
def is_responses_api_model(model_name: str) -> bool:
43+
"""Return True if the model should use the OpenAI Responses API."""
44+
return bool(_GPT5_PATTERN.match(model_name))
45+
46+
3747
class UMLBotConfig:
3848
"""
3949
Configuration class for UMLBot.
@@ -88,6 +98,11 @@ class UMLBotConfig:
8898
)
8999
DIAGRAM_SUCCESS_MSG = "Diagram generated successfully using LLM."
90100

101+
# Responses API reasoning effort for GPT-5 series models.
102+
# Valid values: "none" (gpt-5.2+), "minimal" (< gpt-5.2), "low", "medium",
103+
# "high", or None (model default).
104+
REASONING_EFFORT: str | None = os.getenv("REASONING_EFFORT", "low")
105+
91106
# PlantUML JAR rendering
92107
PLANTUML_JAR_PATH = os.getenv(
93108
"UMLBOT_PLANTUML_JAR_PATH",

UMLBot/services/diagram_service.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from PIL import Image, ImageDraw, ImageFont
1414

15-
from UMLBot.config.config import UMLBotConfig
15+
from UMLBot.config.config import UMLBotConfig, is_responses_api_model
1616
from UMLBot.diagram_handlers import (
1717
C4DraftHandler,
1818
ERDraftHandler,
@@ -235,11 +235,14 @@ def _generate_from_description(
235235
openai_compatible_model: str,
236236
) -> DiagramGenerationResult:
237237
"""Shared diagram generation pipeline with per-request LLM credentials."""
238+
_use_responses: bool = is_responses_api_model(openai_compatible_model)
238239
handler._init_openai(
239240
openai_compatible_endpoint=openai_compatible_endpoint,
240241
openai_compatible_key=openai_compatible_key,
241242
openai_compatible_model=openai_compatible_model,
242243
name="UMLBot",
244+
use_responses_api=_use_responses,
245+
reasoning_effort=UMLBotConfig.REASONING_EFFORT if _use_responses else None,
243246
)
244247

245248
try:

docker-compose.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ services:
44
environment:
55
PYTHONUNBUFFERED: 1
66
UMLBOT_PLANTUML_JAR_PATH: /opt/plantuml/plantuml.jar
7+
REASONING_EFFORT: ${REASONING_EFFORT:-low}
78
ports:
89
- "8000:8000"
910
healthcheck:
@@ -13,3 +14,16 @@ services:
1314
retries: 10
1415
start_period: 30s
1516
restart: unless-stopped
17+
18+
streamlit:
19+
build: .
20+
command: ["streamlit", "run", "streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
21+
environment:
22+
PYTHONUNBUFFERED: 1
23+
UMLBOT_ENDPOINT: http://umlbot:8000
24+
ports:
25+
- "8501:8501"
26+
depends_on:
27+
umlbot:
28+
condition: service_healthy
29+
restart: unless-stopped

docs/env_var_config_pattern.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Environment Variable Configuration Pattern
2+
3+
This document describes the pattern for using environment variables to override
4+
application configuration values. The goal is a **single name** that appears
5+
identically in the Python config class, the Docker Compose `environment:`
6+
block, the Azure Container App configuration screen, and any `.env` files.
7+
8+
## Core rule
9+
10+
> **The Python constant name and the environment variable name MUST be
11+
> identical.**
12+
13+
When an operator sets `REASONING_EFFORT=medium` on an Azure container, they
14+
should be able to grep the codebase for that exact string and land on the
15+
config class attribute that consumes it. No prefix translation, no casing
16+
changes.
17+
18+
## How to add a new overridable config value
19+
20+
### 1. Add the class attribute with `os.getenv`
21+
22+
```python
23+
# UMLBot/config/config.py
24+
class UMLBotConfig:
25+
# <short comment explaining the setting and valid values>
26+
MY_NEW_SETTING: str = os.getenv("MY_NEW_SETTING", "default_value")
27+
```
28+
29+
- The first argument to `os.getenv` is the **same string** as the attribute name.
30+
- Always provide a sensible default so the app runs without any env vars set.
31+
- Use a type annotation on the attribute.
32+
33+
### 2. Add it to `docker-compose.yml`
34+
35+
```yaml
36+
services:
37+
umlbot:
38+
environment:
39+
MY_NEW_SETTING: ${MY_NEW_SETTING:-default_value}
40+
```
41+
42+
The `${VAR:-default}` syntax lets the host's env override the compose default,
43+
which in turn overrides the Python default. Use the **same default** in both
44+
places to avoid surprises.
45+
46+
### 3. Add it to `.env.example`
47+
48+
```bash
49+
# Description of the setting (default: default_value)
50+
# MY_NEW_SETTING=default_value
51+
```
52+
53+
Keep entries commented out so a bare `cp .env.example .env` produces a
54+
working configuration that matches the Python defaults.
55+
56+
### 4. Reference it in code
57+
58+
```python
59+
from UMLBot.config.config import UMLBotConfig
60+
61+
value = UMLBotConfig.MY_NEW_SETTING
62+
```
63+
64+
Never call `os.getenv` a second time elsewhere in the code. All env var
65+
reads are centralized in the config class.
66+
67+
## Naming conventions
68+
69+
| Scope | Convention | Example |
70+
|-------|-----------|---------|
71+
| UMLBot-specific infrastructure | `UMLBOT_` prefix | `UMLBOT_PLANTUML_JAR_PATH` |
72+
| LLM / API behaviour settings | Descriptive, no prefix | `REASONING_EFFORT` |
73+
| Cross-service URLs | `UMLBOT_` prefix | `UMLBOT_ENDPOINT` |
74+
75+
The prefix is useful when an env var might collide with another service's
76+
variable in a shared container group. For settings that are clearly
77+
domain-specific (like `REASONING_EFFORT` for OpenAI's Responses API), a prefix
78+
adds noise without reducing ambiguity.
79+
80+
Use your judgement, but when in doubt, match the name the external API or
81+
documentation already uses.
82+
83+
## Current inventory
84+
85+
| Python constant | Env var | Default | Location |
86+
|----------------|---------|---------|----------|
87+
| `UMLBOT_PLANTUML_JAR_PATH` | `UMLBOT_PLANTUML_JAR_PATH` | `/opt/plantuml/plantuml.jar` | `UMLBotConfig` |
88+
| `UMLBOT_DATA_DIR`* | `UMLBOT_DATA_DIR` | `/data` | `UMLBotConfig` |
89+
| `REASONING_EFFORT` | `REASONING_EFFORT` | `low` | `UMLBotConfig` |
90+
| `UMLBOT_ENDPOINT`** | `UMLBOT_ENDPOINT` | `http://localhost:8000` | `streamlit_app.py` |
91+
92+
\* `UMLBOT_DATA_DIR` is consumed as `DATA_DIR` in the config class today.
93+
This is a legacy inconsistency that should be aligned in a future PR.
94+
95+
\** `UMLBOT_ENDPOINT` lives in `streamlit_app.py` as `API_BASE_URL` rather
96+
than in `UMLBotConfig`. This is another legacy inconsistency to align.
97+
98+
## Anti-patterns
99+
100+
- **Different names for the same thing.** Don't name the attribute
101+
`JAR_PATH` and the env var `UMLBOT_PLANTUML_JAR_PATH`. An operator
102+
reading the Azure config screen should be able to find the code with a
103+
single search.
104+
105+
- **Scattered `os.getenv` calls.** Every env-var read should be in
106+
`UMLBotConfig` (or the equivalent config module for other projects).
107+
Duplicate reads in service code make it hard to audit what the app
108+
depends on.
109+
110+
- **Missing defaults.** If a setting doesn't have a default, the app crashes
111+
on startup when the env var is absent. If a value is truly required (like
112+
an API key), use `manage_sensitive()` instead of `os.getenv`.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ dependencies = [
77
# Runtime ---------------------------------------------------------------
88
"aiweb-common @ git+https://github.com/UABPeriopAI/llm_utils.git@develop",
99
"fastapi>=0.118.3",
10+
"langchain-openai>=1.1.0",
11+
"openai>=2.20.0",
1012
"Pillow",
1113
"python-multipart>=0.0.20",
1214
"rich>=14.2.0",

streamlit_app.py

Lines changed: 9 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,7 @@
4848
MODEL_TO_USE: str = "gpt-5.2"
4949

5050
# Backend URL from environment with localhost fallback for local dev
51-
API_BASE_URL: str = os.environ.get(
52-
"UMLBOT_ENDPOINT", "http://localhost:8000"
53-
)
51+
API_BASE_URL: str = os.environ.get("UMLBOT_ENDPOINT", "http://localhost:8000")
5452

5553
DEFAULT_TIMEOUT: int = 120
5654

@@ -94,9 +92,7 @@ def _fetch_remote_config() -> Dict[str, Any]:
9492
if _remote_config is not None:
9593
return _remote_config
9694
try:
97-
resp = requests.get(
98-
f"{API_BASE_URL.rstrip('/')}/v01/config", timeout=10
99-
)
95+
resp = requests.get(f"{API_BASE_URL.rstrip('/')}/v01/config", timeout=10)
10096
resp.raise_for_status()
10197
_remote_config = resp.json()
10298
return _remote_config
@@ -259,9 +255,7 @@ def api_render(
259255
render_path: str = ENDPOINT_MAP[mode][1]
260256
payload: Dict[str, Any] = {"plantuml_code": plantuml_code}
261257
try:
262-
resp = requests.post(
263-
_build_url(render_path), json=payload, timeout=60
264-
)
258+
resp = requests.post(_build_url(render_path), json=payload, timeout=60)
265259
resp.raise_for_status()
266260
data = resp.json()
267261
return {
@@ -393,9 +387,7 @@ def _init_session_state(config: Dict[str, Any]) -> None:
393387
if "theme" not in st.session_state:
394388
st.session_state["theme"] = ""
395389
if "uml_subtype" not in st.session_state:
396-
st.session_state["uml_subtype"] = config.get(
397-
"default_diagram_type", "Use Case"
398-
)
390+
st.session_state["uml_subtype"] = config.get("default_diagram_type", "Use Case")
399391

400392

401393
# ---------------------------------------------------------------------------
@@ -458,9 +450,7 @@ def _handle_chat_input(
458450
else:
459451
st.session_state[error_key] = result.get("message", "Generation failed.")
460452
st.session_state[status_key] = ""
461-
history.append(
462-
{"role": "assistant", "content": f"Error: {result.get('message', '')}"}
463-
)
453+
history.append({"role": "assistant", "content": f"Error: {result.get('message', '')}"})
464454

465455
st.session_state[history_key] = history
466456

@@ -498,18 +488,12 @@ def _render_mode_tab(mode: str, config: Dict[str, Any]) -> None:
498488
error_key: str = _state_key(mode, "error_message")
499489

500490
# UML sub-type dropdown (only for uml mode)
501-
diagram_types: List[str] = config.get(
502-
"diagram_types", ["Use Case", "Class", "Sequence"]
503-
)
491+
diagram_types: List[str] = config.get("diagram_types", ["Use Case", "Class", "Sequence"])
504492
if mode == "uml":
505493
current_subtype: str = st.session_state.get(
506494
"uml_subtype", config.get("default_diagram_type", "Use Case")
507495
)
508-
idx: int = (
509-
diagram_types.index(current_subtype)
510-
if current_subtype in diagram_types
511-
else 0
512-
)
496+
idx: int = diagram_types.index(current_subtype) if current_subtype in diagram_types else 0
513497
st.session_state["uml_subtype"] = st.selectbox(
514498
"UML Diagram Type",
515499
options=diagram_types,
@@ -627,17 +611,15 @@ def show_umlbot_page() -> None:
627611
apply_uab_font()
628612

629613
st.title(f"{page_icon} {page_title}")
630-
st.markdown(
631-
"""
614+
st.markdown("""
632615
**Interactive diagram generation powered by LLM + PlantUML**
633616
634617
Describe what you want in plain language and the AI will generate
635618
PlantUML code and render it. You can iterate on the diagram through
636619
the chat, or edit the code directly and re-render.
637620
638621
---
639-
"""
640-
)
622+
""")
641623

642624
config: Dict[str, Any] = _fetch_remote_config()
643625
_init_session_state(config)

0 commit comments

Comments
 (0)