From 7bc753e1fe123174fce8289b7ce5a94c23de9b37 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Thu, 25 Sep 2025 17:46:39 +0800 Subject: [PATCH 01/18] support openai images/edits api(single picture) --- xinference/api/restful_api.py | 263 +++++++++++++++++++++++++++++++++- 1 file changed, 262 insertions(+), 1 deletion(-) diff --git a/xinference/api/restful_api.py b/xinference/api/restful_api.py index b4b8be3b7c..412d773929 100644 --- a/xinference/api/restful_api.py +++ b/xinference/api/restful_api.py @@ -739,7 +739,19 @@ async def internal_exception_handler(request: Request, exc: Exception): else None ), ) - # SD WebUI API + self._router.add_api_route( + "/v1/images/edits", + self.create_image_edits, + methods=["POST"], + response_model=ImageList, + dependencies=( + [Security(self._auth_service, scopes=["models:read"])] + if self.is_authenticated() + else None + ), + ) + + # SD WebUI API self._router.add_api_route( "/sdapi/v1/options", self.sdapi_options, @@ -2290,6 +2302,255 @@ async def create_ocr( self.handle_request_limit_error(e) raise HTTPException(status_code=500, detail=str(e)) + async def create_image_edits( + self, + request: Request, + prompt: str = Form(...), + mask: Optional[UploadFile] = File(None, media_type="application/octet-stream"), + model: Optional[str] = Form(None), + n: Optional[int] = Form(1), + size: Optional[str] = Form("1024x1024"), + response_format: Optional[str] = Form("url"), + stream: Optional[bool] = Form(False), + ) -> Response: + """OpenAI-compatible image edit endpoint.""" + import io + + # Parse multipart form data to handle files + form = await request.form() + files = {} + + # Extract all uploaded files + for key, value in form.items(): + if hasattr(value, 'filename') and value.filename: + if key not in files: + files[key] = [] + files[key].append(value) + + # Get image files - OpenAI client can send multiple 'image' fields + image_files = files.get('image', []) + if not image_files: + raise HTTPException(status_code=400, detail="At least one image file is required") + + if not prompt: + raise HTTPException(status_code=400, detail="Prompt is required") + + if len(prompt) > 1000: + raise HTTPException( + status_code=400, detail="Prompt must be less than 1000 characters" + ) + + # Validate size format + valid_sizes = ["256x256", "512x512", "1024x1024"] + if size not in valid_sizes: + raise HTTPException( + status_code=400, detail=f"Size must be one of {valid_sizes}" + ) + + # Validate response format + if response_format not in ["url", "b64_json"]: + raise HTTPException( + status_code=400, detail="response_format must be 'url' or 'b64_json'" + ) + + # Convert size format from "1024x1024" to "1024*1024" for internal processing + internal_size = size.replace("x", "*") + + # Get default model if not specified + if not model: + try: + models = await (await self._get_supervisor_ref()).list_models() + image_models = [ + name + for name, info in models.items() + if info["model_type"] == "image" + and info.get("model_ability", []) + and ( + "image2image" in info["model_ability"] + or "inpainting" in info["model_ability"] + ) + ] + if not image_models: + raise HTTPException( + status_code=400, detail="No available image models found" + ) + model = image_models[0] + except Exception as e: + logger.error(f"Failed to get available models: {e}", exc_info=True) + raise HTTPException( + status_code=500, detail="Failed to get available models" + ) + + model_uid = model + try: + model_ref = await (await self._get_supervisor_ref()).get_model(model_uid) + except ValueError as ve: + logger.error(str(ve), exc_info=True) + await self._report_error_event(model_uid, str(ve)) + raise HTTPException(status_code=400, detail=str(ve)) + except Exception as e: + logger.error(e, exc_info=True) + await self._report_error_event(model_uid, str(e)) + raise HTTPException(status_code=500, detail=str(e)) + + request_id = None + try: + self._add_running_task(request_id) + + # Handle streaming if requested + if stream: + return EventSourceResponse(self._stream_image_edit( + model_ref, image_files, mask, prompt, internal_size, response_format, n + )) + + # Read and process all images + images = [] + for img in image_files: + image_content = await img.read() + image_file = io.BytesIO(image_content) + pil_image = Image.open(image_file) + images.append(pil_image) + + # Use the first image as primary, others as reference + primary_image = images[0] + reference_images = images[1:] if len(images) > 1 else [] + + # Prepare model parameters + model_params = { + "prompt": prompt, + "n": n or 1, + "size": internal_size, + "response_format": response_format, + "denoising_strength": 0.75, # Default strength for image editing + "reference_images": reference_images, # Pass reference images + } + + # Generate the image + if mask: + # Use inpainting for masked edits + mask_content = await mask.read() + mask_image = Image.open(io.BytesIO(mask_content)) + result = await model_ref.inpainting( + image=primary_image, + mask_image=mask_image, + **model_params, + ) + else: + # Use image-to-image for general edits + result = await model_ref.image_to_image(image=primary_image, **model_params) + + # Return the result directly (should be ImageList format) + return Response(content=result, media_type="application/json") + + except asyncio.CancelledError: + err_str = f"The request has been cancelled: {request_id}" + logger.error(err_str) + await self._report_error_event(model_uid, err_str) + raise HTTPException(status_code=409, detail=err_str) + except Exception as e: + e = await self._get_model_last_error(model_ref.uid, e) + logger.error(e, exc_info=True) + await self._report_error_event(model_uid, str(e)) + self.handle_request_limit_error(e) + raise HTTPException(status_code=500, detail=str(e)) + + async def _stream_image_edit(self, model_ref, images, mask, prompt, size, response_format, n): + """Stream image editing progress and results""" + import io + import json + from datetime import datetime + + try: + # Send start event + yield { + "event": "start", + "data": json.dumps({ + "type": "image_edit_started", + "timestamp": datetime.now().isoformat(), + "prompt": prompt, + "image_count": len(images) + }) + } + + # Read and process all images + image_objects = [] + for img in images: + image_content = await img.read() + image_file = io.BytesIO(image_content) + pil_image = Image.open(image_file) + image_objects.append(pil_image) + + # Use the first image as primary, others as reference + primary_image = image_objects[0] + reference_images = image_objects[1:] if len(image_objects) > 1 else [] + + # Send processing event + yield { + "event": "processing", + "data": json.dumps({ + "type": "images_loaded", + "timestamp": datetime.now().isoformat(), + "primary_image_size": primary_image.size, + "reference_images_count": len(reference_images) + }) + } + + # Prepare model parameters + model_params = { + "prompt": prompt, + "n": n or 1, + "size": size, + "response_format": response_format, + "denoising_strength": 0.75, + "reference_images": reference_images, + } + + # Generate the image + if mask: + mask_content = await mask.read() + mask_image = Image.open(io.BytesIO(mask_content)) + yield { + "event": "processing", + "data": json.dumps({ + "type": "mask_loaded", + "timestamp": datetime.now().isoformat(), + "mask_size": mask_image.size + }) + } + result = await model_ref.inpainting( + image=primary_image, + mask_image=mask_image, + **model_params, + ) + else: + yield { + "event": "processing", + "data": json.dumps({ + "type": "starting_generation", + "timestamp": datetime.now().isoformat() + }) + } + result = await model_ref.image_to_image(image=primary_image, **model_params) + + # Parse the result and send final event in OpenAI format + result_data = json.loads(result) + + # Send completion event with OpenAI-compatible format + yield { + "event": "complete", + "data": json.dumps(result_data) # Direct send the result in OpenAI format + } + + except Exception as e: + yield { + "event": "error", + "data": json.dumps({ + "type": "image_edit_error", + "timestamp": datetime.now().isoformat(), + "error": str(e) + }) + } + async def create_flexible_infer(self, request: Request) -> Response: payload = await request.json() From cca906c01d06558b0e86f68639d40e92d33386cd Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Thu, 25 Sep 2025 17:51:29 +0800 Subject: [PATCH 02/18] support openai images/edits api(single picture) --- xinference/api/restful_api.py | 104 +++++++++++++++++++++------------- 1 file changed, 66 insertions(+), 38 deletions(-) diff --git a/xinference/api/restful_api.py b/xinference/api/restful_api.py index 412d773929..d3ef3e69e4 100644 --- a/xinference/api/restful_api.py +++ b/xinference/api/restful_api.py @@ -751,7 +751,7 @@ async def internal_exception_handler(request: Request, exc: Exception): ), ) - # SD WebUI API + # SD WebUI API self._router.add_api_route( "/sdapi/v1/options", self.sdapi_options, @@ -2318,19 +2318,21 @@ async def create_image_edits( # Parse multipart form data to handle files form = await request.form() - files = {} + files: dict[str, list] = {} # Extract all uploaded files for key, value in form.items(): - if hasattr(value, 'filename') and value.filename: + if hasattr(value, "filename") and value.filename: if key not in files: files[key] = [] files[key].append(value) # Get image files - OpenAI client can send multiple 'image' fields - image_files = files.get('image', []) + image_files = files.get("image", []) if not image_files: - raise HTTPException(status_code=400, detail="At least one image file is required") + raise HTTPException( + status_code=400, detail="At least one image file is required" + ) if not prompt: raise HTTPException(status_code=400, detail="Prompt is required") @@ -2399,9 +2401,17 @@ async def create_image_edits( # Handle streaming if requested if stream: - return EventSourceResponse(self._stream_image_edit( - model_ref, image_files, mask, prompt, internal_size, response_format, n - )) + return EventSourceResponse( + self._stream_image_edit( + model_ref, + image_files, + mask, + prompt, + internal_size, + response_format, + n, + ) + ) # Read and process all images images = [] @@ -2437,7 +2447,9 @@ async def create_image_edits( ) else: # Use image-to-image for general edits - result = await model_ref.image_to_image(image=primary_image, **model_params) + result = await model_ref.image_to_image( + image=primary_image, **model_params + ) # Return the result directly (should be ImageList format) return Response(content=result, media_type="application/json") @@ -2454,7 +2466,9 @@ async def create_image_edits( self.handle_request_limit_error(e) raise HTTPException(status_code=500, detail=str(e)) - async def _stream_image_edit(self, model_ref, images, mask, prompt, size, response_format, n): + async def _stream_image_edit( + self, model_ref, images, mask, prompt, size, response_format, n + ): """Stream image editing progress and results""" import io import json @@ -2464,12 +2478,14 @@ async def _stream_image_edit(self, model_ref, images, mask, prompt, size, respon # Send start event yield { "event": "start", - "data": json.dumps({ - "type": "image_edit_started", - "timestamp": datetime.now().isoformat(), - "prompt": prompt, - "image_count": len(images) - }) + "data": json.dumps( + { + "type": "image_edit_started", + "timestamp": datetime.now().isoformat(), + "prompt": prompt, + "image_count": len(images), + } + ), } # Read and process all images @@ -2487,12 +2503,14 @@ async def _stream_image_edit(self, model_ref, images, mask, prompt, size, respon # Send processing event yield { "event": "processing", - "data": json.dumps({ - "type": "images_loaded", - "timestamp": datetime.now().isoformat(), - "primary_image_size": primary_image.size, - "reference_images_count": len(reference_images) - }) + "data": json.dumps( + { + "type": "images_loaded", + "timestamp": datetime.now().isoformat(), + "primary_image_size": primary_image.size, + "reference_images_count": len(reference_images), + } + ), } # Prepare model parameters @@ -2511,11 +2529,13 @@ async def _stream_image_edit(self, model_ref, images, mask, prompt, size, respon mask_image = Image.open(io.BytesIO(mask_content)) yield { "event": "processing", - "data": json.dumps({ - "type": "mask_loaded", - "timestamp": datetime.now().isoformat(), - "mask_size": mask_image.size - }) + "data": json.dumps( + { + "type": "mask_loaded", + "timestamp": datetime.now().isoformat(), + "mask_size": mask_image.size, + } + ), } result = await model_ref.inpainting( image=primary_image, @@ -2525,12 +2545,16 @@ async def _stream_image_edit(self, model_ref, images, mask, prompt, size, respon else: yield { "event": "processing", - "data": json.dumps({ - "type": "starting_generation", - "timestamp": datetime.now().isoformat() - }) + "data": json.dumps( + { + "type": "starting_generation", + "timestamp": datetime.now().isoformat(), + } + ), } - result = await model_ref.image_to_image(image=primary_image, **model_params) + result = await model_ref.image_to_image( + image=primary_image, **model_params + ) # Parse the result and send final event in OpenAI format result_data = json.loads(result) @@ -2538,17 +2562,21 @@ async def _stream_image_edit(self, model_ref, images, mask, prompt, size, respon # Send completion event with OpenAI-compatible format yield { "event": "complete", - "data": json.dumps(result_data) # Direct send the result in OpenAI format + "data": json.dumps( + result_data + ), # Direct send the result in OpenAI format } except Exception as e: yield { "event": "error", - "data": json.dumps({ - "type": "image_edit_error", - "timestamp": datetime.now().isoformat(), - "error": str(e) - }) + "data": json.dumps( + { + "type": "image_edit_error", + "timestamp": datetime.now().isoformat(), + "error": str(e), + } + ), } async def create_flexible_infer(self, request: Request) -> Response: From 6bdb0ab8a1a8fbc399e1784d1398645eacaedbc1 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Fri, 26 Sep 2025 17:07:18 +0800 Subject: [PATCH 03/18] support openai images/edits api(single picture) --- xinference/api/restful_api.py | 197 +++++++++++++++++++++++++++++----- 1 file changed, 171 insertions(+), 26 deletions(-) diff --git a/xinference/api/restful_api.py b/xinference/api/restful_api.py index d3ef3e69e4..f0b92ac216 100644 --- a/xinference/api/restful_api.py +++ b/xinference/api/restful_api.py @@ -2317,19 +2317,72 @@ async def create_image_edits( import io # Parse multipart form data to handle files - form = await request.form() - files: dict[str, list] = {} + content_type = request.headers.get("content-type", "") + + if "multipart/form-data" in content_type: + # Try manual multipart parsing for better duplicate field handling + try: + image_files = await self._parse_multipart_manual(request) + except Exception as e: + logger.error(f"Manual parsing failed, falling back to FastAPI: {e}") + # Fallback to FastAPI form parsing + form = await request.form() + files: dict[str, list] = {} + for key, value in form.items(): + if hasattr(value, "filename") and value.filename: + if key not in files: + files[key] = [] + files[key].append(value) + + image_files = files.get("image", []) + if not image_files: + image_files = files.get("image[]", []) + if not image_files: + image_files = files.get("images", []) - # Extract all uploaded files - for key, value in form.items(): - if hasattr(value, "filename") and value.filename: - if key not in files: - files[key] = [] - files[key].append(value) + else: + # Fallback to FastAPI form parsing + form = await request.form() + files: dict[str, list] = {} + for key, value in form.items(): + if hasattr(value, "filename") and value.filename: + if key not in files: + files[key] = [] + files[key].append(value) + + image_files = files.get("image", []) + if not image_files: + image_files = files.get("image[]", []) + if not image_files: + image_files = files.get("images", []) + + all_file_keys = [] + if "multipart/form-data" in content_type: + all_file_keys = [f"image[] (x{len(image_files)})"] if image_files else [] + else: + # Fallback to FastAPI form parsing + form = await request.form() + files: dict[str, list] = {} + for key, value in form.items(): + if hasattr(value, "filename") and value.filename: + if key not in files: + files[key] = [] + files[key].append(value) + + # Get image files + image_files = files.get("image", []) + if not image_files: + image_files = files.get("image[]", []) + if not image_files: + image_files = files.get("images", []) + + logger.info(f"Total image files found: {len(image_files)}") - # Get image files - OpenAI client can send multiple 'image' fields - image_files = files.get("image", []) if not image_files: + # Debug: log all received file fields + logger.warning( + f"No image files found. Available file fields: {all_file_keys}" + ) raise HTTPException( status_code=400, detail="At least one image file is required" ) @@ -2399,12 +2452,49 @@ async def create_image_edits( try: self._add_running_task(request_id) + # Read and process all images (needed for both streaming and non-streaming) + images = [] + for i, img in enumerate(image_files): + image_content = await img.read() + image_file = io.BytesIO(image_content) + pil_image = Image.open(image_file) + + # Debug: save the received image for inspection + debug_filename = f"/tmp/received_image_{i}_{pil_image.mode}_{pil_image.size[0]}x{pil_image.size[1]}.png" + pil_image.save(debug_filename) + logger.info(f"Saved received image {i} to {debug_filename}") + + # Convert to RGB format to avoid channel mismatch errors + if pil_image.mode == "RGBA": + logger.info(f"Converting RGBA image {i} to RGB") + # Create white background for RGBA images + background = Image.new("RGB", pil_image.size, (255, 255, 255)) + background.paste(pil_image, mask=pil_image.split()[3]) + pil_image = background + elif pil_image.mode != "RGB": + logger.info(f"Converting {pil_image.mode} image {i} to RGB") + pil_image = pil_image.convert("RGB") + + # Debug: save the converted image + converted_filename = f"/tmp/converted_image_{i}_RGB_{pil_image.size[0]}x{pil_image.size[1]}.png" + pil_image.save(converted_filename) + logger.info(f"Saved converted image {i} to {converted_filename}") + + images.append(pil_image) + + # Debug: log image summary + logger.info(f"Processing {len(images)} images:") + for i, img in enumerate(images): + logger.info( + f" Image {i}: mode={img.mode}, size={img.size}, filename={image_files[i].filename if hasattr(image_files[i], 'filename') else 'unknown'}" + ) + # Handle streaming if requested if stream: return EventSourceResponse( self._stream_image_edit( model_ref, - image_files, + images, # Pass processed images instead of raw files mask, prompt, internal_size, @@ -2413,14 +2503,6 @@ async def create_image_edits( ) ) - # Read and process all images - images = [] - for img in image_files: - image_content = await img.read() - image_file = io.BytesIO(image_content) - pil_image = Image.open(image_file) - images.append(pil_image) - # Use the first image as primary, others as reference primary_image = images[0] reference_images = images[1:] if len(images) > 1 else [] @@ -2433,6 +2515,7 @@ async def create_image_edits( "response_format": response_format, "denoising_strength": 0.75, # Default strength for image editing "reference_images": reference_images, # Pass reference images + "negative_prompt": " ", # Space instead of empty string to prevent filtering } # Generate the image @@ -2466,6 +2549,66 @@ async def create_image_edits( self.handle_request_limit_error(e) raise HTTPException(status_code=500, detail=str(e)) + async def _parse_multipart_manual(self, request: Request): + """Manually parse multipart form data to handle duplicate field names""" + import io + + from multipart.multipart import parse_options_header + + content_type = request.headers.get("content-type", "") + if not content_type: + return [] + + # Parse content type and boundary + content_type, options = parse_options_header(content_type.encode("utf-8")) + if content_type != b"multipart/form-data": + return [] + + boundary = options.get(b"boundary") + if not boundary: + return [] + + # Get the raw body + body = await request.body() + + # Parse multipart data manually + image_files = [] + try: + # Import multipart parser + from multipart.multipart import MultipartParser + + # Parse the multipart data + parser = MultipartParser( + io.BytesIO(body), + boundary.decode("utf-8") if isinstance(boundary, bytes) else boundary, + ) + + for part in parser: + # Check if this part is an image file + field_name = part.name + filename = part.filename or "" + + # Look for image fields with different naming conventions + if field_name in ["image", "image[]", "images"] and filename: + # Create a file-like object from the part data + file_obj = io.BytesIO(part.data) + file_obj.filename = filename + file_obj.content_type = ( + part.content_type or "application/octet-stream" + ) + image_files.append(file_obj) + + logger.info( + f"Manual multipart parsing found {len(image_files)} image files" + ) + + except Exception as e: + logger.error(f"Manual multipart parsing failed: {e}") + # Return empty list to trigger fallback + return [] + + return image_files + async def _stream_image_edit( self, model_ref, images, mask, prompt, size, response_format, n ): @@ -2488,13 +2631,14 @@ async def _stream_image_edit( ), } - # Read and process all images - image_objects = [] - for img in images: - image_content = await img.read() - image_file = io.BytesIO(image_content) - pil_image = Image.open(image_file) - image_objects.append(pil_image) + # Images are already processed in the main method, just use them directly + image_objects = images + logger.info(f"Streaming: Using {len(image_objects)} pre-processed images") + + # Debug: log streaming image summary + logger.info(f"Streaming: Processing {len(image_objects)} images:") + for i, img in enumerate(image_objects): + logger.info(f" Streaming Image {i}: mode={img.mode}, size={img.size}") # Use the first image as primary, others as reference primary_image = image_objects[0] @@ -2521,6 +2665,7 @@ async def _stream_image_edit( "response_format": response_format, "denoising_strength": 0.75, "reference_images": reference_images, + "negative_prompt": " ", # Space instead of empty string to prevent filtering } # Generate the image From 334a8dd5748bf006080d72909f97479f6e38955d Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Sun, 28 Sep 2025 15:53:36 +0800 Subject: [PATCH 04/18] support openai images/edits api --- xinference/api/restful_api.py | 67 ++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/xinference/api/restful_api.py b/xinference/api/restful_api.py index f0b92ac216..7676675a64 100644 --- a/xinference/api/restful_api.py +++ b/xinference/api/restful_api.py @@ -2327,34 +2327,34 @@ async def create_image_edits( logger.error(f"Manual parsing failed, falling back to FastAPI: {e}") # Fallback to FastAPI form parsing form = await request.form() - files: dict[str, list] = {} + multipart_files: dict[str, list] = {} for key, value in form.items(): if hasattr(value, "filename") and value.filename: - if key not in files: - files[key] = [] - files[key].append(value) + if key not in multipart_files: + multipart_files[key] = [] + multipart_files[key].append(value) - image_files = files.get("image", []) + image_files = multipart_files.get("image", []) if not image_files: - image_files = files.get("image[]", []) + image_files = multipart_files.get("image[]", []) if not image_files: - image_files = files.get("images", []) + image_files = multipart_files.get("images", []) else: # Fallback to FastAPI form parsing form = await request.form() - files: dict[str, list] = {} + fallback_files: dict[str, list] = {} for key, value in form.items(): if hasattr(value, "filename") and value.filename: - if key not in files: - files[key] = [] - files[key].append(value) + if key not in fallback_files: + fallback_files[key] = [] + fallback_files[key].append(value) - image_files = files.get("image", []) + image_files = fallback_files.get("image", []) if not image_files: - image_files = files.get("image[]", []) + image_files = fallback_files.get("image[]", []) if not image_files: - image_files = files.get("images", []) + image_files = fallback_files.get("images", []) all_file_keys = [] if "multipart/form-data" in content_type: @@ -2362,19 +2362,19 @@ async def create_image_edits( else: # Fallback to FastAPI form parsing form = await request.form() - files: dict[str, list] = {} + debug_files: dict[str, list] = {} for key, value in form.items(): if hasattr(value, "filename") and value.filename: - if key not in files: - files[key] = [] - files[key].append(value) + if key not in debug_files: + debug_files[key] = [] + debug_files[key].append(value) # Get image files - image_files = files.get("image", []) + image_files = debug_files.get("image", []) if not image_files: - image_files = files.get("image[]", []) + image_files = debug_files.get("image[]", []) if not image_files: - image_files = files.get("images", []) + image_files = debug_files.get("images", []) logger.info(f"Total image files found: {len(image_files)}") @@ -2553,6 +2553,23 @@ async def _parse_multipart_manual(self, request: Request): """Manually parse multipart form data to handle duplicate field names""" import io + class FileWrapper: + """Wrapper for BytesIO to add filename and content_type attributes""" + + def __init__(self, data, filename, content_type="application/octet-stream"): + self._file = io.BytesIO(data) + self.filename = filename + self.content_type = content_type + + def read(self, *args, **kwargs): + return self._file.read(*args, **kwargs) + + def seek(self, *args, **kwargs): + return self._file.seek(*args, **kwargs) + + def tell(self, *args, **kwargs): + return self._file.tell(*args, **kwargs) + from multipart.multipart import parse_options_header content_type = request.headers.get("content-type", "") @@ -2591,10 +2608,10 @@ async def _parse_multipart_manual(self, request: Request): # Look for image fields with different naming conventions if field_name in ["image", "image[]", "images"] and filename: # Create a file-like object from the part data - file_obj = io.BytesIO(part.data) - file_obj.filename = filename - file_obj.content_type = ( - part.content_type or "application/octet-stream" + file_obj = FileWrapper( + part.data, + filename, + part.content_type or "application/octet-stream", ) image_files.append(file_obj) From 4e785cf57bdf55fcca5f0380923ffe2b418bd1ac Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Sun, 28 Sep 2025 17:34:51 +0800 Subject: [PATCH 05/18] modify size problem --- xinference/api/restful_api.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/xinference/api/restful_api.py b/xinference/api/restful_api.py index 7676675a64..7620225b72 100644 --- a/xinference/api/restful_api.py +++ b/xinference/api/restful_api.py @@ -2309,7 +2309,7 @@ async def create_image_edits( mask: Optional[UploadFile] = File(None, media_type="application/octet-stream"), model: Optional[str] = Form(None), n: Optional[int] = Form(1), - size: Optional[str] = Form("1024x1024"), + size: Optional[str] = Form("original"), response_format: Optional[str] = Form("url"), stream: Optional[bool] = Form(False), ) -> Response: @@ -2395,8 +2395,8 @@ async def create_image_edits( status_code=400, detail="Prompt must be less than 1000 characters" ) - # Validate size format - valid_sizes = ["256x256", "512x512", "1024x1024"] + # Validate size format - allow original size or standard sizes + valid_sizes = ["256x256", "512x512", "1024x1024", "original"] if size not in valid_sizes: raise HTTPException( status_code=400, detail=f"Size must be one of {valid_sizes}" @@ -2409,7 +2409,11 @@ async def create_image_edits( ) # Convert size format from "1024x1024" to "1024*1024" for internal processing - internal_size = size.replace("x", "*") + # If "original" is specified, we'll pass empty string to let model use original image size + if size == "original": + internal_size = "" + else: + internal_size = size.replace("x", "*") # Get default model if not specified if not model: @@ -2508,10 +2512,17 @@ async def create_image_edits( reference_images = images[1:] if len(images) > 1 else [] # Prepare model parameters + # If size is "original", use the original image dimensions + if internal_size == "original": + original_width, original_height = primary_image.size + model_size = f"{original_width}*{original_height}" + else: + model_size = internal_size + model_params = { "prompt": prompt, "n": n or 1, - "size": internal_size, + "size": model_size, "response_format": response_format, "denoising_strength": 0.75, # Default strength for image editing "reference_images": reference_images, # Pass reference images From 5f8d6c8f9adad02e223a7caeb5bff91a97d31c34 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Sun, 28 Sep 2025 17:45:22 +0800 Subject: [PATCH 06/18] modify size problem --- xinference/api/restful_api.py | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/xinference/api/restful_api.py b/xinference/api/restful_api.py index 7620225b72..6d7213f962 100644 --- a/xinference/api/restful_api.py +++ b/xinference/api/restful_api.py @@ -2395,12 +2395,6 @@ async def create_image_edits( status_code=400, detail="Prompt must be less than 1000 characters" ) - # Validate size format - allow original size or standard sizes - valid_sizes = ["256x256", "512x512", "1024x1024", "original"] - if size not in valid_sizes: - raise HTTPException( - status_code=400, detail=f"Size must be one of {valid_sizes}" - ) # Validate response format if response_format not in ["url", "b64_json"]: @@ -2409,11 +2403,6 @@ async def create_image_edits( ) # Convert size format from "1024x1024" to "1024*1024" for internal processing - # If "original" is specified, we'll pass empty string to let model use original image size - if size == "original": - internal_size = "" - else: - internal_size = size.replace("x", "*") # Get default model if not specified if not model: @@ -2501,7 +2490,7 @@ async def create_image_edits( images, # Pass processed images instead of raw files mask, prompt, - internal_size, + size.replace("x", "*"), # Convert size format for streaming response_format, n, ) @@ -2512,12 +2501,11 @@ async def create_image_edits( reference_images = images[1:] if len(images) > 1 else [] # Prepare model parameters - # If size is "original", use the original image dimensions - if internal_size == "original": - original_width, original_height = primary_image.size - model_size = f"{original_width}*{original_height}" + # If size is "original", use empty string to let model determine original dimensions + if size == "original": + model_size = "" else: - model_size = internal_size + model_size = size.replace("x", "*") model_params = { "prompt": prompt, @@ -2686,10 +2674,16 @@ async def _stream_image_edit( } # Prepare model parameters + # If size is "original", use empty string to let model determine original dimensions + if size == "original": + model_size = "" + else: + model_size = size + model_params = { "prompt": prompt, "n": n or 1, - "size": size, + "size": model_size, "response_format": response_format, "denoising_strength": 0.75, "reference_images": reference_images, From 89ea51c80bf0079d6eaa8f0fb3d930f6398ef840 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Sun, 28 Sep 2025 18:10:13 +0800 Subject: [PATCH 07/18] modify size problem --- xinference/api/restful_api.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/xinference/api/restful_api.py b/xinference/api/restful_api.py index 6d7213f962..488dd747a4 100644 --- a/xinference/api/restful_api.py +++ b/xinference/api/restful_api.py @@ -2395,15 +2395,12 @@ async def create_image_edits( status_code=400, detail="Prompt must be less than 1000 characters" ) - # Validate response format if response_format not in ["url", "b64_json"]: raise HTTPException( status_code=400, detail="response_format must be 'url' or 'b64_json'" ) - # Convert size format from "1024x1024" to "1024*1024" for internal processing - # Get default model if not specified if not model: try: @@ -2490,7 +2487,7 @@ async def create_image_edits( images, # Pass processed images instead of raw files mask, prompt, - size.replace("x", "*"), # Convert size format for streaming + size.replace("x", "*") if size else "", # Convert size format for streaming response_format, n, ) @@ -2505,7 +2502,7 @@ async def create_image_edits( if size == "original": model_size = "" else: - model_size = size.replace("x", "*") + model_size = size.replace("x", "*") if size else "" model_params = { "prompt": prompt, From 71c981ad2f5e69a546f4e3630621d5682158f5a1 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Sun, 28 Sep 2025 18:12:31 +0800 Subject: [PATCH 08/18] modify size problem --- xinference/api/restful_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/xinference/api/restful_api.py b/xinference/api/restful_api.py index 488dd747a4..9c0a3fab23 100644 --- a/xinference/api/restful_api.py +++ b/xinference/api/restful_api.py @@ -2487,7 +2487,9 @@ async def create_image_edits( images, # Pass processed images instead of raw files mask, prompt, - size.replace("x", "*") if size else "", # Convert size format for streaming + ( + size.replace("x", "*") if size else "" + ), # Convert size format for streaming response_format, n, ) From e435ba18d93cd20e56fcee95dd52a9b5ccc29ac0 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Sun, 28 Sep 2025 18:17:59 +0800 Subject: [PATCH 09/18] modify size problem --- xinference/api/restful_api.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/xinference/api/restful_api.py b/xinference/api/restful_api.py index 9c0a3fab23..576e836df4 100644 --- a/xinference/api/restful_api.py +++ b/xinference/api/restful_api.py @@ -2028,7 +2028,7 @@ async def create_images(self, request: Request) -> Response: ) return Response(content=image_list, media_type="application/json") except asyncio.CancelledError: - err_str = f"The request has been cancelled: {request_id}" + err_str = f"The request has been cancelled: {request_id or 'unknown'}" logger.error(err_str) await self._report_error_event(model_uid, err_str) raise HTTPException(status_code=409, detail=err_str) @@ -2187,7 +2187,7 @@ async def create_variations( ) return Response(content=image_list, media_type="application/json") except asyncio.CancelledError: - err_str = f"The request has been cancelled: {request_id}" + err_str = f"The request has been cancelled: {request_id or 'unknown'}" logger.error(err_str) await self._report_error_event(model_uid, err_str) raise HTTPException(status_code=409, detail=err_str) @@ -2247,7 +2247,7 @@ async def create_inpainting( ) return Response(content=image_list, media_type="application/json") except asyncio.CancelledError: - err_str = f"The request has been cancelled: {request_id}" + err_str = f"The request has been cancelled: {request_id or 'unknown'}" logger.error(err_str) await self._report_error_event(model_uid, err_str) raise HTTPException(status_code=409, detail=err_str) @@ -2291,7 +2291,7 @@ async def create_ocr( ) return Response(content=text, media_type="text/plain") except asyncio.CancelledError: - err_str = f"The request has been cancelled: {request_id}" + err_str = f"The request has been cancelled: {request_id or 'unknown'}" logger.error(err_str) await self._report_error_event(model_uid, err_str) raise HTTPException(status_code=409, detail=err_str) @@ -2536,7 +2536,7 @@ async def create_image_edits( return Response(content=result, media_type="application/json") except asyncio.CancelledError: - err_str = f"The request has been cancelled: {request_id}" + err_str = f"The request has been cancelled: {request_id or 'unknown'}" logger.error(err_str) await self._report_error_event(model_uid, err_str) raise HTTPException(status_code=409, detail=err_str) @@ -2801,7 +2801,7 @@ async def create_videos(self, request: Request) -> Response: ) return Response(content=video_list, media_type="application/json") except asyncio.CancelledError: - err_str = f"The request has been cancelled: {request_id}" + err_str = f"The request has been cancelled: {request_id or 'unknown'}" logger.error(err_str) await self._report_error_event(model_uid, err_str) raise HTTPException(status_code=409, detail=err_str) @@ -2850,7 +2850,7 @@ async def create_videos_from_images( ) return Response(content=video_list, media_type="application/json") except asyncio.CancelledError: - err_str = f"The request has been cancelled: {request_id}" + err_str = f"The request has been cancelled: {request_id or 'unknown'}" logger.error(err_str) await self._report_error_event(model_uid, err_str) raise HTTPException(status_code=409, detail=err_str) @@ -2901,7 +2901,7 @@ async def create_videos_from_first_last_frame( ) return Response(content=video_list, media_type="application/json") except asyncio.CancelledError: - err_str = f"The request has been cancelled: {request_id}" + err_str = f"The request has been cancelled: {request_id or 'unknown'}" logger.error(err_str) await self._report_error_event(model_uid, err_str) raise HTTPException(status_code=409, detail=err_str) From 3593f376ab521f4d71a236ce193a3fe2cd049698 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Sun, 28 Sep 2025 18:24:06 +0800 Subject: [PATCH 10/18] modify size problem --- .github/workflows/python.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml index 30173cc0cb..a307d26801 100644 --- a/.github/workflows/python.yaml +++ b/.github/workflows/python.yaml @@ -45,7 +45,7 @@ jobs: sortPaths: "xinference" configuration: "--check-only --diff --sp setup.cfg" - name: mypy - run: pip install 'mypy<1.16.0' && mypy --install-types --non-interactive xinference + run: pip install 'mypy==1.18.1' && mypy --install-types --non-interactive xinference - name: codespell run: pip install codespell && codespell --ignore-words-list thirdparty xinference - name: Set up Node.js From 16423dc19f42b52f80b203ae59163873c21ac327 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Sun, 28 Sep 2025 18:28:16 +0800 Subject: [PATCH 11/18] modify size problem --- xinference/core/supervisor.py | 4 ++++ xinference/model/llm/transformers/chatglm.py | 1 + 2 files changed, 5 insertions(+) diff --git a/xinference/core/supervisor.py b/xinference/core/supervisor.py index d4183aeb82..adf3d9515e 100644 --- a/xinference/core/supervisor.py +++ b/xinference/core/supervisor.py @@ -1649,6 +1649,7 @@ async def get_model(self, model_uid: str) -> xo.ActorRefType["ModelActor"]: if isinstance(worker_ref, list): # get first worker to fetch information if model across workers worker_ref = worker_ref[0] + assert not isinstance(worker_ref, (list, tuple)), "worker_ref must be a single worker" return await worker_ref.get_model(model_uid=replica_model_uid) @log_async(logger=logger) @@ -1661,6 +1662,7 @@ async def get_model_status(self, replica_model_uid: str): if isinstance(worker_ref, list): # get status from first shard if model has multiple shards across workers worker_ref = worker_ref[0] + assert not isinstance(worker_ref, (list, tuple)), "worker_ref must be a single worker" return await worker_ref.get_model_status(replica_model_uid) @log_async(logger=logger) @@ -1679,6 +1681,7 @@ async def describe_model(self, model_uid: str) -> Dict[str, Any]: if isinstance(worker_ref, list): # get status from first shard if model has multiple shards across workers worker_ref = worker_ref[0] + assert not isinstance(worker_ref, (list, tuple)), "worker_ref must be a single worker" info = await worker_ref.describe_model(model_uid=replica_model_uid) info["replica"] = replica_info.replica return info @@ -1754,6 +1757,7 @@ async def abort_request( if isinstance(worker_ref, list): # get status from first shard if model has multiple shards across workers worker_ref = worker_ref[0] + assert not isinstance(worker_ref, (list, tuple)), "worker_ref must be a single worker" model_ref = await worker_ref.get_model(model_uid=rep_mid) result_info = await model_ref.abort_request(request_id, block_duration) res["msg"] = result_info diff --git a/xinference/model/llm/transformers/chatglm.py b/xinference/model/llm/transformers/chatglm.py index 792e8b9b4c..958eea7a5b 100644 --- a/xinference/model/llm/transformers/chatglm.py +++ b/xinference/model/llm/transformers/chatglm.py @@ -472,6 +472,7 @@ def prepare_batch_inference(self, req_list: List[InferenceRequest]): r.prompt = self._process_messages( r.prompt, tools=tools, tool_choice=tool_choice ) + assert isinstance(r.prompt, list), "r.prompt must be a list after processing" r.full_prompt = self.get_full_context( r.prompt, self.model_family.chat_template, # type: ignore From 5423f7a570f28da41d372fb3a70a2ee628b90585 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Sun, 28 Sep 2025 18:29:48 +0800 Subject: [PATCH 12/18] modify size problem --- xinference/core/supervisor.py | 16 ++++++++++++---- xinference/model/llm/transformers/chatglm.py | 4 +++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/xinference/core/supervisor.py b/xinference/core/supervisor.py index adf3d9515e..81e659efaa 100644 --- a/xinference/core/supervisor.py +++ b/xinference/core/supervisor.py @@ -1649,7 +1649,9 @@ async def get_model(self, model_uid: str) -> xo.ActorRefType["ModelActor"]: if isinstance(worker_ref, list): # get first worker to fetch information if model across workers worker_ref = worker_ref[0] - assert not isinstance(worker_ref, (list, tuple)), "worker_ref must be a single worker" + assert not isinstance( + worker_ref, (list, tuple) + ), "worker_ref must be a single worker" return await worker_ref.get_model(model_uid=replica_model_uid) @log_async(logger=logger) @@ -1662,7 +1664,9 @@ async def get_model_status(self, replica_model_uid: str): if isinstance(worker_ref, list): # get status from first shard if model has multiple shards across workers worker_ref = worker_ref[0] - assert not isinstance(worker_ref, (list, tuple)), "worker_ref must be a single worker" + assert not isinstance( + worker_ref, (list, tuple) + ), "worker_ref must be a single worker" return await worker_ref.get_model_status(replica_model_uid) @log_async(logger=logger) @@ -1681,7 +1685,9 @@ async def describe_model(self, model_uid: str) -> Dict[str, Any]: if isinstance(worker_ref, list): # get status from first shard if model has multiple shards across workers worker_ref = worker_ref[0] - assert not isinstance(worker_ref, (list, tuple)), "worker_ref must be a single worker" + assert not isinstance( + worker_ref, (list, tuple) + ), "worker_ref must be a single worker" info = await worker_ref.describe_model(model_uid=replica_model_uid) info["replica"] = replica_info.replica return info @@ -1757,7 +1763,9 @@ async def abort_request( if isinstance(worker_ref, list): # get status from first shard if model has multiple shards across workers worker_ref = worker_ref[0] - assert not isinstance(worker_ref, (list, tuple)), "worker_ref must be a single worker" + assert not isinstance( + worker_ref, (list, tuple) + ), "worker_ref must be a single worker" model_ref = await worker_ref.get_model(model_uid=rep_mid) result_info = await model_ref.abort_request(request_id, block_duration) res["msg"] = result_info diff --git a/xinference/model/llm/transformers/chatglm.py b/xinference/model/llm/transformers/chatglm.py index 958eea7a5b..52c0e2846e 100644 --- a/xinference/model/llm/transformers/chatglm.py +++ b/xinference/model/llm/transformers/chatglm.py @@ -472,7 +472,9 @@ def prepare_batch_inference(self, req_list: List[InferenceRequest]): r.prompt = self._process_messages( r.prompt, tools=tools, tool_choice=tool_choice ) - assert isinstance(r.prompt, list), "r.prompt must be a list after processing" + assert isinstance( + r.prompt, list + ), "r.prompt must be a list after processing" r.full_prompt = self.get_full_context( r.prompt, self.model_family.chat_template, # type: ignore From a1a4a20be4c26cbc15841913ca24e935742700e3 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Tue, 30 Sep 2025 15:56:06 +0800 Subject: [PATCH 13/18] xinference client --- xinference/api/restful_api.py | 42 ++--- .../client/restful/async_restful_client.py | 146 +++++++++++++++++- xinference/client/restful/restful_client.py | 120 ++++++++++++++ xinference/types.py | 9 ++ 4 files changed, 292 insertions(+), 25 deletions(-) diff --git a/xinference/api/restful_api.py b/xinference/api/restful_api.py index 576e836df4..36fecba06e 100644 --- a/xinference/api/restful_api.py +++ b/xinference/api/restful_api.py @@ -2028,7 +2028,7 @@ async def create_images(self, request: Request) -> Response: ) return Response(content=image_list, media_type="application/json") except asyncio.CancelledError: - err_str = f"The request has been cancelled: {request_id or 'unknown'}" + err_str = f"The request has been cancelled: {request_id}" logger.error(err_str) await self._report_error_event(model_uid, err_str) raise HTTPException(status_code=409, detail=err_str) @@ -2187,7 +2187,7 @@ async def create_variations( ) return Response(content=image_list, media_type="application/json") except asyncio.CancelledError: - err_str = f"The request has been cancelled: {request_id or 'unknown'}" + err_str = f"The request has been cancelled: {request_id}" logger.error(err_str) await self._report_error_event(model_uid, err_str) raise HTTPException(status_code=409, detail=err_str) @@ -2247,7 +2247,7 @@ async def create_inpainting( ) return Response(content=image_list, media_type="application/json") except asyncio.CancelledError: - err_str = f"The request has been cancelled: {request_id or 'unknown'}" + err_str = f"The request has been cancelled: {request_id}" logger.error(err_str) await self._report_error_event(model_uid, err_str) raise HTTPException(status_code=409, detail=err_str) @@ -2291,7 +2291,7 @@ async def create_ocr( ) return Response(content=text, media_type="text/plain") except asyncio.CancelledError: - err_str = f"The request has been cancelled: {request_id or 'unknown'}" + err_str = f"The request has been cancelled: {request_id}" logger.error(err_str) await self._report_error_event(model_uid, err_str) raise HTTPException(status_code=409, detail=err_str) @@ -2322,7 +2322,10 @@ async def create_image_edits( if "multipart/form-data" in content_type: # Try manual multipart parsing for better duplicate field handling try: - image_files = await self._parse_multipart_manual(request) + image_files, manual_mask = await self._parse_multipart_manual(request) + # Use manually parsed mask if available, otherwise keep the original + if manual_mask is not None: + mask = manual_mask except Exception as e: logger.error(f"Manual parsing failed, falling back to FastAPI: {e}") # Fallback to FastAPI form parsing @@ -2387,14 +2390,6 @@ async def create_image_edits( status_code=400, detail="At least one image file is required" ) - if not prompt: - raise HTTPException(status_code=400, detail="Prompt is required") - - if len(prompt) > 1000: - raise HTTPException( - status_code=400, detail="Prompt must be less than 1000 characters" - ) - # Validate response format if response_format not in ["url", "b64_json"]: raise HTTPException( @@ -2572,22 +2567,23 @@ def tell(self, *args, **kwargs): content_type = request.headers.get("content-type", "") if not content_type: - return [] + return [], None # Parse content type and boundary content_type, options = parse_options_header(content_type.encode("utf-8")) if content_type != b"multipart/form-data": - return [] + return [], None boundary = options.get(b"boundary") if not boundary: - return [] + return [], None # Get the raw body body = await request.body() # Parse multipart data manually image_files = [] + mask_file = None try: # Import multipart parser from multipart.multipart import MultipartParser @@ -2612,17 +2608,25 @@ def tell(self, *args, **kwargs): part.content_type or "application/octet-stream", ) image_files.append(file_obj) + elif field_name == "mask" and filename: + # Handle mask file + mask_file = FileWrapper( + part.data, + filename, + part.content_type or "application/octet-stream", + ) + logger.info(f"Manual multipart parsing found mask file: {filename}") logger.info( - f"Manual multipart parsing found {len(image_files)} image files" + f"Manual multipart parsing found {len(image_files)} image files and mask: {mask_file is not None}" ) except Exception as e: logger.error(f"Manual multipart parsing failed: {e}") # Return empty list to trigger fallback - return [] + return [], None - return image_files + return image_files, mask_file async def _stream_image_edit( self, model_ref, images, mask, prompt, size, response_format, n diff --git a/xinference/client/restful/async_restful_client.py b/xinference/client/restful/async_restful_client.py index 5e39291d41..91b6636c96 100644 --- a/xinference/client/restful/async_restful_client.py +++ b/xinference/client/restful/async_restful_client.py @@ -74,8 +74,11 @@ def __init__(self, model_uid: str, base_url: str, auth_headers: Dict): self._model_uid = model_uid self._base_url = base_url self.auth_headers = auth_headers + # 设置更长的默认超时,因为图像编辑需要很长时间 + self.timeout = aiohttp.ClientTimeout(total=1800) # 30分钟默认超时 self.session = aiohttp.ClientSession( - connector=aiohttp.TCPConnector(force_close=True) + connector=aiohttp.TCPConnector(force_close=True), + timeout=self.timeout ) async def close(self): @@ -339,7 +342,7 @@ async def image_to_image( for key, value in params.items(): files.append((key, (None, value))) files.append(("image", ("image", image, "application/octet-stream"))) - response = await self.session.post(url, files=files, headers=self.auth_headers) + response = await self.session.post(url, data=files, headers=self.auth_headers) if response.status != 200: raise RuntimeError( f"Failed to variants the images, detail: {await _get_error_string(response)}" @@ -349,6 +352,134 @@ async def image_to_image( await _release_response(response) return response_data + async def image_edit( + self, + image: Union[Union[str, bytes], List[Union[str, bytes]]], + prompt: str, + mask: Optional[Union[str, bytes]] = None, + n: int = 1, + size: Optional[str] = None, + response_format: str = "url", + **kwargs, + ) -> "ImageList": + """ + Edit image(s) by the input text and optional mask. + + Parameters + ---------- + image: `Union[Union[str, bytes], List[Union[str, bytes]]]` + The input image(s) to edit. Can be: + - Single image: file path, URL, or binary image data + - Multiple images: list of file paths, URLs, or binary image data + When multiple images are provided, the first image is used as the primary image + and subsequent images are used as reference images for better editing results. + prompt: `str` + The prompt or prompts to guide image editing. If not defined, you need to pass `prompt_embeds`. + mask: `Optional[Union[str, bytes]]`, optional + An optional mask image. White pixels in the mask are repainted while black pixels are preserved. + If provided, this will trigger inpainting mode. If not provided, this will trigger image-to-image mode. + n: `int`, defaults to 1 + The number of images to generate per prompt. Must be between 1 and 10. + size: `Optional[str]`, optional + The width*height in pixels of the generated image. If not specified, uses the original image size. + response_format: `str`, defaults to `url` + The format in which the generated images are returned. Must be one of url or b64_json. + **kwargs + Additional parameters to pass to the model. + + Returns + ------- + ImageList + A list of edited image objects. + + Raises + ------ + RuntimeError + If the image editing request fails. + + Examples + -------- + # Single image editing + result = await model.image_edit( + image="path/to/image.png", + prompt="make this image look like a painting" + ) + + # Multiple image editing with reference images + result = await model.image_edit( + image=["primary_image.png", "reference1.jpg", "reference2.png"], + prompt="edit the main image using the style from reference images" + ) + """ + url = f"{self._base_url}/v1/images/edits" + params = { + "model": self._model_uid, + "prompt": prompt, + "n": n, + "size": size, + "response_format": response_format, + "kwargs": json.dumps(kwargs), + } + params = _filter_params(params) + files: List[Any] = [] + for key, value in params.items(): + files.append((key, (None, value))) + + # Handle single image or multiple images + import aiohttp + + data = aiohttp.FormData() + + # Add all parameters as form fields + for key, value in params.items(): + if value is not None: + data.add_field(key, str(value)) + + # Handle single image or multiple images + if isinstance(image, list): + # Multiple images - send as image[] array + for i, img in enumerate(image): + if isinstance(img, str): + # File path - read file content + with open(img, 'rb') as f: + content = f.read() + data.add_field(f"image[]", content, filename=f"image_{i}.png", content_type="image/png") + else: + # Binary data + data.add_field(f"image[]", img, filename=f"image_{i}.png", content_type="image/png") + else: + # Single image + if isinstance(image, str): + # File path - read file content + with open(image, 'rb') as f: + content = f.read() + data.add_field("image", content, filename="image.png", content_type="image/png") + else: + # Binary data + data.add_field("image", image, filename="image.png", content_type="image/png") + + if mask is not None: + if isinstance(mask, str): + # File path - read file content + with open(mask, 'rb') as f: + content = f.read() + data.add_field("mask", content, filename="mask.png", content_type="image/png") + else: + # Binary data + data.add_field("mask", mask, filename="mask.png", content_type="image/png") + + try: + response = await self.session.post(url, data=data, headers=self.auth_headers) + if response.status != 200: + raise RuntimeError( + f"Failed to edit the images, detail: {await _get_error_string(response)}" + ) + + response_data = await response.json() + return response_data + finally: + await _release_response(response) if 'response' in locals() else None + async def inpainting( self, image: Union[str, bytes], @@ -419,7 +550,7 @@ async def inpainting( ("mask_image", mask_image, "application/octet-stream"), ) ) - response = await self.session.post(url, files=files, headers=self.auth_headers) + response = await self.session.post(url, data=files, headers=self.auth_headers) if response.status != 200: raise RuntimeError( f"Failed to inpaint the images, detail: {await _get_error_string(response)}" @@ -440,7 +571,7 @@ async def ocr(self, image: Union[str, bytes], **kwargs): for key, value in params.items(): files.append((key, (None, value))) files.append(("image", ("image", image, "application/octet-stream"))) - response = await self.session.post(url, files=files, headers=self.auth_headers) + response = await self.session.post(url, data=files, headers=self.auth_headers) if response.status != 200: raise RuntimeError( f"Failed to ocr the images, detail: {await _get_error_string(response)}" @@ -530,7 +661,7 @@ async def image_to_video( for key, value in params.items(): files.append((key, (None, value))) files.append(("image", ("image", image, "application/octet-stream"))) - response = await self.session.post(url, files=files, headers=self.auth_headers) + response = await self.session.post(url, data=files, headers=self.auth_headers) if response.status != 200: raise RuntimeError( f"Failed to create the video from image, detail: {await _get_error_string(response)}" @@ -970,8 +1101,11 @@ def __init__(self, base_url, api_key: Optional[str] = None): self.base_url = base_url self._headers: Dict[str, str] = {} self._cluster_authed = False + # 设置更长的默认超时,因为图像编辑需要很长时间 + self.timeout = aiohttp.ClientTimeout(total=1800) # 30分钟默认超时 self.session = aiohttp.ClientSession( - connector=aiohttp.TCPConnector(force_close=True) + connector=aiohttp.TCPConnector(force_close=True), + timeout=self.timeout ) self._check_cluster_authenticated() if api_key is not None and self._cluster_authed: diff --git a/xinference/client/restful/restful_client.py b/xinference/client/restful/restful_client.py index 95a80fee5f..5bbd54507c 100644 --- a/xinference/client/restful/restful_client.py +++ b/xinference/client/restful/restful_client.py @@ -312,6 +312,126 @@ def image_to_image( response_data = response.json() return response_data + def image_edit( + self, + image: Union[Union[str, bytes], List[Union[str, bytes]]], + prompt: str, + mask: Optional[Union[str, bytes]] = None, + n: int = 1, + size: Optional[str] = None, + response_format: str = "url", + **kwargs, + ) -> "ImageList": + """ + Edit image(s) by the input text and optional mask. + + Parameters + ---------- + image: `Union[Union[str, bytes], List[Union[str, bytes]]]` + The input image(s) to edit. Can be: + - Single image: file path, URL, or binary image data + - Multiple images: list of file paths, URLs, or binary image data + When multiple images are provided, the first image is used as the primary image + and subsequent images are used as reference images for better editing results. + prompt: `str` + The prompt or prompts to guide image editing. If not defined, you need to pass `prompt_embeds`. + mask: `Optional[Union[str, bytes]]`, optional + An optional mask image. White pixels in the mask are repainted while black pixels are preserved. + If provided, this will trigger inpainting mode. If not provided, this will trigger image-to-image mode. + n: `int`, defaults to 1 + The number of images to generate per prompt. Must be between 1 and 10. + size: `Optional[str]`, optional + The width*height in pixels of the generated image. If not specified, uses the original image size. + response_format: `str`, defaults to `url` + The format in which the generated images are returned. Must be one of url or b64_json. + **kwargs + Additional parameters to pass to the model. + + Returns + ------- + ImageList + A list of edited image objects. + + Raises + ------ + RuntimeError + If the image editing request fails. + + Examples + -------- + # Single image editing + result = model.image_edit( + image="path/to/image.png", + prompt="make this image look like a painting" + ) + + # Multiple image editing with reference images + result = model.image_edit( + image=["primary_image.png", "reference1.jpg", "reference2.png"], + prompt="edit the main image using the style from reference images" + ) + """ + url = f"{self._base_url}/v1/images/edits" + params = { + "model": self._model_uid, + "prompt": prompt, + "n": n, + "size": size, + "response_format": response_format, + "kwargs": json.dumps(kwargs), + } + files: List[Any] = [] + for key, value in params.items(): + if value is not None: + files.append((key, (None, value))) + + # Handle single image or multiple images using requests format + if isinstance(image, list): + # Multiple images - send as image[] array + for i, img in enumerate(image): + if isinstance(img, str): + # File path - open file + f = open(img, 'rb') + files.append((f"image[]", (f"image_{i}", f, "application/octet-stream"))) + else: + # Binary data + files.append((f"image[]", (f"image_{i}", img, "application/octet-stream"))) + else: + # Single image + if isinstance(image, str): + # File path - open file + f = open(image, 'rb') + files.append(("image", ("image", f, "application/octet-stream"))) + else: + # Binary data + files.append(("image", ("image", image, "application/octet-stream"))) + + if mask is not None: + if isinstance(mask, str): + # File path - open file + f = open(mask, 'rb') + files.append(("mask", ("mask", f, "application/octet-stream"))) + else: + # Binary data + files.append(("mask", ("mask", mask, "application/octet-stream"))) + + try: + response = self.session.post(url, files=files, headers=self.auth_headers) + if response.status_code != 200: + raise RuntimeError( + f"Failed to edit the images, detail: {_get_error_string(response)}" + ) + + response_data = response.json() + return response_data + finally: + # Close all opened files + for file_item in files: + if len(file_item) >= 2 and hasattr(file_item[1], '__len__') and len(file_item[1]) >= 2: + file_obj = file_item[1][1] + if hasattr(file_obj, 'close'): + file_obj.close() + def inpainting( self, image: Union[str, bytes], diff --git a/xinference/types.py b/xinference/types.py index 9e2289613f..99278baabd 100644 --- a/xinference/types.py +++ b/xinference/types.py @@ -47,6 +47,15 @@ class ImageList(TypedDict): data: List[Image] +class ImageEditRequest(TypedDict, total=False): + image: Union[Union[str, bytes], List[Union[str, bytes]]] + mask: Optional[Union[str, bytes]] + prompt: str + n: int + size: Optional[str] + response_format: str + + class SDAPIResult(TypedDict): images: List[str] parameters: dict From 7e72dda1f708bf33ba0d9db96b1db9f21ee2363b Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Tue, 30 Sep 2025 16:04:43 +0800 Subject: [PATCH 14/18] xinference client --- .../client/restful/async_restful_client.py | 151 +++++++++++++++++- 1 file changed, 149 insertions(+), 2 deletions(-) diff --git a/xinference/client/restful/async_restful_client.py b/xinference/client/restful/async_restful_client.py index 91b6636c96..9c5ea598ac 100644 --- a/xinference/client/restful/async_restful_client.py +++ b/xinference/client/restful/async_restful_client.py @@ -352,6 +352,154 @@ async def image_to_image( await _release_response(response) return response_data + async def image_edit( + self, + image: Union[Union[str, bytes], List[Union[str, bytes]]], + prompt: str, + mask: Optional[Union[str, bytes]] = None, + n: int = 1, + size: Optional[str] = None, + response_format: str = "url", + **kwargs, + ) -> "ImageList": + """ + Edit image(s) by the input text and optional mask. + + Parameters + ---------- + image: `Union[Union[str, bytes], List[Union[str, bytes]]]` + The input image(s) to edit. Can be: + - Single image: file path, URL, or binary image data + - Multiple images: list of file paths, URLs, or binary image data + When multiple images are provided, the first image is used as the primary image + and subsequent images are used as reference images for better editing results. + prompt: `str` + The prompt or prompts to guide image editing. If not defined, you need to pass `prompt_embeds`. + mask: `Optional[Union[str, bytes]]`, optional + An optional mask image. White pixels in the mask are repainted while black pixels are preserved. + If provided, this will trigger inpainting mode. If not provided, this will trigger image-to-image mode. + n: `int`, defaults to 1 + The number of images to generate per prompt. Must be between 1 and 10. + size: `Optional[str]`, optional + The width*height in pixels of the generated image. If not specified, uses the original image size. + response_format: `str`, defaults to `url` + The format in which the generated images are returned. Must be one of url or b64_json. + **kwargs + Additional parameters to pass to the model. + + Returns + ------- + ImageList + A list of edited image objects. + + Raises + ------ + RuntimeError + If the image editing request fails. + + Examples + -------- + # Single image editing + result = await model.image_edit( + image="path/to/image.png", + prompt="make this image look like a painting" + ) + + # Multiple image editing with reference images + result = await model.image_edit( + image=["primary_image.png", "reference1.jpg", "reference2.png"], + prompt="edit the main image using the style from reference images" + ) + """ + url = f"{self._base_url}/v1/images/edits" + params = { + "model": self._model_uid, + "prompt": prompt, + "n": n, + "size": size, + "response_format": response_format, + "kwargs": json.dumps(kwargs), + } + params = _filter_params(params) + files: List[Any] = [] + for key, value in params.items(): + files.append((key, (None, value))) + + # Handle single image or multiple images + import aiohttp + + data = aiohttp.FormData() + + # Add all parameters as form fields + for key, value in params.items(): + if value is not None: + data.add_field(key, str(value)) + + # Handle single image or multiple images + if isinstance(image, list): + # Multiple images - send as image[] array + for i, img in enumerate(image): + if isinstance(img, str): + # File path - read file content + with open(img, "rb") as f: + content = f.read() + data.add_field( + f"image[]", + content, + filename=f"image_{i}.png", + content_type="image/png", + ) + else: + # Binary data + data.add_field( + f"image[]", + img, + filename=f"image_{i}.png", + content_type="image/png", + ) + else: + # Single image + if isinstance(image, str): + # File path - read file content + with open(image, "rb") as f: + content = f.read() + data.add_field( + "image", content, filename="image.png", content_type="image/png" + ) + else: + # Binary data + data.add_field( + "image", image, filename="image.png", content_type="image/png" + ) + + if mask is not None: + if isinstance(mask, str): + # File path - read file content + with open(mask, "rb") as f: + content = f.read() + data.add_field( + "mask", content, filename="mask.png", content_type="image/png" + ) + else: + # Binary data + data.add_field( + "mask", mask, filename="mask.png", content_type="image/png" + ) + + try: + response = await self.session.post( + url, data=data, headers=self.auth_headers + ) + if response.status != 200: + raise RuntimeError( + f"Failed to edit the images, detail: {await _get_error_string(response)}" + ) + + response_data = await response.json() + return response_data + finally: + await _release_response(response) if "response" in locals() else None + async def image_edit( self, image: Union[Union[str, bytes], List[Union[str, bytes]]], @@ -1104,8 +1252,7 @@ def __init__(self, base_url, api_key: Optional[str] = None): # 设置更长的默认超时,因为图像编辑需要很长时间 self.timeout = aiohttp.ClientTimeout(total=1800) # 30分钟默认超时 self.session = aiohttp.ClientSession( - connector=aiohttp.TCPConnector(force_close=True), - timeout=self.timeout + connector=aiohttp.TCPConnector(force_close=True), timeout=self.timeout ) self._check_cluster_authenticated() if api_key is not None and self._cluster_authed: From c355268da265af0fb86a15e3f6b594aba984232e Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Tue, 30 Sep 2025 16:26:06 +0800 Subject: [PATCH 15/18] xinference client --- .../client/restful/async_restful_client.py | 129 +----------------- 1 file changed, 1 insertion(+), 128 deletions(-) diff --git a/xinference/client/restful/async_restful_client.py b/xinference/client/restful/async_restful_client.py index 9ba42f2f35..1979608fe8 100644 --- a/xinference/client/restful/async_restful_client.py +++ b/xinference/client/restful/async_restful_client.py @@ -500,134 +500,7 @@ async def image_edit( finally: await _release_response(response) if "response" in locals() else None - async def image_edit( - self, - image: Union[Union[str, bytes], List[Union[str, bytes]]], - prompt: str, - mask: Optional[Union[str, bytes]] = None, - n: int = 1, - size: Optional[str] = None, - response_format: str = "url", - **kwargs, - ) -> "ImageList": - """ - Edit image(s) by the input text and optional mask. - - Parameters - ---------- - image: `Union[Union[str, bytes], List[Union[str, bytes]]]` - The input image(s) to edit. Can be: - - Single image: file path, URL, or binary image data - - Multiple images: list of file paths, URLs, or binary image data - When multiple images are provided, the first image is used as the primary image - and subsequent images are used as reference images for better editing results. - prompt: `str` - The prompt or prompts to guide image editing. If not defined, you need to pass `prompt_embeds`. - mask: `Optional[Union[str, bytes]]`, optional - An optional mask image. White pixels in the mask are repainted while black pixels are preserved. - If provided, this will trigger inpainting mode. If not provided, this will trigger image-to-image mode. - n: `int`, defaults to 1 - The number of images to generate per prompt. Must be between 1 and 10. - size: `Optional[str]`, optional - The width*height in pixels of the generated image. If not specified, uses the original image size. - response_format: `str`, defaults to `url` - The format in which the generated images are returned. Must be one of url or b64_json. - **kwargs - Additional parameters to pass to the model. - - Returns - ------- - ImageList - A list of edited image objects. - - Raises - ------ - RuntimeError - If the image editing request fails. - - Examples - -------- - # Single image editing - result = await model.image_edit( - image="path/to/image.png", - prompt="make this image look like a painting" - ) - - # Multiple image editing with reference images - result = await model.image_edit( - image=["primary_image.png", "reference1.jpg", "reference2.png"], - prompt="edit the main image using the style from reference images" - ) - """ - url = f"{self._base_url}/v1/images/edits" - params = { - "model": self._model_uid, - "prompt": prompt, - "n": n, - "size": size, - "response_format": response_format, - "kwargs": json.dumps(kwargs), - } - params = _filter_params(params) - files: List[Any] = [] - for key, value in params.items(): - files.append((key, (None, value))) - - # Handle single image or multiple images - import aiohttp - - data = aiohttp.FormData() - - # Add all parameters as form fields - for key, value in params.items(): - if value is not None: - data.add_field(key, str(value)) - - # Handle single image or multiple images - if isinstance(image, list): - # Multiple images - send as image[] array - for i, img in enumerate(image): - if isinstance(img, str): - # File path - read file content - with open(img, 'rb') as f: - content = f.read() - data.add_field(f"image[]", content, filename=f"image_{i}.png", content_type="image/png") - else: - # Binary data - data.add_field(f"image[]", img, filename=f"image_{i}.png", content_type="image/png") - else: - # Single image - if isinstance(image, str): - # File path - read file content - with open(image, 'rb') as f: - content = f.read() - data.add_field("image", content, filename="image.png", content_type="image/png") - else: - # Binary data - data.add_field("image", image, filename="image.png", content_type="image/png") - - if mask is not None: - if isinstance(mask, str): - # File path - read file content - with open(mask, 'rb') as f: - content = f.read() - data.add_field("mask", content, filename="mask.png", content_type="image/png") - else: - # Binary data - data.add_field("mask", mask, filename="mask.png", content_type="image/png") - - try: - response = await self.session.post(url, data=data, headers=self.auth_headers) - if response.status != 200: - raise RuntimeError( - f"Failed to edit the images, detail: {await _get_error_string(response)}" - ) - - response_data = await response.json() - return response_data - finally: - await _release_response(response) if 'response' in locals() else None - + async def inpainting( self, image: Union[str, bytes], From 4eca05bbf22d33758cffc2314f46058b3f7e318e Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Tue, 30 Sep 2025 16:27:59 +0800 Subject: [PATCH 16/18] xinference client --- xinference/client/restful/async_restful_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/xinference/client/restful/async_restful_client.py b/xinference/client/restful/async_restful_client.py index 1979608fe8..b38f32f9be 100644 --- a/xinference/client/restful/async_restful_client.py +++ b/xinference/client/restful/async_restful_client.py @@ -500,7 +500,6 @@ async def image_edit( finally: await _release_response(response) if "response" in locals() else None - async def inpainting( self, image: Union[str, bytes], From b688bae4931141cf8b3aab817b4165dc292669f1 Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Tue, 30 Sep 2025 16:30:52 +0800 Subject: [PATCH 17/18] xinference client --- .../client/restful/async_restful_client.py | 3 +-- xinference/client/restful/restful_client.py | 22 +++++++++++++------ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/xinference/client/restful/async_restful_client.py b/xinference/client/restful/async_restful_client.py index b38f32f9be..c64652ed8d 100644 --- a/xinference/client/restful/async_restful_client.py +++ b/xinference/client/restful/async_restful_client.py @@ -77,8 +77,7 @@ def __init__(self, model_uid: str, base_url: str, auth_headers: Dict): # 设置更长的默认超时,因为图像编辑需要很长时间 self.timeout = aiohttp.ClientTimeout(total=1800) # 30分钟默认超时 self.session = aiohttp.ClientSession( - connector=aiohttp.TCPConnector(force_close=True), - timeout=self.timeout + connector=aiohttp.TCPConnector(force_close=True), timeout=self.timeout ) async def close(self): diff --git a/xinference/client/restful/restful_client.py b/xinference/client/restful/restful_client.py index 30b1210621..c5d3ab485f 100644 --- a/xinference/client/restful/restful_client.py +++ b/xinference/client/restful/restful_client.py @@ -408,16 +408,20 @@ def image_edit( for i, img in enumerate(image): if isinstance(img, str): # File path - open file - f = open(img, 'rb') - files.append((f"image[]", (f"image_{i}", f, "application/octet-stream"))) + f = open(img, "rb") + files.append( + (f"image[]", (f"image_{i}", f, "application/octet-stream")) + ) else: # Binary data - files.append((f"image[]", (f"image_{i}", img, "application/octet-stream"))) + files.append( + (f"image[]", (f"image_{i}", img, "application/octet-stream")) + ) else: # Single image if isinstance(image, str): # File path - open file - f = open(image, 'rb') + f = open(image, "rb") files.append(("image", ("image", f, "application/octet-stream"))) else: # Binary data @@ -426,7 +430,7 @@ def image_edit( if mask is not None: if isinstance(mask, str): # File path - open file - f = open(mask, 'rb') + f = open(mask, "rb") files.append(("mask", ("mask", f, "application/octet-stream"))) else: # Binary data @@ -444,9 +448,13 @@ def image_edit( finally: # Close all opened files for file_item in files: - if len(file_item) >= 2 and hasattr(file_item[1], '__len__') and len(file_item[1]) >= 2: + if ( + len(file_item) >= 2 + and hasattr(file_item[1], "__len__") + and len(file_item[1]) >= 2 + ): file_obj = file_item[1][1] - if hasattr(file_obj, 'close'): + if hasattr(file_obj, "close"): file_obj.close() def inpainting( From fa01e7e0f9b6ea969f19e852c274a9f25cd9e63b Mon Sep 17 00:00:00 2001 From: OliverBryant <2713999266@qq.com> Date: Tue, 30 Sep 2025 16:48:33 +0800 Subject: [PATCH 18/18] xinference client --- xinference/model/llm/sglang/core.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/xinference/model/llm/sglang/core.py b/xinference/model/llm/sglang/core.py index 72506ab969..b6f28e86bf 100644 --- a/xinference/model/llm/sglang/core.py +++ b/xinference/model/llm/sglang/core.py @@ -362,9 +362,16 @@ def match_json( def _convert_state_to_completion_chunk( request_id: str, model: str, output_text: str, meta_info: Dict ) -> CompletionChunk: - finish_reason = meta_info.get("finish_reason", None) - if isinstance(finish_reason, dict) and "type" in finish_reason: - finish_reason = finish_reason["type"] + finish_reason_raw = meta_info.get("finish_reason", None) + finish_reason: Optional[str] = None + if isinstance(finish_reason_raw, dict) and "type" in finish_reason_raw: + finish_reason = ( + str(finish_reason_raw["type"]) + if finish_reason_raw["type"] is not None + else None + ) + elif isinstance(finish_reason_raw, str): + finish_reason = finish_reason_raw choices: List[CompletionChoice] = [ CompletionChoice( text=output_text, @@ -392,9 +399,16 @@ def _convert_state_to_completion_chunk( def _convert_state_to_completion( request_id: str, model: str, output_text: str, meta_info: Dict ) -> Completion: - finish_reason = meta_info.get("finish_reason", None) - if isinstance(finish_reason, dict) and "type" in finish_reason: - finish_reason = finish_reason["type"] + finish_reason_raw = meta_info.get("finish_reason", None) + finish_reason: Optional[str] = None + if isinstance(finish_reason_raw, dict) and "type" in finish_reason_raw: + finish_reason = ( + str(finish_reason_raw["type"]) + if finish_reason_raw["type"] is not None + else None + ) + elif isinstance(finish_reason_raw, str): + finish_reason = finish_reason_raw choices = [ CompletionChoice( text=output_text,