fix(together_ai): add image generation and image edit transformations for image-to-image models

This commit is contained in:
mateo-berri 2026-07-17 14:16:49 -04:00
parent 215ce9f7c1
commit 08d36eb6ed
14 changed files with 962 additions and 52 deletions

View file

@ -393,6 +393,7 @@ def image_generation(
litellm.LlmProviders.VERTEX_AI,
litellm.LlmProviders.OPENROUTER,
litellm.LlmProviders.DASHSCOPE,
litellm.LlmProviders.TOGETHER_AI,
):
if image_generation_config is None:
raise ValueError(f"image generation config is not supported for {custom_llm_provider}")

View file

@ -0,0 +1,62 @@
import litellm
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.secret_managers.main import get_secret_str
from litellm.types.utils import ImageObject
TOGETHER_AI_DEFAULT_API_BASE = "https://api.together.xyz/v1"
class TogetherAIException(BaseLLMException):
pass
def resolve_together_ai_api_key(api_key: str | None) -> str | None:
return (
api_key
or litellm.api_key
or get_secret_str("TOGETHER_API_KEY")
or get_secret_str("TOGETHER_AI_API_KEY")
or get_secret_str("TOGETHERAI_API_KEY")
or get_secret_str("TOGETHER_AI_TOKEN")
)
def get_together_ai_images_generations_url(api_base: str | None) -> str:
base = (api_base or get_secret_str("TOGETHER_AI_API_BASE") or TOGETHER_AI_DEFAULT_API_BASE).rstrip("/")
if base.endswith("/images/generations"):
return base
return f"{base}/images/generations"
def parse_openai_size_to_width_height(size: str) -> tuple[int, int] | None:
parts = size.lower().split("x")
if len(parts) != 2 or not all(part.isdigit() for part in parts):
return None
return int(parts[0]), int(parts[1])
def map_openai_image_param_to_together_ai(key: str, value: object) -> tuple[tuple[str, object], ...]:
if key == "size" and isinstance(value, str):
dimensions = parse_openai_size_to_width_height(value)
if dimensions is None:
return ()
width, height = dimensions
return (("width", width), ("height", height))
if key == "response_format" and value == "b64_json":
return (("response_format", "base64"),)
return ((key, value),)
def together_ai_image_data_to_image_objects(response_json: dict) -> list[ImageObject]:
data = response_json.get("data")
if not isinstance(data, list):
return []
return [
ImageObject(
url=item.get("url"),
b64_json=item.get("b64_json"),
revised_prompt=None,
)
for item in data
if isinstance(item, dict)
]

View file

@ -0,0 +1,11 @@
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
from .transformation import TogetherAIImageEditConfig
__all__ = [
"TogetherAIImageEditConfig",
]
def get_together_ai_image_edit_config(model: str) -> BaseImageEditConfig:
return TogetherAIImageEditConfig()

View file

