diff --git a/CHANGELOG.md b/CHANGELOG.md index c7cf931..4c1a665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to the AxonFlow Java SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.5.0] - 2026-02-19 + +### Added + +- **Media Governance Types**: `MediaContent`, `MediaAnalysisResult`, `MediaAnalysisResponse` for multimodal image governance +- **Media support in `proxyLLMCall()`**: Pass images (base64 or URL) via `ClientRequest.Builder.media()` for governance analysis before LLM routing + +### Changed + +- **Response cache skipped for media requests**: Requests containing media bypass the response cache (binary content makes cache keys unreliable) + +### Breaking + +- `MediaAnalysisResult.getExtractedText()` replaced by `isHasExtractedText()` (boolean) and `getExtractedTextLength()` (int). Raw extracted text is no longer exposed in API responses. + +--- + ## [3.4.0] - 2026-02-13 ### Added @@ -13,18 +30,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `failWorkflow(workflowId, reason)` + async variant + overload without reason - Sends `POST /api/v1/workflows/{id}/fail` - **HITL Queue API** (Enterprise): Human-in-the-loop approval queue management - - `listHITLQueue(opts)` — list pending approvals with filtering - - `getHITLRequest(requestId)` — get approval details - - `approveHITLRequest(requestId, review)` — approve a request - - `rejectHITLRequest(requestId, review)` — reject a request - - `getHITLStats()` — dashboard statistics + - `listHITLQueue(opts)`: list pending approvals with filtering + - `getHITLRequest(requestId)`: get approval details + - `approveHITLRequest(requestId, review)`: approve a request + - `rejectHITLRequest(requestId, review)`: reject a request + - `getHITLStats()`: dashboard statistics - New types: `HITLApprovalRequest`, `HITLQueueListOptions`, `HITLQueueListResponse`, `HITLReviewInput`, `HITLStats` ## [3.3.1] - 2026-02-12 ### Fixed -- **`listUnifiedExecutions` deserialization**: Fixed Jackson deserialization failure on `UnifiedListExecutionsResponse` — added `@JsonCreator` and `@JsonProperty` annotations to constructor. Without this, `listUnifiedExecutions()` threw "no Creators, like default constructor, exist" error. +- **`listUnifiedExecutions` deserialization**: Fixed Jackson deserialization failure on `UnifiedListExecutionsResponse`. Added `@JsonCreator` and `@JsonProperty` annotations to constructor. Without this, `listUnifiedExecutions()` threw "no Creators, like default constructor, exist" error. - **SSE streaming endpoint path**: `streamExecutionStatus()` now uses correct `/api/v1/unified/executions/{id}/stream` path (was incorrectly pointing to `/api/v1/executions/{id}/stream` which is the Execution Replay API) --- diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index 0b73a6b..cf54b91 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -587,45 +587,54 @@ public ClientResponse proxyLLMCall(ClientRequest request) { .context(request.getContext()) .llmProvider(request.getLlmProvider()) .model(request.getModel()) + .media(request.getMedia()) .build(); } final ClientRequest finalRequest = effectiveRequest; - // Check cache first + // Media requests must not be cached — binary content makes cache keys unreliable + boolean hasMedia = finalRequest.getMedia() != null && !finalRequest.getMedia().isEmpty(); + + // Check cache first (skip for media requests) String cacheKey = ResponseCache.generateKey( finalRequest.getRequestType(), finalRequest.getQuery(), finalRequest.getUserToken() ); - return cache.get(cacheKey, ClientResponse.class).orElseGet(() -> { - ClientResponse response = retryExecutor.execute(() -> { - Request httpRequest = buildRequest("POST", "/api/request", finalRequest); - try (Response httpResponse = httpClient.newCall(httpRequest).execute()) { - ClientResponse result = parseResponse(httpResponse, ClientResponse.class); - - if (result.isBlocked()) { - throw new PolicyViolationException( - result.getBlockReason(), - result.getBlockingPolicyName(), - result.getPolicyInfo() != null - ? result.getPolicyInfo().getPoliciesEvaluated() - : null - ); - } + if (!hasMedia) { + java.util.Optional cached = cache.get(cacheKey, ClientResponse.class); + if (cached.isPresent()) { + return cached.get(); + } + } - return result; + ClientResponse response = retryExecutor.execute(() -> { + Request httpRequest = buildRequest("POST", "/api/request", finalRequest); + try (Response httpResponse = httpClient.newCall(httpRequest).execute()) { + ClientResponse result = parseResponse(httpResponse, ClientResponse.class); + + if (result.isBlocked()) { + throw new PolicyViolationException( + result.getBlockReason(), + result.getBlockingPolicyName(), + result.getPolicyInfo() != null + ? result.getPolicyInfo().getPoliciesEvaluated() + : null + ); } - }, "proxyLLMCall"); - // Cache successful responses - if (response.isSuccess() && !response.isBlocked()) { - cache.put(cacheKey, response); + return result; } + }, "proxyLLMCall"); + + // Cache successful responses (skip for media requests) + if (!hasMedia && response.isSuccess() && !response.isBlocked()) { + cache.put(cacheKey, response); + } - return response; - }); + return response; } /** diff --git a/src/main/java/com/getaxonflow/sdk/types/ClientRequest.java b/src/main/java/com/getaxonflow/sdk/types/ClientRequest.java index 542526b..691deae 100644 --- a/src/main/java/com/getaxonflow/sdk/types/ClientRequest.java +++ b/src/main/java/com/getaxonflow/sdk/types/ClientRequest.java @@ -18,8 +18,10 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -62,6 +64,9 @@ public final class ClientRequest { @JsonProperty("model") private final String model; + @JsonProperty("media") + private final List media; + private ClientRequest(Builder builder) { this.query = Objects.requireNonNull(builder.query, "query cannot be null"); // Default to "anonymous" if userToken is null or empty (community mode) @@ -71,6 +76,7 @@ private ClientRequest(Builder builder) { this.context = builder.context != null ? Collections.unmodifiableMap(new HashMap<>(builder.context)) : null; this.llmProvider = builder.llmProvider; this.model = builder.model; + this.media = builder.media != null ? Collections.unmodifiableList(new ArrayList<>(builder.media)) : null; } public String getQuery() { @@ -101,6 +107,10 @@ public String getModel() { return model; } + public List getMedia() { + return media; + } + public static Builder builder() { return new Builder(); } @@ -116,12 +126,13 @@ public boolean equals(Object o) { Objects.equals(requestType, that.requestType) && Objects.equals(context, that.context) && Objects.equals(llmProvider, that.llmProvider) && - Objects.equals(model, that.model); + Objects.equals(model, that.model) && + Objects.equals(media, that.media); } @Override public int hashCode() { - return Objects.hash(query, userToken, clientId, requestType, context, llmProvider, model); + return Objects.hash(query, userToken, clientId, requestType, context, llmProvider, model, media); } @Override @@ -133,6 +144,7 @@ public String toString() { ", requestType='" + requestType + '\'' + ", llmProvider='" + llmProvider + '\'' + ", model='" + model + '\'' + + ", media=" + media + '}'; } @@ -147,6 +159,7 @@ public static final class Builder { private Map context; private String llmProvider; private String model; + private List media; private Builder() {} @@ -243,6 +256,17 @@ public Builder model(String model) { return this; } + /** + * Sets optional media content (images) for multimodal governance. + * + * @param media list of media content items + * @return this builder + */ + public Builder media(List media) { + this.media = media; + return this; + } + /** * Builds the ClientRequest instance. * diff --git a/src/main/java/com/getaxonflow/sdk/types/ClientResponse.java b/src/main/java/com/getaxonflow/sdk/types/ClientResponse.java index 2ed08de..1d5690e 100644 --- a/src/main/java/com/getaxonflow/sdk/types/ClientResponse.java +++ b/src/main/java/com/getaxonflow/sdk/types/ClientResponse.java @@ -60,6 +60,9 @@ public final class ClientResponse { @JsonProperty("budget_info") private final BudgetInfo budgetInfo; + @JsonProperty("media_analysis") + private final MediaAnalysisResponse mediaAnalysis; + public ClientResponse( @JsonProperty("success") boolean success, @JsonProperty("data") Object data, @@ -69,7 +72,8 @@ public ClientResponse( @JsonProperty("block_reason") String blockReason, @JsonProperty("policy_info") PolicyInfo policyInfo, @JsonProperty("error") String error, - @JsonProperty("budget_info") BudgetInfo budgetInfo) { + @JsonProperty("budget_info") BudgetInfo budgetInfo, + @JsonProperty("media_analysis") MediaAnalysisResponse mediaAnalysis) { this.success = success; this.data = data; this.result = result; @@ -79,6 +83,7 @@ public ClientResponse( this.policyInfo = policyInfo; this.error = error; this.budgetInfo = budgetInfo; + this.mediaAnalysis = mediaAnalysis; } /** @@ -193,6 +198,15 @@ public BudgetInfo getBudgetInfo() { return budgetInfo; } + /** + * Returns media analysis results if media was submitted. + * + * @return the media analysis response, may be null + */ + public MediaAnalysisResponse getMediaAnalysis() { + return mediaAnalysis; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -206,12 +220,13 @@ public boolean equals(Object o) { Objects.equals(blockReason, that.blockReason) && Objects.equals(policyInfo, that.policyInfo) && Objects.equals(error, that.error) && - Objects.equals(budgetInfo, that.budgetInfo); + Objects.equals(budgetInfo, that.budgetInfo) && + Objects.equals(mediaAnalysis, that.mediaAnalysis); } @Override public int hashCode() { - return Objects.hash(success, data, result, planId, blocked, blockReason, policyInfo, error, budgetInfo); + return Objects.hash(success, data, result, planId, blocked, blockReason, policyInfo, error, budgetInfo, mediaAnalysis); } @Override @@ -223,6 +238,7 @@ public String toString() { ", policyInfo=" + policyInfo + ", error='" + error + '\'' + ", budgetInfo=" + budgetInfo + + ", mediaAnalysis=" + mediaAnalysis + '}'; } } diff --git a/src/main/java/com/getaxonflow/sdk/types/MediaAnalysisResponse.java b/src/main/java/com/getaxonflow/sdk/types/MediaAnalysisResponse.java new file mode 100644 index 0000000..651337e --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/MediaAnalysisResponse.java @@ -0,0 +1,74 @@ +/* + * Copyright 2025 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Aggregated media analysis results in the response. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class MediaAnalysisResponse { + + @JsonProperty("results") + private final List results; + + @JsonProperty("total_cost_usd") + private final double totalCostUsd; + + @JsonProperty("analysis_time_ms") + private final long analysisTimeMs; + + public MediaAnalysisResponse( + @JsonProperty("results") List results, + @JsonProperty("total_cost_usd") double totalCostUsd, + @JsonProperty("analysis_time_ms") long analysisTimeMs) { + this.results = results != null ? Collections.unmodifiableList(results) : Collections.emptyList(); + this.totalCostUsd = totalCostUsd; + this.analysisTimeMs = analysisTimeMs; + } + + public List getResults() { return results; } + public double getTotalCostUsd() { return totalCostUsd; } + public long getAnalysisTimeMs() { return analysisTimeMs; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MediaAnalysisResponse that = (MediaAnalysisResponse) o; + return Double.compare(totalCostUsd, that.totalCostUsd) == 0 && + analysisTimeMs == that.analysisTimeMs && + Objects.equals(results, that.results); + } + + @Override + public int hashCode() { + return Objects.hash(results, totalCostUsd, analysisTimeMs); + } + + @Override + public String toString() { + return "MediaAnalysisResponse{results=" + (results != null ? results.size() : 0) + + ", totalCostUsd=" + totalCostUsd + + ", analysisTimeMs=" + analysisTimeMs + '}'; + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/MediaAnalysisResult.java b/src/main/java/com/getaxonflow/sdk/types/MediaAnalysisResult.java new file mode 100644 index 0000000..a086e51 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/MediaAnalysisResult.java @@ -0,0 +1,171 @@ +/* + * Copyright 2025 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Analysis results for a single media item. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class MediaAnalysisResult { + + @JsonProperty("media_index") + private final int mediaIndex; + + @JsonProperty("sha256_hash") + private final String sha256Hash; + + @JsonProperty("has_faces") + private final boolean hasFaces; + + @JsonProperty("face_count") + private final int faceCount; + + @JsonProperty("has_biometric_data") + private final boolean hasBiometricData; + + @JsonProperty("nsfw_score") + private final double nsfwScore; + + @JsonProperty("violence_score") + private final double violenceScore; + + @JsonProperty("content_safe") + private final boolean contentSafe; + + @JsonProperty("document_type") + private final String documentType; + + @JsonProperty("is_sensitive_document") + private final boolean isSensitiveDocument; + + @JsonProperty("has_pii") + private final boolean hasPII; + + @JsonProperty("pii_types") + private final List piiTypes; + + @JsonProperty("has_extracted_text") + private final boolean hasExtractedText; + + @JsonProperty("extracted_text_length") + private final int extractedTextLength; + + @JsonProperty("estimated_cost_usd") + private final double estimatedCostUsd; + + @JsonProperty("warnings") + private final List warnings; + + public MediaAnalysisResult( + @JsonProperty("media_index") int mediaIndex, + @JsonProperty("sha256_hash") String sha256Hash, + @JsonProperty("has_faces") boolean hasFaces, + @JsonProperty("face_count") int faceCount, + @JsonProperty("has_biometric_data") boolean hasBiometricData, + @JsonProperty("nsfw_score") double nsfwScore, + @JsonProperty("violence_score") double violenceScore, + @JsonProperty("content_safe") boolean contentSafe, + @JsonProperty("document_type") String documentType, + @JsonProperty("is_sensitive_document") boolean isSensitiveDocument, + @JsonProperty("has_pii") boolean hasPII, + @JsonProperty("pii_types") List piiTypes, + @JsonProperty("has_extracted_text") boolean hasExtractedText, + @JsonProperty("extracted_text_length") int extractedTextLength, + @JsonProperty("estimated_cost_usd") double estimatedCostUsd, + @JsonProperty("warnings") List warnings) { + this.mediaIndex = mediaIndex; + this.sha256Hash = sha256Hash; + this.hasFaces = hasFaces; + this.faceCount = faceCount; + this.hasBiometricData = hasBiometricData; + this.nsfwScore = nsfwScore; + this.violenceScore = violenceScore; + this.contentSafe = contentSafe; + this.documentType = documentType; + this.isSensitiveDocument = isSensitiveDocument; + this.hasPII = hasPII; + this.piiTypes = piiTypes != null ? Collections.unmodifiableList(piiTypes) : Collections.emptyList(); + this.hasExtractedText = hasExtractedText; + this.extractedTextLength = extractedTextLength; + this.estimatedCostUsd = estimatedCostUsd; + this.warnings = warnings != null ? Collections.unmodifiableList(warnings) : Collections.emptyList(); + } + + public int getMediaIndex() { return mediaIndex; } + public String getSha256Hash() { return sha256Hash; } + public boolean isHasFaces() { return hasFaces; } + public int getFaceCount() { return faceCount; } + public boolean isHasBiometricData() { return hasBiometricData; } + public double getNsfwScore() { return nsfwScore; } + public double getViolenceScore() { return violenceScore; } + public boolean isContentSafe() { return contentSafe; } + public String getDocumentType() { return documentType; } + public boolean isSensitiveDocument() { return isSensitiveDocument; } + public boolean isHasPII() { return hasPII; } + public List getPiiTypes() { return piiTypes; } + public boolean isHasExtractedText() { return hasExtractedText; } + public int getExtractedTextLength() { return extractedTextLength; } + public double getEstimatedCostUsd() { return estimatedCostUsd; } + public List getWarnings() { return warnings; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MediaAnalysisResult that = (MediaAnalysisResult) o; + return mediaIndex == that.mediaIndex && + hasFaces == that.hasFaces && + faceCount == that.faceCount && + hasBiometricData == that.hasBiometricData && + Double.compare(nsfwScore, that.nsfwScore) == 0 && + Double.compare(violenceScore, that.violenceScore) == 0 && + contentSafe == that.contentSafe && + isSensitiveDocument == that.isSensitiveDocument && + hasPII == that.hasPII && + hasExtractedText == that.hasExtractedText && + extractedTextLength == that.extractedTextLength && + Double.compare(estimatedCostUsd, that.estimatedCostUsd) == 0 && + Objects.equals(sha256Hash, that.sha256Hash) && + Objects.equals(documentType, that.documentType) && + Objects.equals(piiTypes, that.piiTypes) && + Objects.equals(warnings, that.warnings); + } + + @Override + public int hashCode() { + return Objects.hash(mediaIndex, sha256Hash, hasFaces, faceCount, + hasBiometricData, nsfwScore, violenceScore, contentSafe, + documentType, isSensitiveDocument, hasPII, piiTypes, + hasExtractedText, extractedTextLength, estimatedCostUsd, warnings); + } + + @Override + public String toString() { + return "MediaAnalysisResult{mediaIndex=" + mediaIndex + + ", contentSafe=" + contentSafe + + ", hasPII=" + hasPII + + ", hasFaces=" + hasFaces + + ", hasExtractedText=" + hasExtractedText + + ", extractedTextLength=" + extractedTextLength + '}'; + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/MediaContent.java b/src/main/java/com/getaxonflow/sdk/types/MediaContent.java new file mode 100644 index 0000000..9c2aacc --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/MediaContent.java @@ -0,0 +1,117 @@ +/* + * Copyright 2025 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Objects; + +/** + * Media content (image) to include with a request for governance analysis. + * + *

