Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,26 +140,39 @@ Generated files are written through the `AssetStore` protocol to the
references are stored in Postgres. An S3-compatible store is needed before deploying
multiple application instances.

## OpenAI Image Playground
## Image Playground

To enable real GPT Image 2 generation while leaving Etsy and Printful mocked:
To enable real image generation while leaving Etsy and Printful mocked:

```text
INTEGRATIONS_ENABLED=true
ETSY_MODE=mock
PRINTFUL_MODE=mock
OPENAI_MODE=real
OPENAI_API_KEY=...
GEMINI_API_KEY=...
IMAGE_GENERATION_MODEL=gpt-image-2
OPENAI_IMAGE_MODEL=gpt-image-2
OPENAI_IMAGE_QUALITY=medium
OPENAI_IMAGE_WIDTH=1024
OPENAI_IMAGE_HEIGHT=1024
OPENAI_IMAGE_MAX_COST_USD=0.50
```

The Design Studio stores every prompt and outcome in Postgres. A request is rejected
before generation when its configured output-cost estimate exceeds the per-image
limit. Dollar amounts are estimates because the generation response reports token
The Design Studio can generate with OpenAI `gpt-image-2` or Google Gemini image
models: `gemini-3.1-flash-image`, `gemini-3-pro-image`, and
`gemini-2.5-flash-image`. `GOOGLE_API_KEY` is also accepted and takes precedence
over `GEMINI_API_KEY`. Gemini sizes are limited to documented API sizes and are
rejected before generation when unsupported. For now, Gemini REST generation is
limited to 1024x1024 because the live API rejects the optional sizing config
shown in the current docs.

Create a Gemini key from Google AI Studio:
https://aistudio.google.com/app/apikey

The Design Studio stores every prompt and outcome in Postgres. A request is
rejected before generation when its configured output-cost estimate exceeds the
per-image limit. Dollar amounts are estimates because provider responses report
usage rather than the final billed amount.

## Trend Research And Reports
Expand Down
43 changes: 39 additions & 4 deletions src/ecommerce_agent/api/posting_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import Annotated, Any, cast

import httpx
from fastapi import APIRouter, Depends, Request, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
Expand Down Expand Up @@ -36,6 +36,7 @@
from ecommerce_agent.pipeline.errors import ConflictError, NotFoundError
from ecommerce_agent.services import ServiceContainer
from ecommerce_agent.services.etsy import EtsyOutcomeUnknown
from ecommerce_agent.services.image_models import gemini_image_size, provider_for_model
from ecommerce_agent.services.printful import PrintfulError