@ -0,0 +1,164 @@
import base64
from io import BufferedReader, BytesIO
from typing import TYPE_CHECKING, Any, Union
import httpx
from httpx._types import RequestFiles
from litellm.images.utils import ImageEditRequestUtils
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
from litellm.llms.together_ai.common_utils import (
TogetherAIException,
get_together_ai_images_generations_url,
map_openai_image_param_to_together_ai,
resolve_together_ai_api_key,
together_ai_image_data_to_image_objects,
)
from litellm.types.images.main import ImageEditOptionalRequestParams
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import FileTypes, ImageResponse
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
from ...base_llm.chat.transformation import BaseLLMException as _BaseLLMException
LiteLLMLoggingObj = _LiteLLMLoggingObj
BaseLLMException = _BaseLLMException
else:
LiteLLMLoggingObj = Any
BaseLLMException = Any
class TogetherAIImageEditConfig(BaseImageEditConfig):
def get_supported_openai_params(self, model: str) -> list:
return ["n", "size", "response_format"]
def map_openai_params(
self,
image_edit_optional_params: ImageEditOptionalRequestParams,
model: str,
drop_params: bool,
) -> dict:
return dict(
mapped_item
for key, value in image_edit_optional_params.items()
for mapped_item in map_openai_image_param_to_together_ai(key, value)
)
def validate_environment(
self,
headers: dict,
model: str,
api_key: str | None = None,
litellm_params: dict | None = None,
api_base: str | None = None,
) -> dict:
resolved_api_key = resolve_together_ai_api_key(api_key)
if resolved_api_key is None:
raise TogetherAIException(
message="Together AI API key is not set. Set TOGETHERAI_API_KEY or pass api_key.",
status_code=401,
headers={},
)
return {**headers, "Authorization": f"Bearer {resolved_api_key}"}
def use_multipart_form_data(self) -> bool:
return False
def get_complete_url(
self,
model: str,
api_base: str | None,
litellm_params: dict,
) -> str:
return get_together_ai_images_generations_url(api_base)
def transform_image_edit_request(
self,
model: str,
prompt: str | None,
image: FileTypes | None,
image_edit_optional_request_params: dict,
litellm_params: GenericLiteLLMParams,
headers: dict,
) -> tuple[dict, RequestFiles]:
images = image if isinstance(image, list) else ([image] if image is not None else [])
data_urls = tuple(self._to_data_url(img) for img in images if img is not None)
if len(data_urls) == 0:
raise TogetherAIException(
message="image is required for Together AI image edit requests.",
status_code=400,
headers={},
)
if len(data_urls) > 1:
raise TogetherAIException(
message="Together AI image edit supports a single input image per request.",
status_code=400,
headers={},
)
prompt_params = {"prompt": prompt} if prompt is not None else {}
optional_body_params = {
k: v for k, v in image_edit_optional_request_params.items() if k not in ("extra_headers", "extra_body")
}
extra_body = image_edit_optional_request_params.get("extra_body") or {}
request_body: dict[str, Any] = {
"model": model,
"image_url": data_urls[0],
**prompt_params,
**optional_body_params,
**extra_body,
}
empty_files: RequestFiles = []
return request_body, empty_files
def transform_image_edit_response(
self,
model: str,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
) -> ImageResponse:
try:
response_json = raw_response.json()
except ValueError as e:
raise TogetherAIException(
message=f"Error parsing Together AI image edit response: {e}",
status_code=raw_response.status_code,
headers=raw_response.headers,
)
model_response = ImageResponse()
model_response.data = together_ai_image_data_to_image_objects(response_json)
return model_response
def get_error_class(
self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers]
) -> BaseLLMException:
return TogetherAIException(
message=error_message,
status_code=status_code,
headers=headers,
)
def _to_data_url(self, image: FileTypes) -> str:
mime_type = ImageEditRequestUtils.get_image_content_type(image)
encoded = base64.b64encode(self._read_image_bytes(image)).decode("utf-8")
return f"data:{mime_type};base64,{encoded}"
def _read_image_bytes(self, image: FileTypes) -> bytes:
if isinstance(image, bytes):
return image
if isinstance(image, bytearray):
return bytes(image)
if isinstance(image, (BytesIO, BufferedReader)):
current_pos = image.tell()
image.seek(0)
data = image.read()
image.seek(current_pos)
return data
if isinstance(image, tuple) and len(image) >= 2 and image[1] is not None:
return self._read_image_bytes(image[1])
raise TogetherAIException(
message=f"Unsupported image type for Together AI image edit: {type(image)}",
status_code=400,
headers={},
)

View file

@ -0,0 +1,13 @@
from litellm.llms.base_llm.image_generation.transformation import (
BaseImageGenerationConfig,
)
from .transformation import TogetherAIImageGenerationConfig
__all__ = [
"TogetherAIImageGenerationConfig",
]
def get_together_ai_image_generation_config(model: str) -> BaseImageGenerationConfig:
return TogetherAIImageGenerationConfig()

View file

