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
104 changes: 104 additions & 0 deletions src/Orbit.Api/Controllers/ChatController.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using System.Text.Json;
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Orbit.Api.Extensions;
using Orbit.Api.RateLimiting;
using Orbit.Application.Chat.Commands;
using Orbit.Application.Chat.Models;
using Orbit.Application.Common;
using Orbit.Domain.Common;
using Orbit.Domain.Interfaces;
Expand Down Expand Up @@ -66,6 +69,104 @@
return result.ToPayGateAwareResult(v => Ok(v));
}

[HttpPost("stream")]
[RequestSizeLimit(10_485_760)] [RequestFormLimits(MultipartBodyLengthLimit = 10_485_760)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> ProcessChatStream(
[FromForm] string message,
[FromForm] string? history,
IFormFile? image,
CancellationToken cancellationToken,
[FromForm] string? clientContext = null,
[FromForm] string? confirmationToken = null)
{
if (string.IsNullOrWhiteSpace(message) || message.Length > AppConstants.MaxChatMessageLength)
return BadRequest(new { error = $"Message must be between 1 and {AppConstants.MaxChatMessageLength} characters" });

var (imageData, imageMimeType, imageError) = await ProcessImageAsync(image, cancellationToken);
if (imageError is not null)
return imageError;

var (chatHistory, historyError) = ParseChatHistory(history);
if (historyError is not null)
return historyError;

var (parsedClientContext, clientContextError) = ParseClientContext(clientContext);
if (clientContextError is not null)
return clientContextError;

await StartEventStreamAsync(cancellationToken);

var command = new ProcessUserChatCommand(
HttpContext.GetUserId(),
message,
imageData,
imageMimeType,
chatHistory,
parsedClientContext,
confirmationToken,
HttpContext.User.GetAgentAuthMethod(),
HttpContext.User.GetGrantedAgentScopes(),
HttpContext.User.IsReadOnlyCredential(),
HttpContext.TraceIdentifier,
streamEvent => WriteEventAsync(streamEvent, cancellationToken));

await StreamCommandResultAsync(command, cancellationToken);
return new EmptyResult();
}

private async Task StartEventStreamAsync(CancellationToken cancellationToken)
{
Response.ContentType = "text/event-stream";
Response.Headers.CacheControl = "no-cache, no-store";
Response.Headers["X-Accel-Buffering"] = "no";
HttpContext.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering();
await WriteEventAsync(ChatStreamEvent.Started(), cancellationToken);
}

private async Task StreamCommandResultAsync(ProcessUserChatCommand command, CancellationToken cancellationToken)
{
try
{
var result = await mediator.Send(command, cancellationToken);
var finalEvent = result.IsSuccess
? ChatStreamEvent.Final(result.Value)
: ToErrorEvent(result);
await WriteEventAsync(finalEvent, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}

Check warning on line 140 in src/Orbit.Api/Controllers/ChatController.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Either remove or fill this block of code.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ6yx2VYgEQsGobqCWiz&open=AZ6yx2VYgEQsGobqCWiz&pullRequest=199
catch (ValidationException ex)
{
await WriteEventAsync(
ChatStreamEvent.Failure(StatusCodes.Status400BadRequest, ex.Message),
cancellationToken);
}
catch (Exception ex)
{
LogChatStreamFailed(logger, ex);
await WriteEventAsync(
ChatStreamEvent.Failure(StatusCodes.Status500InternalServerError, "AI service temporarily unavailable"),
cancellationToken);
}
}

private static ChatStreamEvent ToErrorEvent(Result<ChatResponse> result)
{
var status = result.ErrorCode == Result.PayGateErrorCode
? StatusCodes.Status403Forbidden
: StatusCodes.Status400BadRequest;
return ChatStreamEvent.Failure(status, result.Error, result.ErrorCode);
}

private async Task WriteEventAsync(ChatStreamEvent streamEvent, CancellationToken cancellationToken)
{
await Response.WriteAsync($"data: {streamEvent.ToJson()}\n\n", cancellationToken);
await Response.Body.FlushAsync(cancellationToken);
}

private async Task<(byte[]? Data, string? MimeType, IActionResult? Error)> ProcessImageAsync(
IFormFile? image, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -157,4 +258,7 @@
[LoggerMessage(EventId = 3, Level = LogLevel.Warning, Message = "Chat client context parse failed: {Error}")]
private static partial void LogClientContextParseFailed(ILogger logger, Exception ex, string error);

[LoggerMessage(EventId = 4, Level = LogLevel.Error, Message = "Chat stream processing failed")]
private static partial void LogChatStreamFailed(ILogger logger, Exception ex);

}
23 changes: 21 additions & 2 deletions src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
AgentAuthMethod AuthMethod = AgentAuthMethod.Jwt,
IReadOnlyList<string>? GrantedScopes = null,
bool IsReadOnlyCredential = false,
string? CorrelationId = null) : IRequest<Result<ChatResponse>>;
string? CorrelationId = null,
Func<ChatStreamEvent, Task>? StreamSink = null) : IRequest<Result<ChatResponse>>;

