Skip to content

Refactor message extraction to support reasoning content #62

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
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
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ plugins {
}

group = "ee.carlrobert"
version = "0.8.38"
version = "0.8.39"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we remove this? I usually increment it when I modify the changelog.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure


repositories {
mavenCentral()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package ee.carlrobert.llm.client.openai.completion;

import org.jetbrains.annotations.Nullable;

class CompletionDeltaModel {
@Nullable String reasoningContent;
@Nullable String content;

public CompletionDeltaModel(@Nullable String reasoningContent, @Nullable String content) {
this.reasoningContent = reasoningContent;
this.content = content;
}

@Nullable
public String getReasoningContent() {
return reasoningContent;
}

@Nullable
public String getContent() {
return content;
}
}
Original file line number Diff line number Diff line change
@@ -1,49 +1,91 @@
package ee.carlrobert.llm.client.openai.completion;

import static ee.carlrobert.llm.client.DeserializationUtil.OBJECT_MAPPER;

import com.fasterxml.jackson.core.JsonProcessingException;
import ee.carlrobert.llm.client.openai.completion.response.OpenAIChatCompletionResponse;
import ee.carlrobert.llm.client.openai.completion.response.OpenAIChatCompletionResponseChoice;
import ee.carlrobert.llm.client.openai.completion.response.OpenAIChatCompletionResponseChoiceDelta;
import ee.carlrobert.llm.completion.CompletionEventListener;
import ee.carlrobert.llm.completion.CompletionEventSourceListener;

import java.util.Objects;
import java.util.stream.Stream;

import static ee.carlrobert.llm.client.DeserializationUtil.OBJECT_MAPPER;

public class OpenAIChatCompletionEventSourceListener extends CompletionEventSourceListener<String> {

private CompletionDeltaModel prevCompletionDeltaModel = null;

public OpenAIChatCompletionEventSourceListener(CompletionEventListener<String> listener) {
super(listener);
}

/**
* Content of the first choice.
* Returns the first valid message content extracted from the OpenAI Chat Completion response.
*
* <p>This method implements the following new logic:
* <ul>
* <li>Search all choices which are not null</li>
* <li>Search all deltas which are not null</li>
* <li>Use first content which is not null or blank (whitespace)</li>
* <li>Otherwise use "" (empty string) if no match can be found</li>
* <li>Deserializes the provided JSON string into an {@code OpenAIChatCompletionResponse}.</li>
* <li>Retrieves the list of choices and filters out any null elements.</li>
* <li>For each valid choice, obtains the delta and inspects its fields:
* <ul>
* <li>If {@code reasoningContent} is available, wraps it into a delta model with only the reasoning set.</li>
* <li>Otherwise, wraps the {@code content} field into a delta model.</li>
* </ul>
* </li>
* <li>Chooses the first delta model with a non-null, non-blank content; if none is found, defaults to an empty string.</li>
* <li>Formats the output message:
* <ul>
* <li>Prepends an opening {@code <think>} tag when initiating reasoning content.</li>
* <li>Appends a closing {@code </think>} tag and new line characters if transitioning from reasoning to normal content.</li>
* </ul>
* </li>
* </ul>
*
* @return First non-blank content which can be found, otherwise {@code ""}
* @param data the JSON string received from the OpenAI Chat Completion API.
* @return the formatted message extracted from the first valid delta model or an empty string if no matching content is found.
* @throws JsonProcessingException if an error occurs during JSON processing.
*/
protected String getMessage(String data) throws JsonProcessingException {
var choices = OBJECT_MAPPER
.readValue(data, OpenAIChatCompletionResponse.class)
.getChoices();
return (choices == null ? Stream.<OpenAIChatCompletionResponseChoice>empty() : choices.stream())
var response = OBJECT_MAPPER.readValue(data, OpenAIChatCompletionResponse.class);
var choices = response.getChoices();

CompletionDeltaModel completionDeltaModel = choices == null ? new CompletionDeltaModel(null, "") : choices.stream()
.filter(Objects::nonNull)
.map(OpenAIChatCompletionResponseChoice::getDelta)
.filter(Objects::nonNull)
.map(OpenAIChatCompletionResponseChoiceDelta::getContent)
.filter(Objects::nonNull)
.map(delta ->
delta.getReasoningContent() != null
? new CompletionDeltaModel(delta.getReasoningContent(), null)
: new CompletionDeltaModel(null, delta.getContent())
)
.findFirst()
.orElse("");
.orElse(new CompletionDeltaModel(null, ""));

return getMessageFromDeltaModel(completionDeltaModel);
}

@Override
protected ErrorDetails getErrorDetails(String data) throws JsonProcessingException {
return OBJECT_MAPPER.readValue(data, ApiResponseError.class).getError();
}

private String getMessageFromDeltaModel(CompletionDeltaModel completionDeltaModel) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like an ugly hack to make it compliant with how ProxyAI parses the thinking output.

Perhaps we can change the logic of how and where the respected thinking output is taken. For example, similar support (new param) was just recently added for Google Gemini requests as well, but as far as I know, it's not accepted in the UI logic yet.

<think> tags were DeepSeek's invention and the first ever model displaying its thinking process, hence the logic in the plugin repo.

#61

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to do this by replacing the return type from a string to a class with two fields (message, reasoningContent). but it turned out that a lot of changes had to be made on the plug-in side, because the OpenAI format is used not only by CustomOpenAI, but also by many other providers. it didn't seem advisable without prior refactoring.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking ahead, I think there will be a lot of changes when mcp integration begins, which will require rewriting the integration of the llm client library.

I'm waiting for a resolution, leave it that way, or still make the transition to the class in the OpenAI response.

StringBuilder sb = new StringBuilder();
String reasoning = completionDeltaModel.getReasoningContent();
String content = completionDeltaModel.getContent();

if (reasoning != null) {
if (prevCompletionDeltaModel == null || prevCompletionDeltaModel.getReasoningContent() == null) {
sb.append("<think>");
}
sb.append(reasoning);
} else if (content != null) {
if (prevCompletionDeltaModel != null && prevCompletionDeltaModel.getReasoningContent() != null) {
sb.append("</think>\n\n");
}
sb.append(content);
}

prevCompletionDeltaModel = completionDeltaModel;
return sb.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,19 @@ public class OpenAIChatCompletionResponseChoiceDelta {

private final String role;
private final String content;
private final String reasoningContent;
private final List<ToolCall> toolCalls;

@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
public OpenAIChatCompletionResponseChoiceDelta(
@JsonProperty("role") String role,
@JsonProperty("content") String content,
@JsonProperty("reasoning_content") String reasoningContent,
@JsonProperty("tool_calls") List<ToolCall> toolCalls) {
this.role = role;
this.content = content;
this.toolCalls = toolCalls;
this.reasoningContent = reasoningContent;
}

public String getRole() {
Expand All @@ -33,4 +36,8 @@ public String getContent() {
public List<ToolCall> getToolCalls() {
return toolCalls;
}

public String getReasoningContent() {
return reasoningContent;
}
}
Loading