| Server IP : 3.147.158.171 / Your IP : 216.73.216.88 Web Server : Apache/2.4.67 (Amazon Linux) OpenSSL/3.5.5 System : Linux ip-172-31-2-178.us-east-2.compute.internal 6.1.172-216.329.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Wed May 20 06:31:34 UTC 2026 x86_64 User : ec2-user ( 1000) PHP Version : 8.4.21 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /tsai/www/html/api.turmansolutions.ai/app/services/ |
Upload File : |
"""Service for gpt-image-2 generation via the Responses API image_generation tool.
See api/scripts/gpt_image_2_findings.md for the empirical surface.
"""
import base64
import logging
import os
from typing import Any, AsyncGenerator, Optional
import requests
from fastapi import HTTPException
from app.appTypes import (
GptImage2Request,
GptImageQuality,
GptImageSize,
SiteConfig,
)
from app.clients import async_openai_client
from app.services.image import ImageService, get_default_image_format
from app.services import usage_history
from app.utilities.path_utils import PathUtils
_image_service = ImageService()
path_utils = PathUtils()
_MIME_MAP = {
'png': 'image/png',
'jpeg': 'image/jpeg',
'jpg': 'image/jpeg',
'webp': 'image/webp',
}
def _enum_value(v: Any) -> Any:
return v.value if hasattr(v, 'value') else v
def resolve_output_format(req: GptImage2Request) -> str:
"""Resolve output format. Forces png when transparent background is requested."""
fmt = _enum_value(req.output_format) if req.output_format else get_default_image_format()
if _enum_value(req.background) == 'transparent' and fmt != 'png':
fmt = 'png'
return fmt
def validate_request(req: GptImage2Request) -> None:
"""Validate combinations the OpenAI API would reject anyway, but cheaper to fail early."""
if req.partial_images is not None:
if req.partial_images < 1 or req.partial_images > 3:
raise HTTPException(400, 'partial_images must be between 1 and 3')
if not req.stream:
raise HTTPException(400, 'partial_images requires stream=true')
if _enum_value(req.background) == 'transparent':
fmt = _enum_value(req.output_format) if req.output_format else get_default_image_format()
if fmt and fmt != 'png':
raise HTTPException(400, 'background=transparent requires output_format=png')
def _resolve_input_image_to_bytes(img_data: str) -> tuple[bytes, str]:
"""Accept a base64 string, a Drupal URL, or any other http(s) URL; return (bytes, mime_type).
Drupal-hosted URLs are read from local disk via path_utils. Other http(s) URLs
(e.g. WordPress media URLs for series-reference images) are fetched over HTTP.
"""
if img_data.startswith(('http://', 'https://')):
drupal_host = os.environ.get('DRUPAL_HOST', '')
drupal_base = path_utils.get_drupal_base_dir()
if drupal_host and drupal_base and img_data.startswith(drupal_host):
relative_path = img_data[len(drupal_host):]
file_path = drupal_base + relative_path
ext = os.path.splitext(file_path)[1].lstrip('.').lower()
mime = _MIME_MAP.get(ext, 'image/png')
with open(file_path, 'rb') as f:
return f.read(), mime
resp = requests.get(img_data, timeout=15)
resp.raise_for_status()
ext = os.path.splitext(img_data.split('?', 1)[0])[1].lstrip('.').lower()
mime = _MIME_MAP.get(ext) or resp.headers.get('Content-Type', 'image/png').split(';', 1)[0].strip()
return resp.content, mime
return base64.b64decode(img_data), 'image/png'
def _build_input_images_content(input_images: list[str]) -> list[dict[str, Any]]:
"""Build input_image content parts for a Responses API multimodal user message.
A reference image that fails to resolve is logged and skipped, not fatal —
missing visual continuity should not block article generation.
"""
parts: list[dict[str, Any]] = []
for raw in input_images:
try:
img_bytes, mime = _resolve_input_image_to_bytes(raw)
except Exception as e:
preview = raw[:80] + ('…' if len(raw) > 80 else '')
logging.warning(f'[gpt-image-2] skipping input image (resolve failed): {type(e).__name__}: {e} — {preview}')
continue
b64 = base64.b64encode(img_bytes).decode('ascii')
parts.append({
'type': 'input_image',
'image_url': f'data:{mime};base64,{b64}',
})
return parts
def build_tool_options(req: GptImage2Request) -> dict[str, Any]:
"""Map a GptImage2Request into the image_generation tool's accepted options."""
tool: dict[str, Any] = {'type': 'image_generation'}
size = _enum_value(req.size)
if size:
tool['size'] = size
quality = _enum_value(req.quality)
if quality:
tool['quality'] = quality
fmt = resolve_output_format(req)
if fmt:
tool['output_format'] = fmt
bg = _enum_value(req.background)
if bg:
tool['background'] = bg
fidelity = _enum_value(req.input_fidelity)
if fidelity:
tool['input_fidelity'] = fidelity
if req.partial_images is not None:
tool['partial_images'] = req.partial_images
return tool
def build_responses_kwargs(req: GptImage2Request) -> dict[str, Any]:
"""Build kwargs for async_openai_client.responses.create()."""
tool = build_tool_options(req)
kwargs: dict[str, Any] = {
'model': req.driver_model,
'tools': [tool],
}
if req.input_images and len(req.input_images) > 0:
content: list[dict[str, Any]] = [{'type': 'input_text', 'text': req.prompt}]
content.extend(_build_input_images_content(req.input_images))
kwargs['input'] = [{'role': 'user', 'content': content}]
else:
kwargs['input'] = req.prompt
if req.previous_response_id:
kwargs['previous_response_id'] = req.previous_response_id
return kwargs
def extract_final_image_b64(response: Any) -> Optional[str]:
"""Pull the final image bytes (base64) out of a non-streamed Responses result."""
output = getattr(response, 'output', None) or []
for item in output:
if getattr(item, 'type', None) == 'image_generation_call':
result = getattr(item, 'result', None)
if isinstance(result, str):
return result
return None
def _summarize_response_for_log(response: Any) -> str:
"""Compact diagnostic summary used when image extraction fails.
Captures response.id/status, per-output-item type/status/error, and any
text content from message items (the driver model's refusal lives there).
"""
try:
parts: list[str] = []
parts.append(f"id={getattr(response, 'id', None)}")
parts.append(f"status={getattr(response, 'status', None)}")
err = getattr(response, 'error', None)
if err is not None:
parts.append(f"error={err!r}")
output = getattr(response, 'output', None) or []
item_summaries: list[str] = []
for item in output:
attrs: dict[str, Any] = {'type': getattr(item, 'type', None)}
for a in ('status', 'id', 'name'):
if hasattr(item, a):
attrs[a] = getattr(item, a)
item_err = getattr(item, 'error', None)
if item_err is not None:
attrs['error'] = repr(item_err)[:300]
content = getattr(item, 'content', None)
if isinstance(content, list):
texts: list[str] = []
for c in content:
text = getattr(c, 'text', None) or (c.get('text') if isinstance(c, dict) else None)
if isinstance(text, str):
texts.append(text)
if texts:
attrs['text'] = (' | '.join(texts))[:500]
result = getattr(item, 'result', None)
if isinstance(result, str):
attrs['result_b64_len'] = len(result)
elif result is not None:
attrs['result_repr'] = repr(result)[:200]
item_summaries.append(repr(attrs))
parts.append('output=[' + ', '.join(item_summaries) + ']')
return ' '.join(parts)
except Exception as e:
return f'[summary failed: {type(e).__name__}: {e}]'
async def stream_events(req: GptImage2Request) -> AsyncGenerator[dict[str, Any], None]:
"""Stream events from the Responses API and yield normalized records.
Yielded shapes:
{'kind': 'partial', 'index': int, 'b64': str}
{'kind': 'final', 'b64': str}
{'kind': 'error', 'message': str}
"""
kwargs = build_responses_kwargs(req)
kwargs['stream'] = True
logging.info(f'[gpt-image-2] streaming responses.create kwargs keys: {list(kwargs.keys())}, tool: {kwargs["tools"][0]}')
final_emitted = False
try:
stream = await async_openai_client.responses.create(**kwargs)
async for event in stream:
evt_type = getattr(event, 'type', '') or ''
if 'partial_image' in evt_type:
b64 = getattr(event, 'b64_json', None) or getattr(event, 'partial_image_b64', None)
idx = getattr(event, 'partial_image_index', 0) or 0
if isinstance(b64, str):
yield {'kind': 'partial', 'index': int(idx), 'b64': b64}
continue
if evt_type.endswith('completed') or evt_type == 'response.completed':
response = getattr(event, 'response', None)
if response is not None:
b64 = extract_final_image_b64(response)
if b64:
yield {'kind': 'final', 'b64': b64}
final_emitted = True
break
logging.error(
f'[gpt-image-2] no image in streamed response: {_summarize_response_for_log(response)}'
)
if not final_emitted:
yield {'kind': 'error', 'message': 'No image data received from Responses API'}
except Exception as e:
logging.error(f'[gpt-image-2] streaming error: {e}', exc_info=True)
yield {'kind': 'error', 'message': str(e)}
async def generate_once(req: GptImage2Request) -> str:
"""Non-streaming generation. Returns the b64-encoded image."""
kwargs = build_responses_kwargs(req)
response = await async_openai_client.responses.create(**kwargs)
b64 = extract_final_image_b64(response)
if not b64:
logging.error(
f'[gpt-image-2] no image in response: {_summarize_response_for_log(response)}'
)
raise HTTPException(500, 'No image data received from Responses API')
return b64
async def create_image_for_article(
prompt: str,
site: SiteConfig,
input_images: Optional[list[str]] = None,
size: GptImageSize = GptImageSize._1536x1024,
quality: GptImageQuality = GptImageQuality.medium,
driver_model: str = "gpt-5",
) -> tuple[str, str]:
"""Generate an article image via gpt-image-2 and persist it.
Returns (url, base64). Mirrors the v1 ImageService.create_image contract so
callers (wp_service) only see a different module name.
"""
logging.info(
f'[gpt-image-2] article image: driver={driver_model} size={_enum_value(size)} '
f'quality={_enum_value(quality)} input_images={len(input_images) if input_images else 0}'
)
req = GptImage2Request(
prompt=prompt,
user='article-creation',
driver_model=driver_model,
stream=False,
size=size,
quality=quality,
input_images=input_images,
)
b64 = await generate_once(req)
usage_history.record_image(
model='gpt-image-2',
size=_enum_value(size),
quality=_enum_value(quality),
operation='article_image',
b64=b64,
site_slug=site.slug,
)
fmt = resolve_output_format(req)
urls = _image_service.save_b64_and_get_url(b64, site, fmt)
if not urls:
raise HTTPException(500, 'Failed to persist gpt-image-2 result')
return urls[0], b64