public record ChatResponse(
string? AiMessage,
Expand Down Expand Up @@ -168,13 +169,16 @@
LogCallingAiIntentService(logger, toolDeclarations.Count);
var aiStopwatch = System.Diagnostics.Stopwatch.StartNew();

var aiStreamSink = BuildAiStreamSink(request.StreamSink);

var response = await ai.IntentService.SendWithToolsAsync(
request.Message,
systemPrompt,
toolDeclarations,
request.ImageData,
request.ImageMimeType,
request.History,
aiStreamSink,
cancellationToken);

aiStopwatch.Stop();
Expand All @@ -192,11 +196,15 @@
while (aiResponse.HasToolCalls && iteration < MaxToolIterations)
{
iteration++;
if (request.StreamSink is not null)
await request.StreamSink(ChatStreamEvent.Round(iteration));

var continueResponse = await ProcessToolCallsAsync(
aiResponse,
request,
executionResults,
iteration,
aiStreamSink,
cancellationToken);

if (continueResponse is null)
Expand Down Expand Up @@ -251,11 +259,22 @@
/// Processes one iteration of AI tool calls: orders them, executes each, and sends results
/// back to the AI. Returns the next AI response, or null if continuation failed.
/// </summary>
private static Func<AiStreamEvent, Task>? BuildAiStreamSink(Func<ChatStreamEvent, Task>? streamSink)
{
if (streamSink is null)
return null;

return aiEvent => streamSink(aiEvent.Kind == AiStreamEventKind.Delta
? ChatStreamEvent.Delta(aiEvent.Text ?? "")
: ChatStreamEvent.Reset());
}

private async Task<AiResponse?> ProcessToolCallsAsync(
AiResponse aiResponse,
ProcessUserChatCommand request,
ToolExecutionAccumulator executionResults,
int iteration,
Func<AiStreamEvent, Task>? aiStreamSink,
CancellationToken cancellationToken)
{
LogToolCallingIteration(logger, iteration, aiResponse.ToolCalls!.Count);
Expand All @@ -274,7 +293,7 @@
executionResults.Add(actionResult, operationResult, policyDenial, pendingOperation);
}

var continueResult = await ai.IntentService.ContinueWithToolResultsAsync(aiResponse.ConversationContext!, toolResults, cancellationToken);
var continueResult = await ai.IntentService.ContinueWithToolResultsAsync(aiResponse.ConversationContext!, toolResults, aiStreamSink, cancellationToken);
if (continueResult.IsFailure)
{
LogContinueWithToolResultsFailed(logger, continueResult.Error);
Expand All @@ -293,7 +312,7 @@
ActionResult? ActionResult,
AgentOperationResult? OperationResult,
AgentPolicyDenial? PolicyDenial,
PendingAgentOperation? PendingOperation)> ExecuteSingleToolCallAsync(

Check warning on line 315 in src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Refactor this method to reduce its Cognitive Complexity from 22 to the 15 allowed.
AiToolCall call,
ProcessUserChatCommand request,
CancellationToken cancellationToken)
Expand Down Expand Up @@ -479,7 +498,7 @@
result.EntityName);
}

private static AgentContextSnapshot BuildAgentContextSnapshot(

Check warning on line 501 in src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Method has 8 parameters, which is greater than the 7 authorized.
User? user,
AgentClientContext? clientContext,
IReadOnlyList<string> featureFlags,
Expand Down Expand Up @@ -613,7 +632,7 @@
return msgEl.GetString();
}
catch (JsonException)
{

Check warning on line 635 in src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Either remove or fill this block of code.
}

return text;
Expand Down Expand Up @@ -667,7 +686,7 @@
if (operationResult.Status != AgentOperationStatus.Succeeded)
return;

foreach (var surface in ExtractRelatedSurfaces(operationResult.Payload))

