Skip to content

Commit c72c504

Browse files
rlmelvinclaude
andcommitted
feat: add GPT-5 Responses API support
Detect GPT-5 series models and pass use_responses_api and reasoning_effort flags to WorkflowHandler._init_openai so the aiweb_common layer uses the Responses API for those models. - Add is_responses_api_model() helper and REASONING_EFFORT config - Pin langchain-openai>=1.1.0, openai>=2.20.0 - Add streamlit service to docker-compose for local full-stack testing - Fix pre-existing streamlit test failures from PR #6 signature change - Add test coverage for model detection and flag wiring Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7766ebe commit c72c504

7 files changed

Lines changed: 180 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

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)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Tests for GPT-5 Responses API migration: model detection and flag wiring."""
2+
3+
from unittest.mock import MagicMock, patch
4+
5+
import pytest
6+
7+
from UMLBot.config.config import UMLBotConfig, is_responses_api_model
8+
9+
# ---------------------------------------------------------------------------
10+
# is_responses_api_model
11+
# ---------------------------------------------------------------------------
12+
13+
14+
class TestIsResponsesApiModel:
15+
"""Tests for the GPT-5 model detection helper."""
16+
17+
def test_gpt5_matches(self) -> None:
18+
assert is_responses_api_model("gpt-5") is True
19+
20+
def test_gpt5_mini_matches(self) -> None:
21+
assert is_responses_api_model("gpt-5-mini") is True
22+
23+
def test_gpt5_nano_matches(self) -> None:
24+
assert is_responses_api_model("gpt-5-nano") is True
25+
26+
def test_gpt5_no_dash_matches(self) -> None:
27+
assert is_responses_api_model("gpt5") is True
28+
29+
def test_gpt5_case_insensitive(self) -> None:
30+
assert is_responses_api_model("GPT-5") is True
31+
32+
def test_gpt5_dot_variant_matches(self) -> None:
33+
assert is_responses_api_model("gpt-5.2") is True
34+
35+
def test_gpt4o_does_not_match(self) -> None:
36+
assert is_responses_api_model("gpt-4o") is False
37+
38+
def test_gpt4o_mini_does_not_match(self) -> None:
39+
assert is_responses_api_model("gpt-4o-mini") is False
40+
41+
def test_empty_string(self) -> None:
42+
assert is_responses_api_model("") is False
43+
44+
def test_other_model(self) -> None:
45+
assert is_responses_api_model("claude-3-opus") is False
46+
47+
48+
# ---------------------------------------------------------------------------
49+
# REASONING_EFFORT config
50+
# ---------------------------------------------------------------------------
51+
52+
53+
class TestReasoningEffortConfig:
54+
"""Verify REASONING_EFFORT is present and has a valid default."""
55+
56+
def test_default_value(self) -> None:
57+
assert UMLBotConfig.REASONING_EFFORT in ("none", "minimal", "low", "medium", "high", None)
58+
59+
60+
# ---------------------------------------------------------------------------
61+
# _generate_from_description passes Responses API flags
62+
# ---------------------------------------------------------------------------
63+
64+
65+
class TestResponsesApiWiring:
66+
"""Verify that _generate_from_description passes the correct flags to _init_openai."""
67+
68+
@patch("UMLBot.services.diagram_service._render_plantuml_jar")
69+
def test_gpt5_model_sets_responses_api_flag(self, mock_render: MagicMock) -> None:
70+
from UMLBot.services.diagram_service import DiagramService
71+
72+
mock_render.return_value = (None, "rendered")
73+
service = DiagramService()
74+
75+
handler = MagicMock()
76+
handler.process.return_value = "@startuml\n@enduml"
77+
78+
service._generate_from_description(
79+
handler=handler,
80+
description="test",
81+
diagram_type="Class",
82+
theme=None,
83+
fallback_template="@startuml\n@enduml",
84+
failure_log="fail",
85+
openai_compatible_endpoint="https://api.openai.com/v1",
86+
openai_compatible_key="sk-test",
87+
openai_compatible_model="gpt-5-mini",
88+
)
89+
90+
handler._init_openai.assert_called_once()
91+
call_kwargs = handler._init_openai.call_args[1]
92+
assert call_kwargs["use_responses_api"] is True
93+
assert call_kwargs["reasoning_effort"] is not None
94+
95+
@patch("UMLBot.services.diagram_service._render_plantuml_jar")
96+
def test_gpt4o_model_does_not_set_responses_api_flag(self, mock_render: MagicMock) -> None:
97+
from UMLBot.services.diagram_service import DiagramService
98+
99+
mock_render.return_value = (None, "rendered")
100+
service = DiagramService()
101+
102+
handler = MagicMock()
103+
handler.process.return_value = "@startuml\n@enduml"
104+
105+
service._generate_from_description(
106+
handler=handler,
107+
description="test",
108+
diagram_type="Class",
109+
theme=None,
110+
fallback_template="@startuml\n@enduml",
111+
failure_log="fail",
112+
openai_compatible_endpoint="https://api.openai.com/v1",
113+
openai_compatible_key="sk-test",
114+
openai_compatible_model="gpt-4o-mini",
115+
)
116+
117+
handler._init_openai.assert_called_once()
118+
call_kwargs = handler._init_openai.call_args[1]
119+
assert call_kwargs["use_responses_api"] is False
120+
assert call_kwargs["reasoning_effort"] is None

0 commit comments

Comments
 (0)