Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

---
Expand Down
55 changes: 32 additions & 23 deletions src/main/java/com/getaxonflow/sdk/AxonFlow.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClientResponse> 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;
}

/**
Expand Down
28 changes: 26 additions & 2 deletions src/main/java/com/getaxonflow/sdk/types/ClientRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -62,6 +64,9 @@ public final class ClientRequest {
@JsonProperty("model")
private final String model;

@JsonProperty("media")
private final List<MediaContent> 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)
Expand All @@ -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() {
Expand Down Expand Up @@ -101,6 +107,10 @@ public String getModel() {
return model;
}

public List<MediaContent> getMedia() {
return media;
}

public static Builder builder() {
return new Builder();
}
Expand All @@ -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
Expand All @@ -133,6 +144,7 @@ public String toString() {
", requestType='" + requestType + '\'' +
", llmProvider='" + llmProvider + '\'' +
", model='" + model + '\'' +
", media=" + media +
'}';
}

Expand All @@ -147,6 +159,7 @@ public static final class Builder {
private Map<String, Object> context;
private String llmProvider;
private String model;
private List<MediaContent> media;

private Builder() {}

Expand Down Expand Up @@ -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<MediaContent> media) {
this.media = media;
return this;
}

/**
* Builds the ClientRequest instance.
*
Expand Down
22 changes: 19 additions & 3 deletions src/main/java/com/getaxonflow/sdk/types/ClientResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -79,6 +83,7 @@ public ClientResponse(
this.policyInfo = policyInfo;
this.error = error;
this.budgetInfo = budgetInfo;
this.mediaAnalysis = mediaAnalysis;
}

/**
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -223,6 +238,7 @@ public String toString() {
", policyInfo=" + policyInfo +
", error='" + error + '\'' +
", budgetInfo=" + budgetInfo +
", mediaAnalysis=" + mediaAnalysis +
'}';
}
}
74 changes: 74 additions & 0 deletions src/main/java/com/getaxonflow/sdk/types/MediaAnalysisResponse.java
Original file line number Diff line number Diff line change
@@ -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<MediaAnalysisResult> results;

@JsonProperty("total_cost_usd")
private final double totalCostUsd;

@JsonProperty("analysis_time_ms")
private final long analysisTimeMs;

public MediaAnalysisResponse(
@JsonProperty("results") List<MediaAnalysisResult> 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<MediaAnalysisResult> 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 + '}';
}
}
Loading
Loading