Check warning on line 689 in src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Loops should be simplified using the "Where" LINQ method
{
if (_seenRelatedSurfaces.Add(surface))
_relatedSurfaces.Add(surface);
Expand Down Expand Up @@ -905,7 +924,7 @@
{
var node = JsonNode.Parse(args.GetRawText());
if (node is not JsonObject argsObject || argsObject["message"] is not JsonValue messageValue
|| !messageValue.TryGetValue(out string? message) || message is null)

Check warning on line 927 in src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Change this condition so that it does not always evaluate to 'False'.
{
return args;
}
Expand Down
40 changes: 40 additions & 0 deletions src/Orbit.Application/Chat/Models/ChatStreamEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Orbit.Application.Chat.Commands;

namespace Orbit.Application.Chat.Models;

/// <summary>
/// One server-sent event in the chat stream contract shared with the clients.
/// started/round/delta/reset report progress, final carries the complete ChatResponse,
/// and error carries an HTTP-equivalent status plus the same error/code shape the
/// buffered endpoint returns. Serialized camelCase with enums as strings, mirroring
/// the regular API responses.
/// </summary>
public sealed record ChatStreamEvent(
string Type,
string? Text = null,
int? Iteration = null,
ChatResponse? Response = null,
int? Status = null,
string? Error = null,
string? Code = null)
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};

public static ChatStreamEvent Started() => new("started");
public static ChatStreamEvent Round(int iteration) => new("round", Iteration: iteration);
public static ChatStreamEvent Delta(string text) => new("delta", Text: text);
public static ChatStreamEvent Reset() => new("reset");
public static ChatStreamEvent Final(ChatResponse response) => new("final", Response: response);

public static ChatStreamEvent Failure(int status, string error, string? code = null) =>
new("error", Status: status, Error: error, Code: code);

public string ToJson() => JsonSerializer.Serialize(this, SerializerOptions);
}
2 changes: 2 additions & 0 deletions src/Orbit.Domain/Interfaces/IAiIntentService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@

public interface IAiIntentService
{
Task<Result<AiResponse>> SendWithToolsAsync(

Check warning on line 8 in src/Orbit.Domain/Interfaces/IAiIntentService.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Method has 8 parameters, which is greater than the 7 authorized.
string userMessage,
string systemPrompt,
IReadOnlyList<object> toolDeclarations,
byte[]? imageData = null,
string? imageMimeType = null,
IReadOnlyList<ChatHistoryMessage>? history = null,
Func<AiStreamEvent, Task>? streamSink = null,
CancellationToken cancellationToken = default);

Check warning on line 16 in src/Orbit.Domain/Interfaces/IAiIntentService.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Method has 8 parameters, which is greater than the 7 authorized.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ6yx2TOgEQsGobqCWiy&open=AZ6yx2TOgEQsGobqCWiy&pullRequest=199

Task<Result<AiResponse>> ContinueWithToolResultsAsync(
AiConversationContext conversationContext,
IReadOnlyList<AiToolCallResult> results,
Func<AiStreamEvent, Task>? streamSink = null,
CancellationToken cancellationToken = default);
}
18 changes: 18 additions & 0 deletions src/Orbit.Domain/Models/AiStreamEvent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Orbit.Domain.Models;

public enum AiStreamEventKind
{
Delta,
Reset
}

/// <summary>
/// Incremental output emitted while an AI completion streams. Delta carries a text
/// fragment of the answer; Reset tells the consumer to discard text emitted so far
/// because the round turned out to be a tool-call round.
/// </summary>
public sealed record AiStreamEvent(AiStreamEventKind Kind, string? Text)
{
public static AiStreamEvent Delta(string text) => new(AiStreamEventKind.Delta, text);
public static AiStreamEvent Reset() => new(AiStreamEventKind.Reset, null);
}
6 changes: 6 additions & 0 deletions src/Orbit.Infrastructure/AI/AiCompletionClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ public AiCompletionClient(IOptions<AiSettings> options, ILogger<AiCompletionClie
});
}

internal AiCompletionClient(ChatClient chatClient, ILogger<AiCompletionClient> logger)
{
_chatClient = chatClient;
_logger = logger;
}

/// <summary>
/// Direct access to the underlying ChatClient for advanced scenarios (tool calling, multi-turn).
/// </summary>
Expand Down
3 changes: 2 additions & 1 deletion src/Orbit.Infrastructure/Services/AgentCatalogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ private static IReadOnlyList<AgentCapability> BuildCapabilities()
controllerActions:
[
"ChatController.ProcessChat",
"ChatController.ProcessChatStream",
"AiController.ConfirmPendingOperation",
"AiController.MarkPendingOperationStepUp",
"AiController.VerifyPendingOperationStepUp",
Expand Down Expand Up @@ -1192,7 +1193,7 @@ private static IReadOnlyList<AppSurface> BuildSurfaces()
["Send a prompt to the chat endpoint.", "The backend resolves tool calls.", "Review pending confirmations before destructive actions."],
["Chat may use clientContext as UI hints only.", "Authorization is always backend-enforced."],
[AgentCapabilityIds.ChatInteract],
["ChatController.ProcessChat"]),
["ChatController.ProcessChat", "ChatController.ProcessChatStream"]),

new AppSurface(
"profile-preferences",
Expand Down
Loading
Loading