Skip to content

Commit 6a8e2da

Browse files
Add image generation tool support
Enable the Responses API image_generation tool with configurable quality, size, and background options. Handle streaming events including partial images, and render generated images inline in the chat. Closes #42 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d079293 commit 6a8e2da

6 files changed

Lines changed: 480 additions & 3 deletions

File tree

routers/chat.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@
2525
ResponseMcpCallInProgressEvent, ResponseMcpCallArgumentsDeltaEvent,
2626
ResponseWebSearchCallInProgressEvent, ResponseWebSearchCallSearchingEvent,
2727
ResponseWebSearchCallCompletedEvent,
28+
ResponseImageGenCallInProgressEvent, ResponseImageGenCallGeneratingEvent,
29+
ResponseImageGenCallCompletedEvent, ResponseImageGenCallPartialImageEvent,
2830
)
29-
from openai.types.responses.response_output_item import McpApprovalRequest
31+
from openai.types.responses.response_output_item import McpApprovalRequest, ImageGenerationCall
3032
from openai.types.responses import ResponseFunctionToolCall, ResponseComputerToolCall
3133
from openai.types.responses.response_code_interpreter_tool_call import ResponseCodeInterpreterToolCall
3234
from openai._types import NOT_GIVEN
@@ -183,6 +185,18 @@ async def event_generator() -> AsyncGenerator[str, None]:
183185
tools.append(ws_tool)
184186
if "computer_use" in enabled_tools:
185187
tools.append(build_computer_tool())
188+
if "image_generation" in enabled_tools:
189+
ig_tool: Dict[str, Any] = {"type": "image_generation"}
190+
ig_quality = os.getenv("IMAGE_GENERATION_QUALITY", "auto").strip()
191+
if ig_quality in {"low", "medium", "high"}:
192+
ig_tool["quality"] = ig_quality
193+
ig_size = os.getenv("IMAGE_GENERATION_SIZE", "auto").strip()
194+
if ig_size in {"1024x1024", "1536x1024", "1024x1536"}:
195+
ig_tool["size"] = ig_size
196+
ig_background = os.getenv("IMAGE_GENERATION_BACKGROUND", "auto").strip()
197+
if ig_background in {"opaque", "transparent"}:
198+
ig_tool["background"] = ig_background
199+
tools.append(ig_tool)
186200

187201
try:
188202
stream = await client.responses.create( # type: ignore[call-overload]
@@ -227,7 +241,8 @@ async def iterate_stream(s, response_id: str = "") -> AsyncGenerator[str, None]:
227241
ResponseMcpCallInProgressEvent() | \
228242
ResponseFunctionCallArgumentsDoneEvent() | \
229243
ResponseWebSearchCallInProgressEvent() | \
230-
ResponseWebSearchCallCompletedEvent():
244+
ResponseWebSearchCallCompletedEvent() | \
245+
ResponseImageGenCallCompletedEvent():
231246
# Don't need to handle "in progress" or intermediate "done" events
232247
# (though long-running code interpreter interpreting might warrant handling)
233248
continue
@@ -351,6 +366,28 @@ async def iterate_stream(s, response_id: str = "") -> AsyncGenerator[str, None]:
351366
)
352367
)
353368

369+
case ResponseImageGenCallInProgressEvent() | ResponseImageGenCallGeneratingEvent():
370+
current_item_id = event.item_id
371+
yield sse_format(
372+
"toolCallCreated",
373+
templates.get_template('components/assistant-step.html').render(
374+
step_type='toolCall',
375+
step_id=event.item_id,
376+
content="Generating image..."
377+
)
378+
)
379+
380+
case ResponseImageGenCallPartialImageEvent():
381+
# Display partial image as it streams in
382+
img_html = (
383+
f'<div class="imageOutput">'
384+
f'<img src="data:image/png;base64,{event.partial_image_b64}" '
385+
f'alt="Partial image (generating...)" '
386+
f'onclick="openImagePreview(this.src)" style="cursor:pointer" />'
387+
f'</div>'
388+
)
389+
yield sse_format("imageOutput", img_html)
390+
354391
case ResponseContentPartAddedEvent():
355392
# This event indicates the start of annotations; skip creating a new assistantMessage
356393
continue
@@ -504,6 +541,18 @@ async def iterate_stream(s, response_id: str = "") -> AsyncGenerator[str, None]:
504541
async for out in iterate_stream(next_stream, response_id):
505542
yield out
506543