Supported formats: JPEG, PNG, GIF, WebP. Images can be provided as + * base64-encoded data or referenced by URL. + * + *

Example usage: + *

{@code
+ * MediaContent image = MediaContent.builder()
+ *     .source("base64")
+ *     .mimeType("image/jpeg")
+ *     .base64Data(encodedImage)
+ *     .build();
+ * }
+ */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public final class MediaContent { + + @JsonProperty("source") + private final String source; + + @JsonProperty("base64_data") + private final String base64Data; + + @JsonProperty("url") + private final String url; + + @JsonProperty("mime_type") + private final String mimeType; + + private MediaContent(Builder builder) { + this.source = Objects.requireNonNull(builder.source, "source cannot be null"); + this.base64Data = builder.base64Data; + this.url = builder.url; + this.mimeType = Objects.requireNonNull(builder.mimeType, "mimeType cannot be null"); + } + + // Jackson deserialization constructor + public MediaContent( + @JsonProperty("source") String source, + @JsonProperty("base64_data") String base64Data, + @JsonProperty("url") String url, + @JsonProperty("mime_type") String mimeType) { + this.source = source; + this.base64Data = base64Data; + this.url = url; + this.mimeType = mimeType; + } + + public String getSource() { return source; } + public String getBase64Data() { return base64Data; } + public String getUrl() { return url; } + public String getMimeType() { return mimeType; } + + public static Builder builder() { return new Builder(); } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MediaContent that = (MediaContent) o; + return Objects.equals(source, that.source) && + Objects.equals(base64Data, that.base64Data) && + Objects.equals(url, that.url) && + Objects.equals(mimeType, that.mimeType); + } + + @Override + public int hashCode() { + return Objects.hash(source, base64Data, url, mimeType); + } + + @Override + public String toString() { + return "MediaContent{source='" + source + "', mimeType='" + mimeType + "'}"; + } + + public static final class Builder { + private String source; + private String base64Data; + private String url; + private String mimeType; + + private Builder() {} + + public Builder source(String source) { this.source = source; return this; } + public Builder base64Data(String base64Data) { this.base64Data = base64Data; return this; } + public Builder url(String url) { this.url = url; return this; } + public Builder mimeType(String mimeType) { this.mimeType = mimeType; return this; } + + public MediaContent build() { return new MediaContent(this); } + } +} diff --git a/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java b/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java index a217cf5..46560f3 100644 --- a/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java +++ b/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java @@ -2228,4 +2228,63 @@ void streamExecutionStatusShouldSendCorrectHeaders() { verify(getRequestedFor(urlEqualTo("/api/v1/unified/executions/exec_123/stream")) .withHeader("Accept", equalTo("text/event-stream"))); } + + // ======================================================================== + // Media Cache Skip + // ======================================================================== + + @Test + @DisplayName("proxyLLMCall should skip cache with media") + void proxyLLMCallShouldSkipCacheWithMedia() { + stubFor(post(urlEqualTo("/api/request")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"blocked\":false}"))); + + MediaContent mediaItem = MediaContent.builder() + .source("base64") + .mimeType("image/png") + .base64Data("dGVzdC1pbWFnZQ==") + .build(); + + ClientRequest request = ClientRequest.builder() + .query("describe image") + .userToken("user-123") + .requestType(RequestType.CHAT) + .media(List.of(mediaItem)) + .build(); + + // First call + axonflow.proxyLLMCall(request); + // Second call — should NOT use cache + axonflow.proxyLLMCall(request); + + // Both calls should hit the server (no caching for media) + verify(exactly(2), postRequestedFor(urlEqualTo("/api/request"))); + } + + @Test + @DisplayName("proxyLLMCall should use cache without media") + void proxyLLMCallShouldUseCacheWithoutMedia() { + stubFor(post(urlEqualTo("/api/request")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"success\":true,\"blocked\":false}"))); + + ClientRequest request = ClientRequest.builder() + .query("hello") + .userToken("user-123") + .requestType(RequestType.CHAT) + .build(); + + // First call + axonflow.proxyLLMCall(request); + // Second call — should use cache + axonflow.proxyLLMCall(request); + + // Only one call should hit the server (second cached) + verify(exactly(1), postRequestedFor(urlEqualTo("/api/request"))); + } } diff --git a/src/test/java/com/getaxonflow/sdk/types/MoreTypesTest.java b/src/test/java/com/getaxonflow/sdk/types/MoreTypesTest.java index 7c42d36..e00f360 100644 --- a/src/test/java/com/getaxonflow/sdk/types/MoreTypesTest.java +++ b/src/test/java/com/getaxonflow/sdk/types/MoreTypesTest.java @@ -1381,7 +1381,7 @@ void shouldCreateSuccessfulResponse() { ClientResponse response = new ClientResponse( true, "Response data", "result text", "plan-123", - false, null, policyInfo, null, null + false, null, policyInfo, null, null, null ); assertThat(response.isSuccess()).isTrue(); @@ -1398,7 +1398,7 @@ void shouldCreateSuccessfulResponse() { void shouldCreateBlockedResponse() { ClientResponse response = new ClientResponse( false, null, null, null, - true, "Request blocked by policy: pii-check", null, null, null + true, "Request blocked by policy: pii-check", null, null, null, null ); assertThat(response.isSuccess()).isFalse(); @@ -1411,7 +1411,7 @@ void shouldCreateBlockedResponse() { void shouldCreateErrorResponse() { ClientResponse response = new ClientResponse( false, null, null, null, - false, null, null, "Internal server error", null + false, null, null, "Internal server error", null, null ); assertThat(response.isSuccess()).isFalse(); @@ -1423,7 +1423,7 @@ void shouldCreateErrorResponse() { void shouldExtractBlockingPolicyNameFormat1() { ClientResponse response = new ClientResponse( false, null, null, null, - true, "Request blocked by policy: my-policy", null, null, null + true, "Request blocked by policy: my-policy", null, null, null, null ); assertThat(response.getBlockingPolicyName()).isEqualTo("my-policy"); @@ -1434,7 +1434,7 @@ void shouldExtractBlockingPolicyNameFormat1() { void shouldExtractBlockingPolicyNameFormat2() { ClientResponse response = new ClientResponse( false, null, null, null, - true, "Blocked by policy: another-policy", null, null, null + true, "Blocked by policy: another-policy", null, null, null, null ); assertThat(response.getBlockingPolicyName()).isEqualTo("another-policy"); @@ -1445,7 +1445,7 @@ void shouldExtractBlockingPolicyNameFormat2() { void shouldExtractBlockingPolicyNameBracket() { ClientResponse response = new ClientResponse( false, null, null, null, - true, "[policy-name] Detailed description", null, null, null + true, "[policy-name] Detailed description", null, null, null, null ); assertThat(response.getBlockingPolicyName()).isEqualTo("policy-name"); @@ -1456,7 +1456,7 @@ void shouldExtractBlockingPolicyNameBracket() { void shouldReturnFullReasonWhenNoPattern() { ClientResponse response = new ClientResponse( false, null, null, null, - true, "Custom block reason", null, null, null + true, "Custom block reason", null, null, null, null ); assertThat(response.getBlockingPolicyName()).isEqualTo("Custom block reason"); @@ -1466,10 +1466,10 @@ void shouldReturnFullReasonWhenNoPattern() { @DisplayName("should return null for null or empty block reason") void shouldReturnNullForNullOrEmpty() { ClientResponse nullReason = new ClientResponse( - true, null, null, null, false, null, null, null, null + true, null, null, null, false, null, null, null, null, null ); ClientResponse emptyReason = new ClientResponse( - true, null, null, null, false, "", null, null, null + true, null, null, null, false, "", null, null, null, null ); assertThat(nullReason.getBlockingPolicyName()).isNull(); @@ -1497,9 +1497,9 @@ void shouldDeserializeFromJson() throws Exception { @Test @DisplayName("should implement equals and hashCode") void shouldImplementEqualsAndHashCode() { - ClientResponse r1 = new ClientResponse(true, "data", null, null, false, null, null, null, null); - ClientResponse r2 = new ClientResponse(true, "data", null, null, false, null, null, null, null); - ClientResponse r3 = new ClientResponse(false, "data", null, null, false, null, null, null, null); + ClientResponse r1 = new ClientResponse(true, "data", null, null, false, null, null, null, null, null); + ClientResponse r2 = new ClientResponse(true, "data", null, null, false, null, null, null, null, null); + ClientResponse r3 = new ClientResponse(false, "data", null, null, false, null, null, null, null, null); assertThat(r1).isEqualTo(r2); assertThat(r1.hashCode()).isEqualTo(r2.hashCode()); @@ -1510,7 +1510,7 @@ void shouldImplementEqualsAndHashCode() { @DisplayName("should have toString") void shouldHaveToString() { ClientResponse response = new ClientResponse( - true, null, null, null, false, null, null, null, null + true, null, null, null, false, null, null, null, null, null ); assertThat(response.toString()).contains("ClientResponse"); }