@ -0,0 +1,123 @@
from typing import TYPE_CHECKING, Any, Union
import httpx
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.image_generation.transformation import (
BaseImageGenerationConfig,
)
from litellm.llms.together_ai.common_utils import (
TogetherAIException,
get_together_ai_images_generations_url,
map_openai_image_param_to_together_ai,
resolve_together_ai_api_key,
together_ai_image_data_to_image_objects,
)
from litellm.types.llms.openai import (
AllMessageValues,
OpenAIImageGenerationOptionalParams,
)
from litellm.types.utils import ImageResponse
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
LiteLLMLoggingObj = _LiteLLMLoggingObj
else:
LiteLLMLoggingObj = Any
class TogetherAIImageGenerationConfig(BaseImageGenerationConfig):
def get_supported_openai_params(self, model: str) -> list[OpenAIImageGenerationOptionalParams]:
return ["n", "size", "response_format"]
def map_openai_params(
self,
non_default_params: dict,
optional_params: dict,
model: str,
drop_params: bool,
) -> dict:
return {
**optional_params,
**dict(
mapped_item
for key, value in non_default_params.items()
for mapped_item in map_openai_image_param_to_together_ai(key, value)
),
}
def get_complete_url(
self,
api_base: str | None,
api_key: str | None,
model: str,
optional_params: dict,
litellm_params: dict,
stream: bool | None = None,
) -> str:
return get_together_ai_images_generations_url(api_base)
def validate_environment(
self,
headers: dict,
model: str,
messages: list[AllMessageValues],
optional_params: dict,
litellm_params: dict,
api_key: str | None = None,
api_base: str | None = None,
) -> dict:
resolved_api_key = resolve_together_ai_api_key(api_key)
if resolved_api_key is None:
raise TogetherAIException(
message="Together AI API key is not set. Set TOGETHERAI_API_KEY or pass api_key.",
status_code=401,
headers={},
)
return {**headers, "Authorization": f"Bearer {resolved_api_key}"}
def transform_image_generation_request(
self,
model: str,
prompt: str,
optional_params: dict,
litellm_params: dict,
headers: dict,
) -> dict:
extra_body = optional_params.get("extra_body") or {}
body_params = {k: v for k, v in optional_params.items() if k not in ("extra_body", "extra_headers")}
return {"model": model, "prompt": prompt, **body_params, **extra_body}
def transform_image_generation_response(
self,
model: str,
raw_response: httpx.Response,
model_response: ImageResponse,
logging_obj: LiteLLMLoggingObj,
request_data: dict,
optional_params: dict,
litellm_params: dict,
encoding: Any,
api_key: str | None = None,
json_mode: bool | None = None,
) -> ImageResponse:
try:
response_json = raw_response.json()
except ValueError as e:
raise TogetherAIException(
message=f"Error parsing Together AI image generation response: {e}",
status_code=raw_response.status_code,
headers=raw_response.headers,
)
model_response.data = together_ai_image_data_to_image_objects(response_json)
return model_response
def get_error_class(
self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers]
) -> BaseLLMException:
return TogetherAIException(
message=error_message,
status_code=status_code,
headers=headers,
)

View file