router = APIRouter(prefix="/posting", tags=["posting"])
Expand Down Expand Up @@ -291,6 +292,11 @@ async def generate_lifestyle_image(
"The Printful mockup could not be downloaded for AI image generation."
) from error
content_type = response.headers.get("content-type", "image/jpeg").split(";", 1)[0]
settings = request.app.state.settings
model = body.model or settings.image_generation_model
width = body.width or settings.openai_image_width
height = body.height or settings.openai_image_height
_validate_image_model(model, width=width, height=height, settings=settings)
generated = await services.image.generate(
ImageGenerationRequest(
prompt=(
Expand All @@ -299,8 +305,9 @@ async def generate_lifestyle_image(
f"possible. {body.prompt}"
),
product_concept_id=f"posting-{item.id}",
width=request.app.state.settings.openai_image_width,
height=request.app.state.settings.openai_image_height,
model=model,
width=width,
height=height,
references=(
ImageReference(
filename="printful-reference.jpg",
Expand All @@ -320,14 +327,42 @@ async def generate_lifestyle_image(
approved=False,
ai_drift_acknowledged=False,
alt_text=item.image_alt_text,
metadata_={"warning": "AI image may drift from the exact product design."},
metadata_={
"warning": "AI image may drift from the exact product design.",
"model": generated.model,
"provider": generated.provider,
"generation_metadata": generated.metadata,
},
)
session.add(image)
await session.commit()
await session.refresh(image)
return _image_view(image)


def _validate_image_model(model: str, *, width: int, height: int, settings: Any) -> None:
provider_name = provider_for_model(model, openai_model=settings.openai_image_model)
if provider_name is None:
raise HTTPException(status_code=422, detail=f"Unsupported image model: {model}.")
if provider_name == "gemini" and gemini_image_size(model, width, height) is None:
raise HTTPException(
status_code=422,
detail=(
f"{model} does not support {width}x{height}. "
"Choose one of the documented Gemini image sizes."
),
)
if settings.openai_mode == "real":
if provider_name == "openai" and settings.openai_api_key is None:
raise HTTPException(
status_code=422, detail="OpenAI image generation is not configured."
)
if provider_name == "gemini" and not (settings.gemini_api_key or settings.google_api_key):
raise HTTPException(
status_code=422, detail="Gemini image generation is not configured."
)


@router.patch("/images/{image_id}")
async def update_listing_image(
image_id: uuid.UUID,
Expand Down
130 changes: 111 additions & 19 deletions src/ecommerce_agent/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
DecisionRequest,
ImageGenerationCreateRequest,
ImageGenerationUpdateRequest,
ProductWorkflowRequest,
PublishRequest,
RunResponse,
)
Expand Down Expand Up @@ -44,6 +45,12 @@
from ecommerce_agent.pipeline.events import log_event
from ecommerce_agent.pipeline.transitions import advance, require_status
from ecommerce_agent.services import ServiceContainer
from ecommerce_agent.services.image_models import (
gemini_image_size,
image_model_options,
is_gemini_image_model,
provider_for_model,
)
from ecommerce_agent.services.openai_images import (
SUPPORTED_OUTPUT_FORMATS,
OpenAIImageError,
Expand All @@ -56,7 +63,7 @@
ServicesDep = Annotated[ServiceContainer, Depends(get_services)]
MAX_REFERENCE_BYTES = 10 * 1024 * 1024
MAX_REFERENCE_PIXELS = 25_000_000
MAX_IMAGE_PIXELS = 4_194_304
MAX_IMAGE_PIXELS = 18_000_000
REFERENCE_CONTENT_TYPES = {
"PNG": "image/png",
"JPEG": "image/jpeg",
Expand All @@ -68,6 +75,7 @@
class ParsedImageRequest:
prompt: str
title: str
model: str | None
width: int | None
height: int | None
output_format: str
Expand All @@ -80,6 +88,37 @@ async def health() -> dict[str, str]:
return {"status": "ok"}


@router.get("/image-models")
async def list_image_models(request: Request) -> list[dict[str, Any]]:
settings = request.app.state.settings
mock_mode = settings.openai_mode != "real"
options = image_model_options(
openai_model=settings.openai_image_model,
openai_available=mock_mode or settings.openai_api_key is not None,
gemini_available=mock_mode
or settings.gemini_api_key is not None
or settings.google_api_key is not None,
)
return [
{
"model": option.model,
"label": option.label,
"provider": option.provider,
"available": option.available,
"sizes": [
{
"width": size.width,
"height": size.height,
"aspect_ratio": size.aspect_ratio,
"image_size": size.image_size,
}
for size in option.sizes
],
}
for option in options
]


@router.post("/image-generations", status_code=status.HTTP_201_CREATED)
async def create_image_generation(
request: Request,
Expand Down Expand Up @@ -124,6 +163,7 @@ async def edit_image_generation(
parsed = ParsedImageRequest(
prompt=parsed.prompt,
title=parent.title,
model=parsed.model,
width=parsed.width,
height=parsed.height,
output_format=parsed.output_format,
Expand All @@ -148,25 +188,30 @@ async def _execute_image_generation(
services: ServiceContainer,
) -> dict[str, Any]:
settings = request.app.state.settings
model = parsed.model or settings.image_generation_model
width = parsed.width or settings.openai_image_width
height = parsed.height or settings.openai_image_height
_validate_image_model(model, width=width, height=height, settings=settings)
is_real = settings.openai_mode == "real"
provider_name = provider_for_model(model, openai_model=settings.openai_image_model)
try:
output_estimate = (
estimate_image_output_cost(
model=settings.openai_image_model,
if not is_real:
output_estimate = Decimal("0")
reference_reserve = Decimal("0")
elif provider_name == "gemini":
size = gemini_image_size(model, width, height)
output_estimate = size.estimated_cost_usd if size is not None else Decimal("0")
reference_reserve = Decimal("0")
else:
output_estimate = estimate_image_output_cost(
model=model,
quality=settings.openai_image_quality,
width=width,
height=height,
)
if is_real
else Decimal("0")
)
reference_reserve = (
Decimal(str(settings.openai_image_reference_cost_reserve_usd)) * len(parsed.references)
if is_real
else Decimal("0")
)
reference_reserve = Decimal(
str(settings.openai_image_reference_cost_reserve_usd)
) * len(parsed.references)
estimated_cost = output_estimate + reference_reserve
except OpenAIImageError:
estimated_cost = Decimal("0")
Expand All @@ -178,9 +223,9 @@ async def _execute_image_generation(
status=ImageGenerationStatus.PENDING,
width=width,
height=height,
provider="openai" if is_real else "mock",
model=settings.openai_image_model if is_real else "mock-image-v1",
quality=settings.openai_image_quality,
provider=provider_name if is_real and provider_name is not None else "mock",
model=model if is_real else "mock-image-v1",
quality="standard" if provider_name == "gemini" else settings.openai_image_quality,
output_format=parsed.output_format,
background_removal_friendly=parsed.background_removal_friendly,
estimated_cost_usd=estimated_cost,
Expand All @@ -193,6 +238,7 @@ async def _execute_image_generation(
ImageGenerationRequest(
prompt=generation.prompt,
product_concept_id=str(generation.id),
model=model,
width=generation.width,
height=generation.height,
references=parsed.references,
Expand Down Expand Up @@ -235,6 +281,7 @@ async def _parse_image_request(request: Request, *, max_references: int) -> Pars
content_type = request.headers.get("content-type", "")
prompt_value: Any
title_value: Any
model_value: Any
width_value: Any
height_value: Any
output_format_value: Any
Expand All @@ -244,14 +291,15 @@ async def _parse_image_request(request: Request, *, max_references: int) -> Pars
form = await request.form()
prompt_value = form.get("prompt")
title_value = form.get("title", "Untitled design")
model_value = form.get("model")
width_value = form.get("width")
height_value = form.get("height")
output_format_value = form.get("output_format", "png")
background_value: Any = form.get("background_removal_friendly", "true")
uploads = [
value
for key, value in form.multi_items()
if key == "reference_images" and isinstance(value, UploadFile)
if key == "reference_images" and isinstance(value, UploadFile) and value.filename
]
if len(uploads) > max_references:
raise HTTPException(
Expand All @@ -268,6 +316,7 @@ async def _parse_image_request(request: Request, *, max_references: int) -> Pars
) from error
prompt_value = body.prompt
title_value = body.title
model_value = body.model
width_value = body.width
height_value = body.height
output_format_value = body.output_format
Expand Down Expand Up @@ -299,6 +348,7 @@ async def _parse_image_request(request: Request, *, max_references: int) -> Pars
return ParsedImageRequest(
prompt=prompt,
title=title,
model=str(model_value).strip() if model_value else None,
width=width,
height=height,
output_format=output_format,
Expand Down Expand Up @@ -369,11 +419,34 @@ def _parse_dimension(value: Any, label: str) -> int | None:
dimension = int(value)
except (TypeError, ValueError) as error:
raise HTTPException(status_code=422, detail=f"Invalid image {label}.") from error
if dimension <= 0 or dimension > 4096:
if dimension <= 0 or dimension > 5504:
raise HTTPException(status_code=422, detail=f"Invalid image {label}.")
return dimension


def _validate_image_model(model: str, *, width: int, height: int, settings: Any) -> None:
provider_name = provider_for_model(model, openai_model=settings.openai_image_model)
if provider_name is None:
raise HTTPException(status_code=422, detail=f"Unsupported image model: {model}.")
if settings.openai_mode == "real":
if provider_name == "openai" and settings.openai_api_key is None:
raise HTTPException(
status_code=422, detail="OpenAI image generation is not configured."
)
if provider_name == "gemini" and not (settings.gemini_api_key or settings.google_api_key):
raise HTTPException(
status_code=422, detail="Gemini image generation is not configured."
)
if is_gemini_image_model(model) and gemini_image_size(model, width, height) is None:
raise HTTPException(
status_code=422,
detail=(
f"{model} does not support {width}x{height}. "
"Choose one of the documented Gemini image sizes."
),
)


def _extension(output_format: str) -> str:
return "jpg" if output_format == "jpeg" else output_format

Expand Down Expand Up @@ -454,14 +527,33 @@ async def start_research(session: SessionDep, services: ServicesDep) -> RunRespo
response_model=RunResponse,
status_code=status.HTTP_201_CREATED,
)
async def start_product(product_id: uuid.UUID, session: SessionDep) -> RunResponse:
async def start_product(
product_id: uuid.UUID,
request: Request,
session: SessionDep,
body: ProductWorkflowRequest | None = None,
) -> RunResponse:
product = await session.get(Product, product_id)
if product is None:
raise NotFoundError("Product concept not found.")
image_model = body.image_model if body is not None else None
if image_model:
settings = request.app.state.settings
_validate_image_model(
image_model,
width=settings.openai_image_width,
height=settings.openai_image_height,
settings=settings,
)
require_status(product, ProductStatus.CONCEPT)
advance(product, ProductStatus.GENERATING_DESIGN)
await log_event(session, "product", product.id, "start", "Product workflow started.")
await enqueue(session, "generate_design", product.id)
await enqueue(
session,
"generate_design",
product.id,
{"image_model": image_model} if image_model else None,
)
await session.commit()
return RunResponse(id=product.id, subject_type="product", status=product.status.value)

Expand Down
Loading
Loading