feat: Add OAI_RESPONSES model provider and implement Responses API client tests
- Updated ModelProfile to include OAI_RESPONSES in the ModelProvider enum. - Created CompletionClientTest to validate text and chat completion functionalities. - Added ResponsesApiClientTest to test Responses API interactions, including model listing and conversation handling. - Implemented ResponsesApiDialectTestConfiguration for managing reasoning efforts in tests. - Updated application-test.properties to include a new profile for responses. - Added local-responses.properties configuration for the Responses API.
This commit is contained in:
@@ -461,10 +461,10 @@ public interface BaseAgent {
|
||||
return;
|
||||
}
|
||||
final var completion = context.getLastCompletion();
|
||||
if ((completion.response() == null || completion.response().isEmpty()) && completion.toolCalls().isEmpty()) {
|
||||
if (completion.response().isEmpty() && completion.toolCalls().isEmpty() && completion.outputItems().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
context.getConversation().addTurn(new Conversation.Turn(TurnType.ASSISTANT, completion.response() == null ? "" : completion.response(), completion.toolCalls(), null));
|
||||
context.getConversation().addTurn(new Conversation.Turn(TurnType.ASSISTANT, completion.response(), completion.toolCalls(), null, null, completion.outputItems()));
|
||||
}
|
||||
|
||||
private void rememberToolCallData(final AgentContext context, final ToolCallData toolCallData) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import de.extio.lmlib.client.openrouter.OpenRouterModelDiscovery;
|
||||
import de.extio.lmlib.client.ollama.OllamaClient;
|
||||
import de.extio.lmlib.client.oai.completion.chat.ChatCompletionClient;
|
||||
import de.extio.lmlib.client.oai.completion.text.TextCompletionClient;
|
||||
import de.extio.lmlib.client.oai.responses.ResponsesApiClient;
|
||||
import de.extio.lmlib.profile.ProfileAutoConfiguration;
|
||||
import de.extio.lmlib.token.TokenizerAutoConfiguration;
|
||||
|
||||
@@ -37,6 +38,12 @@ public class ClientAutoConfiguration {
|
||||
return new ChatCompletionClient();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
ResponsesApiClient responsesApiClient() {
|
||||
return new ResponsesApiClient();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
OpenRouterModelDiscovery openRouterModelDiscovery() {
|
||||
|
||||
@@ -3,8 +3,22 @@ package de.extio.lmlib.client;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final record Completion(String response, String reasoning, CompletionFinishReason finishReason, CompletionStatistics statistics, List<ToolCall> toolCalls) {
|
||||
/**
|
||||
* Normalized result returned by lm-lib clients.
|
||||
*
|
||||
* @param response the assistant-visible text output
|
||||
* @param reasoning the separately exposed reasoning summary or reasoning text
|
||||
* @param finishReason why generation stopped
|
||||
* @param statistics token, duration, cache, and cost accounting for the request
|
||||
* @param toolCalls tool calls returned by the provider when the response stopped
|
||||
* for tool execution
|
||||
* @param outputItems provider-specific structured output items that need to
|
||||
* survive follow-up rounds, primarily for stateless OpenAI Responses tool
|
||||
* loops
|
||||
*/
|
||||
public final record Completion(String response, String reasoning, CompletionFinishReason finishReason, CompletionStatistics statistics, List<ToolCall> toolCalls, List<Map<String, Object>> outputItems) {
|
||||
|
||||
public Completion {
|
||||
response = response == null ? "" : response;
|
||||
@@ -12,10 +26,22 @@ public final record Completion(String response, String reasoning, CompletionFini
|
||||
finishReason = finishReason == null ? CompletionFinishReason.DONE : finishReason;
|
||||
statistics = statistics == null ? new CompletionStatistics(0, Duration.ZERO, 0, 0, 0, 0, BigDecimal.ZERO, false) : statistics;
|
||||
toolCalls = toolCalls == null ? List.of() : List.copyOf(toolCalls);
|
||||
outputItems = outputItems == null ? List.of() : outputItems.stream().map(outputItem -> outputItem == null ? Map.<String, Object>of() : Map.copyOf(outputItem)).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a completion without provider-specific output items.
|
||||
*/
|
||||
public Completion(final String response, final String reasoning, final CompletionFinishReason finishReason, final CompletionStatistics statistics, final List<ToolCall> toolCalls) {
|
||||
this(response, reasoning, finishReason, statistics, toolCalls, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a plain text completion without tool calls or provider-specific
|
||||
* output items.
|
||||
*/
|
||||
public Completion(final String response, final String reasoning, final CompletionFinishReason finishReason, final CompletionStatistics statistics) {
|
||||
this(response, reasoning, finishReason, statistics, List.of());
|
||||
this(response, reasoning, finishReason, statistics, List.of(), List.of());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ package de.extio.lmlib.client;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Mutable conversation history shared across lm-lib clients and agents.
|
||||
*/
|
||||
public final class Conversation {
|
||||
|
||||
public static Conversation create(final String system, final String user) {
|
||||
@@ -57,21 +61,53 @@ public final class Conversation {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the assistant tool-call round and the corresponding tool results.
|
||||
* <p>
|
||||
* When the completion contains structured output items, they are preserved on
|
||||
* the assistant turn so Responses-style stateless replay can round-trip the
|
||||
* provider payload untouched.
|
||||
*
|
||||
* @param completion the completion that requested tool execution
|
||||
* @param toolResults the tool outputs to append after the assistant turn
|
||||
*/
|
||||
public void appendToolCallRound(final Completion completion, final List<ToolResult> toolResults) {
|
||||
if (completion == null) {
|
||||
throw new IllegalArgumentException("completion must not be null");
|
||||
}
|
||||
final var assistantText = completion.toolCalls().isEmpty() ? completion.response() : "";
|
||||
this.appendToolCallRound(assistantText, completion.toolCalls(), toolResults);
|
||||
this.appendToolCallRound(assistantText, completion.toolCalls(), toolResults, null, completion.outputItems());
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends an assistant tool-call round using only normalized lm-lib fields.
|
||||
*
|
||||
* @param assistantText assistant text to append before the tool results
|
||||
* @param toolCalls tool calls returned by the provider
|
||||
* @param toolResults tool outputs to append after the assistant turn
|
||||
*/
|
||||
public void appendToolCallRound(final String assistantText, final List<ToolCall> toolCalls, final List<ToolResult> toolResults) {
|
||||
this.addTurn(new Turn(TurnType.ASSISTANT, assistantText == null ? "" : assistantText, toolCalls, null));
|
||||
this.appendToolCallRound(assistantText, toolCalls, toolResults, null, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends an assistant tool-call round including provider-specific replay
|
||||
* metadata.
|
||||
*
|
||||
* @param assistantText assistant text to append before the tool results
|
||||
* @param toolCalls tool calls returned by the provider
|
||||
* @param toolResults tool outputs to append after the assistant turn
|
||||
* @param phase optional assistant phase metadata for providers that emit it
|
||||
* @param outputItems provider-specific structured output items that should be
|
||||
* preserved for follow-up requests
|
||||
*/
|
||||
public void appendToolCallRound(final String assistantText, final List<ToolCall> toolCalls, final List<ToolResult> toolResults, final String phase, final List<Map<String, Object>> outputItems) {
|
||||
this.addTurn(new Turn(TurnType.ASSISTANT, assistantText == null ? "" : assistantText, toolCalls, null, phase, outputItems));
|
||||
if (toolResults == null || toolResults.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (final var toolResult : toolResults) {
|
||||
this.addTurn(new Turn(TurnType.TOOL, toolResult.text(), null, toolResult.toolCallId()));
|
||||
this.addTurn(new Turn(TurnType.TOOL, toolResult.text(), null, toolResult.toolCallId(), null, List.of()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,29 +139,75 @@ public final class Conversation {
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional metadata associated with the whole conversation, for example a user
|
||||
* id or an upstream conversation id.
|
||||
*/
|
||||
public String getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets optional conversation-level metadata.
|
||||
*/
|
||||
public void setMetadata(final String metadata) {
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn roles supported by lm-lib conversations.
|
||||
*/
|
||||
public enum TurnType {
|
||||
SYSTEM, USER, ASSISTANT, TOOL
|
||||
}
|
||||
|
||||
public record Turn(TurnType type, String text, List<ToolCall> toolCalls, String toolCallId) {
|
||||
/**
|
||||
* Single conversation turn.
|
||||
*
|
||||
* @param type logical role of the turn
|
||||
* @param text normalized text payload for the turn
|
||||
* @param toolCalls tool calls attached to assistant turns
|
||||
* @param toolCallId tool-call id referenced by tool result turns
|
||||
* @param phase optional assistant phase metadata used by Responses-style replay
|
||||
* @param outputItems provider-specific structured output items that should be
|
||||
* replayed verbatim in later requests
|
||||
*/
|
||||
public record Turn(TurnType type, String text, List<ToolCall> toolCalls, String toolCallId, String phase, List<Map<String, Object>> outputItems) {
|
||||
|
||||
public Turn {
|
||||
toolCalls = toolCalls == null ? List.of() : List.copyOf(toolCalls);
|
||||
outputItems = outputItems == null ? List.of() : outputItems.stream().map(outputItem -> outputItem == null ? Map.<String, Object>of() : Map.copyOf(outputItem)).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a turn without provider-specific replay metadata.
|
||||
*/
|
||||
public Turn(final TurnType type, final String text, final List<ToolCall> toolCalls, final String toolCallId) {
|
||||
this(type, text, toolCalls, toolCallId, null, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a plain turn with an explicit assistant phase and no tool-call or
|
||||
* replay payload.
|
||||
*/
|
||||
public Turn(final TurnType type, final String text, final String phase) {
|
||||
this(type, text, List.of(), null, phase, List.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a plain turn without tool-call or replay metadata.
|
||||
*/
|
||||
public Turn(final TurnType type, final String text) {
|
||||
this(type, text, List.of(), null);
|
||||
this(type, text, List.of(), null, null, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result payload returned by a caller after executing a tool.
|
||||
*
|
||||
* @param text serialized tool output
|
||||
* @param toolCallId tool-call id the output belongs to
|
||||
*/
|
||||
public record ToolResult(String text, String toolCallId) {
|
||||
|
||||
public ToolResult {
|
||||
|
||||
@@ -92,12 +92,12 @@ public class CachedClient implements Client {
|
||||
|
||||
final var cachedCompletion = skipCache ? null : this.cacheRepository.get(key);
|
||||
if (cachedCompletion != null) {
|
||||
return new Completion(cachedCompletion.response(), cachedCompletion.reasoning(), cachedCompletion.finishReason(), new CompletionStatistics(0, Duration.ZERO, cachedCompletion.inTokens(), cachedCompletion.cachedInTokens(), cachedCompletion.outTokens(), cachedCompletion.reasoningOutTokens(), BigDecimal.ZERO, true), cachedCompletion.toolCalls());
|
||||
return new Completion(cachedCompletion.response(), cachedCompletion.reasoning(), cachedCompletion.finishReason(), new CompletionStatistics(0, Duration.ZERO, cachedCompletion.inTokens(), cachedCompletion.cachedInTokens(), cachedCompletion.outTokens(), cachedCompletion.reasoningOutTokens(), BigDecimal.ZERO, true), cachedCompletion.toolCalls(), cachedCompletion.outputItems());
|
||||
}
|
||||
|
||||
final var completion = supplier.get();
|
||||
if (completion != null) {
|
||||
this.cacheRepository.put(key, new CachedCompletion(completion.response(), completion.reasoning(), completion.finishReason(), completion.toolCalls(), completion.statistics().inTokens(), completion.statistics().cachedInTokens(), completion.statistics().outTokens(), completion.statistics().reasoningOutTokens(), OffsetDateTime.now()));
|
||||
this.cacheRepository.put(key, new CachedCompletion(completion.response(), completion.reasoning(), completion.finishReason(), completion.toolCalls(), completion.outputItems(), completion.statistics().inTokens(), completion.statistics().cachedInTokens(), completion.statistics().outTokens(), completion.statistics().reasoningOutTokens(), OffsetDateTime.now()));
|
||||
}
|
||||
return completion;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,19 @@ package de.extio.lmlib.client.cached;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import de.extio.lmlib.client.CompletionFinishReason;
|
||||
import de.extio.lmlib.client.ToolCall;
|
||||
|
||||
public record CachedCompletion(String response, String reasoning, CompletionFinishReason finishReason, List<ToolCall> toolCalls, long inTokens, long cachedInTokens, long outTokens, long reasoningOutTokens, OffsetDateTime created) {
|
||||
|
||||
/**
|
||||
* Serializable cache entry for normalized lm-lib completions.
|
||||
*/
|
||||
public record CachedCompletion(String response, String reasoning, CompletionFinishReason finishReason, List<ToolCall> toolCalls, List<Map<String, Object>> outputItems, long inTokens, long cachedInTokens, long outTokens, long reasoningOutTokens, OffsetDateTime created) {
|
||||
|
||||
public CachedCompletion {
|
||||
toolCalls = toolCalls == null ? List.of() : List.copyOf(toolCalls);
|
||||
outputItems = outputItems == null ? List.of() : outputItems.stream().map(outputItem -> outputItem == null ? Map.<String, Object>of() : Map.copyOf(outputItem)).toList();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
package de.extio.lmlib.client.oai.responses;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
import de.extio.lmlib.profile.ModelProfile;
|
||||
|
||||
/**
|
||||
* Optional request-shaping hook for the standalone OpenAI Responses client.
|
||||
* <p>
|
||||
* Applications can register this as a Spring bean to opt into Responses-specific
|
||||
* request fields without affecting the chat-completions clients.
|
||||
*/
|
||||
public interface OpenAiResponsesApiDialect {
|
||||
|
||||
/**
|
||||
* Include value that asks the Responses API to return encrypted reasoning
|
||||
* payloads. This is required when reasoning items should survive stateless
|
||||
* tool-call loops.
|
||||
*/
|
||||
String INCLUDE_REASONING_ENCRYPTED_CONTENT = "reasoning.encrypted_content";
|
||||
|
||||
/**
|
||||
* Controls whether OpenAI should persist the server-side response state for
|
||||
* later retrieval or follow-up requests.
|
||||
* <p>
|
||||
* lm-lib defaults this to {@code false} because the Responses client replays the
|
||||
* full conversation statelessly.
|
||||
*
|
||||
* @param modelProfile the resolved model profile for the request
|
||||
* @return {@code true} to store responses server-side, otherwise {@code false}
|
||||
*/
|
||||
default boolean store(final ModelProfile modelProfile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls the optional {@code background} request flag.
|
||||
* <p>
|
||||
* When enabled, the provider may continue processing the response after the
|
||||
* initial request has been accepted.
|
||||
*
|
||||
* @param modelProfile the resolved model profile for the request
|
||||
* @return {@code true} or {@code false} to send the flag, or {@code null} to
|
||||
* omit it
|
||||
*/
|
||||
default Boolean background(final ModelProfile modelProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional output sections to request via the Responses {@code include}
|
||||
* field.
|
||||
* <p>
|
||||
* Use this for optional payloads such as encrypted reasoning, tool results, or
|
||||
* output logprobs.
|
||||
*
|
||||
* @param modelProfile the resolved model profile for the request
|
||||
* @return includable field names to send with the request
|
||||
*/
|
||||
default List<String> include(final ModelProfile modelProfile) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps the number of built-in tool calls the Responses API may execute during a
|
||||
* single request.
|
||||
*
|
||||
* @param modelProfile the resolved model profile for the request
|
||||
* @return the maximum number of built-in tool calls, or {@code null} to omit
|
||||
* the field
|
||||
*/
|
||||
default Integer maxToolCalls(final ModelProfile modelProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplies request metadata that OpenAI stores alongside the generated
|
||||
* response.
|
||||
* <p>
|
||||
* This is useful for provider-side filtering, trace correlation, or dashboard
|
||||
* inspection.
|
||||
*
|
||||
* @param modelProfile the resolved model profile for the request
|
||||
* @return metadata entries to attach to the request
|
||||
*/
|
||||
default Map<String, String> metadata(final ModelProfile modelProfile) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the optional {@code prompt_cache_key} used by OpenAI prompt caching.
|
||||
*
|
||||
* @param modelProfile the resolved model profile for the request
|
||||
* @return the cache key, or {@code null} to omit it
|
||||
*/
|
||||
default String promptCacheKey(final ModelProfile modelProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects how long OpenAI may retain cached prompt prefixes for this request.
|
||||
*
|
||||
* @param modelProfile the resolved model profile for the request
|
||||
* @return the prompt-cache retention policy, or {@code null} to omit it
|
||||
*/
|
||||
default PromptCacheRetention promptCacheRetention(final ModelProfile modelProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the Responses {@code reasoning} object for reasoning-capable
|
||||
* models.
|
||||
* <p>
|
||||
* This controls effort level and optional summary generation settings.
|
||||
*
|
||||
* @param modelProfile the resolved model profile for the request
|
||||
* @return the reasoning configuration to send, or {@code null} to omit it
|
||||
*/
|
||||
default ResponsesReasoning reasoning(final ModelProfile modelProfile) {
|
||||
return new ResponsesReasoning(ReasoningEffort.MEDIUM, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplies the optional {@code safety_identifier} field.
|
||||
* <p>
|
||||
* This should be a stable, non-identifying user or tenant token that helps the
|
||||
* provider detect abuse patterns.
|
||||
*
|
||||
* @param modelProfile the resolved model profile for the request
|
||||
* @return the safety identifier, or {@code null} to omit it
|
||||
*/
|
||||
default String safetyIdentifier(final ModelProfile modelProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the OpenAI processing tier for the request when the provider supports
|
||||
* tiered serving.
|
||||
*
|
||||
* @param modelProfile the resolved model profile for the request
|
||||
* @return the requested service tier, or {@code null} to omit it
|
||||
*/
|
||||
default ServiceTier serviceTier(final ModelProfile modelProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the optional Responses {@code stream_options} object.
|
||||
*
|
||||
* @param modelProfile the resolved model profile for the request
|
||||
* @return stream options, or {@code null} to omit them
|
||||
*/
|
||||
default ResponsesStreamOptions streamOptions(final ModelProfile modelProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the optional Responses {@code text} object.
|
||||
* <p>
|
||||
* This can be used to influence text-only output shaping such as verbosity.
|
||||
*
|
||||
* @param modelProfile the resolved model profile for the request
|
||||
* @return text output options, or {@code null} to omit them
|
||||
*/
|
||||
default ResponsesText text(final ModelProfile modelProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests token-level log probability data for text outputs.
|
||||
*
|
||||
* @param modelProfile the resolved model profile for the request
|
||||
* @return the number of top logprobs to request, or {@code null} to omit the
|
||||
* field
|
||||
*/
|
||||
default Integer topLogprobs(final ModelProfile modelProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects how the provider should react when the input exceeds the model's
|
||||
* context window.
|
||||
*
|
||||
* @param modelProfile the resolved model profile for the request
|
||||
* @return the truncation mode, or {@code null} to omit it
|
||||
*/
|
||||
default Truncation truncation(final ModelProfile modelProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request payload for the Responses {@code reasoning} object.
|
||||
*
|
||||
* @param effort the reasoning budget requested from the model
|
||||
* @param generateSummary optional instruction for whether and how the provider
|
||||
* should generate reasoning summaries
|
||||
* @param summary optional summary detail level for reasoning output
|
||||
*/
|
||||
record ResponsesReasoning(ReasoningEffort effort, ReasoningSummaryGenerateMode generateSummary, ReasoningSummary summary) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Request payload for the Responses {@code stream_options} object.
|
||||
*
|
||||
* @param includeObfuscation whether streaming responses should include the
|
||||
* provider's obfuscation metadata when available
|
||||
*/
|
||||
record ResponsesStreamOptions(Boolean includeObfuscation) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Request payload for the Responses {@code text} object.
|
||||
*
|
||||
* @param verbosity desired verbosity level for text output
|
||||
*/
|
||||
record ResponsesText(TextVerbosity verbosity) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retention policy for prompt caching.
|
||||
*/
|
||||
enum PromptCacheRetention {
|
||||
/** Retain cached prompt prefixes in volatile memory only. */
|
||||
IN_MEMORY("in_memory"),
|
||||
/** Retain cached prompt prefixes for up to 24 hours. */
|
||||
DAY_24H("24h");
|
||||
|
||||
private final String wireValue;
|
||||
|
||||
PromptCacheRetention(final String wireValue) {
|
||||
this.wireValue = wireValue;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getWireValue() {
|
||||
return this.wireValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reasoning effort levels supported by the Responses API.
|
||||
*/
|
||||
enum ReasoningEffort {
|
||||
/** Disable extra reasoning tokens when the model supports that mode. */
|
||||
NONE("none"),
|
||||
/** Request the smallest possible reasoning budget. */
|
||||
MINIMAL("minimal"),
|
||||
/** Request a low reasoning budget. */
|
||||
LOW("low"),
|
||||
/** Request the default medium reasoning budget. */
|
||||
MEDIUM("medium"),
|
||||
/** Request a high reasoning budget. */
|
||||
HIGH("high"),
|
||||
/** Request the highest extended reasoning budget. */
|
||||
XHIGH("xhigh");
|
||||
|
||||
private final String wireValue;
|
||||
|
||||
ReasoningEffort(final String wireValue) {
|
||||
this.wireValue = wireValue;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getWireValue() {
|
||||
return this.wireValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary detail levels for returned reasoning summaries.
|
||||
*/
|
||||
enum ReasoningSummary {
|
||||
/** Let the provider pick the summary detail level automatically. */
|
||||
AUTO("auto"),
|
||||
/** Request a short reasoning summary. */
|
||||
CONCISE("concise"),
|
||||
/** Request a more detailed reasoning summary. */
|
||||
DETAILED("detailed");
|
||||
|
||||
private final String wireValue;
|
||||
|
||||
ReasoningSummary(final String wireValue) {
|
||||
this.wireValue = wireValue;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getWireValue() {
|
||||
return this.wireValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modes for asking the provider to generate reasoning summaries.
|
||||
*/
|
||||
enum ReasoningSummaryGenerateMode {
|
||||
/** Let the provider decide whether and how to generate a summary. */
|
||||
AUTO("auto"),
|
||||
/** Generate a short reasoning summary. */
|
||||
CONCISE("concise"),
|
||||
/** Generate a detailed reasoning summary. */
|
||||
DETAILED("detailed"),
|
||||
/** Do not generate a reasoning summary. */
|
||||
NONE("none");
|
||||
|
||||
private final String wireValue;
|
||||
|
||||
ReasoningSummaryGenerateMode(final String wireValue) {
|
||||
this.wireValue = wireValue;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getWireValue() {
|
||||
return this.wireValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI processing tiers supported by the Responses API.
|
||||
*/
|
||||
enum ServiceTier {
|
||||
/** Let the provider choose the service tier automatically. */
|
||||
AUTO("auto"),
|
||||
/** Use the standard service tier. */
|
||||
DEFAULT("default"),
|
||||
/** Use flex processing when available. */
|
||||
FLEX("flex"),
|
||||
/** Use priority processing when available. */
|
||||
PRIORITY("priority");
|
||||
|
||||
private final String wireValue;
|
||||
|
||||
ServiceTier(final String wireValue) {
|
||||
this.wireValue = wireValue;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getWireValue() {
|
||||
return this.wireValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbosity levels for text output.
|
||||
*/
|
||||
enum TextVerbosity {
|
||||
/** Prefer shorter text outputs. */
|
||||
LOW("low"),
|
||||
/** Prefer balanced text outputs. */
|
||||
MEDIUM("medium"),
|
||||
/** Prefer more detailed text outputs. */
|
||||
HIGH("high");
|
||||
|
||||
private final String wireValue;
|
||||
|
||||
TextVerbosity(final String wireValue) {
|
||||
this.wireValue = wireValue;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getWireValue() {
|
||||
return this.wireValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Input truncation strategies for oversized requests.
|
||||
*/
|
||||
enum Truncation {
|
||||
/** Allow the provider to drop older context automatically. */
|
||||
AUTO("auto"),
|
||||
/** Reject oversized requests instead of truncating them. */
|
||||
DISABLED("disabled");
|
||||
|
||||
private final String wireValue;
|
||||
|
||||
Truncation(final String wireValue) {
|
||||
this.wireValue = wireValue;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getWireValue() {
|
||||
return this.wireValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,6 @@ public record ModelProfile(
|
||||
}
|
||||
|
||||
public static enum ModelProvider {
|
||||
OAI_TEXT_COMPLETION, OAI_CHAT_COMPLETION, OPENROUTER, OLLAMA;
|
||||
OAI_TEXT_COMPLETION, OAI_CHAT_COMPLETION, OAI_RESPONSES, OPENROUTER, OLLAMA;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package de.extio.lmlib.client.oai;
|
||||
package de.extio.lmlib.client.oai.completion;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
@@ -0,0 +1,264 @@
|
||||
package de.extio.lmlib.client.oai.responses;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
import de.extio.lmlib.client.ClientService;
|
||||
import de.extio.lmlib.client.CompletionFinishReason;
|
||||
import de.extio.lmlib.client.Conversation;
|
||||
import de.extio.lmlib.client.ToolCall;
|
||||
import de.extio.lmlib.client.ToolCallData;
|
||||
import de.extio.lmlib.client.ToolDefinition;
|
||||
import de.extio.lmlib.client.ToolParameters;
|
||||
import de.extio.lmlib.grader.Grader2;
|
||||
import de.extio.lmlib.profile.ModelProfile;
|
||||
import de.extio.lmlib.profile.ModelProfileService;
|
||||
|
||||
@Disabled("This test requires a Responses API compatible endpoint")
|
||||
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
|
||||
@SpringBootConfiguration
|
||||
@EnableAutoConfiguration
|
||||
@Import(ResponsesApiDialectTestConfiguration.class)
|
||||
@TestPropertySource("classpath:/application-test.properties")
|
||||
public class ResponsesApiClientTest {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(ResponsesApiClientTest.class);
|
||||
|
||||
@Autowired
|
||||
private ResponsesApiClient responsesApiClient;
|
||||
|
||||
@Autowired
|
||||
private ModelProfileService modelProfileService;
|
||||
|
||||
@Autowired
|
||||
private ClientService clientService;
|
||||
|
||||
@Autowired
|
||||
private ResponsesApiDialectTestConfiguration.MutableOpenAiResponsesApiDialect openAiResponsesApiDialect;
|
||||
|
||||
@Test
|
||||
void listModels() throws Exception {
|
||||
final var modelProfile = this.modelProfile();
|
||||
final var modelNames = this.responsesApiClient.getModelNames(modelProfile, true);
|
||||
LOGGER.info("Models: {}", modelNames);
|
||||
assertFalse(modelNames.isEmpty());
|
||||
assertFalse(modelNames.getFirst().isBlank());
|
||||
assertEquals(ModelProfile.ModelProvider.OAI_RESPONSES, this.clientService.getClient(modelProfile).getModelProvider());
|
||||
}
|
||||
|
||||
@Test
|
||||
void conversation() throws Exception {
|
||||
this.openAiResponsesApiDialect.setReasoning(OpenAiResponsesApiDialect.ReasoningEffort.NONE);
|
||||
runCompletion();
|
||||
}
|
||||
|
||||
@Test
|
||||
void conversationWithReasoning() throws Exception {
|
||||
this.openAiResponsesApiDialect.setReasoning(OpenAiResponsesApiDialect.ReasoningEffort.MEDIUM);
|
||||
runCompletion();
|
||||
}
|
||||
|
||||
private void runCompletion() {
|
||||
final var modelProfile = this.modelProfile();
|
||||
final var completion = this.responsesApiClient.conversation(
|
||||
modelProfile,
|
||||
Conversation.create("You are a helpful assistant", "Say the color is green"),
|
||||
null,
|
||||
false);
|
||||
|
||||
LOGGER.info(completion.reasoning());
|
||||
LOGGER.info(completion.response());
|
||||
LOGGER.info(completion.statistics().toString());
|
||||
|
||||
assertTrue(Grader2.assessScoreBinary("Does the text mention the color green?", completion.response(), modelProfile, this.clientService));
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamConversation() throws Exception {
|
||||
this.openAiResponsesApiDialect.setReasoning(OpenAiResponsesApiDialect.ReasoningEffort.NONE);
|
||||
runStreamedCompletion();
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamConversationWithReasoning() throws Exception {
|
||||
this.openAiResponsesApiDialect.setReasoning(OpenAiResponsesApiDialect.ReasoningEffort.MEDIUM);
|
||||
runStreamedCompletion();
|
||||
}
|
||||
|
||||
private void runStreamedCompletion() {
|
||||
final var modelProfile = this.modelProfile();
|
||||
final var conversation = Conversation.create("You are a helpful assistant", "I would like to play a game of chess.");
|
||||
conversation.addTurn(new Conversation.Turn(Conversation.TurnType.ASSISTANT, "Which opening do you choose?"));
|
||||
conversation.addTurn(new Conversation.Turn(Conversation.TurnType.USER, "You choose, but explain the opening to me."));
|
||||
final var responseBuilder = new StringBuilder();
|
||||
final var reasoningBuilder = new StringBuilder();
|
||||
final var completion = this.responsesApiClient.streamConversation(modelProfile, conversation, chunk -> {
|
||||
if (chunk.reasoningContent() != null) {
|
||||
System.out.print(chunk.reasoningContent());
|
||||
reasoningBuilder.append(chunk.reasoningContent());
|
||||
}
|
||||
if (chunk.content() != null) {
|
||||
System.out.print(chunk.content());
|
||||
responseBuilder.append(chunk.content());
|
||||
}
|
||||
}, null, false);
|
||||
|
||||
LOGGER.info(completion.statistics().toString());
|
||||
|
||||
assertEquals(completion.response(), responseBuilder.toString());
|
||||
if (completion.reasoning() != null && !completion.reasoning().isBlank()) {
|
||||
assertEquals(completion.reasoning(), reasoningBuilder.toString());
|
||||
}
|
||||
assertTrue(Grader2.assessScoreBinary("Does the text explain a chess opening?", completion.response(), modelProfile, this.clientService));
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolCallingConversation() throws Exception {
|
||||
this.openAiResponsesApiDialect.setReasoning(OpenAiResponsesApiDialect.ReasoningEffort.MEDIUM);
|
||||
final var modelProfile = this.modelProfile();
|
||||
final var weatherTool = new ToolDefinition(
|
||||
"get_weather",
|
||||
"Gets the current weather for a given location.",
|
||||
ToolParameters.create(Map.of("location", "City and country, for example Berlin, Germany")),
|
||||
true);
|
||||
final var conversation = Conversation.create(
|
||||
"You are a helpful assistant. Use the provided tools when the user asks for location information. After receiving tool results, answer the question from the tool output.",
|
||||
"What is the weather in Berlin, Germany? Use the tool.");
|
||||
final var completion = this.responsesApiClient.conversation(
|
||||
modelProfile,
|
||||
conversation,
|
||||
ToolCallData.required(List.of(weatherTool)).withParallelToolCalls(true),
|
||||
false);
|
||||
|
||||
LOGGER.info(completion.reasoning());
|
||||
LOGGER.info(completion.response());
|
||||
LOGGER.info("Tool calls: {}", completion.toolCalls());
|
||||
LOGGER.info(completion.statistics().toString());
|
||||
|
||||
assertEquals(CompletionFinishReason.TOOL_CALLS, completion.finishReason());
|
||||
assertFalse(completion.outputItems().isEmpty());
|
||||
assertFalse(completion.toolCalls().isEmpty());
|
||||
final var weatherToolCall = this.findToolCall(completion.toolCalls(), "get_weather");
|
||||
assertTrue(weatherToolCall.arguments().contains("Berlin"));
|
||||
|
||||
conversation.appendToolCallRound(completion, List.of(
|
||||
new Conversation.ToolResult(ToolParameters.create()
|
||||
.add("forecast", "Berlin is rainy and 21C")
|
||||
.json(), weatherToolCall.id())));
|
||||
|
||||
final var finalCompletion = this.responsesApiClient.conversation(modelProfile, conversation, ToolCallData.auto(List.of(weatherTool)), false);
|
||||
LOGGER.info(finalCompletion.reasoning());
|
||||
LOGGER.info(finalCompletion.response());
|
||||
LOGGER.info(finalCompletion.statistics().toString());
|
||||
assertEquals(CompletionFinishReason.DONE, finalCompletion.finishReason());
|
||||
assertTrue(Grader2.assessScoreBinary("Does the text say that Berlin is rainy and 21C?", finalCompletion.response(), modelProfile, this.clientService));
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamToolCallingConversationBothRounds() throws Exception {
|
||||
this.openAiResponsesApiDialect.setReasoning(OpenAiResponsesApiDialect.ReasoningEffort.MEDIUM);
|
||||
final var modelProfile = this.modelProfile();
|
||||
final var weatherTool = new ToolDefinition(
|
||||
"get_weather",
|
||||
"Gets the current weather for a given location.",
|
||||
ToolParameters.create(Map.of("location", "City and country, for example Berlin, Germany")),
|
||||
true);
|
||||
final var conversation = Conversation.create(
|
||||
"You are a helpful assistant. Use the provided tools when the user asks for location information. After receiving tool results, answer the question from the tool output.",
|
||||
"What is the weather in Berlin, Germany? Use the tool.");
|
||||
final var firstRoundResponseBuilder = new StringBuilder();
|
||||
final var firstRoundReasoningBuilder = new StringBuilder();
|
||||
final var completion = this.responsesApiClient.streamConversation(
|
||||
modelProfile,
|
||||
conversation,
|
||||
chunk -> {
|
||||
if (chunk.reasoningContent() != null) {
|
||||
System.out.print(chunk.reasoningContent());
|
||||
firstRoundReasoningBuilder.append(chunk.reasoningContent());
|
||||
}
|
||||
if (chunk.content() != null) {
|
||||
System.out.print(chunk.content());
|
||||
firstRoundResponseBuilder.append(chunk.content());
|
||||
}
|
||||
},
|
||||
ToolCallData.required(List.of(weatherTool)).withParallelToolCalls(true),
|
||||
false);
|
||||
|
||||
LOGGER.info(completion.reasoning());
|
||||
LOGGER.info(completion.response());
|
||||
LOGGER.info("Tool calls: {}", completion.toolCalls());
|
||||
LOGGER.info(completion.statistics().toString());
|
||||
|
||||
assertEquals(CompletionFinishReason.TOOL_CALLS, completion.finishReason());
|
||||
assertEquals(completion.response(), firstRoundResponseBuilder.toString());
|
||||
if (completion.reasoning() != null && !completion.reasoning().isBlank()) {
|
||||
assertEquals(completion.reasoning(), firstRoundReasoningBuilder.toString());
|
||||
}
|
||||
assertFalse(completion.outputItems().isEmpty());
|
||||
assertFalse(completion.toolCalls().isEmpty());
|
||||
final var weatherToolCall = this.findToolCall(completion.toolCalls(), "get_weather");
|
||||
assertTrue(weatherToolCall.arguments().contains("Berlin"));
|
||||
|
||||
conversation.appendToolCallRound(completion, List.of(
|
||||
new Conversation.ToolResult(ToolParameters.create()
|
||||
.add("forecast", "Berlin is rainy and 21C")
|
||||
.json(), weatherToolCall.id())));
|
||||
|
||||
final var secondRoundResponseBuilder = new StringBuilder();
|
||||
final var secondRoundReasoningBuilder = new StringBuilder();
|
||||
final var finalCompletion = this.responsesApiClient.streamConversation(
|
||||
modelProfile,
|
||||
conversation,
|
||||
chunk -> {
|
||||
if (chunk.reasoningContent() != null) {
|
||||
System.out.print(chunk.reasoningContent());
|
||||
secondRoundReasoningBuilder.append(chunk.reasoningContent());
|
||||
}
|
||||
if (chunk.content() != null) {
|
||||
System.out.print(chunk.content());
|
||||
secondRoundResponseBuilder.append(chunk.content());
|
||||
}
|
||||
},
|
||||
ToolCallData.auto(List.of(weatherTool)),
|
||||
false);
|
||||
|
||||
LOGGER.info(finalCompletion.reasoning());
|
||||
LOGGER.info(finalCompletion.response());
|
||||
LOGGER.info(finalCompletion.statistics().toString());
|
||||
|
||||
assertEquals(CompletionFinishReason.DONE, finalCompletion.finishReason());
|
||||
assertEquals(finalCompletion.response(), secondRoundResponseBuilder.toString());
|
||||
if (finalCompletion.reasoning() != null && !finalCompletion.reasoning().isBlank()) {
|
||||
assertEquals(finalCompletion.reasoning(), secondRoundReasoningBuilder.toString());
|
||||
}
|
||||
assertTrue(Grader2.assessScoreBinary("Does the text say that Berlin is rainy and 21C?", finalCompletion.response(), modelProfile, this.clientService));
|
||||
}
|
||||
|
||||
private ModelProfile modelProfile() {
|
||||
return this.modelProfileService.getModelProfile("profile.model.responses");
|
||||
}
|
||||
|
||||
private ToolCall findToolCall(final List<ToolCall> toolCalls, final String toolName) {
|
||||
return toolCalls.stream()
|
||||
.filter(toolCall -> toolName.equals(toolCall.name()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("Missing tool call: " + toolName));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.extio.lmlib.client.oai.responses;
|
||||
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
import de.extio.lmlib.profile.ModelProfile;
|
||||
|
||||
@TestConfiguration
|
||||
public class ResponsesApiDialectTestConfiguration {
|
||||
|
||||
@Bean
|
||||
MutableOpenAiResponsesApiDialect openAiResponsesApiDialect() {
|
||||
return new MutableOpenAiResponsesApiDialect();
|
||||
}
|
||||
|
||||
public static class MutableOpenAiResponsesApiDialect implements OpenAiResponsesApiDialect {
|
||||
|
||||
private volatile ReasoningEffort reasoning = ReasoningEffort.MEDIUM;
|
||||
|
||||
@Override
|
||||
public ResponsesReasoning reasoning(final ModelProfile modelProfile) {
|
||||
return this.reasoning == null ? null : new ResponsesReasoning(this.reasoning, null, null);
|
||||
}
|
||||
|
||||
public void setReasoning(final ReasoningEffort reasoning) {
|
||||
this.reasoning = reasoning;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ profile.model.cold=local-chatcompletion
|
||||
|
||||
profile.model.textcompletion=local-completion
|
||||
profile.model.chatcompletion=local-chatcompletion
|
||||
profile.model.responses=local-responses
|
||||
profile.model.ollama=local-ollama-chatcompletion
|
||||
profile.model.openrouter=local-openrouter-chatcompletion
|
||||
#profile.model.azure=llama3.3-70b-azure
|
||||
|
||||
16
src/test/resources/local-responses.properties
Normal file
16
src/test/resources/local-responses.properties
Normal file
@@ -0,0 +1,16 @@
|
||||
category=RESPONSES
|
||||
prompts=
|
||||
tokenizer=llamaServer
|
||||
tokenEncoding=cl100k_base
|
||||
maxTokens=2500
|
||||
maxContextLength=16000
|
||||
temperature=0.4
|
||||
topP=1.0
|
||||
modelProvider=OAI_RESPONSES
|
||||
modelName=
|
||||
url=http://localhost:5001
|
||||
apiKey=
|
||||
cost1MInTokens=0
|
||||
cost1MCachedInTokens=0
|
||||
cost1MOutTokens=0
|
||||
cost1MReasoningOutTokens=0
|
||||
Reference in New Issue
Block a user