544+
elif isinstance(event.item, ImageGenerationCall):
545+
current_item_id = event.item.id
546+
if event.item.result:
547+
img_html = (
548+
f'<div class="imageOutput">'
549+
f'<img src="data:image/png;base64,{event.item.result}" '
550+
f'alt="Generated image" '
551+
f'onclick="openImagePreview(this.src)" style="cursor:pointer" />'
552+
f'</div>'
553+
)
554+
yield sse_format("imageOutput", img_html)
555+
507556
elif isinstance(event.item, ResponseComputerToolCall):
508557
current_item_id = event.item.id
509558
call_id = event.item.call_id

routers/setup.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ async def read_setup(
8888
web_search_region = os.getenv("WEB_SEARCH_LOCATION_REGION", "")
8989
web_search_timezone = os.getenv("WEB_SEARCH_LOCATION_TIMEZONE", "")
9090

91+
# Image generation config
92+
image_generation_quality = os.getenv("IMAGE_GENERATION_QUALITY", "auto")
93+
image_generation_size = os.getenv("IMAGE_GENERATION_SIZE", "auto")
94+
image_generation_background = os.getenv("IMAGE_GENERATION_BACKGROUND", "auto")
95+
9196
if not openai_api_key:
9297
setup_message = "OpenAI API key is missing."
9398

@@ -141,6 +146,9 @@ async def read_setup(
141146
"web_search_city": web_search_city,
142147
"web_search_region": web_search_region,
143148
"web_search_timezone": web_search_timezone,
149+
"image_generation_quality": image_generation_quality,
150+
"image_generation_size": image_generation_size,
151+
"image_generation_background": image_generation_background,
144152
}
145153
)
146154

@@ -235,6 +243,9 @@ async def save_app_config(
235243
web_search_city: Optional[str] = Form(default=None),
236244
web_search_region: Optional[str] = Form(default=None),
237245
web_search_timezone: Optional[str] = Form(default=None),
246+
image_generation_quality: Optional[str] = Form(default=None),
247+
image_generation_size: Optional[str] = Form(default=None),
248+
image_generation_background: Optional[str] = Form(default=None),
238249
) -> RedirectResponse:
239250
status = "success"
240251
message_text = ""
@@ -340,6 +351,21 @@ def get_or_empty(items: List[str], i: int) -> str:
340351
update_env_file("WEB_SEARCH_LOCATION_TIMEZONE", (web_search_timezone or "").strip())
341352
status = "success"
342353
message_text = "Web search configuration saved."
354+
elif action == "save_image_generation_config":
355+
quality = (image_generation_quality or "auto").strip()
356+
if quality not in {"auto", "low", "medium", "high"}:
357+
quality = "auto"
358+
size = (image_generation_size or "auto").strip()
359+
if size not in {"auto", "1024x1024", "1536x1024", "1024x1536"}:
360+
size = "auto"
361+
background = (image_generation_background or "auto").strip()
362+
if background not in {"auto", "opaque", "transparent"}:
363+
background = "auto"
364+
update_env_file("IMAGE_GENERATION_QUALITY", quality)
365+
update_env_file("IMAGE_GENERATION_SIZE", size)
366+
update_env_file("IMAGE_GENERATION_BACKGROUND", background)
367+
status = "success"
368+
message_text = "Image generation configuration saved."
343369
else:
344370
# Standard app config save
345371
if model is None or instructions is None:

templates/setup.html

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ <h3>Model &amp; Instructions</h3>
9595
<input type="checkbox" name="tool_types" value="computer_use" {% if "computer_use" in current_tools %}checked{% endif %}>
9696
Computer Use
9797
</label>
98+
<label>
99+
<input type="checkbox" name="tool_types" value="image_generation" {% if "image_generation" in current_tools %}checked{% endif %}>
100+
Image Generation
101+
</label>
98102
</fieldset>
99103

