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
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,17 @@ public String getFinishReason() {
return finishReason;
}

/**
* Creates a new instance of ChatResponse with the specified ID,
* copying all other fields from this instance.
*
* @param newId the new identifier
* @return a new ChatResponse instance
*/
public ChatResponse withId(String newId) {
return new ChatResponse(newId, this.content, this.usage, this.metadata, this.finishReason);
}

/**
* Creates a new builder for ChatResponse.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,25 @@ private Flux<ChatResponse> streamWithHttpClient(
Flux<ChatResponse> responseFlux;
if (stream) {
responseFlux =
httpClient.stream(request)
.map(response -> formatter.parseResponse(response, Instant.now()));
Flux.defer(
() -> {
final String[] finalStableId = new String[1];

return httpClient.stream(request)
.map(
response -> {
ChatResponse parsedResponse =
formatter.parseResponse(
response, Instant.now());

if (finalStableId[0] == null) {
finalStableId[0] = parsedResponse.getId();
return parsedResponse;
}

return parsedResponse.withId(finalStableId[0]);
});
});
} else {
responseFlux =
Flux.defer(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -969,4 +969,59 @@ void testRealQwen3Thinking() {
assertEquals("POST", request.getMethod());
assertTrue(request.getUrl().endsWith("/api/chat"));
}

@Test
@DisplayName("Should populate and maintain stable ID for streaming responses")
void testStreamStableMessageId() {
// no ID field is included, simulating real Ollama behavior
String part1 =
"{\"model\":\""
+ TEST_MODEL_NAME
+ "\",\"message\":{\"role\":\"assistant\",\"content\":\"Thinking...\"},\"done\":false}";
String part2 =
"{\"model\":\""
+ TEST_MODEL_NAME
+ "\",\"message\":{\"role\":\"assistant\",\"content\":\""
+ " Hello\"},\"done\":false}";
String part3 =
"{\"model\":\""
+ TEST_MODEL_NAME
+ "\",\"message\":{\"role\":\"assistant\",\"content\":\""
+ " World\"},\"done\":false}";
String part4 =
"{\"model\":\"" + TEST_MODEL_NAME + "\",\"done\":true,\"total_duration\":100}";

when(httpTransport.stream(any(HttpRequest.class)))
.thenReturn(Flux.just(part1, part2, part3, part4));

GenerateOptions genOptions = OllamaOptions.builder().build().toGenerateOptions();

List<ChatResponse> responses =
model.stream(
List.of(Msg.builder().role(MsgRole.USER).textContent("Hi").build()),
null,
genOptions)
.collectList()
.block();

assertNotNull(responses);
assertFalse(responses.isEmpty(), "Stream responses should not be empty");

// collect all the IDs for assertion
List<String> collectedIds = new ArrayList<>();
for (int i = 0; i < responses.size(); i++) {
String id = responses.get(i).getId();

assertNotNull(id);
collectedIds.add(id);
}

// verify the stability of the ID (Stable ID)
long uniqueIdCount = collectedIds.stream().distinct().count();
assertEquals(
1,
uniqueIdCount,
"All stream chunks for a single response must share the same stable ID! Found IDs: "
+ collectedIds);
}
}
Loading