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:
extio-de
2026-05-14 20:26:59 +02:00
parent e3dda708f2
commit 12a90917ee
15 changed files with 1950 additions and 21 deletions

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -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());
}
}

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -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;
}
}