100104
{# ── Display ── #}
@@ -165,6 +169,50 @@ <h3>Web Search</h3>
165169

166170

167171

172+
{# ── Image Generation Configuration ── #}
173+
{% if "image_generation" in current_tools %}
174+
<div class="setupSection">
175+
<h3>Image Generation</h3>
176+
<p class="setupMessage">Configure image generation quality, size, and background options.</p>
177+
<form action="{{ url_for('save_app_config') }}" method="POST">
178+
<input type="hidden" name="action" value="save_image_generation_config">
179+
180+
<div>
181+
<label for="image-gen-quality">Quality:</label>
182+
<select name="image_generation_quality" id="image-gen-quality" class="input">
183+
<option value="auto" {% if image_generation_quality == "auto" %}selected{% endif %}>Auto (default)</option>
184+
<option value="low" {% if image_generation_quality == "low" %}selected{% endif %}>Low (fast, lower cost)</option>
185+
<option value="medium" {% if image_generation_quality == "medium" %}selected{% endif %}>Medium</option>
186+
<option value="high" {% if image_generation_quality == "high" %}selected{% endif %}>High</option>
187+
</select>
188+
</div>
189+
190+
<div>
191+
<label for="image-gen-size">Size:</label>
192+
<select name="image_generation_size" id="image-gen-size" class="input">
193+
<option value="auto" {% if image_generation_size == "auto" %}selected{% endif %}>Auto (default)</option>
194+
<option value="1024x1024" {% if image_generation_size == "1024x1024" %}selected{% endif %}>1024x1024 (square)</option>
195+
<option value="1536x1024" {% if image_generation_size == "1536x1024" %}selected{% endif %}>1536x1024 (landscape)</option>
196+
<option value="1024x1536" {% if image_generation_size == "1024x1536" %}selected{% endif %}>1024x1536 (portrait)</option>
197+
</select>
198+
</div>
199+
200+
<div>
201+
<label for="image-gen-background">Background:</label>
202+
<select name="image_generation_background" id="image-gen-background" class="input">
203+
<option value="auto" {% if image_generation_background == "auto" %}selected{% endif %}>Auto (default)</option>
204+
<option value="opaque" {% if image_generation_background == "opaque" %}selected{% endif %}>Opaque</option>
205+
<option value="transparent" {% if image_generation_background == "transparent" %}selected{% endif %}>Transparent</option>
206+
</select>
207+
</div>
208+
209+
<div class="centered-button-container">
210+
<button type="submit" class="button">Save image generation config</button>
211+
</div>
212+
</form>
213+
</div>
214+
{% endif %}
215+
168216
{# ── Custom Functions & MCP Servers ── #}
169217
{% if "function" in current_tools or "mcp" in current_tools %}
170218
<form action="{{ url_for('save_app_config') }}" method="POST" id="tool-config-form">

tests/conftest.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,25 @@ def env_computer_use_only(app_server: int) -> Iterator[None]:
332332
yield
333333

334334

335+
@pytest.fixture
336+
def env_image_generation_only(app_server: int) -> Iterator[None]:
337+
with _dotenv({**_BASE_ENV, "ENABLED_TOOLS": "image_generation"}):
338+
yield
339+
340+
341+
@pytest.fixture
342+
def env_image_generation_with_config(app_server: int) -> Iterator[None]:
343+
with _dotenv({
344+
**_BASE_ENV,
345+
"ENABLED_TOOLS": "image_generation",
346+
"IMAGE_GENERATION_QUALITY": "high",
347+
"IMAGE_GENERATION_SIZE": "1024x1536",
348+
"IMAGE_GENERATION_BACKGROUND": "transparent",
349+
}):
350+
yield
351+
352+
335353
@pytest.fixture
336354
def env_all_tools(app_server: int) -> Iterator[None]:
337-
with _dotenv({**_BASE_ENV, "ENABLED_TOOLS": "file_search,function,mcp,web_search,computer_use"}):
355+
with _dotenv({**_BASE_ENV, "ENABLED_TOOLS": "file_search,function,mcp,web_search,computer_use,image_generation"}):
338356
yield

tests/test_setup_page_rendering.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,85 @@ def test_no_other_tool_sections(
551551
expect(mcp_rows).not_to_be_visible()
552552

553553

554+
class TestImageGenerationTool:
555+
"""Tests for image_generation tool conditional rendering."""
556+
557+
def test_image_generation_checkbox_visible(
558+
self, page: Page, base_url: str, app_server, env_api_key_no_tools
559+
):
560+
"""Image generation checkbox should be visible in the tools fieldset."""
561+
page.goto(f"{base_url}/setup/")
562+
563+
image_gen_cb = page.locator('input[value="image_generation"]')
564+
expect(image_gen_cb).to_be_visible()
565+
566+
def test_image_generation_checkbox_checked_when_enabled(
567+
self, page: Page, base_url: str, app_server, env_image_generation_only
568+
):
569+
"""The image_generation checkbox should be checked when enabled."""
570+
page.goto(f"{base_url}/setup/")
571+
572+
image_gen_cb = page.locator('input[value="image_generation"]')
573+
expect(image_gen_cb).to_be_checked()
574+
575+
def test_shows_image_generation_config_section(
576+
self, page: Page, base_url: str, app_server, env_image_generation_only
577+
):
578+
"""When image_generation is enabled, the config section should be visible."""
579+
page.goto(f"{base_url}/setup/")
580+
581+
heading = page.locator('h3:has-text("Image Generation")')
582+
expect(heading).to_be_visible()
583+
584+
quality = page.locator("#image-gen-quality")
585+
expect(quality).to_be_visible()
586+
587+
size = page.locator("#image-gen-size")
588+
expect(size).to_be_visible()
589+
590+
background = page.locator("#image-gen-background")
591+
expect(background).to_be_visible()
592+
593+
def test_hides_image_generation_config_when_not_enabled(
594+
self, page: Page, base_url: str, app_server, env_api_key_no_tools
595+
):
596+
"""Image generation config section should NOT be visible when not enabled."""
597+
page.goto(f"{base_url}/setup/")
598+
599+
quality = page.locator("#image-gen-quality")
600+
expect(quality).not_to_be_visible()
601+
602+
def test_populates_saved_config_values(
603+
self, page: Page, base_url: str, app_server, env_image_generation_with_config
604+
):
605+
"""Saved image generation config values should be populated in the form."""
606+
page.goto(f"{base_url}/setup/")
607+
608+
quality = page.locator("#image-gen-quality")
609+
expect(quality).to_have_value("high")
610+
611+
size = page.locator("#image-gen-size")
612+
expect(size).to_have_value("1024x1536")
613+
614+
background = page.locator("#image-gen-background")
615+
expect(background).to_have_value("transparent")
616+
617+
def test_no_other_tool_sections(
618+
self, page: Page, base_url: str, app_server, env_image_generation_only
619+
):
620+
"""File search, function, and MCP sections should NOT be visible."""
621+
page.goto(f"{base_url}/setup/")
622+
623+
upload_form = page.locator("#upload-form")
624+
expect(upload_form).not_to_be_visible()
625+
626+
registry_rows = page.locator("#registry-rows")
627+
expect(registry_rows).not_to_be_visible()
628+
629+
mcp_rows = page.locator("#mcp-rows")
630+
expect(mcp_rows).not_to_be_visible()
631+
632+
554633
class TestAllToolsEnabled:
555634
"""Tests for when all three conditional tools are enabled."""
556635

@@ -585,6 +664,15 @@ def test_shows_web_search_config(
585664
context_size = page.locator("#web-search-context-size")
586665
expect(context_size).to_be_visible()
587666

667+
def test_shows_image_generation_config(
668+
self, page: Page, base_url: str, app_server, env_all_tools
669+
):
670+
"""Image generation config section should be visible when all tools enabled."""
671+
page.goto(f"{base_url}/setup/")
672+
673+
quality = page.locator("#image-gen-quality")
674+
expect(quality).to_be_visible()
675+
588676
def test_no_computer_use_config(
589677
self, page: Page, base_url: str, app_server, env_all_tools
590678
):
@@ -614,3 +702,6 @@ def test_all_tool_checkboxes_checked(
614702

615703
computer_use_cb = page.locator('input[value="computer_use"]')
616704
expect(computer_use_cb).to_be_checked()
705+
706+
image_gen_cb = page.locator('input[value="image_generation"]')
707+
expect(image_gen_cb).to_be_checked()

0 commit comments

Comments
 (0)