feat: Enhance OpenAiProviderDialect for improved request handling and update related classes

- Refactored OpenAiProviderDialect to include methods for managing chat token limits and reasoning effort.
- Updated ChatCompletionClient and AbstractCompletionClient to utilize the new dialect methods for request shaping.
- Modified ChatCompletionRequest to replace reasoning properties with a single reasoningEffort field.
- Adjusted tests to reflect changes in request handling and dialect behavior.
- Updated README to clarify the new request shaping capabilities and defaults.
This commit is contained in:
Stephan Birkl
2026-05-07 18:22:12 +02:00
parent 244aa2574b
commit 33f76defa5
7 changed files with 177 additions and 128 deletions

View File

@@ -1,26 +1,101 @@
package de.extio.lmlib.client.oai;
import com.fasterxml.jackson.annotation.JsonValue;
import de.extio.lmlib.profile.ModelProfile;
/**
* Optional dialect hook for OpenAI-compatible clients.
* <p>
* Applications can register this as a Spring bean to adapt request shaping for
* providers that are not fully aligned with the current OpenAI Chat
* Completions API surface.
* <p>
* If no bean is present, lm-lib uses the defaults defined on this interface.
*/
public interface OpenAiProviderDialect {
/**
* Whether lm-lib should send the top-level {@code usage} request flag.
* <p>
* This is disabled by default because it is not part of the official OpenAI
* Chat Completions request schema and is mainly useful for llama-server style
* extensions.
*
* @param modelProfile the resolved model profile
* @return {@code true} if the request should include the top-level
* {@code usage} flag
*/
default boolean sendUsage(final ModelProfile modelProfile) {
return true;
return false;
}
/**
* Whether lm-lib should send a top-level {@code reasoning_effort} request
* field.
*
* @param modelProfile the resolved model profile
* @return {@code true} if {@code reasoning_effort} should be sent
*/
default boolean sendReasoning(final ModelProfile modelProfile) {
return true;
}
default boolean sendMaxCompletionTokens(final ModelProfile modelProfile) {
return true;
/**
* Selects which token limit request parameter should be sent for chat
* completions.
* <p>
* {@code max_completion_tokens} is the current OpenAI recommendation, while
* older llama-server style implementations may still require
* {@code max_tokens}.
*
* @param modelProfile the resolved model profile
* @return the token limit parameter mode to use
*/
default ChatTokenLimitParameterMode chatTokenLimitParameterMode(final ModelProfile modelProfile) {
return ChatTokenLimitParameterMode.MAX_COMPLETION_TOKENS;
}
default String reasoningEffort(final ModelProfile modelProfile) {
return "medium";
/**
* Returns the reasoning effort to send for chat completions when reasoning is
* enabled.
*
* @param modelProfile the resolved model profile
* @return the reasoning effort value to send
*/
default ReasoningEffort reasoningEffort(final ModelProfile modelProfile) {
return ReasoningEffort.MEDIUM;
}
default String reasoningSummaryDetails(final ModelProfile modelProfile) {
return "concise";
/**
* Controls which chat token limit field lm-lib should send.
*/
enum ChatTokenLimitParameterMode {
MAX_COMPLETION_TOKENS,
MAX_TOKENS,
BOTH
}
/**
* Official OpenAI reasoning effort values for chat completions.
*/
enum ReasoningEffort {
NONE("none"),
MINIMAL("minimal"),
LOW("low"),
MEDIUM("medium"),
HIGH("high"),
XHIGH("xhigh");
private final String wireValue;
ReasoningEffort(final String wireValue) {
this.wireValue = wireValue;
}
@JsonValue
public String getWireValue() {
return this.wireValue;
}
}
}

View File

@@ -156,18 +156,14 @@ public abstract class AbstractCompletionClient implements Client, DisposableBean
return this.getOpenAiProviderDialect().sendReasoning(modelProfile);
}
protected boolean sendMaxCompletionTokens(final ModelProfile modelProfile) {
return this.getOpenAiProviderDialect().sendMaxCompletionTokens(modelProfile);
protected OpenAiProviderDialect.ChatTokenLimitParameterMode chatTokenLimitParameterMode(final ModelProfile modelProfile) {
return this.getOpenAiProviderDialect().chatTokenLimitParameterMode(modelProfile);
}
protected String reasoningEffort(final ModelProfile modelProfile) {
protected OpenAiProviderDialect.ReasoningEffort reasoningEffort(final ModelProfile modelProfile) {
return this.getOpenAiProviderDialect().reasoningEffort(modelProfile);
}
protected String reasoningSummaryDetails(final ModelProfile modelProfile) {
return this.getOpenAiProviderDialect().reasoningSummaryDetails(modelProfile);
}
protected String getPrimaryModelName(final ModelProfile modelProfile) {
return this.getModelNames(modelProfile, false).stream().findFirst().orElse("");
}

View File

@@ -169,10 +169,7 @@ public class ChatCompletionClient extends AbstractCompletionClient {
final var request = new ChatCompletionRequest();
request.setModel(this.getPrimaryModelName(modelProfile));
request.setMessages(chat);
request.setMaxTokens(modelProfile.maxTokens());
if (this.sendMaxCompletionTokens(modelProfile)) {
request.setMaxCompletionTokens(modelProfile.maxTokens());
}
this.applyChatTokenLimitParameterMode(request, modelProfile);
request.setTemperature(modelProfile.temperature());
request.setTopP(modelProfile.topP());
request.setStream(streaming);
@@ -183,26 +180,8 @@ public class ChatCompletionClient extends AbstractCompletionClient {
request.setUsage(true);
}
final var reasoningEffort = this.reasoningEffort(modelProfile);
if (this.sendReasoning(modelProfile) && reasoningEffort != null && !reasoningEffort.isBlank()) {
request.setReasoning(new ChatCompletionRequest.ChatCompletionsRequestReasoning());
try {
request.getReasoning().setEffort(ChatCompletionRequest.ChatCompletionsRequestReasoningEffort.valueOf(reasoningEffort));
}
catch (final IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid reasoning effort for the current OAI provider dialect: " + reasoningEffort, e);
}
}
final var reasoningSummaryDetails = this.reasoningSummaryDetails(modelProfile);
if (this.sendReasoning(modelProfile) && reasoningSummaryDetails != null && !reasoningSummaryDetails.isBlank()) {
if (request.getReasoning() == null) {
request.setReasoning(new ChatCompletionRequest.ChatCompletionsRequestReasoning());
}
try {
request.getReasoning().setSummary(ChatCompletionRequest.ChatCompletionsRequestReasoningSummaryDetails.valueOf(reasoningSummaryDetails).name());
}
catch (final IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid reasoning summary details for the current OAI provider dialect: " + reasoningSummaryDetails, e);
}
if (this.sendReasoning(modelProfile) && reasoningEffort != null) {
request.setReasoningEffort(reasoningEffort);
}
if (toolCallData != null && toolCallData.hasTools()) {
request.setTools(this.createTools(toolCallData.tools()));
@@ -212,6 +191,27 @@ public class ChatCompletionClient extends AbstractCompletionClient {
return request;
}
private void applyChatTokenLimitParameterMode(final ChatCompletionRequest request, final ModelProfile modelProfile) {
final var mode = this.chatTokenLimitParameterMode(modelProfile);
if (mode == null) {
throw new IllegalArgumentException("Chat token limit parameter mode must not be null");
}
switch (mode) {
case MAX_COMPLETION_TOKENS:
request.setMaxCompletionTokens(modelProfile.maxTokens());
break;
case MAX_TOKENS:
request.setMaxTokens(modelProfile.maxTokens());
break;
case BOTH:
request.setMaxCompletionTokens(modelProfile.maxTokens());
request.setMaxTokens(modelProfile.maxTokens());
break;
default:
throw new IllegalArgumentException("Unsupported chat token limit parameter mode: " + mode);
}
}
private List<ChatCompletionRequest.ChatCompletionTool> createTools(final List<ToolDefinition> toolDefinitions) {
return toolDefinitions.stream().map(toolDefinition -> {
final var function = new ChatCompletionRequest.ChatCompletionFunction();

View File

@@ -5,6 +5,7 @@ import java.util.Map;
import com.fasterxml.jackson.annotation.*;
import de.extio.lmlib.client.oai.OpenAiProviderDialect.ReasoningEffort;
import de.extio.lmlib.client.oai.completion.AbstractCompletionRequest;
final class ChatCompletionRequest extends AbstractCompletionRequest {
@@ -13,7 +14,7 @@ final class ChatCompletionRequest extends AbstractCompletionRequest {
private Integer maxCompletionTokens;
private ChatCompletionsRequestReasoning reasoning;
private ReasoningEffort reasoningEffort;
private List<ChatCompletionTool> tools;
@@ -39,13 +40,13 @@ final class ChatCompletionRequest extends AbstractCompletionRequest {
this.maxCompletionTokens = maxCompletionTokens;
}
@JsonProperty("reasoning")
public ChatCompletionsRequestReasoning getReasoning() {
return this.reasoning;
@JsonProperty("reasoning_effort")
public ReasoningEffort getReasoningEffort() {
return this.reasoningEffort;
}
public void setReasoning(final ChatCompletionsRequestReasoning reasoning) {
this.reasoning = reasoning;
public void setReasoningEffort(final ReasoningEffort reasoningEffort) {
this.reasoningEffort = reasoningEffort;
}
@JsonProperty("tools")
@@ -75,40 +76,6 @@ final class ChatCompletionRequest extends AbstractCompletionRequest {
this.parallelToolCalls = parallelToolCalls;
}
final static class ChatCompletionsRequestReasoning {
private ChatCompletionsRequestReasoningEffort effort;
private String summary;
@JsonProperty("effort")
public ChatCompletionsRequestReasoningEffort getEffort() {
return this.effort;
}
public void setEffort(final ChatCompletionsRequestReasoningEffort effort) {
this.effort = effort;
}
@JsonProperty("summary")
public String getSummary() {
return this.summary;
}
public void setSummary(final String summary) {
this.summary = summary;
}
}
static enum ChatCompletionsRequestReasoningEffort {
minimal, low, medium, high
}
static enum ChatCompletionsRequestReasoningSummaryDetails {
auto, concise, detailed
}
static final class ChatCompletionTool {
private String type;

View File

@@ -2,7 +2,6 @@ package de.extio.lmlib.client.oai.completion.chat;
import static org.junit.jupiter.api.Assertions.assertEquals;
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;
@@ -18,7 +17,7 @@ import de.extio.lmlib.profile.ModelProfile.ModelProvider;
class ChatCompletionClientRequestTest {
@Test
void defaultsToAllRequestFieldsWhenNoDialectIsConfigured() {
void defaultsToOfficialRequestFieldsWhenNoDialectIsConfigured() {
final var client = new TestChatCompletionClient();
final var request = client.createRequest(
List.of(new ChatMessage("user", "hello", null)),
@@ -26,30 +25,24 @@ class ChatCompletionClientRequestTest {
false,
null);
assertTrue(request.getUsage());
assertNotNull(request.getReasoning());
assertEquals(ChatCompletionRequest.ChatCompletionsRequestReasoningEffort.medium, request.getReasoning().getEffort());
assertEquals("concise", request.getReasoning().getSummary());
assertNull(request.getUsage());
assertEquals(OpenAiProviderDialect.ReasoningEffort.MEDIUM, request.getReasoningEffort());
assertEquals(2048, request.getMaxCompletionTokens());
assertNull(request.getMaxTokens());
}
@Test
void omitsDialectDisabledRequestFields() {
void omitsDialectDisabledReasoningAndUsesLegacyMaxTokens() {
final var client = new TestChatCompletionClient();
client.setDialect(new OpenAiProviderDialect() {
@Override
public boolean sendUsage(final ModelProfile modelProfile) {
return false;
}
@Override
public boolean sendReasoning(final ModelProfile modelProfile) {
return false;
}
@Override
public boolean sendMaxCompletionTokens(final ModelProfile modelProfile) {
return false;
public ChatTokenLimitParameterMode chatTokenLimitParameterMode(final ModelProfile modelProfile) {
return ChatTokenLimitParameterMode.MAX_TOKENS;
}
});
@@ -60,24 +53,19 @@ class ChatCompletionClientRequestTest {
null);
assertNull(request.getUsage());
assertNull(request.getReasoning());
assertNull(request.getReasoningEffort());
assertNull(request.getMaxCompletionTokens());
assertEquals(2048, request.getMaxTokens());
assertFalse(request.isStream());
}
@Test
void usesDialectProvidedReasoningDefaults() {
void usesDialectProvidedReasoningEffort() {
final var client = new TestChatCompletionClient();
client.setDialect(new OpenAiProviderDialect() {
@Override
public String reasoningEffort(final ModelProfile modelProfile) {
return "high";
}
@Override
public String reasoningSummaryDetails(final ModelProfile modelProfile) {
return "detailed";
public ReasoningEffort reasoningEffort(final ModelProfile modelProfile) {
return ReasoningEffort.HIGH;
}
});
@@ -87,9 +75,33 @@ class ChatCompletionClientRequestTest {
false,
null);
assertNotNull(request.getReasoning());
assertEquals(ChatCompletionRequest.ChatCompletionsRequestReasoningEffort.high, request.getReasoning().getEffort());
assertEquals("detailed", request.getReasoning().getSummary());
assertEquals(OpenAiProviderDialect.ReasoningEffort.HIGH, request.getReasoningEffort());
}
@Test
void canEnableUsageAndSendBothTokenLimitFields() {
final var client = new TestChatCompletionClient();
client.setDialect(new OpenAiProviderDialect() {
@Override
public boolean sendUsage(final ModelProfile modelProfile) {
return true;
}
@Override
public ChatTokenLimitParameterMode chatTokenLimitParameterMode(final ModelProfile modelProfile) {
return ChatTokenLimitParameterMode.BOTH;
}
});
final var request = client.createRequest(
List.of(new ChatMessage("user", "hello", null)),
this.createProfile(),
false,
null);
assertTrue(request.getUsage());
assertEquals(2048, request.getMaxCompletionTokens());
assertEquals(2048, request.getMaxTokens());
}
private ModelProfile createProfile() {

View File

@@ -14,26 +14,26 @@ import de.extio.lmlib.profile.ModelProfile.ModelProvider;
class TextCompletionClientRequestTest {
@Test
void defaultsToUsageWhenNoDialectIsConfigured() {
void omitsUsageWhenNoDialectIsConfigured() {
final var client = new TestTextCompletionClient();
final var request = client.createRequest("prompt", this.createProfile(), false);
assertTrue(request.getUsage());
assertNull(request.getUsage());
}
@Test
void omitsUsageWhenDialectDisablesIt() {
void includesUsageWhenDialectEnablesIt() {
final var client = new TestTextCompletionClient();
client.setDialect(new OpenAiProviderDialect() {
@Override
public boolean sendUsage(final ModelProfile modelProfile) {
return false;
return true;
}
});
final var request = client.createRequest("prompt", this.createProfile(), false);
assertNull(request.getUsage());
assertTrue(request.getUsage());
}
private ModelProfile createProfile() {