From ca68d868e42c31ab7c16ec4c806048862afa0d40 Mon Sep 17 00:00:00 2001 From: andreadimaio Date: Sun, 9 Aug 2026 12:14:58 +0200 Subject: [PATCH] Add ModelGatewayImageService for Model Gateway image generation Adds `ModelGatewayImageService`, a new service that generates images from text prompts via the IBM watsonx.ai Model Gateway. --- README.md | 1 + docs/content/advanced/spi.md | 1 + docs/content/index.mdx | 1 + .../model-gateway/image-generation.md | 207 +++++++ docs/content/services/model-gateway/index.md | 1 + docs/sidebars.ts | 7 +- .../ibm/watsonx/ai/WatsonxJacksonModule.java | 44 ++ .../com/ibm/watsonx/ai/WatsonxService.java | 2 + .../ai/gateway/image/DefaultRestClient.java | 74 +++ .../ModelGatewayImageGenerationRequest.java | 67 +++ .../image/ModelGatewayImageParameters.java | 550 ++++++++++++++++++ .../image/ModelGatewayImageRequest.java | 109 ++++ .../image/ModelGatewayImageResponse.java | 60 ++ .../image/ModelGatewayImageRestClient.java | 50 ++ .../image/ModelGatewayImageService.java | 131 +++++ .../ai/ContextDepedencyInjectionTest.java | 20 +- .../ibm/watsonx/ai/CustomHttpClientTest.java | 52 ++ .../ai/client/CustomRestClientTest.java | 19 + .../CustomModelGatewayImageRestClient.java | 36 ++ .../image/ModelGatewayImageServiceTest.java | 492 ++++++++++++++++ .../ai/it/ModelGatewayImageServiceIT.java | 80 +++ .../watsonx/ai/utils/ServiceLoaderUtils.java | 4 + 22 files changed, 2004 insertions(+), 4 deletions(-) create mode 100644 docs/content/services/model-gateway/image-generation.md create mode 100644 modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/DefaultRestClient.java create mode 100644 modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageGenerationRequest.java create mode 100644 modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageParameters.java create mode 100644 modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageRequest.java create mode 100644 modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageResponse.java create mode 100644 modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageRestClient.java create mode 100644 modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageService.java create mode 100644 modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/client/impl/CustomModelGatewayImageRestClient.java create mode 100644 modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageServiceTest.java create mode 100644 modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/it/ModelGatewayImageServiceIT.java diff --git a/README.md b/README.md index 2eae5f43..dc0540f4 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Proxy layer that routes requests to third-party foundation models (OpenAI, Anthr | **[Chat](https://ibm.github.io/watsonx-ai-java-sdk/services/model-gateway/chat/)** | Synchronous and streaming chat completions to any configured model | | **[Catalog](https://ibm.github.io/watsonx-ai-java-sdk/services/model-gateway/catalog/)** | List all configured models and retrieve individual model details by UUID or alias | | **[Embeddings](https://ibm.github.io/watsonx-ai-java-sdk/services/model-gateway/embeddings/)** | Generate vector embeddings from text using any configured embedding model | +| **[Image Generation](https://ibm.github.io/watsonx-ai-java-sdk/services/model-gateway/image-generation/)** | Generate images from text prompts using any configured image model | ### Document Processing diff --git a/docs/content/advanced/spi.md b/docs/content/advanced/spi.md index eee8abfd..dbb01cbb 100644 --- a/docs/content/advanced/spi.md +++ b/docs/content/advanced/spi.md @@ -36,6 +36,7 @@ Every service delegates HTTP communication to an abstract `WatsonxRestClient`, w | `ModelGatewayService` | `ModelGatewayRestClient` | | `ModelGatewayCatalogService` | `ModelGatewayCatalogRestClient` | | `ModelGatewayEmbeddingService` | `ModelGatewayEmbeddingRestClient` | +| `ModelGatewayImageService` | `ModelGatewayImageRestClient` | | `FileService` | `FileRestClient` | | `BatchService` | `BatchRestClient` | diff --git a/docs/content/index.mdx b/docs/content/index.mdx index e1ec19b8..d10a00c2 100644 --- a/docs/content/index.mdx +++ b/docs/content/index.mdx @@ -73,6 +73,7 @@ Proxy layer that routes requests to third-party foundation models (OpenAI, Anthr | **[Chat](services/model-gateway/chat)** | Synchronous and streaming chat completions to any configured model | | **[Catalog](services/model-gateway/catalog)** | List and retrieve models configured in the gateway | | **[Embeddings](services/model-gateway/embeddings)** | Generate vector embeddings from text using any configured embedding model | +| **[Image Generation](services/model-gateway/image-generation)** | Generate images from text prompts using any configured image model | ### Document processing diff --git a/docs/content/services/model-gateway/image-generation.md b/docs/content/services/model-gateway/image-generation.md new file mode 100644 index 00000000..4fcb3ed1 --- /dev/null +++ b/docs/content/services/model-gateway/image-generation.md @@ -0,0 +1,207 @@ +--- +id: image-generation +title: Image Generation +--- + +# Model Gateway - Image Generation + +The `ModelGatewayImageService` generates images from text prompts using any image model available through the **IBM watsonx.ai Model Gateway** (DALL-E 3, gpt-image-1, and others). Only providers that expose image models can be used here. To see what your gateway actually offers, ask `ModelGatewayCatalogService`, see [Catalog](./catalog/). + +> **Setup required:** The Model Gateway must be installed and configured by an administrator before use. See [Model Gateway Prerequisites](/services/model-gateway#prerequisites). + +## Quick Start + +```java +ModelGatewayImageService service = ModelGatewayImageService.builder() + .baseUrl(CloudRegion.DALLAS) + .apiKey(WATSONX_API_KEY) + .modelId("gpt-image-1") + .build(); + +ModelGatewayImageResponse response = service.generate("A futuristic city at sunset"); +String b64 = response.data().get(0).b64Json(); +``` + +--- + +## Service Configuration + +### Basic Setup + +```java +ModelGatewayImageService service = ModelGatewayImageService.builder() + .baseUrl(CloudRegion.DALLAS) + .apiKey(WATSONX_API_KEY) + .modelId("gpt-image-1") + .build(); +``` + +### Builder Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `apiKey` | String | Conditional | API key for IBM Cloud authentication | +| `authenticator` | Authenticator | Conditional | Custom authentication (alternative to `apiKey`) | +| `baseUrl` | String / CloudRegion | Yes | watsonx.ai ML endpoint | +| `modelId` | String | Yes | Image model identifier (e.g., `"gpt-image-1"`) | +| `timeout` | Duration | No | Request timeout (default: 60 seconds) | +| `logRequests` | Boolean | No | Enable request logging (default: false) | +| `logResponses` | Boolean | No | Enable response logging (default: false) | +| `httpClient` | HttpClient | No | Custom HTTP client | +| `verifySsl` | Boolean | No | SSL certificate verification (default: true) | +| `version` | String | No | API version override | + +> Either `apiKey` or `authenticator` must be provided. + +### On-premises deployments + +`apiKey` configures an IBM Cloud authenticator. On **IBM watsonx.ai software** (on-premises, CP4D) pass a `CP4DAuthenticator` through `authenticator` and use your instance URL as the `baseUrl`. The `CloudRegion` enum does not apply there. See [Authentication](/authentication#cp4d-authentication). + +```java +ModelGatewayImageService service = ModelGatewayImageService.builder() + .baseUrl("https://cpd.example.com") + .authenticator( + CP4DAuthenticator.builder() + .url("https://cpd.example.com") + .username(CP4D_USERNAME) + .apiKey(CP4D_API_KEY) + .build() + ) + .modelId("gpt-image-1") + .build(); +``` + +--- + +## Generating Images + +### From a Prompt String + +```java +ModelGatewayImageResponse response = service.generate("A serene mountain landscape"); +``` + +### With Parameters + +Use `ModelGatewayImageParameters` to configure the optional request options: + +```java +ModelGatewayImageParameters parameters = ModelGatewayImageParameters.builder() + .n(1) + .size(Size.SIZE_1024X1024) + .quality(Quality.HIGH) + .responseFormat(ResponseFormat.B64_JSON) + .style(Style.VIVID) + .outputFormat(OutputFormat.PNG) + .background(Background.TRANSPARENT) + .moderation(Moderation.LOW) + .user("user-123") + .build(); + +ModelGatewayImageResponse response = service.generate("A serene mountain landscape", parameters); +``` + +### With a Request Object + +`ModelGatewayImageRequest` bundles the prompt and the parameters into a single value you can build once and reuse: + +```java +ModelGatewayImageRequest request = ModelGatewayImageRequest.builder() + .prompt("A serene mountain landscape") + .parameters(parameters) + .build(); + +ModelGatewayImageResponse response = service.generate(request); +``` + +### Request Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `prompt` | String | Yes | Text description of the desired image. | +| `parameters` | ModelGatewayImageParameters | No | Optional request options | + +### Image Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `background` | `Background` / String | `auto` | Background transparency: `transparent`, `opaque`, or `auto`. Transparency requires an `outputFormat` that supports it, so `png` or `webp` | +| `moderation` | `Moderation` / String | `auto` | Content moderation level: `low` for less restrictive filtering, or `auto` | +| `n` | Integer | 1 | Number of images to generate, from 1 to 10 | +| `outputCompression` | Integer | 100 | Compression level from 0 to 100, for the `webp` and `jpeg` formats only | +| `outputFormat` | `OutputFormat` / String | `jpeg` | File format: `png`, `jpeg`, `webp`, or `auto` | +| `partialImages` | Integer | 0 | Number of partial images streamed before the final result, from 0 to 3. With 0 the image arrives in a single event | +| `quality` | `Quality` / String | `auto` | Image quality: `auto`, `high`, `medium`, `low`, `hd`, or `standard` | +| `responseFormat` | `ResponseFormat` / String | `url` | Return format: `url` or `b64_json` | +| `size` | `Size` / String | `1024x1024` | Dimensions of the generated image | +| `style` | `Style` / String | `vivid` | Visual style: `vivid` for hyper-real and dramatic images, `natural` for more natural ones | +| `user` | String | | Unique identifier for the end-user, passed through to the upstream provider to help it detect abuse | + +--- + +## Response Fields + +| Field | Type | Description | +|-------|------|-------------| +| `created()` | long | UNIX timestamp in seconds of when the model response was created | +| `data()` | `List` | Generated image objects, between 1 and 10 of them | +| `background()` | String | Background setting used, never `auto` | +| `outputFormat()` | String | Output format used, never `auto` | +| `quality()` | String | Quality level used, never `auto` | +| `size()` | String | Size used, never `auto` | +| `usage()` | `Usage` | Token usage, or `null` if not returned. On OpenAI only `gpt-image-1` reports it | + +### `ImageData` Fields + +| Field | Type | Description | +|-------|------|-------------| +| `url()` | String | Image URL, or `null` when `b64_json` format was requested. Unsupported by `gpt-image-1` | +| `b64Json()` | String | Base64-encoded image data, or `null` when `url` format was used | +| `revisedPrompt()` | String | Revised prompt, if the model modified it. On OpenAI only `dall-e-3` returns it | + +The returned `data()` list is unmodifiable. + +### Response formats + +`responseFormat` decides which of the two `ImageData` fields is populated. With `b64_json` the image bytes travel inline and you decode them yourself: + +```java +byte[] image = Base64.getDecoder().decode(response.data().get(0).b64Json()); +Files.write(Path.of("image.png"), image); +``` + +With `url` the provider stores the image and returns a link to it. On OpenAI that link stays valid for 60 minutes after generation, so download the image before you need it again: + +```java +String url = response.data().get(0).url(); +``` + +The field you did not request comes back `null`, so it also tells you which format the response came back in. Not every model honours the setting: OpenAI supports `responseFormat` only on `dall-e-2` and `dall-e-3`, while `gpt-image-1` always returns Base64. + +### `Usage` Fields + +| Field | Type | Description | +|-------|------|-------------| +| `inputTokens()` | long | Tokens in the input prompt, images and text together | +| `outputTokens()` | long | Output tokens generated by the model | +| `totalTokens()` | long | Total tokens used | +| `inputTokensDetails()` | `InputTokensDetails` | Breakdown by token type | + +### `InputTokensDetails` Fields + +| Field | Type | Description | +|-------|------|-------------| +| `textTokens()` | long | Text tokens in the prompt | +| `imageTokens()` | long | Image tokens in the prompt | + +--- + +## Related Resources + +- [IBM watsonx.ai Model Gateway](https://www.ibm.com/docs/en/watsonx/w-and-w/2.4.x?topic=models-model-gateway) +- [Setting up the Model Gateway in the UI](https://www.ibm.com/docs/en/watsonx/w-and-w/2.4.x?topic=gateway-setting-up-model-in-ui) +- [Managing the Model Gateway](https://www.ibm.com/docs/en/watsonx/w-and-w/2.4.x?topic=gateway-managing-model) +- [Model Gateway Catalog Documentation](./catalog) +- [Model Gateway Chat Documentation](./chat) +- [Model Gateway Embeddings Documentation](./embeddings) +- [Authentication](/authentication) diff --git a/docs/content/services/model-gateway/index.md b/docs/content/services/model-gateway/index.md index b126023e..b92aaca2 100644 --- a/docs/content/services/model-gateway/index.md +++ b/docs/content/services/model-gateway/index.md @@ -68,6 +68,7 @@ The table below lists the Model Gateway operations available in this SDK. | List models | `ModelGatewayCatalogService` | Available | [Catalog](./catalog/) | | Get model | `ModelGatewayCatalogService` | Available | [Catalog](./catalog/) | | Embeddings | `ModelGatewayEmbeddingService` | Available | [Embeddings](./embeddings/) | +| Image generation | `ModelGatewayImageService` | Available | [Image Generation](./image-generation/) | | Audio transcription | - | Not yet implemented | - | --- diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 6cd8f7a3..cb200b8d 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -60,9 +60,10 @@ const sidebars: SidebarsConfig = { link: { type: 'doc', id: 'services/model-gateway/index' }, collapsed: true, items: [ - { type: 'doc', id: 'services/model-gateway/chat', label: 'Chat' }, - { type: 'doc', id: 'services/model-gateway/catalog', label: 'Catalog' }, - { type: 'doc', id: 'services/model-gateway/embeddings', label: 'Embeddings' }, + { type: 'doc', id: 'services/model-gateway/chat', label: 'Chat' }, + { type: 'doc', id: 'services/model-gateway/catalog', label: 'Catalog' }, + { type: 'doc', id: 'services/model-gateway/embeddings', label: 'Embeddings' }, + { type: 'doc', id: 'services/model-gateway/image-generation', label: 'Image Generation' }, ], }, { type: 'doc', id: 'services/file-service', label: 'File Service' }, diff --git a/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/WatsonxJacksonModule.java b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/WatsonxJacksonModule.java index 34f6bd4d..a7a096a7 100644 --- a/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/WatsonxJacksonModule.java +++ b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/WatsonxJacksonModule.java @@ -55,6 +55,9 @@ import com.ibm.watsonx.ai.gateway.embedding.ModelGatewayEmbeddingResponse; import com.ibm.watsonx.ai.gateway.embedding.ModelGatewayEmbeddingResponse.Embedding; import com.ibm.watsonx.ai.gateway.embedding.ModelGatewayEmbeddingResponse.Usage; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageResponse; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageResponse.ImageData; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageResponse.InputTokensDetails; import com.ibm.watsonx.ai.textgeneration.Moderation; import com.ibm.watsonx.ai.textgeneration.Moderation.InputRanges; import com.ibm.watsonx.ai.textgeneration.TextGenerationParameters; @@ -105,6 +108,10 @@ public WatsonxJacksonModule() { setMixInAnnotation(TextChatResponse.DetectionResult.class, TextChatResponseDetectionResultMixin.class); // --- Gateway Mixin --- // + setMixInAnnotation(ModelGatewayImageResponse.class, ModelGatewayImageResponseMixin.class); + setMixInAnnotation(ImageData.class, ModelGatewayImageDataMixin.class); + setMixInAnnotation(ModelGatewayImageResponse.Usage.class, ModelGatewayImageUsageMixin.class); + setMixInAnnotation(InputTokensDetails.class, ModelGatewayImageInputTokensDetailsMixin.class); setMixInAnnotation(ModelGatewayEmbeddingResponse.class, ModelGatewayEmbeddingResponseMixin.class); setMixInAnnotation(Embedding.class, ModelGatewayEmbeddingMixin.class); setMixInAnnotation(Usage.class, ModelGatewayEmbeddingUsageMixin.class); @@ -1023,4 +1030,41 @@ public ModelGatewayEmbeddingUsageMixin( @JsonProperty("prompt_tokens") int promptTokens, @JsonProperty("total_tokens") int totalTokens) {} } + + public abstract static class ModelGatewayImageResponseMixin { + @JsonCreator + public ModelGatewayImageResponseMixin( + @JsonProperty("created") long created, + @JsonProperty("data") List data, + @JsonProperty("background") String background, + @JsonProperty("output_format") String outputFormat, + @JsonProperty("quality") String quality, + @JsonProperty("size") String size, + @JsonProperty("usage") ModelGatewayImageResponse.Usage usage) {} + } + + public abstract static class ModelGatewayImageDataMixin { + @JsonCreator + public ModelGatewayImageDataMixin( + @JsonProperty("url") String url, + @JsonProperty("b64_json") String b64Json, + @JsonProperty("revised_prompt") String revisedPrompt) {} + } + + public abstract static class ModelGatewayImageUsageMixin { + @JsonCreator + public ModelGatewayImageUsageMixin( + @JsonProperty("input_tokens") long inputTokens, + @JsonProperty("output_tokens") long outputTokens, + @JsonProperty("total_tokens") long totalTokens, + @JsonProperty("input_tokens_details") InputTokensDetails inputTokensDetails) {} + } + + public abstract static class ModelGatewayImageInputTokensDetailsMixin { + @JsonCreator + public ModelGatewayImageInputTokensDetailsMixin( + @JsonProperty("image_tokens") long imageTokens, + @JsonProperty("text_tokens") long textTokens) {} + } + } diff --git a/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/WatsonxService.java b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/WatsonxService.java index 55a33675..af28db09 100644 --- a/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/WatsonxService.java +++ b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/WatsonxService.java @@ -23,6 +23,7 @@ import com.ibm.watsonx.ai.gateway.catalog.ModelGatewayCatalogService; import com.ibm.watsonx.ai.gateway.chat.ModelGatewayService; import com.ibm.watsonx.ai.gateway.embedding.ModelGatewayEmbeddingService; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageService; import com.ibm.watsonx.ai.rerank.RerankService; import com.ibm.watsonx.ai.textgeneration.TextGenerationService; import com.ibm.watsonx.ai.textprocessing.schema.create.CreateSchemaService; @@ -59,6 +60,7 @@ * @see MergeSchemaService * @see ModelGatewayCatalogService * @see ModelGatewayEmbeddingService + * @see ModelGatewayImageService * @see ModelGatewayService */ public abstract class WatsonxService { diff --git a/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/DefaultRestClient.java b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/DefaultRestClient.java new file mode 100644 index 00000000..73a1fd24 --- /dev/null +++ b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/DefaultRestClient.java @@ -0,0 +1,74 @@ +/* + * Copyright 2025 IBM Corporation + * SPDX-License-Identifier: Apache-2.0 + */ +package com.ibm.watsonx.ai.gateway.image; + +import static com.ibm.watsonx.ai.core.Json.fromJson; +import static com.ibm.watsonx.ai.core.Json.toJson; +import static java.util.Objects.requireNonNull; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse.BodyHandlers; +import com.ibm.watsonx.ai.core.factory.HttpClientFactory; +import com.ibm.watsonx.ai.core.http.SyncHttpClient; +import com.ibm.watsonx.ai.core.http.interceptors.LoggerInterceptor.LogMode; + +/** + * Default implementation of the {@link ModelGatewayImageRestClient} abstract class. + */ +final class DefaultRestClient extends ModelGatewayImageRestClient { + + private final SyncHttpClient syncHttpClient; + + DefaultRestClient(Builder builder) { + super(builder); + requireNonNull(authenticator, "authenticator is mandatory"); + syncHttpClient = HttpClientFactory.createSync(authenticator, httpClient, LogMode.of(logRequests, logResponses)); + } + + @Override + public ModelGatewayImageResponse generate(ModelGatewayImageGenerationRequest request) { + var url = URI.create(baseUrl + "/ml/gateway/v1/images/generations?version=%s".formatted(version)); + + var httpRequest = HttpRequest.newBuilder(url) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .POST(BodyPublishers.ofString(toJson(request))) + .timeout(timeout) + .build(); + + try { + var httpResponse = syncHttpClient.send(httpRequest, BodyHandlers.ofString()); + return fromJson(httpResponse.body(), ModelGatewayImageResponse.class); + } catch (IOException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + /** + * Returns a new {@link Builder} instance. + */ + static Builder builder() { + return new Builder(); + } + + /** + * Builder class for constructing {@link DefaultRestClient} instances with configurable parameters. + */ + public static final class Builder extends ModelGatewayImageRestClient.Builder { + + private Builder() {} + + /** + * Builds a {@link DefaultRestClient} instance using the configured parameters. + * + * @return a new instance of {@link DefaultRestClient} + */ + public DefaultRestClient build() { + return new DefaultRestClient(this); + } + } +} diff --git a/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageGenerationRequest.java b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageGenerationRequest.java new file mode 100644 index 00000000..d348d0e6 --- /dev/null +++ b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageGenerationRequest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2025 IBM Corporation + * SPDX-License-Identifier: Apache-2.0 + */ +package com.ibm.watsonx.ai.gateway.image; + +import static java.util.Objects.requireNonNullElse; + +/** + * Payload request for the Model Gateway image generation endpoint. + * + * @param model the model identifier + * @param prompt the text description of the desired image + * @param background the background transparency of the generated images, or {@code null} if not set + * @param moderation the content moderation level applied to the generated images, or {@code null} if not set + * @param n the number of images to generate, or {@code null} if not set + * @param outputCompression the compression level applied to the generated images, or {@code null} if not set + * @param outputFormat the file format of the generated images, or {@code null} if not set + * @param partialImages the number of partial images streamed before the final result, or {@code null} if not set + * @param quality the quality of the generated images, or {@code null} if not set + * @param responseFormat the format in which the generated images are returned, or {@code null} if not set + * @param size the dimensions of the generated images, or {@code null} if not set + * @param style the visual style of the generated images, or {@code null} if not set + * @param user a unique identifier representing the end-user, or {@code null} if not set + */ +public record ModelGatewayImageGenerationRequest( + String model, + String prompt, + String background, + String moderation, + Integer n, + Integer outputCompression, + String outputFormat, + Integer partialImages, + String quality, + String responseFormat, + String size, + String style, + String user) { + + /** + * Creates the payload for the given model by flattening a request and its parameters. + * + * @param model the model identifier + * @param request the {@link ModelGatewayImageRequest} containing the prompt and optional parameters + * @return a new {@link ModelGatewayImageGenerationRequest} + */ + static ModelGatewayImageGenerationRequest of(String model, ModelGatewayImageRequest request) { + + var parameters = requireNonNullElse(request.parameters(), ModelGatewayImageParameters.builder().build()); + + return new ModelGatewayImageGenerationRequest( + model, + request.prompt(), + parameters.background(), + parameters.moderation(), + parameters.n(), + parameters.outputCompression(), + parameters.outputFormat(), + parameters.partialImages(), + parameters.quality(), + parameters.responseFormat(), + parameters.size(), + parameters.style(), + parameters.user()); + } +} diff --git a/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageParameters.java b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageParameters.java new file mode 100644 index 00000000..5a5002af --- /dev/null +++ b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageParameters.java @@ -0,0 +1,550 @@ +/* + * Copyright 2025 IBM Corporation + * SPDX-License-Identifier: Apache-2.0 + */ +package com.ibm.watsonx.ai.gateway.image; + +import static java.util.Objects.isNull; + +/** + * Parameters specific to the Model Gateway image generation endpoint. + *

+ * Example usage: + * + *

{@code
+ * ModelGatewayImageParameters parameters = ModelGatewayImageParameters.builder()
+ *     .n(1)
+ *     .size(Size.SIZE_1024X1024)
+ *     .responseFormat(ResponseFormat.URL)
+ *     .build();
+ * }
+ * + * @see ModelGatewayImageService + */ +public final class ModelGatewayImageParameters { + + /** + * The background transparency options for generated images. + */ + public enum Background { + TRANSPARENT("transparent"), + OPAQUE("opaque"), + AUTO("auto"); + + private final String value; + + Background(String value) { + this.value = value; + } + + /** + * Returns the string value sent to the API. + * + * @return the API string representation + */ + public String value() { + return value; + } + } + + /** + * The content moderation level applied to the generated images. + */ + public enum Moderation { + LOW("low"), + AUTO("auto"); + + private final String value; + + Moderation(String value) { + this.value = value; + } + + /** + * Returns the string value sent to the API. + * + * @return the API string representation + */ + public String value() { + return value; + } + } + + /** + * The output format of the generated image. + */ + public enum OutputFormat { + PNG("png"), + JPEG("jpeg"), + WEBP("webp"), + AUTO("auto"); + + private final String value; + + OutputFormat(String value) { + this.value = value; + } + + /** + * Returns the string value sent to the API. + * + * @return the API string representation + */ + public String value() { + return value; + } + } + + /** + * The quality of the generated image. + */ + public enum Quality { + AUTO("auto"), + HIGH("high"), + MEDIUM("medium"), + LOW("low"), + HD("hd"), + STANDARD("standard"); + + private final String value; + + Quality(String value) { + this.value = value; + } + + /** + * Returns the string value sent to the API. + * + * @return the API string representation + */ + public String value() { + return value; + } + } + + /** + * The format in which the generated images are returned. + */ + public enum ResponseFormat { + URL("url"), + B64_JSON("b64_json"); + + private final String value; + + ResponseFormat(String value) { + this.value = value; + } + + /** + * Returns the string value sent to the API. + * + * @return the API string representation + */ + public String value() { + return value; + } + } + + /** + * The size of the generated images. + */ + public enum Size { + SIZE_256X256("256x256"), + SIZE_512X512("512x512"), + SIZE_1024X1024("1024x1024"), + SIZE_1536X1024("1536x1024"), + SIZE_1024X1536("1024x1536"), + SIZE_1792X1024("1792x1024"), + SIZE_1024X1792("1024x1792"), + AUTO("auto"); + + private final String value; + + Size(String value) { + this.value = value; + } + + /** + * Returns the string value sent to the API. + * + * @return the API string representation + */ + public String value() { + return value; + } + } + + /** + * The style of the generated images. + */ + public enum Style { + VIVID("vivid"), + NATURAL("natural"); + + private final String value; + + Style(String value) { + this.value = value; + } + + /** + * Returns the string value sent to the API. + * + * @return the API string representation + */ + public String value() { + return value; + } + } + + private final String background; + private final String moderation; + private final Integer n; + private final Integer outputCompression; + private final String outputFormat; + private final Integer partialImages; + private final String quality; + private final String responseFormat; + private final String size; + private final String style; + private final String user; + + private ModelGatewayImageParameters(Builder builder) { + background = builder.background; + moderation = builder.moderation; + n = builder.n; + outputCompression = builder.outputCompression; + outputFormat = builder.outputFormat; + partialImages = builder.partialImages; + quality = builder.quality; + responseFormat = builder.responseFormat; + size = builder.size; + style = builder.style; + user = builder.user; + } + + /** + * Returns the background transparency of the generated images. + * + * @return the background setting, or {@code null} if not set + */ + public String background() { + return background; + } + + /** + * Returns the content moderation level applied to the generated images. + * + * @return the moderation level, or {@code null} if not set + */ + public String moderation() { + return moderation; + } + + /** + * Returns the number of images to generate. + * + * @return the number of images, or {@code null} if not set + */ + public Integer n() { + return n; + } + + /** + * Returns the compression level applied to the generated images. + * + * @return the output compression level (0-100), or {@code null} if not set + */ + public Integer outputCompression() { + return outputCompression; + } + + /** + * Returns the file format of the generated images. + * + * @return the output format, or {@code null} if not set + */ + public String outputFormat() { + return outputFormat; + } + + /** + * Returns the number of partial images streamed before the final result. + * + * @return the number of partial images, or {@code null} if not set + */ + public Integer partialImages() { + return partialImages; + } + + /** + * Returns the quality of the generated images. + * + * @return the image quality, or {@code null} if not set + */ + public String quality() { + return quality; + } + + /** + * Returns the format in which the generated images are returned. + * + * @return the response format, or {@code null} if not set + */ + public String responseFormat() { + return responseFormat; + } + + /** + * Returns the dimensions of the generated images. + * + * @return the image size, or {@code null} if not set + */ + public String size() { + return size; + } + + /** + * Returns the visual style of the generated images. + * + * @return the image style, or {@code null} if not set + */ + public String style() { + return style; + } + + /** + * Returns a unique identifier representing the end-user. + * + * @return the user identifier, or {@code null} if not set + */ + public String user() { + return user; + } + + /** + * Returns a new {@link Builder} instance. + * + * @return a new {@link Builder} + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder class for constructing {@link ModelGatewayImageParameters} instances. + */ + public static final class Builder { + + private String background; + private String moderation; + private Integer n; + private Integer outputCompression; + private String outputFormat; + private Integer partialImages; + private String quality; + private String responseFormat; + private String size; + private String style; + private String user; + + private Builder() {} + + /** + * Sets the background transparency of the generated images using the {@link Background} enum. + * + * @param background the background setting + */ + public Builder background(Background background) { + this.background = isNull(background) ? null : background.value(); + return this; + } + + /** + * Sets the background transparency of the generated images ({@code "transparent"}, {@code "opaque"} or {@code "auto"}). + * + * @param background the background setting + */ + public Builder background(String background) { + this.background = background; + return this; + } + + /** + * Sets the content moderation level applied to the generated images using the {@link Moderation} enum. + * + * @param moderation the moderation level + */ + public Builder moderation(Moderation moderation) { + this.moderation = isNull(moderation) ? null : moderation.value(); + return this; + } + + /** + * Sets the content moderation level applied to the generated images ({@code "low"} or {@code "auto"}). + * + * @param moderation the moderation level + */ + public Builder moderation(String moderation) { + this.moderation = moderation; + return this; + } + + /** + * Sets the number of images to generate (1-10). + * + * @param n the number of images + */ + public Builder n(Integer n) { + this.n = n; + return this; + } + + /** + * Sets the compression level applied to the generated images (0-100, WebP and JPEG only). + * + * @param outputCompression the output compression level + */ + public Builder outputCompression(Integer outputCompression) { + this.outputCompression = outputCompression; + return this; + } + + /** + * Sets the file format of the generated images using the {@link OutputFormat} enum. + * + * @param outputFormat the output format + */ + public Builder outputFormat(OutputFormat outputFormat) { + this.outputFormat = isNull(outputFormat) ? null : outputFormat.value(); + return this; + } + + /** + * Sets the file format of the generated images ({@code "png"}, {@code "jpeg"}, {@code "webp"} or {@code "auto"}). + * + * @param outputFormat the output format + */ + public Builder outputFormat(String outputFormat) { + this.outputFormat = outputFormat; + return this; + } + + /** + * Sets the number of partial images streamed before the final result (0-3). + * + * @param partialImages the number of partial images + */ + public Builder partialImages(Integer partialImages) { + this.partialImages = partialImages; + return this; + } + + /** + * Sets the quality of the generated images using the {@link Quality} enum. + * + * @param quality the image quality + */ + public Builder quality(Quality quality) { + this.quality = isNull(quality) ? null : quality.value(); + return this; + } + + /** + * Sets the quality of the generated images ({@code "auto"}, {@code "high"}, {@code "medium"}, {@code "low"}, {@code "hd"} or + * {@code "standard"}). + * + * @param quality the image quality + */ + public Builder quality(String quality) { + this.quality = quality; + return this; + } + + /** + * Sets the format in which the generated images are returned using the {@link ResponseFormat} enum. + * + * @param responseFormat the response format + */ + public Builder responseFormat(ResponseFormat responseFormat) { + this.responseFormat = isNull(responseFormat) ? null : responseFormat.value(); + return this; + } + + /** + * Sets the format in which the generated images are returned ({@code "url"} or {@code "b64_json"}). + * + * @param responseFormat the response format + */ + public Builder responseFormat(String responseFormat) { + this.responseFormat = responseFormat; + return this; + } + + /** + * Sets the dimensions of the generated images using the {@link Size} enum. + * + * @param size the image size + */ + public Builder size(Size size) { + this.size = isNull(size) ? null : size.value(); + return this; + } + + /** + * Sets the dimensions of the generated images (e.g. {@code "1024x1024"}). + * + * @param size the image size + */ + public Builder size(String size) { + this.size = size; + return this; + } + + /** + * Sets the visual style of the generated images using the {@link Style} enum. + * + * @param style the image style + */ + public Builder style(Style style) { + this.style = isNull(style) ? null : style.value(); + return this; + } + + /** + * Sets the visual style of the generated images ({@code "vivid"} or {@code "natural"}). + * + * @param style the image style + */ + public Builder style(String style) { + this.style = style; + return this; + } + + /** + * Sets a unique identifier representing the end-user. + * + * @param user the user identifier + */ + public Builder user(String user) { + this.user = user; + return this; + } + + /** + * Builds a {@link ModelGatewayImageParameters} instance using the configured parameters. + * + * @return a new instance of {@link ModelGatewayImageParameters} + */ + public ModelGatewayImageParameters build() { + return new ModelGatewayImageParameters(this); + } + } + + @Override + public String toString() { + return "ModelGatewayImageParameters [background=" + background + ", moderation=" + moderation + ", n=" + n + ", outputCompression=" + + outputCompression + ", outputFormat=" + outputFormat + ", partialImages=" + partialImages + ", quality=" + quality + + ", responseFormat=" + responseFormat + ", size=" + size + ", style=" + style + ", user=" + user + "]"; + } +} diff --git a/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageRequest.java b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageRequest.java new file mode 100644 index 00000000..b57c9a11 --- /dev/null +++ b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageRequest.java @@ -0,0 +1,109 @@ +/* + * Copyright 2025 IBM Corporation + * SPDX-License-Identifier: Apache-2.0 + */ +package com.ibm.watsonx.ai.gateway.image; + +/** + * Represents an image generation request for the {@link ModelGatewayImageService}. + *

+ * Example usage: + * + *

{@code
+ * var parameters = ModelGatewayImageParameters.builder()
+ *     .n(1)
+ *     .size(Size.SIZE_1024X1024)
+ *     .responseFormat(ResponseFormat.URL)
+ *     .build();
+ *
+ * ModelGatewayImageRequest request = ModelGatewayImageRequest.builder()
+ *     .prompt("A futuristic city at sunset")
+ *     .parameters(parameters)
+ *     .build();
+ * }
+ * + * @see ModelGatewayImageService + * @see ModelGatewayImageParameters + */ +public final class ModelGatewayImageRequest { + + private final String prompt; + private final ModelGatewayImageParameters parameters; + + private ModelGatewayImageRequest(Builder builder) { + prompt = builder.prompt; + parameters = builder.parameters; + } + + /** + * Returns the text description of the desired image. + * + * @return the prompt, or {@code null} if not set + */ + public String prompt() { + return prompt; + } + + /** + * Returns the image generation parameters. + * + * @return the image generation parameters, or {@code null} if not set + */ + public ModelGatewayImageParameters parameters() { + return parameters; + } + + /** + * Returns a new {@link Builder} instance. + * + * @return a new {@link Builder} + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder class for constructing {@link ModelGatewayImageRequest} instances. + */ + public static final class Builder { + + private String prompt; + private ModelGatewayImageParameters parameters; + + private Builder() {} + + /** + * Sets the text description of the desired image. + * + * @param prompt the prompt + */ + public Builder prompt(String prompt) { + this.prompt = prompt; + return this; + } + + /** + * Sets the parameters controlling the image generation model behavior. + * + * @param parameters a {@link ModelGatewayImageParameters} instance + */ + public Builder parameters(ModelGatewayImageParameters parameters) { + this.parameters = parameters; + return this; + } + + /** + * Builds a {@link ModelGatewayImageRequest} instance using the configured parameters. + * + * @return a new instance of {@link ModelGatewayImageRequest} + */ + public ModelGatewayImageRequest build() { + return new ModelGatewayImageRequest(this); + } + } + + @Override + public String toString() { + return "ModelGatewayImageRequest [prompt=" + prompt + ", parameters=" + parameters + "]"; + } +} diff --git a/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageResponse.java b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageResponse.java new file mode 100644 index 00000000..a8f834b3 --- /dev/null +++ b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageResponse.java @@ -0,0 +1,60 @@ +/* + * Copyright 2025 IBM Corporation + * SPDX-License-Identifier: Apache-2.0 + */ +package com.ibm.watsonx.ai.gateway.image; + +import static java.util.Objects.isNull; +import java.util.List; + +/** + * Response returned by the Model Gateway image generation endpoint. + * + * @param created the UNIX timestamp in seconds of when the response was created + * @param data the list of generated image objects + * @param background the background setting used for the generated images, never {@code "auto"} + * @param outputFormat the output format of the generated images, never {@code "auto"} + * @param quality the quality of the generated images, never {@code "auto"} + * @param size the size of the generated images, never {@code "auto"} + * @param usage the token usage information, or {@code null} if not returned + */ +public record ModelGatewayImageResponse( + long created, + List data, + String background, + String outputFormat, + String quality, + String size, + Usage usage) { + + public ModelGatewayImageResponse { + data = isNull(data) ? null : List.copyOf(data); + } + + /** + * Represents a single generated image object. + * + * @param url the URL of the image, or {@code null} when the {@code "b64_json"} format was requested + * @param b64Json the Base64-encoded image data, or {@code null} when the {@code "url"} format was requested + * @param revisedPrompt the revised prompt used for generation, if any + */ + public record ImageData(String url, String b64Json, String revisedPrompt) {} + + /** + * Token usage information for the image generation request. + * + * @param inputTokens the number of tokens, images and text, in the input prompt + * @param outputTokens the number of output tokens generated by the model + * @param totalTokens the total number of tokens used + * @param inputTokensDetails detailed breakdown of input token usage + */ + public record Usage(long inputTokens, long outputTokens, long totalTokens, InputTokensDetails inputTokensDetails) {} + + /** + * Detailed breakdown of input tokens. + * + * @param imageTokens the number of image tokens in the input prompt + * @param textTokens the number of text tokens in the input prompt + */ + public record InputTokensDetails(long imageTokens, long textTokens) {} +} diff --git a/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageRestClient.java b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageRestClient.java new file mode 100644 index 00000000..077b49b9 --- /dev/null +++ b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageRestClient.java @@ -0,0 +1,50 @@ +/* + * Copyright 2025 IBM Corporation + * SPDX-License-Identifier: Apache-2.0 + */ +package com.ibm.watsonx.ai.gateway.image; + +import java.util.ServiceLoader; +import java.util.function.Supplier; +import com.ibm.watsonx.ai.WatsonxRestClient; + +/** + * Abstraction of a REST client for interacting with the IBM watsonx.ai Model Gateway image generation API. + */ +public abstract class ModelGatewayImageRestClient extends WatsonxRestClient { + + protected ModelGatewayImageRestClient(Builder builder) { + super(builder); + } + + /** + * Sends a synchronous image generation request to the Model Gateway. + * + * @param request the {@link ModelGatewayImageGenerationRequest} wire payload already containing model and all parameters + * @return a {@link ModelGatewayImageResponse} containing the generated images + */ + public abstract ModelGatewayImageResponse generate(ModelGatewayImageGenerationRequest request); + + /** + * Creates a new {@link Builder} using the first available {@link ModelGatewayImageRestClientBuilderFactory} discovered via {@link ServiceLoader}. + *

+ * If no factory is found, falls back to the default {@link DefaultRestClient}. + */ + static ModelGatewayImageRestClient.Builder builder() { + return ServiceLoader.load(ModelGatewayImageRestClientBuilderFactory.class).findFirst() + .map(Supplier::get) + .orElse(DefaultRestClient.builder()); + } + + /** + * Builder abstract class for constructing {@link ModelGatewayImageRestClient} instances with configurable parameters. + */ + public abstract static class Builder extends WatsonxRestClient.Builder {} + + /** + * Service Provider Interface for supplying custom {@link Builder} implementations. + *

+ * This allows frameworks to provide their own client implementations. + */ + public interface ModelGatewayImageRestClientBuilderFactory extends Supplier {} +} diff --git a/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageService.java b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageService.java new file mode 100644 index 00000000..6fe4ba01 --- /dev/null +++ b/modules/watsonx-ai/src/main/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageService.java @@ -0,0 +1,131 @@ +/* + * Copyright 2025 IBM Corporation + * SPDX-License-Identifier: Apache-2.0 + */ +package com.ibm.watsonx.ai.gateway.image; + +import static java.util.Objects.requireNonNull; +import com.ibm.watsonx.ai.WatsonxService; +import com.ibm.watsonx.ai.core.auth.Authenticator; + +/** + * Service for interacting with IBM watsonx.ai Model Gateway image generation APIs. + *

+ * Example usage: + * + *

{@code
+ * ModelGatewayImageService imageService = ModelGatewayImageService.builder()
+ *     .baseUrl("https://...")  // or use CloudRegion
+ *     .apiKey("my-api-key")    // creates an IBM Cloud Authenticator
+ *     .modelId("gpt-image-1")
+ *     .build();
+ *
+ * ModelGatewayImageResponse response = imageService.generate("A futuristic city at sunset");
+ * }
+ * + * To use a custom authentication mechanism, configure it explicitly with {@code authenticator(Authenticator)}. + * + * @see Authenticator + */ +public class ModelGatewayImageService extends WatsonxService { + + private final ModelGatewayImageRestClient client; + private final String modelId; + + private ModelGatewayImageService(Builder builder) { + super(builder); + requireNonNull(builder.authenticator(), "authenticator cannot be null"); + modelId = requireNonNull(builder.modelId, "The modelId must be provided"); + + client = ModelGatewayImageRestClient.builder() + .baseUrl(baseUrl) + .version(version) + .logRequests(logRequests) + .logResponses(logResponses) + .timeout(timeout) + .authenticator(builder.authenticator()) + .httpClient(httpClient) + .verifySsl(verifySsl) + .build(); + } + + /** + * Generates an image from the provided prompt text. + * + * @param prompt the text description of the desired image + * @return a {@link ModelGatewayImageResponse} containing the generated images + */ + public ModelGatewayImageResponse generate(String prompt) { + return generate(prompt, null); + } + + /** + * Generates an image from the provided prompt text. + * + * @param prompt the text description of the desired image + * @param parameters the parameters for the image generation request + * @return a {@link ModelGatewayImageResponse} containing the generated images + */ + public ModelGatewayImageResponse generate(String prompt, ModelGatewayImageParameters parameters) { + return generate( + ModelGatewayImageRequest.builder() + .prompt(prompt) + .parameters(parameters) + .build() + ); + } + + /** + * Generates an image from the provided request. + * + * @param request the {@link ModelGatewayImageRequest} containing the prompt and optional parameters + * @return a {@link ModelGatewayImageResponse} containing the generated images + */ + public ModelGatewayImageResponse generate(ModelGatewayImageRequest request) { + requireNonNull(request, "request cannot be null"); + requireNonNull(request.prompt(), "prompt cannot be null"); + + if (request.prompt().isBlank()) + throw new IllegalArgumentException("The prompt must not be blank"); + + return client.generate(ModelGatewayImageGenerationRequest.of(modelId, request)); + } + + /** + * Returns a new {@link Builder} instance. + * + * @return a new {@link Builder} + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder class for constructing {@link ModelGatewayImageService} instances with configurable parameters. + */ + public static final class Builder extends WatsonxService.Builder { + + private String modelId; + + private Builder() {} + + /** + * Sets the model identifier to use for image generation. + * + * @param modelId the model id + */ + public Builder modelId(String modelId) { + this.modelId = modelId; + return this; + } + + /** + * Builds a {@link ModelGatewayImageService} instance using the configured parameters. + * + * @return a new instance of {@link ModelGatewayImageService} + */ + public ModelGatewayImageService build() { + return new ModelGatewayImageService(this); + } + } +} diff --git a/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/ContextDepedencyInjectionTest.java b/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/ContextDepedencyInjectionTest.java index 597db440..093d704e 100644 --- a/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/ContextDepedencyInjectionTest.java +++ b/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/ContextDepedencyInjectionTest.java @@ -18,6 +18,7 @@ import com.ibm.watsonx.ai.foundationmodel.FoundationModelService; import com.ibm.watsonx.ai.gateway.chat.ModelGatewayService; import com.ibm.watsonx.ai.gateway.embedding.ModelGatewayEmbeddingService; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageService; import com.ibm.watsonx.ai.rerank.RerankService; import com.ibm.watsonx.ai.textgeneration.TextGenerationService; import com.ibm.watsonx.ai.textprocessing.schema.create.CreateSchemaService; @@ -49,7 +50,7 @@ public class ContextDepedencyInjectionTest { CreateSchemaService.class, ImproveSchemaService.class, MergeSchemaService.class, TextClassificationService.class, TextExtractionService.class, TimeSeriesService.class, FileService.class, BatchService.class, ToolService.class, ModelGatewayService.class, - ModelGatewayEmbeddingService.class) + ModelGatewayEmbeddingService.class, ModelGatewayImageService.class) .build(); @Inject @@ -106,6 +107,9 @@ public class ContextDepedencyInjectionTest { @Inject ModelGatewayEmbeddingService modelGatewayEmbeddingService; + @Inject + ModelGatewayImageService modelGatewayImageService; + @Inject GoogleSearchTool googleSearchTool; @@ -217,6 +221,11 @@ void should_inject_model_gateway_embedding_service() { assertNotNull(modelGatewayEmbeddingService); } + @Test + void should_inject_model_gateway_image_service() { + assertNotNull(modelGatewayImageService); + } + @Test void should_inject_google_search_tool() { assertNotNull(googleSearchTool); @@ -418,6 +427,15 @@ public ModelGatewayEmbeddingService produceModelGatewayEmbeddingService() { .build(); } + @Produces + public ModelGatewayImageService produceModelGatewayImageService() { + return ModelGatewayImageService.builder() + .baseUrl("https://example.com") + .apiKey("api-key") + .modelId("model-id") + .build(); + } + @Produces public ModelGatewayService produceModelGatewayService() { return ModelGatewayService.builder() diff --git a/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/CustomHttpClientTest.java b/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/CustomHttpClientTest.java index 13a86acc..59a60932 100644 --- a/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/CustomHttpClientTest.java +++ b/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/CustomHttpClientTest.java @@ -23,6 +23,7 @@ import com.ibm.watsonx.ai.foundationmodel.FoundationModelService; import com.ibm.watsonx.ai.gateway.chat.ModelGatewayService; import com.ibm.watsonx.ai.gateway.embedding.ModelGatewayEmbeddingService; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageService; import com.ibm.watsonx.ai.rerank.RerankService; import com.ibm.watsonx.ai.textgeneration.TextGenerationService; import com.ibm.watsonx.ai.textprocessing.schema.create.CreateSchemaService; @@ -1149,4 +1150,55 @@ void should_use_default_http_client_for_model_gateway_embedding_service() throws } }); } + + @Test + void should_use_custom_http_client_for_model_gateway_image_service() throws Exception { + + HttpClient customClient = HttpClient.newHttpClient(); + ModelGatewayImageService imageService = ModelGatewayImageService.builder() + .baseUrl("https://localhost") + .modelId("modelId") + .apiKey("apiKey") + .httpClient(customClient) + .build(); + + Object restclient = getFieldValue(imageService, "client"); + assertEquals(customClient, getFieldValue(restclient, "httpClient")); + assertNotEquals(HttpClientProvider.httpClient(true), getFieldValue(restclient, "httpClient")); + assertNotEquals(HttpClientProvider.httpClient(false), getFieldValue(restclient, "httpClient")); + + Object syncHttpClient = getFieldValue(restclient, "syncHttpClient"); + assertEquals(customClient, getFieldValue(syncHttpClient, "delegate")); + assertNotEquals(HttpClientProvider.httpClient(true), getFieldValue(syncHttpClient, "delegate")); + assertNotEquals(HttpClientProvider.httpClient(false), getFieldValue(syncHttpClient, "delegate")); + } + + @Test + void should_use_default_http_client_for_model_gateway_image_service() throws Exception { + + Stream.of(true, false).forEach(verifySsl -> { + + try { + + HttpClient customClient = HttpClient.newHttpClient(); + ModelGatewayImageService imageService = ModelGatewayImageService.builder() + .baseUrl("https://localhost") + .modelId("modelId") + .apiKey("apiKey") + .verifySsl(verifySsl) + .build(); + + Object restclient = getFieldValue(imageService, "client"); + assertNotEquals(customClient, getFieldValue(restclient, "httpClient")); + assertEquals(HttpClientProvider.httpClient(verifySsl), getFieldValue(restclient, "httpClient")); + + Object syncHttpClient = getFieldValue(restclient, "syncHttpClient"); + assertNotEquals(customClient, getFieldValue(syncHttpClient, "delegate")); + assertEquals(HttpClientProvider.httpClient(verifySsl), getFieldValue(syncHttpClient, "delegate")); + + } catch (Exception e) { + fail(e); + } + }); + } } diff --git a/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/client/CustomRestClientTest.java b/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/client/CustomRestClientTest.java index c733fc83..1288bbc4 100644 --- a/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/client/CustomRestClientTest.java +++ b/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/client/CustomRestClientTest.java @@ -26,6 +26,7 @@ import com.ibm.watsonx.ai.client.impl.CustomMergeSchemaRestClient; import com.ibm.watsonx.ai.client.impl.CustomModelGatewayCatalogRestClient; import com.ibm.watsonx.ai.client.impl.CustomModelGatewayEmbeddingRestClient; +import com.ibm.watsonx.ai.client.impl.CustomModelGatewayImageRestClient; import com.ibm.watsonx.ai.client.impl.CustomModelGatewayRestClient; import com.ibm.watsonx.ai.client.impl.CustomRerankRestClient; import com.ibm.watsonx.ai.client.impl.CustomTextClassificationRestClient; @@ -46,6 +47,7 @@ import com.ibm.watsonx.ai.gateway.catalog.ModelGatewayCatalogService; import com.ibm.watsonx.ai.gateway.chat.ModelGatewayService; import com.ibm.watsonx.ai.gateway.embedding.ModelGatewayEmbeddingService; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageService; import com.ibm.watsonx.ai.rerank.RerankService; import com.ibm.watsonx.ai.textgeneration.TextGenerationService; import com.ibm.watsonx.ai.textprocessing.schema.create.CreateSchemaService; @@ -493,4 +495,21 @@ public void should_use_custom_rest_client_when_building_batch_service() throws E var client = clientField.get(batchService); assertTrue(client instanceof CustomBatchRestClient); } + + @Test + // com.ibm.watsonx.ai.gateway.image.ModelGatewayImageRestClient$ModelGatewayImageRestClientBuilderFactory + public void should_use_custom_rest_client_when_building_model_gateway_image_service() throws Exception { + + ModelGatewayImageService imageService = ModelGatewayImageService.builder() + .apiKey("test") + .modelId("model-id") + .baseUrl("http://localhost") + .build(); + + Class clazz = ModelGatewayImageService.class; + var clientField = clazz.getDeclaredField("client"); + clientField.setAccessible(true); + var client = clientField.get(imageService); + assertTrue(client instanceof CustomModelGatewayImageRestClient); + } } diff --git a/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/client/impl/CustomModelGatewayImageRestClient.java b/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/client/impl/CustomModelGatewayImageRestClient.java new file mode 100644 index 00000000..9a1478b7 --- /dev/null +++ b/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/client/impl/CustomModelGatewayImageRestClient.java @@ -0,0 +1,36 @@ +/* + * Copyright 2025 IBM Corporation + * SPDX-License-Identifier: Apache-2.0 + */ +package com.ibm.watsonx.ai.client.impl; + +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageGenerationRequest; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageResponse; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageRestClient; + +public class CustomModelGatewayImageRestClient extends ModelGatewayImageRestClient { + + CustomModelGatewayImageRestClient(Builder builder) { + super(builder); + } + + @Override + public ModelGatewayImageResponse generate(ModelGatewayImageGenerationRequest request) { + throw new UnsupportedOperationException("Unimplemented method 'generate'"); + } + + public static final class CustomModelGatewayImageRestClientBuilderFactory + implements ModelGatewayImageRestClientBuilderFactory { + @Override + public Builder get() { + return new CustomModelGatewayImageRestClient.Builder(); + } + } + + static final class Builder extends ModelGatewayImageRestClient.Builder { + @Override + public ModelGatewayImageRestClient build() { + return new CustomModelGatewayImageRestClient(this); + } + } +} diff --git a/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageServiceTest.java b/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageServiceTest.java new file mode 100644 index 00000000..131ca2a0 --- /dev/null +++ b/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/gateway/image/ModelGatewayImageServiceTest.java @@ -0,0 +1,492 @@ +/* + * Copyright 2025 IBM Corporation + * SPDX-License-Identifier: Apache-2.0 + */ +package com.ibm.watsonx.ai.gateway.image; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.ibm.watsonx.ai.utils.HttpUtils.bodyPublisherToString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpHeaders; +import java.net.http.HttpResponse.BodyHandler; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import com.ibm.watsonx.ai.AbstractWatsonxTest; +import com.ibm.watsonx.ai.core.exception.WatsonxException; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageParameters.Background; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageParameters.Moderation; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageParameters.OutputFormat; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageParameters.Quality; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageParameters.ResponseFormat; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageParameters.Size; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageParameters.Style; + +@SuppressWarnings("unchecked") +public class ModelGatewayImageServiceTest extends AbstractWatsonxTest { + + private static final String MODEL_ID = "gpt-image-1"; + + private static final String SIMPLE_RESPONSE = """ + { + "created": 1741570283, + "data": [ + { + "b64_json": "abc123", + "url": null, + "revised_prompt": null + } + ], + "background": "transparent", + "output_format": "png", + "size": "1024x1024", + "quality": "high", + "usage": { + "total_tokens": 100, + "input_tokens": 50, + "output_tokens": 50, + "input_tokens_details": { + "text_tokens": 10, + "image_tokens": 40 + } + } + }"""; + + private static final String URL_RESPONSE = """ + { + "created": 1741570283, + "data": [ + { + "url": "https://example.com/image.png", + "revised_prompt": "A city at sunset" + } + ], + "background": "opaque", + "output_format": "png", + "size": "1024x1024", + "quality": "standard", + "usage": null + }"""; + + @BeforeEach + void beforeEach() { + when(mockAuthenticator.token()).thenReturn("my-super-token"); + } + + private void stubHttpResponse(String body) { + try { + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.headers()).thenReturn(HttpHeaders.of( + Map.of("Content-Type", List.of("application/json")), (k, v) -> true)); + when(mockHttpResponse.body()).thenReturn(body); + when(mockSecureHttpClient.send(mockHttpRequest.capture(), any(BodyHandler.class))) + .thenReturn(mockHttpResponse); + } catch (Exception e) { + fail(e); + } + } + + private ModelGatewayImageService buildService() { + return ModelGatewayImageService.builder() + .authenticator(mockAuthenticator) + .modelId(MODEL_ID) + .baseUrl("http://localhost") + .build(); + } + + @Test + void should_parse_b64_json_response() { + stubHttpResponse(SIMPLE_RESPONSE); + withWatsonxServiceMock(() -> { + var response = buildService().generate("A futuristic city at sunset"); + assertNotNull(response); + assertEquals(1741570283L, response.created()); + assertNotNull(response.data()); + assertEquals(1, response.data().size()); + assertEquals("abc123", response.data().get(0).b64Json()); + assertNull(response.data().get(0).url()); + assertNull(response.data().get(0).revisedPrompt()); + assertEquals("transparent", response.background()); + assertEquals("png", response.outputFormat()); + assertEquals("1024x1024", response.size()); + assertEquals("high", response.quality()); + assertNotNull(response.usage()); + assertEquals(100, response.usage().totalTokens()); + assertEquals(50, response.usage().inputTokens()); + assertEquals(50, response.usage().outputTokens()); + assertNotNull(response.usage().inputTokensDetails()); + assertEquals(10, response.usage().inputTokensDetails().textTokens()); + assertEquals(40, response.usage().inputTokensDetails().imageTokens()); + }); + } + + @Test + void should_parse_url_response_with_null_usage() { + stubHttpResponse(URL_RESPONSE); + withWatsonxServiceMock(() -> { + var response = buildService().generate("A city"); + assertNotNull(response); + assertEquals("https://example.com/image.png", response.data().get(0).url()); + assertNull(response.data().get(0).b64Json()); + assertEquals("A city at sunset", response.data().get(0).revisedPrompt()); + assertNull(response.usage()); + }); + } + + @Test + void should_handle_null_data_in_response() { + stubHttpResponse(""" + { + "created": 1741570283, + "data": null, + "background": "opaque", + "output_format": "png", + "size": "1024x1024", + "quality": "standard", + "usage": null + }"""); + withWatsonxServiceMock(() -> { + var response = buildService().generate("A city"); + assertNotNull(response); + assertNull(response.data()); + }); + } + + @Test + void should_return_immutable_data_list() { + stubHttpResponse(SIMPLE_RESPONSE); + withWatsonxServiceMock(() -> { + var response = buildService().generate("A city"); + assertThrows(UnsupportedOperationException.class, () -> response.data().clear()); + }); + } + + @Test + void should_send_model_and_prompt_in_request_body() { + stubHttpResponse(SIMPLE_RESPONSE); + withWatsonxServiceMock(() -> { + buildService().generate("A futuristic city"); + JSONAssert.assertEquals(""" + { "model": "gpt-image-1", "prompt": "A futuristic city" }""", + bodyPublisherToString(mockHttpRequest), true); + }); + } + + @Test + void should_send_all_optional_fields_when_set() { + stubHttpResponse(SIMPLE_RESPONSE); + withWatsonxServiceMock(() -> { + var parameters = ModelGatewayImageParameters.builder() + .background(Background.TRANSPARENT) + .moderation(Moderation.LOW) + .n(2) + .outputCompression(80) + .outputFormat(OutputFormat.PNG) + .partialImages(3) + .quality(Quality.HIGH) + .responseFormat(ResponseFormat.B64_JSON) + .size(Size.SIZE_1024X1024) + .style(Style.VIVID) + .user("user-123") + .build(); + + buildService().generate("A landscape", parameters); + JSONAssert.assertEquals(""" + { + "model": "gpt-image-1", + "prompt": "A landscape", + "background": "transparent", + "moderation": "low", + "n": 2, + "output_compression": 80, + "output_format": "png", + "partial_images": 3, + "quality": "high", + "response_format": "b64_json", + "size": "1024x1024", + "style": "vivid", + "user": "user-123" + }""", + bodyPublisherToString(mockHttpRequest), true); + }); + } + + @Test + void should_send_only_prompt_when_request_has_no_parameters() { + stubHttpResponse(SIMPLE_RESPONSE); + withWatsonxServiceMock(() -> { + buildService().generate(ModelGatewayImageRequest.builder().prompt("Test").build()); + JSONAssert.assertEquals(""" + { "model": "gpt-image-1", "prompt": "Test" }""", + bodyPublisherToString(mockHttpRequest), true); + }); + } + + @Test + void should_send_request_body_built_from_request_object() { + stubHttpResponse(SIMPLE_RESPONSE); + withWatsonxServiceMock(() -> { + var request = ModelGatewayImageRequest.builder() + .prompt("A landscape") + .parameters(ModelGatewayImageParameters.builder().size(Size.SIZE_512X512).build()) + .build(); + + buildService().generate(request); + JSONAssert.assertEquals(""" + { "model": "gpt-image-1", "prompt": "A landscape", "size": "512x512" }""", + bodyPublisherToString(mockHttpRequest), true); + }); + } + + @Test + void should_accept_raw_string_values_for_enum_fields() { + stubHttpResponse(SIMPLE_RESPONSE); + withWatsonxServiceMock(() -> { + var parameters = ModelGatewayImageParameters.builder() + .background("opaque") + .moderation("auto") + .outputFormat("webp") + .quality("standard") + .responseFormat("url") + .size("512x512") + .style("natural") + .build(); + + buildService().generate("x", parameters); + JSONAssert.assertEquals(""" + { + "model": "gpt-image-1", + "prompt": "x", + "background": "opaque", + "moderation": "auto", + "output_format": "webp", + "quality": "standard", + "response_format": "url", + "size": "512x512", + "style": "natural" + }""", + bodyPublisherToString(mockHttpRequest), true); + }); + } + + @Test + void should_ignore_null_enum_values() { + var parameters = ModelGatewayImageParameters.builder() + .background((Background) null) + .moderation((Moderation) null) + .outputFormat((OutputFormat) null) + .quality((Quality) null) + .responseFormat((ResponseFormat) null) + .size((Size) null) + .style((Style) null) + .build(); + + assertNull(parameters.background()); + assertNull(parameters.moderation()); + assertNull(parameters.outputFormat()); + assertNull(parameters.quality()); + assertNull(parameters.responseFormat()); + assertNull(parameters.size()); + assertNull(parameters.style()); + } + + @Test + void should_return_correct_enum_string_values() { + assertEquals("transparent", Background.TRANSPARENT.value()); + assertEquals("opaque", Background.OPAQUE.value()); + assertEquals("auto", Background.AUTO.value()); + assertEquals("low", Moderation.LOW.value()); + assertEquals("auto", Moderation.AUTO.value()); + assertEquals("png", OutputFormat.PNG.value()); + assertEquals("jpeg", OutputFormat.JPEG.value()); + assertEquals("webp", OutputFormat.WEBP.value()); + assertEquals("auto", OutputFormat.AUTO.value()); + assertEquals("auto", Quality.AUTO.value()); + assertEquals("high", Quality.HIGH.value()); + assertEquals("medium", Quality.MEDIUM.value()); + assertEquals("low", Quality.LOW.value()); + assertEquals("hd", Quality.HD.value()); + assertEquals("standard", Quality.STANDARD.value()); + assertEquals("url", ResponseFormat.URL.value()); + assertEquals("b64_json", ResponseFormat.B64_JSON.value()); + assertEquals("256x256", Size.SIZE_256X256.value()); + assertEquals("512x512", Size.SIZE_512X512.value()); + assertEquals("1024x1024", Size.SIZE_1024X1024.value()); + assertEquals("1536x1024", Size.SIZE_1536X1024.value()); + assertEquals("1024x1536", Size.SIZE_1024X1536.value()); + assertEquals("1792x1024", Size.SIZE_1792X1024.value()); + assertEquals("1024x1792", Size.SIZE_1024X1792.value()); + assertEquals("auto", Size.AUTO.value()); + assertEquals("vivid", Style.VIVID.value()); + assertEquals("natural", Style.NATURAL.value()); + } + + @Test + void should_return_to_string() { + var parameters = ModelGatewayImageParameters.builder() + .n(1) + .size(Size.SIZE_1024X1024) + .quality(Quality.HIGH) + .build(); + + var request = ModelGatewayImageRequest.builder() + .prompt("A city") + .parameters(parameters) + .build(); + + assertTrue(request.toString().contains("prompt=A city")); + assertTrue(request.toString().contains("ModelGatewayImageParameters [")); + assertTrue(parameters.toString().contains("n=1")); + assertTrue(parameters.toString().contains("size=1024x1024")); + assertTrue(parameters.toString().contains("quality=high")); + } + + @Test + void should_throw_when_request_is_null() { + withWatsonxServiceMock(() -> { + assertThrows(NullPointerException.class, () -> buildService().generate((ModelGatewayImageRequest) null)); + }); + } + + @Test + void should_throw_when_prompt_string_is_null() { + withWatsonxServiceMock(() -> { + assertThrows(NullPointerException.class, () -> buildService().generate((String) null)); + }); + } + + @Test + void should_throw_when_prompt_in_request_is_null() { + withWatsonxServiceMock(() -> { + assertThrows(NullPointerException.class, + () -> buildService().generate(ModelGatewayImageRequest.builder().build())); + }); + } + + @Test + void should_throw_when_prompt_is_blank() { + withWatsonxServiceMock(() -> { + var ex = assertThrows(IllegalArgumentException.class, () -> buildService().generate(" ")); + assertEquals("The prompt must not be blank", ex.getMessage()); + }); + } + + @Test + void should_throw_when_model_id_is_null() { + withWatsonxServiceMock(() -> { + assertThrows(NullPointerException.class, () -> ModelGatewayImageService.builder() + .authenticator(mockAuthenticator) + .baseUrl("http://localhost") + .build()); + }); + } + + @Test + void should_throw_when_authenticator_is_null() { + withWatsonxServiceMock(() -> { + assertThrows(NullPointerException.class, () -> ModelGatewayImageService.builder() + .modelId(MODEL_ID) + .baseUrl("http://localhost") + .build()); + }); + } + + @Test + void should_wrap_io_exception_in_runtime_exception() throws Exception { + + when(mockSecureHttpClient.send(any(), any())).thenThrow(new IOException("IOException")); + + withWatsonxServiceMock(() -> { + var service = buildService(); + var ex = assertThrows(RuntimeException.class, () -> service.generate("A city")); + assertInstanceOf(IOException.class, ex.getCause()); + }); + } + + @Test + void should_call_correct_endpoint() { + wireMock.stubFor(post("/ml/gateway/v1/images/generations?version=%s".formatted(API_VERSION)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(SIMPLE_RESPONSE))); + + var response = buildWireMockService().generate("A city at sunset"); + assertNotNull(response); + assertEquals(1741570283L, response.created()); + assertEquals("abc123", response.data().get(0).b64Json()); + } + + @Test + void should_throw_watsonx_exception_on_gateway_client_error() { + + wireMock.stubFor(post("/ml/gateway/v1/images/generations?version=%s".formatted(API_VERSION)) + .willReturn(aResponse() + .withStatus(404) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "error": { + "code": "model_not_found", + "message": "The model `gpt-image-1` does not exist.", + "request_id": "req-12345" + } + }"""))); + + var service = buildWireMockService(); + var ex = assertThrows(WatsonxException.class, () -> service.generate("A city")); + + assertEquals(404, ex.statusCode()); + assertNotNull(ex.details().orElse(null)); + assertEquals("req-12345", ex.details().orElse(null).trace()); + assertEquals(1, ex.details().orElse(null).errors().size()); + assertEquals("model_not_found", ex.details().orElse(null).errors().get(0).code()); + assertEquals("The model `gpt-image-1` does not exist.", ex.details().orElse(null).errors().get(0).message()); + } + + @Test + void should_throw_watsonx_exception_on_gateway_server_error() { + + final String ERROR_BODY = """ + { + "error": { + "code": "internal_server_error", + "message": "Upstream provider failed.", + "request_id": "req-500" + } + }"""; + + wireMock.stubFor(post("/ml/gateway/v1/images/generations?version=%s".formatted(API_VERSION)) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "application/json") + .withBody(ERROR_BODY))); + + var service = buildWireMockService(); + var ex = assertThrows(WatsonxException.class, () -> service.generate("A city")); + + assertEquals(500, ex.statusCode()); + JSONAssert.assertEquals(ERROR_BODY, ex.getMessage(), true); + } + + private ModelGatewayImageService buildWireMockService() { + return ModelGatewayImageService.builder() + .authenticator(mockAuthenticator) + .modelId(MODEL_ID) + .baseUrl(URI.create("http://localhost:%s".formatted(wireMock.getPort()))) + .build(); + } +} diff --git a/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/it/ModelGatewayImageServiceIT.java b/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/it/ModelGatewayImageServiceIT.java new file mode 100644 index 00000000..bf10fb98 --- /dev/null +++ b/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/it/ModelGatewayImageServiceIT.java @@ -0,0 +1,80 @@ +/* + * Copyright 2025 IBM Corporation + * SPDX-License-Identifier: Apache-2.0 + */ +package com.ibm.watsonx.ai.it; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import com.ibm.watsonx.ai.core.auth.Authenticator; +import com.ibm.watsonx.ai.core.auth.ibmcloud.IBMCloudAuthenticator; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageParameters; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageParameters.Quality; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageParameters.Size; +import com.ibm.watsonx.ai.gateway.image.ModelGatewayImageService; + +@EnabledIfEnvironmentVariable(named = "WATSONX_API_KEY", matches = ".+") +@EnabledIfEnvironmentVariable(named = "WATSONX_URL", matches = ".+") +@EnabledIfEnvironmentVariable(named = "WATSONX_GATEWAY_IMAGE_MODEL", matches = ".+") +public class ModelGatewayImageServiceIT { + + static final String API_KEY = System.getenv("WATSONX_API_KEY"); + static final String URL = System.getenv("WATSONX_URL"); + static final String IMAGE_MODEL = System.getenv("WATSONX_GATEWAY_IMAGE_MODEL"); + + static final Authenticator authentication = IBMCloudAuthenticator.builder() + .apiKey(API_KEY) + .build(); + + static final ModelGatewayImageService imageService = ModelGatewayImageService.builder() + .baseUrl(URL) + .authenticator(authentication) + .modelId(IMAGE_MODEL) + .logRequests(true) + .logResponses(true) + .build(); + + @Test + void should_return_valid_response_for_single_prompt() { + var response = imageService.generate("A futuristic city at sunset"); + assertNotNull(response); + assertTrue(response.created() > 0); + assertNotNull(response.data()); + assertFalse(response.data().isEmpty()); + + try { + + byte[] image = Base64.getDecoder().decode(response.data().get(0).b64Json()); + var path = Files.write(Path.of("image.png"), image); + Files.delete(path); + + } catch (IOException e) { + fail(e); + } + } + + @Test + void should_return_b64_json_response_format() { + var parameters = ModelGatewayImageParameters.builder() + .size(Size.SIZE_1024X1024) + .quality(Quality.LOW) + .build(); + + var response = imageService.generate("A serene mountain landscape", parameters); + assertNotNull(response); + assertFalse(response.data().isEmpty()); + String b64Json = response.data().get(0).b64Json(); + assertNull(response.data().get(0).url()); + assertNotNull(b64Json); + assertFalse(b64Json.isEmpty()); + } +} diff --git a/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/utils/ServiceLoaderUtils.java b/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/utils/ServiceLoaderUtils.java index a2f748da..7372f35e 100644 --- a/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/utils/ServiceLoaderUtils.java +++ b/modules/watsonx-ai/src/test/java/com/ibm/watsonx/ai/utils/ServiceLoaderUtils.java @@ -118,6 +118,10 @@ public static void setupServiceLoader() throws Exception { "com.ibm.watsonx.ai.gateway.embedding.ModelGatewayEmbeddingRestClient$ModelGatewayEmbeddingRestClientBuilderFactory", "com.ibm.watsonx.ai.client.impl.CustomModelGatewayEmbeddingRestClient$CustomModelGatewayEmbeddingRestClientBuilderFactory"); + createServiceFile(metaInfServices, + "com.ibm.watsonx.ai.gateway.image.ModelGatewayImageRestClient$ModelGatewayImageRestClientBuilderFactory", + "com.ibm.watsonx.ai.client.impl.CustomModelGatewayImageRestClient$CustomModelGatewayImageRestClientBuilderFactory"); + URLClassLoader tempClassLoader = new URLClassLoader( new URL[] { tempDir.toUri().toURL() }, originalClassLoader