From 08d36eb6edf26588e4d6ad3f91199d64c3f00082 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:16:49 -0400 Subject: [PATCH] fix(together_ai): add image generation and image edit transformations for image-to-image models --- litellm/images/main.py | 1 + litellm/llms/together_ai/common_utils.py | 62 +++++ .../llms/together_ai/image_edit/__init__.py | 11 + .../together_ai/image_edit/transformation.py | 164 ++++++++++++ .../together_ai/image_generation/__init__.py | 13 + .../image_generation/transformation.py | 123 +++++++++ ...odel_prices_and_context_window_backup.json | 236 ++++++++++++++---- litellm/utils.py | 12 + model_prices_and_context_window.json | 10 + .../test_litellm/llms/together_ai/__init__.py | 0 .../llms/together_ai/image_edit/__init__.py | 0 ...t_together_ai_image_edit_transformation.py | 182 ++++++++++++++ .../together_ai/image_generation/__init__.py | 0 ...ther_ai_image_generation_transformation.py | 200 +++++++++++++++ 14 files changed, 962 insertions(+), 52 deletions(-) create mode 100644 litellm/llms/together_ai/common_utils.py create mode 100644 litellm/llms/together_ai/image_edit/__init__.py create mode 100644 litellm/llms/together_ai/image_edit/transformation.py create mode 100644 litellm/llms/together_ai/image_generation/__init__.py create mode 100644 litellm/llms/together_ai/image_generation/transformation.py create mode 100644 tests/test_litellm/llms/together_ai/__init__.py create mode 100644 tests/test_litellm/llms/together_ai/image_edit/__init__.py create mode 100644 tests/test_litellm/llms/together_ai/image_edit/test_together_ai_image_edit_transformation.py create mode 100644 tests/test_litellm/llms/together_ai/image_generation/__init__.py create mode 100644 tests/test_litellm/llms/together_ai/image_generation/test_together_ai_image_generation_transformation.py diff --git a/litellm/images/main.py b/litellm/images/main.py index 17ea9aa177b..db59932d234 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -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}") diff --git a/litellm/llms/together_ai/common_utils.py b/litellm/llms/together_ai/common_utils.py new file mode 100644 index 00000000000..984a5879560 --- /dev/null +++ b/litellm/llms/together_ai/common_utils.py @@ -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) + ] diff --git a/litellm/llms/together_ai/image_edit/__init__.py b/litellm/llms/together_ai/image_edit/__init__.py new file mode 100644 index 00000000000..4de1699980c --- /dev/null +++ b/litellm/llms/together_ai/image_edit/__init__.py @@ -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() diff --git a/litellm/llms/together_ai/image_edit/transformation.py b/litellm/llms/together_ai/image_edit/transformation.py new file mode 100644 index 00000000000..df0addd9d55 --- /dev/null +++ b/litellm/llms/together_ai/image_edit/transformation.py @@ -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={}, + ) diff --git a/litellm/llms/together_ai/image_generation/__init__.py b/litellm/llms/together_ai/image_generation/__init__.py new file mode 100644 index 00000000000..51dbb0aa882 --- /dev/null +++ b/litellm/llms/together_ai/image_generation/__init__.py @@ -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() diff --git a/litellm/llms/together_ai/image_generation/transformation.py b/litellm/llms/together_ai/image_generation/transformation.py new file mode 100644 index 00000000000..bf2d3bd5c31 --- /dev/null +++ b/litellm/llms/together_ai/image_generation/transformation.py @@ -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, + ) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index ee996198b28..889f7708acc 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -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": [ { diff --git a/litellm/utils.py b/litellm/utils.py index e19d2b36a52..b4c0c9d66bf 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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 diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index b1a87c444c8..889f7708acc 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -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", diff --git a/tests/test_litellm/llms/together_ai/__init__.py b/tests/test_litellm/llms/together_ai/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/llms/together_ai/image_edit/__init__.py b/tests/test_litellm/llms/together_ai/image_edit/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/llms/together_ai/image_edit/test_together_ai_image_edit_transformation.py b/tests/test_litellm/llms/together_ai/image_edit/test_together_ai_image_edit_transformation.py new file mode 100644 index 00000000000..ff5ef970f87 --- /dev/null +++ b/tests/test_litellm/llms/together_ai/image_edit/test_together_ai_image_edit_transformation.py @@ -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" diff --git a/tests/test_litellm/llms/together_ai/image_generation/__init__.py b/tests/test_litellm/llms/together_ai/image_generation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/llms/together_ai/image_generation/test_together_ai_image_generation_transformation.py b/tests/test_litellm/llms/together_ai/image_generation/test_together_ai_image_generation_transformation.py new file mode 100644 index 00000000000..66326a65ea0 --- /dev/null +++ b/tests/test_litellm/llms/together_ai/image_generation/test_together_ai_image_generation_transformation.py @@ -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"