@ -17381,6 +17381,46 @@
"tpm": 8000000,
"supports_image_size": false
},
"gemini-3-pro-image": {
"input_cost_per_image": 0.0011,
"input_cost_per_token": 2e-06,
"input_cost_per_token_batches": 1e-06,
"litellm_provider": "vertex_ai-language-models",
"max_input_tokens": 65536,
"max_output_tokens": 32768,
"max_tokens": 32768,
"mode": "image_generation",
"output_cost_per_image": 0.134,
"output_cost_per_image_token": 0.00012,
"output_cost_per_token": 1.2e-05,
"output_cost_per_token_batches": 6e-06,
"source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-3-pro-image",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text",
"image"
],
"supports_function_calling": false,
"supports_prompt_caching": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_vision": true,
"supports_web_search": true,
"search_context_cost_per_query": {
"search_context_size_low": 0.014,
"search_context_size_medium": 0.014,
"search_context_size_high": 0.014
},
"web_search_billing_unit": "per_query"
},
"gemini-3-pro-image-preview": {
"input_cost_per_image": 0.0011,
"input_cost_per_token": 2e-06,
@ -17421,6 +17461,44 @@
},
"web_search_billing_unit": "per_query"
},
"gemini-3.1-flash-image": {
"input_cost_per_image": 0.00056,
"input_cost_per_token": 5e-07,
"litellm_provider": "vertex_ai-language-models",
"max_input_tokens": 65536,
"max_output_tokens": 32768,
"max_tokens": 32768,
"mode": "image_generation",
"output_cost_per_image": 0.0672,
"output_cost_per_image_token": 6e-05,
"output_cost_per_token": 3e-06,
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text",
"image"
],
"supports_function_calling": false,
"supports_prompt_caching": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_vision": true,
"supports_web_search": true,
"search_context_cost_per_query": {
"search_context_size_low": 0.014,
"search_context_size_medium": 0.014,
"search_context_size_high": 0.014
},
"web_search_billing_unit": "per_query"
},
"gemini-3.1-flash-image-preview": {
"input_cost_per_image": 0.00056,
"input_cost_per_token": 5e-07,
@ -18866,7 +18944,6 @@
],
"supports_function_calling": false,
"supports_prompt_caching": true,
"supports_reasoning": false,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_vision": true,
@ -18876,7 +18953,8 @@
"search_context_size_medium": 0.014,
"search_context_size_high": 0.014
},
"web_search_billing_unit": "per_query"
"web_search_billing_unit": "per_query",
"supports_reasoning": false
},
"gemini/gemini-3-pro-image-preview": {
"input_cost_per_image": 0.0011,
@ -20312,8 +20390,7 @@
"max_tokens": 16000,
"mode": "chat",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/messages"
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@ -20326,8 +20403,7 @@
"max_tokens": 16000,
"mode": "chat",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/messages"
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@ -20379,8 +20455,7 @@
"max_tokens": 16000,
"mode": "chat",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/messages"
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
@ -23672,7 +23747,7 @@
"input_cost_per_token_batches": 3.75e-07,
"input_cost_per_token_priority": 1.5e-06,
"litellm_provider": "openai",
"max_input_tokens": 272000,
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
@ -23718,7 +23793,7 @@
"input_cost_per_token_batches": 3.75e-07,
"input_cost_per_token_priority": 1.5e-06,
"litellm_provider": "openai",
"max_input_tokens": 272000,
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
@ -23762,7 +23837,7 @@
"input_cost_per_token_flex": 1e-07,
"input_cost_per_token_batches": 1e-07,
"litellm_provider": "openai",
"max_input_tokens": 272000,
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
@ -23805,7 +23880,7 @@
"input_cost_per_token_flex": 1e-07,
"input_cost_per_token_batches": 1e-07,
"litellm_provider": "openai",
"max_input_tokens": 272000,
"max_input_tokens": 1050000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
@ -23845,9 +23920,9 @@
"input_cost_per_token": 1.5e-05,
"input_cost_per_token_batches": 7.5e-06,
"litellm_provider": "openai",
"max_input_tokens": 128000,
"max_output_tokens": 272000,
"max_tokens": 272000,
"max_input_tokens": 400000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
"output_cost_per_token": 0.00012,
"output_cost_per_token_batches": 6e-05,
@ -23881,9 +23956,9 @@
"input_cost_per_token": 1.5e-05,
"input_cost_per_token_batches": 7.5e-06,
"litellm_provider": "openai",
"max_input_tokens": 128000,
"max_output_tokens": 272000,
"max_tokens": 272000,
"max_input_tokens": 400000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "responses",
"output_cost_per_token": 0.00012,
"output_cost_per_token_batches": 6e-05,
@ -31246,6 +31321,22 @@
"supports_reasoning": true,
"supports_tool_choice": true
},
"openrouter/z-ai/glm-5.1": {
"input_cost_per_token": 1.05e-06,
"output_cost_per_token": 3.5e-06,
"cache_read_input_token_cost": 5.25e-07,
"cache_creation_input_token_cost": 0.0,
"litellm_provider": "openrouter",
"max_input_tokens": 202752,
"max_output_tokens": 65535,
"max_tokens": 65535,
"mode": "chat",
"source": "https://openrouter.ai/z-ai/glm-5.1",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
},
"openrouter/minimax/minimax-m2.1": {
"input_cost_per_token": 2.7e-07,
"output_cost_per_token": 1.2e-06,
@ -33759,6 +33850,16 @@
"mode": "chat",
"output_cost_per_token": 1e-07
},
"together_ai/ByteDance-Seed/Seedream-4.0": {
"litellm_provider": "together_ai",
"mode": "image_generation",
"output_cost_per_image": 0.03,
"source": "https://www.together.ai/pricing",
"supported_endpoints": [
"/v1/images/generations",
"/v1/images/edits"
]
},
"together_ai/Qwen/Qwen2.5-72B-Instruct-Turbo": {
"litellm_provider": "together_ai",
"mode": "chat",
@ -39395,6 +39496,21 @@
"supports_tool_choice": true,
"source": "https://docs.z.ai/guides/overview/pricing"
},
"zai/glm-5.1": {
"cache_creation_input_token_cost": 0,
"cache_read_input_token_cost": 2.6e-07,
"input_cost_per_token": 1.4e-06,
"output_cost_per_token": 4.4e-06,
"litellm_provider": "zai",
"max_input_tokens": 200000,
"max_output_tokens": 128000,
"mode": "chat",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
"source": "https://docs.z.ai/guides/overview/pricing"
},
"zai/glm-5-code": {
"cache_creation_input_token_cost": 0,
"cache_read_input_token_cost": 3e-07,
@ -39425,6 +39541,21 @@
"supports_tool_choice": true,
"source": "https://docs.z.ai/guides/overview/pricing"
},
"zai/glm-4.7-flash": {
"cache_creation_input_token_cost": 0,
"cache_read_input_token_cost": 0,
"input_cost_per_token": 0,
"output_cost_per_token": 0,
"litellm_provider": "zai",
"max_input_tokens": 200000,
"max_output_tokens": 128000,
"mode": "chat",
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true,
"source": "https://docs.z.ai/guides/overview/pricing"
},
"zai/glm-4.6": {
"cache_creation_input_token_cost": 0,
"cache_read_input_token_cost": 1.1e-07,
@ -44835,6 +44966,7 @@
"supports_response_schema": true
},
"snowflake/claude-sonnet-4-6": {
"supports_adaptive_thinking": true,
"max_tokens": 16384,
"max_input_tokens": 200000,
"max_output_tokens": 16384,
@ -45253,40 +45385,6 @@
"supports_tool_choice": true,
"supports_vision": false
},
"darkbloom/gemma-4-26b": {
"input_cost_per_token": 3e-08,
"litellm_provider": "darkbloom",
"max_input_tokens": 131072,
"max_output_tokens": 32768,
"max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 1.65e-07,
"source": "https://www.darkbloom.dev/",
"supported_endpoints": [
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_system_messages": true,
"supports_tool_choice": true
},
"darkbloom/gpt-oss-20b": {
"input_cost_per_token": 1.45e-08,
"litellm_provider": "darkbloom",
"max_input_tokens": 131072,
"max_output_tokens": 32768,
"max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 7e-08,
"source": "https://www.darkbloom.dev/",
"supported_endpoints": [
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_system_messages": true,
"supports_tool_choice": true
},
"deepseek/deepseek-v4-pro": {
"cache_creation_input_token_cost": 0.0,
"cache_read_input_token_cost": 3.625e-09,
@ -45442,6 +45540,40 @@
"supports_reasoning": false,
"source": "https://pinstripes.io/pricing"
},
"darkbloom/gemma-4-26b": {
"input_cost_per_token": 3e-08,
"litellm_provider": "darkbloom",
"max_input_tokens": 131072,
"max_output_tokens": 32768,
"max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 1.65e-07,
"source": "https://www.darkbloom.dev/",
"supported_endpoints": [
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_system_messages": true,
"supports_tool_choice": true
},
"darkbloom/gpt-oss-20b": {
"input_cost_per_token": 1.45e-08,
"litellm_provider": "darkbloom",
"max_input_tokens": 131072,
"max_output_tokens": 32768,
"max_tokens": 32768,
"mode": "chat",
"output_cost_per_token": 7e-08,
"source": "https://www.darkbloom.dev/",
"supported_endpoints": [
"/v1/chat/completions"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_system_messages": true,
"supports_tool_choice": true
},
"fallback_generalizations": {
"rules": [
{

View file

@ -8653,6 +8653,12 @@ class ProviderConfigManager:
)
return get_modelscope_image_generation_config(model)
elif LlmProviders.TOGETHER_AI == provider:
from litellm.llms.together_ai.image_generation import (
get_together_ai_image_generation_config,
)
return get_together_ai_image_generation_config(model)
return None
@staticmethod
@ -8800,6 +8806,12 @@ class ProviderConfigManager:
)
return get_openrouter_image_edit_config(model)
elif LlmProviders.TOGETHER_AI == provider:
from litellm.llms.together_ai.image_edit import (
get_together_ai_image_edit_config,
)
return get_together_ai_image_edit_config(model)
return None
@staticmethod

View file

@ -33850,6 +33850,16 @@
"mode": "chat",
"output_cost_per_token": 1e-07
},
"together_ai/ByteDance-Seed/Seedream-4.0": {
"litellm_provider": "together_ai",
"mode": "image_generation",
"output_cost_per_image": 0.03,
"source": "https://www.together.ai/pricing",
"supported_endpoints": [
"/v1/images/generations",
"/v1/images/edits"
]
},
"together_ai/Qwen/Qwen2.5-72B-Instruct-Turbo": {
"litellm_provider": "together_ai",
"mode": "chat",

View file

@ -0,0 +1,182 @@
import base64
from io import BytesIO
from typing import Optional
import httpx
import pytest
import litellm
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.llms.together_ai.common_utils import TogetherAIException
from litellm.llms.together_ai.image_edit.transformation import TogetherAIImageEditConfig
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
SAMPLE_PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
TOGETHER_KEY_ENV_VARS = (
"TOGETHER_API_KEY",
"TOGETHER_AI_API_KEY",
"TOGETHERAI_API_KEY",
"TOGETHER_AI_TOKEN",
)
@pytest.fixture
def together_env(monkeypatch: pytest.MonkeyPatch) -> None:
for var in TOGETHER_KEY_ENV_VARS:
monkeypatch.delenv(var, raising=False)
monkeypatch.delenv("TOGETHER_AI_API_BASE", raising=False)
monkeypatch.setenv("TOGETHERAI_API_KEY", "test-together-key")
monkeypatch.setattr(litellm, "api_key", None)
class RecordingHTTPHandler(HTTPHandler):
def __init__(self, response_json: dict):
super().__init__()
self.response_json = response_json
self.captured_url: Optional[str] = None
self.captured_json: Optional[dict] = None
self.captured_headers: Optional[dict] = None
def post(self, url, data=None, json=None, params=None, headers=None, stream=False, timeout=None, files=None, content=None, logging_obj=None):
self.captured_url = url
self.captured_json = json
self.captured_headers = headers
return httpx.Response(
status_code=200,
json=self.response_json,
request=httpx.Request("POST", url),
)
def test_provider_config_manager_returns_together_ai_image_edit_config():
config = ProviderConfigManager.get_provider_image_edit_config(
model="ByteDance-Seed/Seedream-4.0",
provider=LlmProviders.TOGETHER_AI,
)
assert isinstance(config, TogetherAIImageEditConfig)
def test_use_multipart_form_data_is_false():
assert TogetherAIImageEditConfig().use_multipart_form_data() is False
def test_map_openai_params_maps_size_to_width_height():
mapped = TogetherAIImageEditConfig().map_openai_params(
image_edit_optional_params={"size": "1024x1024", "n": 1},
model="ByteDance-Seed/Seedream-4.0",
drop_params=False,
)
assert mapped == {"width": 1024, "height": 1024, "n": 1}
def test_get_complete_url_defaults_to_together_images_generations():
url = TogetherAIImageEditConfig().get_complete_url(
model="ByteDance-Seed/Seedream-4.0",
api_base=None,
litellm_params={},
)
assert url == "https://api.together.xyz/v1/images/generations"
def test_validate_environment_sets_bearer_auth_from_env(together_env):
headers = TogetherAIImageEditConfig().validate_environment(
headers={},
model="ByteDance-Seed/Seedream-4.0",
)
assert headers["Authorization"] == "Bearer test-together-key"
def test_transform_image_edit_request_builds_json_body_with_data_url():
body, files = TogetherAIImageEditConfig().transform_image_edit_request(
model="ByteDance-Seed/Seedream-4.0",
prompt="Make this look like a watercolor painting",
image=[BytesIO(SAMPLE_PNG_BYTES)],
image_edit_optional_request_params={"width": 1024, "height": 1024},
litellm_params=GenericLiteLLMParams(),
headers={},
)
expected_data_url = f"data:image/png;base64,{base64.b64encode(SAMPLE_PNG_BYTES).decode('utf-8')}"
assert body == {
"model": "ByteDance-Seed/Seedream-4.0",
"prompt": "Make this look like a watercolor painting",
"image_url": expected_data_url,
"width": 1024,
"height": 1024,
}
assert files == []
def test_transform_image_edit_request_rejects_multiple_images():
with pytest.raises(TogetherAIException):
TogetherAIImageEditConfig().transform_image_edit_request(
model="ByteDance-Seed/Seedream-4.0",
prompt="edit",
image=[BytesIO(SAMPLE_PNG_BYTES), BytesIO(SAMPLE_PNG_BYTES)],
image_edit_optional_request_params={},
litellm_params=GenericLiteLLMParams(),
headers={},
)
def test_transform_image_edit_request_rejects_missing_image():
with pytest.raises(TogetherAIException):
TogetherAIImageEditConfig().transform_image_edit_request(
model="ByteDance-Seed/Seedream-4.0",
prompt="edit",
image=None,
image_edit_optional_request_params={},
litellm_params=GenericLiteLLMParams(),
headers={},
)
@pytest.mark.parametrize(
"data_item,expected_url,expected_b64",
[
({"index": 0, "url": "https://api.together.ai/imgproxy/abc.png", "type": "url"}, "https://api.together.ai/imgproxy/abc.png", None),
({"index": 0, "b64_json": "aGVsbG8=", "type": "b64_json"}, None, "aGVsbG8="),
],
)
def test_transform_image_edit_response(data_item, expected_url, expected_b64):
raw_response = httpx.Response(
status_code=200,
json={"id": "abc", "model": "ByteDance-Seed/Seedream-4.0", "object": "list", "data": [data_item]},
request=httpx.Request("POST", "https://api.together.xyz/v1/images/generations"),
)
model_response = TogetherAIImageEditConfig().transform_image_edit_response(
model="ByteDance-Seed/Seedream-4.0",
raw_response=raw_response,
logging_obj=None,
)
assert model_response.data is not None and len(model_response.data) == 1
assert model_response.data[0].url == expected_url
assert model_response.data[0].b64_json == expected_b64
def test_image_edit_no_longer_raises_not_supported(together_env):
client = RecordingHTTPHandler(
response_json={"id": "abc", "data": [{"index": 0, "url": "https://api.together.ai/imgproxy/out.png"}]}
)
response = litellm.image_edit(
model="together_ai/ByteDance-Seed/Seedream-4.0",
image=BytesIO(SAMPLE_PNG_BYTES),
prompt="Make this look like a watercolor painting",
size="1024x1024",
client=client,
)
assert client.captured_url == "https://api.together.xyz/v1/images/generations"
assert client.captured_json is not None
expected_data_url = f"data:image/png;base64,{base64.b64encode(SAMPLE_PNG_BYTES).decode('utf-8')}"
assert client.captured_json["image_url"] == expected_data_url
assert client.captured_json["model"] == "ByteDance-Seed/Seedream-4.0"
assert client.captured_json["prompt"] == "Make this look like a watercolor painting"
assert client.captured_json["width"] == 1024
assert client.captured_json["height"] == 1024
assert "size" not in client.captured_json
assert client.captured_headers is not None
assert client.captured_headers["Authorization"] == "Bearer test-together-key"
assert response.data is not None
assert response.data[0].url == "https://api.together.ai/imgproxy/out.png"

View file

@ -0,0 +1,200 @@
import json
from typing import Optional
import httpx
import pytest
import litellm
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.llms.together_ai.common_utils import TogetherAIException
from litellm.llms.together_ai.image_generation.transformation import (
TogetherAIImageGenerationConfig,
)
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager, get_optional_params_image_gen
TOGETHER_KEY_ENV_VARS = (
"TOGETHER_API_KEY",
"TOGETHER_AI_API_KEY",
"TOGETHERAI_API_KEY",
"TOGETHER_AI_TOKEN",
)
@pytest.fixture
def together_env(monkeypatch: pytest.MonkeyPatch) -> None:
for var in TOGETHER_KEY_ENV_VARS:
monkeypatch.delenv(var, raising=False)
monkeypatch.delenv("TOGETHER_AI_API_BASE", raising=False)
monkeypatch.setenv("TOGETHERAI_API_KEY", "test-together-key")
monkeypatch.setattr(litellm, "api_key", None)
class RecordingHTTPHandler(HTTPHandler):
def __init__(self, response_json: dict):
super().__init__()
self.response_json = response_json
self.captured_url: Optional[str] = None
self.captured_json: Optional[dict] = None
self.captured_headers: Optional[dict] = None
def post(self, url, data=None, json=None, params=None, headers=None, stream=False, timeout=None, files=None, content=None, logging_obj=None):
self.captured_url = url
self.captured_json = json
self.captured_headers = headers
return httpx.Response(
status_code=200,
json=self.response_json,
request=httpx.Request("POST", url),
)
def test_provider_config_manager_returns_together_ai_image_generation_config():
config = ProviderConfigManager.get_provider_image_generation_config(
model="ByteDance-Seed/Seedream-4.0",
provider=LlmProviders.TOGETHER_AI,
)
assert isinstance(config, TogetherAIImageGenerationConfig)
def test_map_openai_params_maps_size_to_width_height():
config = TogetherAIImageGenerationConfig()
mapped = config.map_openai_params(
non_default_params={"size": "1792x1024", "n": 2},
optional_params={},
model="ByteDance-Seed/Seedream-4.0",
drop_params=False,
)
assert mapped == {"width": 1792, "height": 1024, "n": 2}
def test_map_openai_params_maps_b64_json_response_format_to_base64():
config = TogetherAIImageGenerationConfig()
mapped = config.map_openai_params(
non_default_params={"response_format": "b64_json"},
optional_params={},
model="ByteDance-Seed/Seedream-4.0",
drop_params=False,
)
assert mapped == {"response_format": "base64"}
def test_get_optional_params_image_gen_keeps_image_input_params():
config = TogetherAIImageGenerationConfig()
optional_params = get_optional_params_image_gen(
model="ByteDance-Seed/Seedream-4.0",
n=1,
size="1024x1024",
custom_llm_provider="together_ai",
provider_config=config,
image_url="https://example.com/yosemite.png",
steps=28,
)
body = config.transform_image_generation_request(
model="ByteDance-Seed/Seedream-4.0",
prompt="Make this look like a watercolor painting",
optional_params=optional_params,
litellm_params={},
headers={},
)
assert body["image_url"] == "https://example.com/yosemite.png"
assert body["steps"] == 28
assert body["width"] == 1024
assert body["height"] == 1024
assert "size" not in body
assert "extra_body" not in body
def test_get_complete_url_appends_images_generations():
config = TogetherAIImageGenerationConfig()
url = config.get_complete_url(
api_base="https://api.together.xyz/v1",
api_key=None,
model="ByteDance-Seed/Seedream-4.0",
optional_params={},
litellm_params={},
)
assert url == "https://api.together.xyz/v1/images/generations"
def test_validate_environment_sets_bearer_auth_from_env(together_env):
config = TogetherAIImageGenerationConfig()
headers = config.validate_environment(
headers={},
model="ByteDance-Seed/Seedream-4.0",
messages=[],
optional_params={},
litellm_params={},
)
assert headers["Authorization"] == "Bearer test-together-key"
def test_validate_environment_raises_without_key(monkeypatch: pytest.MonkeyPatch):
for var in TOGETHER_KEY_ENV_VARS:
monkeypatch.delenv(var, raising=False)
monkeypatch.setattr(litellm, "api_key", None)
config = TogetherAIImageGenerationConfig()
with pytest.raises(TogetherAIException):
config.validate_environment(
headers={},
model="ByteDance-Seed/Seedream-4.0",
messages=[],
optional_params={},
litellm_params={},
)
@pytest.mark.parametrize(
"data_item,expected_url,expected_b64",
[
({"index": 0, "url": "https://api.together.ai/imgproxy/abc.png", "type": "url"}, "https://api.together.ai/imgproxy/abc.png", None),
({"index": 0, "b64_json": "aGVsbG8=", "type": "b64_json"}, None, "aGVsbG8="),
],
)
def test_transform_image_generation_response(data_item, expected_url, expected_b64):
config = TogetherAIImageGenerationConfig()
raw_response = httpx.Response(
status_code=200,
json={"id": "abc", "model": "ByteDance-Seed/Seedream-4.0", "object": "list", "data": [data_item]},
request=httpx.Request("POST", "https://api.together.xyz/v1/images/generations"),
)
model_response = config.transform_image_generation_response(
model="ByteDance-Seed/Seedream-4.0",
raw_response=raw_response,
model_response=litellm.ImageResponse(),
logging_obj=None,
request_data={},
optional_params={},
litellm_params={},
encoding=None,
)
assert model_response.data is not None and len(model_response.data) == 1
assert model_response.data[0].url == expected_url
assert model_response.data[0].b64_json == expected_b64
def test_image_generation_sends_together_shaped_json_body(together_env):
client = RecordingHTTPHandler(
response_json={"id": "abc", "data": [{"index": 0, "url": "https://api.together.ai/imgproxy/out.png"}]}
)
response = litellm.image_generation(
model="together_ai/ByteDance-Seed/Seedream-4.0",
prompt="Make this look like a watercolor painting",
n=1,
size="1024x1024",
image_url="https://example.com/yosemite.png",
client=client,
)
assert client.captured_url == "https://api.together.xyz/v1/images/generations"
assert client.captured_json == {
"model": "ByteDance-Seed/Seedream-4.0",
"prompt": "Make this look like a watercolor painting",
"n": 1,
"width": 1024,
"height": 1024,
"image_url": "https://example.com/yosemite.png",
}
assert client.captured_headers is not None
assert client.captured_headers["Authorization"] == "Bearer test-together-key"
assert response.data is not None
assert response.data[0].url == "https://api.together.ai/imgproxy/out.png"