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 @@ -3,7 +3,8 @@
/**
* Lifecycle chunk describing the start of one generation step.
*
* <p>Step start chunks are not persisted into {@link UIMessage#parts()} by the stream reader.
* <p>The stream reader persists this lifecycle event as a marker-only {@link StepStartPart}.
* The invocation-local index remains stream diagnostics and is not copied to the persisted part.
*
* @param stepIndex step index
*/
Expand Down
14 changes: 14 additions & 0 deletions api/src/main/java/run/halo/aifoundation/ui/StepStartPart.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package run.halo.aifoundation.ui;

/**
* Marker persisted at the start of one assistant generation step.
*
* <p>The marker's position in {@link UIMessage#parts()} defines the boundary. It intentionally
* carries no invocation-local step index because indexes restart for each model invocation.
*/
public record StepStartPart() implements UIMessagePart {
@Override
public String type() {
return UIMessageChunkType.STEP_START;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public UIMessageChunkReducer(List<UIMessagePart> parts) {
* Applies a stream chunk.
*
* @param chunk stream chunk
* @return true when persisted message parts changed
* @return true when visible persisted message content changed
*/
public boolean accept(UIMessageChunk chunk) {
UIMessageChunkValidator.validate(chunk);
Expand Down Expand Up @@ -74,7 +74,10 @@ public boolean accept(UIMessageChunk chunk) {
tool.providerMetadata());
case ToolChunk tool -> replaceTool(tool);
case FinishStepChunk ignored -> false;
case StartStepChunk ignored -> false;
case StartStepChunk ignored -> {
parts.add(UIMessageParts.stepStart());
yield false;
}
case FinishChunk finish -> {
terminal = terminal.withFinish(finish.finishReason(), finish.usage());
yield false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ public final class UIMessageChunkType {
public static final String START = "start";
/** Starts one generation step. */
public static final String START_STEP = "start-step";
/** Persisted generation-step boundary marker. */
public static final String STEP_START = "step-start";
/** Persisted text part type. */
public static final String TEXT = "text";
/** Opens a streamed text block. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,36 +112,24 @@ private void convertMessage(UIMessage<M> message, int messageIndex) {
var emitted = false;
for (var partIndex = 0; partIndex < message.parts().size(); partIndex++) {
var part = message.parts().get(partIndex);
if (part instanceof StepStartPart) {
emitted |= flushSegment(message, assistantContent, toolContent);
continue;
}
var context = new UIMessageConversionContext<>(messages, message, messageIndex,
part, partIndex);
if (isToolApprovalResponse(part)) {
if (!assistantContent.isEmpty()) {
emitted |= flushSegment(message, assistantContent, toolContent);
}
assistantContent.addAll(convertToolCall((ToolPart) part));
assistantContent.add(convertToolApprovalRequest((ToolPart) part));
emitted |= flushSegment(message, assistantContent, toolContent);
toolContent.add(convertToolApprovalResponse((ToolPart) part));
continue;
}
if (isTerminalToolPart(part)) {
if (!toolContent.isEmpty()) {
emitted |= flushSegment(message, assistantContent, toolContent);
}
assistantContent.addAll(convertToolCall((ToolPart) part));
emitted |= flushSegment(message, assistantContent, toolContent);
toolContent.addAll(convertToolOutput((ToolPart) part));
continue;
}
var converted = convertPart(part, context);
if (isToolResponsePart(part)) {
toolContent.addAll(converted);
} else {
if (!toolContent.isEmpty()) {
emitted |= flushSegment(message, assistantContent, toolContent);
}
assistantContent.addAll(converted);
}
assistantContent.addAll(convertPart(part, context));
}
emitted |= flushSegment(message, assistantContent, toolContent);
if (!emitted) {
Expand All @@ -167,10 +155,6 @@ private boolean flushSegment(UIMessage<M> message,
return emitted;
}

private boolean isToolResponsePart(UIMessagePart part) {
return isTerminalToolPart(part) || isToolApprovalResponse(part);
}

private boolean isToolApprovalResponse(UIMessagePart part) {
return part instanceof ToolPart tool
&& (tool.state() == ToolPartState.APPROVAL_RESPONDED
Expand Down
4 changes: 2 additions & 2 deletions api/src/main/java/run/halo/aifoundation/ui/UIMessagePart.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
* stream chunks such as tool input deltas, finish events, errors, and aborts are
* not represented as parts.
*/
public sealed interface UIMessagePart permits TextPart, ReasoningPart, DataPart, ToolPart,
SourceUrlPart, SourceDocumentPart, FilePart {
public sealed interface UIMessagePart permits StepStartPart, TextPart, ReasoningPart, DataPart,
ToolPart, SourceUrlPart, SourceDocumentPart, FilePart {

/**
* Stable discriminator used by serializers and callers.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public record UIMessagePartIdentity(String type, String id) {
*/
public static UIMessagePartIdentity of(UIMessagePart part) {
return switch (part) {
case StepStartPart value -> new UIMessagePartIdentity(value.type(), value.type());
case TextPart value -> new UIMessagePartIdentity(value.type(), value.id());
case ReasoningPart value -> new UIMessagePartIdentity(value.type(), value.id());
case DataPart value -> new UIMessagePartIdentity(value.type(), value.id());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ public final class UIMessageParts {
private UIMessageParts() {
}

/**
* Creates a persisted generation-step boundary marker.
*
* @return step-start marker
*/
public static StepStartPart stepStart() {
return new StepStartPart();
}

/**
* Creates a persisted text part.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ public static UIMessagePart partFromMap(Map<String, Object> map) {
objectMap(map.get("providerMetadata")));
}
return switch (type) {
case UIMessageChunkType.STEP_START -> UIMessageParts.stepStart();
case UIMessageChunkType.TEXT -> UIMessageParts.text(stringValue(map.get("id")),
stringValue(map.get("text")));
case UIMessageChunkType.REASONING -> UIMessageParts.reasoning(
Expand Down Expand Up @@ -322,6 +323,8 @@ public static Map<String, Object> partToMap(UIMessagePart part) {
var map = new LinkedHashMap<String, Object>();
put(map, "type", part.type());
switch (part) {
case StepStartPart ignored -> {
}
case TextPart text -> {
put(map, "id", text.id());
put(map, "text", text.text());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ private void validatePart(UIMessage<M> message, int messageIndex, UIMessagePart
"UI message part type must not be blank"));
}
switch (part) {
case StepStartPart ignored -> {
if (message.role() != UIMessageRole.ASSISTANT) {
issues.add(issue(message, part, null, "part.step-start.role.invalid",
"Step-start parts are only allowed in assistant messages"));
}
}
case TextPart text -> require(message, part, text.id(), "part.id.required",
"Text part id must not be blank");
case ReasoningPart reasoning -> require(message, part, reasoning.id(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public ModelCapabilities effectiveCapabilities(AiModel model,
var imageGeneration = copy(explicit == null ? null : explicit.getImageGeneration());

if (spec.getModelType() == ModelType.LANGUAGE) {
language = applyLanguageDefaults(language, spec.getFeatures());
language = applyLanguageDefaults(language, spec.getFeatures(), providerType);
}
if (spec.getModelType() == ModelType.IMAGE_GENERATION
&& sources.getImageGeneration() != CapabilitySource.MANUAL) {
Expand All @@ -47,10 +47,18 @@ public ModelCapabilities effectiveCapabilities(AiModel model,
}

private LanguageCapability applyLanguageDefaults(LanguageCapability language,
List<ModelFeature> features) {
List<ModelFeature> features, @Nullable AiProviderType providerType) {
var result = language;
var hasVision = features != null && features.contains(ModelFeature.VISION);
var hasAudioInput = features != null && features.contains(ModelFeature.AUDIO_INPUT);
if (providerType != null) {
result = result == null ? LanguageCapability.unknown() : result;
if (result.getReasoningHistory() == null) {
var providerOptions = providerType.languageModelProviderOptions();
result.setReasoningHistory(providerOptions != null
&& providerOptions.reasoningHistorySupported());
}
}
if (hasVision) {
result = result == null ? LanguageCapability.unknown() : result;
if (result.getImageInput() == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ public class LanguageModelImpl implements LanguageModel {
private final String providerType;
private final LanguageModelProviderOptions providerOptions;
private final run.halo.aifoundation.capability.ModelCapabilities modelCapabilities;
private final boolean reasoningHistorySupported;
private final LanguageModelRequestValidator requestValidator;
private final LanguageModelMessageMapper messageMapper;
private final GenerationMessageHistoryAssembler messageHistoryAssembler;
Expand Down Expand Up @@ -148,6 +149,7 @@ public class LanguageModelImpl implements LanguageModel {
this.providerType = composition.providerType();
this.providerOptions = composition.providerOptions();
this.modelCapabilities = composition.modelCapabilities();
this.reasoningHistorySupported = composition.reasoningHistorySupported();
this.requestValidator = composition.requestValidator();
this.messageMapper = composition.messageMapper();
this.messageHistoryAssembler = composition.messageHistoryAssembler();
Expand Down Expand Up @@ -211,7 +213,7 @@ public StreamTextResult streamText(GenerateTextRequest request) {

@Override
public LanguageModelCapabilities capabilities() {
return LanguageModelCapabilities.of(providerOptions.reasoningHistorySupported(),
return LanguageModelCapabilities.of(reasoningHistorySupported,
modelCapabilities);
}

Expand Down Expand Up @@ -1276,7 +1278,7 @@ protected boolean supportsToolCalling() {
}

protected boolean supportsReasoningHistory() {
return providerOptions.reasoningHistorySupported();
return reasoningHistorySupported;
}

private String toolCallingUnsupportedMessage() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public record LanguageModelRuntimeComposition(
String providerType,
LanguageModelProviderOptions providerOptions,
ModelCapabilities modelCapabilities,
boolean reasoningHistorySupported,
LanguageModelRequestValidator requestValidator,
LanguageModelMessageMapper messageMapper,
GenerationMessageHistoryAssembler messageHistoryAssembler,
Expand Down Expand Up @@ -65,12 +66,14 @@ public static LanguageModelRuntimeComposition create(
var context = configuration.context();
var resolvedOptions = configuration.providerOptions();
var resolvedCapabilities = configuration.modelCapabilities();
var reasoningHistorySupported = reasoningHistorySupported(resolvedCapabilities,
resolvedOptions);
var requestValidator = new LanguageModelRequestValidator(context.providerType(),
resolvedOptions.reasoningHistorySupported(), resolvedCapabilities, context.modelName(),
reasoningHistorySupported, resolvedCapabilities, context.modelName(),
context.providerName(), mediaResourcePolicy, capabilityMatcher);
var messageMapper = new LanguageModelMessageMapper(context.providerType());
var messageHistoryAssembler = new GenerationMessageHistoryAssembler(context.providerType(),
resolvedOptions.reasoningHistorySupported(), messageMapper);
reasoningHistorySupported, messageMapper);
var chatOptionsBuilder = new LanguageModelChatOptionsBuilder(context.providerType(),
context.modelId(),
resolvedOptions, runtimeSupport::writeJson);
Expand All @@ -86,10 +89,19 @@ public static LanguageModelRuntimeComposition create(
runtimeSupport::withToolTimeout);
var toolStepCoordinator = new ToolStepCoordinator(toolExecutor);
return new LanguageModelRuntimeComposition(context.providerType(), resolvedOptions,
resolvedCapabilities, requestValidator, messageMapper, messageHistoryAssembler,
chatOptionsBuilder, responseMapper, reasoningExtractor, toolCallMapper,
structuredOutputHandler, toolExecutor, toolStepCoordinator, new ToolApprovalResolver(),
runtimeSupport);
resolvedCapabilities, reasoningHistorySupported, requestValidator, messageMapper,
messageHistoryAssembler, chatOptionsBuilder, responseMapper, reasoningExtractor,
toolCallMapper, structuredOutputHandler, toolExecutor, toolStepCoordinator,
new ToolApprovalResolver(), runtimeSupport);
}

private static boolean reasoningHistorySupported(ModelCapabilities capabilities,
LanguageModelProviderOptions providerOptions) {
var language = capabilities.getLanguage();
if (language != null && language.getReasoningHistory() != null) {
return language.getReasoningHistory();
}
return providerOptions.reasoningHistorySupported();
}

private static LanguageModelRuntimeConfiguration configuration(String providerType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,8 @@ private void validateReasoningPart(ModelMessageRole role, ModelMessagePart part)
"reasoning content part is only supported for assistant messages");
}
if (!reasoningHistorySupported) {
throw new IllegalArgumentException("reasoning content is not supported by provider type: "
+ providerType);
throw new IllegalArgumentException(
"assistant reasoning history is not supported by the resolved model");
}
if (!hasText(part.getText()) && (part.getProviderMetadata() == null
|| part.getProviderMetadata().isEmpty())) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ void chatRequestBody_replaysReasoningContentForToolContinuation() {
var assistant = AssistantMessage.builder()
.content("")
.properties(Map.of("reasoningContent", "tool reasoning"))
.toolCalls(List.of(
new AssistantMessage.ToolCall("call-1", "function", "weather",
"{\"city\":\"Hangzhou\"}"),
new AssistantMessage.ToolCall("call-2", "function", "search",
"{\"query\":\"Halo\"}")
))
.build();
var prompt = new Prompt(List.of(assistant), chatOptions());

Expand All @@ -66,6 +72,9 @@ void chatRequestBody_replaysReasoningContentForToolContinuation() {

assertThat(messages.getFirst())
.containsEntry("reasoning_content", "tool reasoning");
@SuppressWarnings("unchecked")
var toolCalls = (List<Map<String, Object>>) messages.getFirst().get("tool_calls");
assertThat(toolCalls).hasSize(2);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import run.halo.aifoundation.extension.AiModel;
import run.halo.aifoundation.provider.AiProviderType;
import run.halo.aifoundation.provider.support.AdapterType;
import run.halo.aifoundation.provider.support.LanguageModelProviderOptions;
import run.halo.aifoundation.provider.support.ModelFeature;
import run.halo.aifoundation.provider.support.ModelType;
import run.halo.app.extension.Metadata;
Expand Down Expand Up @@ -126,6 +127,51 @@ void effectiveCapabilities_canUseProviderRecommendedImageAdapter() {
assertThat(capabilities.getImageGeneration().getTextToImage()).isTrue();
}

@Test
void effectiveCapabilities_inheritsProviderReasoningHistoryDefault() {
var model = model(ModelType.LANGUAGE);
var providerType = mock(AiProviderType.class);
when(providerType.languageModelProviderOptions()).thenReturn(
LanguageModelProviderOptions.builder()
.reasoningHistorySupported(true)
.build());

var capabilities = service.effectiveCapabilities(model, providerType);

assertThat(capabilities.getLanguage().getReasoningHistory()).isTrue();
}

@Test
void effectiveCapabilities_modelReasoningHistoryOverridesProviderDefault() {
var model = model(ModelType.LANGUAGE);
model.getSpec().setCapabilities(ModelCapabilities.builder()
.language(LanguageCapability.builder()
.reasoningHistory(false)
.build())
.build());
var providerType = mock(AiProviderType.class);
when(providerType.languageModelProviderOptions()).thenReturn(
LanguageModelProviderOptions.builder()
.reasoningHistorySupported(true)
.build());

var capabilities = service.effectiveCapabilities(model, providerType);

assertThat(capabilities.getLanguage().getReasoningHistory()).isFalse();
}

@Test
void effectiveCapabilities_inheritsUnsupportedProviderReasoningHistoryDefault() {
var model = model(ModelType.LANGUAGE);
var providerType = mock(AiProviderType.class);
when(providerType.languageModelProviderOptions())
.thenReturn(LanguageModelProviderOptions.defaults());

var capabilities = service.effectiveCapabilities(model, providerType);

assertThat(capabilities.getLanguage().getReasoningHistory()).isFalse();
}

@Test
void matcher_treatsUnknownAsUnsupportedAndUsesMediaCoverage() {
var unknown = ModelCapabilities.empty();
Expand Down
Loading
Loading