1305 lines
51 KiB
C#
1305 lines
51 KiB
C#
// <copyright file="ChatEndpoints.cs" company="StellaOps">
|
|
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
|
// </copyright>
|
|
|
|
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.AdvisoryAI.Chat.Assembly;
|
|
using StellaOps.AdvisoryAI.Chat.Inference;
|
|
using StellaOps.AdvisoryAI.Chat.Models;
|
|
using StellaOps.AdvisoryAI.Chat.Options;
|
|
using StellaOps.AdvisoryAI.Chat.Routing;
|
|
using StellaOps.AdvisoryAI.Chat.Services;
|
|
using StellaOps.AdvisoryAI.Chat.Settings;
|
|
using StellaOps.AdvisoryAI.WebService.Contracts;
|
|
using StellaOps.AdvisoryAI.WebService.Security;
|
|
using StellaOps.Auth.ServerIntegration.Tenancy;
|
|
using System.Collections.Immutable;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using static StellaOps.Localization.T;
|
|
|
|
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
|
|
|
|
/// <summary>
|
|
/// API endpoints for Advisory AI Chat with streaming support.
|
|
/// Sprint: SPRINT_20260107_013_003 Task: SVC-003
|
|
/// </summary>
|
|
public static class ChatEndpoints
|
|
{
|
|
private static readonly JsonSerializerOptions StreamJsonOptions = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
WriteIndented = false
|
|
};
|
|
|
|
/// <summary>
|
|
/// Maps chat endpoints to the route builder.
|
|
/// </summary>
|
|
/// <param name="builder">The endpoint route builder.</param>
|
|
/// <returns>The route group builder.</returns>
|
|
public static RouteGroupBuilder MapChatEndpoints(this IEndpointRouteBuilder builder)
|
|
{
|
|
var group = builder.MapGroup("/api/v1/chat")
|
|
.WithTags("Advisory Chat")
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
.RequireTenant();
|
|
|
|
// Single query endpoint (non-streaming)
|
|
group.MapPost("/query", ProcessQueryAsync)
|
|
.WithName("ProcessChatQuery")
|
|
.WithSummary("Processes a chat query and returns an evidence-grounded response")
|
|
.WithDescription("Analyzes the user query, assembles evidence bundle, and generates a response with citations.")
|
|
.Produces<AdvisoryChatQueryResponse>(StatusCodes.Status200OK)
|
|
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
|
|
.Produces<ErrorResponse>(StatusCodes.Status503ServiceUnavailable)
|
|
.ProducesValidationProblem();
|
|
|
|
// Streaming query endpoint
|
|
group.MapPost("/query/stream", StreamQueryAsync)
|
|
.WithName("StreamChatQuery")
|
|
.WithSummary("Streams a chat response as Server-Sent Events")
|
|
.WithDescription("Processes the query and streams the response tokens as SSE events.")
|
|
.Produces(StatusCodes.Status200OK, contentType: "text/event-stream")
|
|
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
|
|
.Produces<ErrorResponse>(StatusCodes.Status503ServiceUnavailable);
|
|
|
|
// Intent detection endpoint (lightweight)
|
|
group.MapPost("/intent", DetectIntentAsync)
|
|
.WithName("DetectChatIntent")
|
|
.WithSummary("Detects intent from a user query without generating a full response")
|
|
.WithDescription("Classifies the user query into one of the advisory chat intents (explain, remediate, assess-risk, compare, etc.) and extracts structured parameters such as finding ID, package PURL, image reference, and environment. Useful for pre-routing or UI intent indicators without consuming LLM quota.")
|
|
.Produces<IntentDetectionResponse>(StatusCodes.Status200OK)
|
|
.ProducesValidationProblem();
|
|
|
|
// Evidence bundle preview endpoint
|
|
group.MapPost("/evidence-preview", PreviewEvidenceBundleAsync)
|
|
.WithName("PreviewEvidenceBundle")
|
|
.WithSummary("Previews the evidence bundle that would be assembled for a query")
|
|
.WithDescription("Assembles and returns a preview of the evidence bundle that would be passed to the LLM for the specified finding, without generating an AI response. Indicates which evidence types are available (VEX, reachability, binary patch, provenance, policy, ops memory, fix options) and their status.")
|
|
.Produces<EvidenceBundlePreviewResponse>(StatusCodes.Status200OK)
|
|
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest);
|
|
|
|
// Settings endpoints
|
|
group.MapGet("/settings", GetChatSettingsAsync)
|
|
.WithName("GetChatSettings")
|
|
.WithSummary("Gets effective chat settings for the caller")
|
|
.WithDescription("Returns the effective advisory chat settings for the current tenant and user, merging global defaults, tenant overrides, and user overrides. Includes quota limits and tool access configuration.")
|
|
.Produces<ChatSettingsResponse>(StatusCodes.Status200OK);
|
|
|
|
group.MapPut("/settings", UpdateChatSettingsAsync)
|
|
.WithName("UpdateChatSettings")
|
|
.WithSummary("Updates chat settings overrides (tenant or user)")
|
|
.WithDescription("Applies quota and tool access overrides for the current tenant (default) or a specific user (scope=user). Overrides are layered on top of global defaults; only fields present in the request body are changed.")
|
|
.Produces<ChatSettingsResponse>(StatusCodes.Status200OK)
|
|
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest);
|
|
|
|
group.MapDelete("/settings", ClearChatSettingsAsync)
|
|
.WithName("ClearChatSettings")
|
|
.WithSummary("Clears chat settings overrides (tenant or user)")
|
|
.WithDescription("Removes all tenant-level or user-level chat settings overrides, reverting the affected scope to global defaults. Use scope=user to clear only the user-level override for the current user.")
|
|
.Produces(StatusCodes.Status204NoContent);
|
|
|
|
// Doctor endpoint
|
|
group.MapGet("/doctor", GetChatDoctorAsync)
|
|
.WithName("GetChatDoctor")
|
|
.WithSummary("Returns chat limit status and tool access diagnostics")
|
|
.WithDescription("Returns a diagnostics report for the current tenant and user, including remaining quota across all dimensions (requests/min, requests/day, tokens/day, tool calls/day), tool provider availability, and the last quota denial if any. Referenced by error responses via the doctor action hint.")
|
|
.Produces<ChatDoctorResponse>(StatusCodes.Status200OK);
|
|
|
|
// Health/status endpoint for chat service
|
|
group.MapGet("/status", GetChatStatusAsync)
|
|
.WithName("GetChatStatus")
|
|
.WithSummary("Gets the status of the advisory chat service")
|
|
.WithDescription("Returns the current operational status of the advisory chat service, including whether chat is enabled, the configured inference provider and model, maximum token limit, and whether guardrails and audit logging are active.")
|
|
.Produces<ChatServiceStatusResponse>(StatusCodes.Status200OK);
|
|
|
|
return group;
|
|
}
|
|
|
|
private static async Task<IResult> ProcessQueryAsync(
|
|
[FromBody] AdvisoryChatQueryRequest request,
|
|
[FromServices] IAdvisoryChatService chatService,
|
|
[FromServices] IOptions<AdvisoryChatOptions> options,
|
|
[FromServices] ILogger<AdvisoryChatQueryRequest> logger,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
[FromHeader(Name = "X-User-Id")] string? userId,
|
|
[FromHeader(Name = "X-Correlation-Id")] string? correlationId,
|
|
CancellationToken ct)
|
|
{
|
|
if (!options.Value.Enabled)
|
|
{
|
|
return Results.Json(
|
|
new ErrorResponse { Error = _t("advisoryai.error.chat_disabled"), Code = "CHAT_DISABLED" },
|
|
statusCode: StatusCodes.Status503ServiceUnavailable);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Query))
|
|
{
|
|
return Results.BadRequest(new ErrorResponse { Error = _t("advisoryai.error.query_empty"), Code = "INVALID_QUERY" });
|
|
}
|
|
|
|
tenantId ??= "default";
|
|
userId ??= "anonymous";
|
|
|
|
logger.LogDebug("Processing chat query for tenant {TenantId}, user {UserId}", tenantId, userId);
|
|
|
|
var serviceRequest = new AdvisoryChatRequest
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = userId,
|
|
Query = request.Query,
|
|
ArtifactDigest = request.ArtifactDigest,
|
|
ImageReference = request.ImageReference,
|
|
Environment = request.Environment,
|
|
CorrelationId = correlationId,
|
|
ConversationId = request.ConversationId,
|
|
UserRoles = request.UserRoles?.ToImmutableArray() ?? ImmutableArray<string>.Empty
|
|
};
|
|
|
|
var result = await chatService.ProcessQueryAsync(serviceRequest, ct);
|
|
|
|
if (!result.Success)
|
|
{
|
|
if (!result.GuardrailBlocked && !result.QuotaBlocked && !result.ToolAccessDenied)
|
|
{
|
|
logger.LogWarning(
|
|
"Chat gateway runtime fallback activated for tenant {TenantId}, user {UserId}. Reason: {Reason}",
|
|
tenantId,
|
|
userId,
|
|
result.Error ?? "processing_failed");
|
|
|
|
return Results.Ok(CreateDeterministicFallbackQueryResponse(request, result));
|
|
}
|
|
|
|
var statusCode = result.GuardrailBlocked
|
|
? StatusCodes.Status400BadRequest
|
|
: result.QuotaBlocked
|
|
? StatusCodes.Status429TooManyRequests
|
|
: result.ToolAccessDenied
|
|
? StatusCodes.Status403Forbidden
|
|
: StatusCodes.Status500InternalServerError;
|
|
|
|
var code = result.GuardrailBlocked
|
|
? "GUARDRAIL_BLOCKED"
|
|
: result.QuotaBlocked
|
|
? result.QuotaCode ?? "QUOTA_EXCEEDED"
|
|
: result.ToolAccessDenied
|
|
? "TOOL_DENIED"
|
|
: "PROCESSING_FAILED";
|
|
|
|
Dictionary<string, object>? details = null;
|
|
if (result.GuardrailBlocked)
|
|
{
|
|
details = result.GuardrailViolations.ToDictionary(v => v, _ => (object)"violation");
|
|
}
|
|
else if (result.QuotaBlocked && result.QuotaStatus is not null)
|
|
{
|
|
details = new Dictionary<string, object> { ["quota"] = result.QuotaStatus };
|
|
}
|
|
else if (result.ToolAccessDenied)
|
|
{
|
|
details = new Dictionary<string, object>
|
|
{
|
|
["reason"] = result.ToolAccessReason ?? "Tool access denied"
|
|
};
|
|
}
|
|
|
|
var doctor = result.QuotaBlocked || result.ToolAccessDenied
|
|
? CreateDoctorAction(code)
|
|
: null;
|
|
|
|
return Results.Json(
|
|
new ErrorResponse
|
|
{
|
|
Error = result.Error ?? "Query processing failed",
|
|
Code = code,
|
|
Details = details,
|
|
Doctor = doctor
|
|
},
|
|
statusCode: statusCode);
|
|
}
|
|
|
|
return Results.Ok(MapToQueryResponse(result));
|
|
}
|
|
|
|
private static async Task StreamQueryAsync(
|
|
[FromBody] AdvisoryChatQueryRequest request,
|
|
[FromServices] IAdvisoryChatIntentRouter intentRouter,
|
|
[FromServices] IEvidenceBundleAssembler evidenceAssembler,
|
|
[FromServices] IAdvisoryChatInferenceClient inferenceClient,
|
|
[FromServices] IOptions<AdvisoryChatOptions> options,
|
|
[FromServices] IAdvisoryChatSettingsService settingsService,
|
|
[FromServices] IAdvisoryChatQuotaService quotaService,
|
|
[FromServices] ILogger<AdvisoryChatQueryRequest> logger,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
[FromHeader(Name = "X-User-Id")] string? userId,
|
|
[FromHeader(Name = "X-Correlation-Id")] string? correlationId,
|
|
HttpContext httpContext,
|
|
CancellationToken ct)
|
|
{
|
|
if (!options.Value.Enabled)
|
|
{
|
|
httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
|
await httpContext.Response.WriteAsJsonAsync(
|
|
new ErrorResponse { Error = _t("advisoryai.error.chat_disabled"), Code = "CHAT_DISABLED" },
|
|
ct);
|
|
return;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Query))
|
|
{
|
|
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
|
|
await httpContext.Response.WriteAsJsonAsync(
|
|
new ErrorResponse { Error = _t("advisoryai.error.query_empty"), Code = "INVALID_QUERY" },
|
|
ct);
|
|
return;
|
|
}
|
|
|
|
tenantId ??= "default";
|
|
userId ??= "anonymous";
|
|
|
|
httpContext.Response.ContentType = "text/event-stream";
|
|
httpContext.Response.Headers.CacheControl = "no-cache";
|
|
httpContext.Response.Headers.Connection = "keep-alive";
|
|
|
|
await httpContext.Response.StartAsync(ct);
|
|
|
|
try
|
|
{
|
|
// Step 1: Route intent
|
|
var routingResult = await intentRouter.RouteAsync(request.Query, ct);
|
|
|
|
await WriteStreamEventAsync(httpContext, "intent", new
|
|
{
|
|
intent = routingResult.Intent.ToString(),
|
|
confidence = routingResult.Confidence,
|
|
parameters = routingResult.Parameters
|
|
}, ct);
|
|
|
|
// Step 2: Resolve context
|
|
var artifactDigest = request.ArtifactDigest ?? ExtractDigestFromImageRef(routingResult.Parameters.ImageReference);
|
|
var findingId = routingResult.Parameters.FindingId;
|
|
|
|
if (string.IsNullOrEmpty(artifactDigest) || string.IsNullOrEmpty(findingId))
|
|
{
|
|
await WriteStreamEventAsync(httpContext, "error", new
|
|
{
|
|
code = "MISSING_CONTEXT",
|
|
message = "Missing artifact digest or finding ID"
|
|
}, ct);
|
|
await WriteStreamEventAsync(httpContext, "done", new { success = false }, ct);
|
|
return;
|
|
}
|
|
|
|
var settings = await settingsService.GetEffectiveSettingsAsync(
|
|
tenantId,
|
|
userId,
|
|
ct);
|
|
|
|
var toolPolicy = AdvisoryChatToolPolicy.Resolve(
|
|
settings.Tools,
|
|
options.Value.DataProviders,
|
|
includeReachability: true,
|
|
includeBinaryPatch: true,
|
|
includeOpsMemory: true);
|
|
|
|
if (!toolPolicy.AllowSbom)
|
|
{
|
|
await WriteStreamEventAsync(httpContext, "error", new
|
|
{
|
|
code = "TOOL_DENIED",
|
|
message = "Tool access denied: sbom.read",
|
|
doctor = CreateDoctorAction("TOOL_DENIED")
|
|
}, ct);
|
|
await WriteStreamEventAsync(httpContext, "done", new { success = false }, ct);
|
|
return;
|
|
}
|
|
|
|
var quotaDecision = await quotaService.TryConsumeAsync(
|
|
new ChatQuotaRequest
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = userId,
|
|
EstimatedTokens = options.Value.Inference.MaxTokens,
|
|
ToolCalls = toolPolicy.ToolCallCount
|
|
},
|
|
settings.Quotas,
|
|
ct);
|
|
|
|
if (!quotaDecision.Allowed)
|
|
{
|
|
var quotaCode = quotaDecision.Code ?? "QUOTA_EXCEEDED";
|
|
await WriteStreamEventAsync(httpContext, "error", new
|
|
{
|
|
code = quotaCode,
|
|
message = quotaDecision.Message ?? "Quota exceeded",
|
|
quota = quotaDecision.Status,
|
|
doctor = CreateDoctorAction(quotaCode)
|
|
}, ct);
|
|
await WriteStreamEventAsync(httpContext, "done", new { success = false }, ct);
|
|
return;
|
|
}
|
|
|
|
// Step 3: Assemble evidence bundle
|
|
await WriteStreamEventAsync(httpContext, "status", new { phase = "assembling_evidence" }, ct);
|
|
|
|
var assemblyResult = await evidenceAssembler.AssembleAsync(
|
|
new EvidenceBundleAssemblyRequest
|
|
{
|
|
TenantId = tenantId,
|
|
ArtifactDigest = artifactDigest,
|
|
ImageReference = request.ImageReference ?? routingResult.Parameters.ImageReference,
|
|
Environment = request.Environment ?? "unknown",
|
|
FindingId = findingId,
|
|
PackagePurl = routingResult.Parameters.Package,
|
|
IncludeSbom = toolPolicy.AllowSbom,
|
|
IncludeVex = toolPolicy.AllowVex,
|
|
IncludePolicy = toolPolicy.AllowPolicy,
|
|
IncludeProvenance = toolPolicy.AllowProvenance,
|
|
IncludeFix = toolPolicy.AllowFix,
|
|
IncludeContext = toolPolicy.AllowContext,
|
|
IncludeReachability = toolPolicy.AllowReachability,
|
|
IncludeBinaryPatch = toolPolicy.AllowBinaryPatch,
|
|
IncludeOpsMemory = toolPolicy.AllowOpsMemory,
|
|
CorrelationId = correlationId
|
|
},
|
|
ct);
|
|
|
|
if (!assemblyResult.Success || assemblyResult.Bundle is null)
|
|
{
|
|
await WriteStreamEventAsync(httpContext, "error", new
|
|
{
|
|
code = "EVIDENCE_ASSEMBLY_FAILED",
|
|
message = assemblyResult.Error ?? "Failed to assemble evidence"
|
|
}, ct);
|
|
await WriteStreamEventAsync(httpContext, "done", new { success = false }, ct);
|
|
return;
|
|
}
|
|
|
|
await WriteStreamEventAsync(httpContext, "evidence", new
|
|
{
|
|
bundleId = assemblyResult.Bundle.BundleId,
|
|
evidenceCount = CountEvidence(assemblyResult.Bundle)
|
|
}, ct);
|
|
|
|
// Step 4: Stream inference response
|
|
await WriteStreamEventAsync(httpContext, "status", new { phase = "generating_response" }, ct);
|
|
|
|
await foreach (var chunk in inferenceClient.StreamResponseAsync(
|
|
assemblyResult.Bundle,
|
|
routingResult,
|
|
ct))
|
|
{
|
|
if (chunk.IsComplete && chunk.FinalResponse is not null)
|
|
{
|
|
await WriteStreamEventAsync(httpContext, "complete", MapToQueryResponse(
|
|
new AdvisoryChatServiceResult
|
|
{
|
|
Success = true,
|
|
Response = chunk.FinalResponse,
|
|
Intent = routingResult.Intent,
|
|
EvidenceAssembled = true
|
|
}), ct);
|
|
}
|
|
else if (!string.IsNullOrEmpty(chunk.Content))
|
|
{
|
|
await WriteStreamEventAsync(httpContext, "token", new { content = chunk.Content }, ct);
|
|
}
|
|
}
|
|
|
|
await WriteStreamEventAsync(httpContext, "done", new { success = true }, ct);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Client disconnected, nothing to do
|
|
logger.LogDebug("Stream cancelled by client");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error during streaming response");
|
|
await WriteStreamEventAsync(httpContext, "error", new
|
|
{
|
|
code = "STREAM_ERROR",
|
|
message = "An error occurred during streaming"
|
|
}, ct);
|
|
await WriteStreamEventAsync(httpContext, "done", new { success = false }, ct);
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> DetectIntentAsync(
|
|
[FromBody] IntentDetectionRequest request,
|
|
[FromServices] IAdvisoryChatIntentRouter intentRouter,
|
|
CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.Query))
|
|
{
|
|
return Results.BadRequest(new ErrorResponse { Error = _t("advisoryai.error.query_empty"), Code = "INVALID_QUERY" });
|
|
}
|
|
|
|
var result = await intentRouter.RouteAsync(request.Query, ct);
|
|
|
|
return Results.Ok(new IntentDetectionResponse
|
|
{
|
|
Intent = result.Intent.ToString(),
|
|
Confidence = result.Confidence,
|
|
NormalizedInput = result.NormalizedInput,
|
|
ExplicitSlashCommand = result.ExplicitSlashCommand,
|
|
Parameters = new IntentParametersResponse
|
|
{
|
|
FindingId = result.Parameters.FindingId,
|
|
Package = result.Parameters.Package,
|
|
ImageReference = result.Parameters.ImageReference,
|
|
Environment = result.Parameters.Environment,
|
|
Duration = result.Parameters.Duration,
|
|
Reason = result.Parameters.Reason
|
|
}
|
|
});
|
|
}
|
|
|
|
private static async Task<IResult> PreviewEvidenceBundleAsync(
|
|
[FromBody] EvidencePreviewRequest request,
|
|
[FromServices] IEvidenceBundleAssembler evidenceAssembler,
|
|
[FromServices] IOptions<AdvisoryChatOptions> options,
|
|
[FromServices] IAdvisoryChatSettingsService settingsService,
|
|
[FromServices] IAdvisoryChatQuotaService quotaService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
[FromHeader(Name = "X-User-Id")] string? userId,
|
|
[FromHeader(Name = "X-Correlation-Id")] string? correlationId,
|
|
CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.FindingId))
|
|
{
|
|
return Results.BadRequest(new ErrorResponse { Error = "FindingId is required", Code = "MISSING_FINDING_ID" });
|
|
}
|
|
|
|
tenantId ??= "default";
|
|
userId ??= "anonymous";
|
|
|
|
var settings = await settingsService.GetEffectiveSettingsAsync(tenantId, userId, ct);
|
|
var toolPolicy = AdvisoryChatToolPolicy.Resolve(
|
|
settings.Tools,
|
|
options.Value.DataProviders,
|
|
includeReachability: true,
|
|
includeBinaryPatch: true,
|
|
includeOpsMemory: true);
|
|
|
|
if (!toolPolicy.AllowSbom)
|
|
{
|
|
return Results.Json(
|
|
new ErrorResponse
|
|
{
|
|
Error = "Tool access denied: sbom.read",
|
|
Code = "TOOL_DENIED",
|
|
Doctor = CreateDoctorAction("TOOL_DENIED")
|
|
},
|
|
statusCode: StatusCodes.Status403Forbidden);
|
|
}
|
|
|
|
var quotaDecision = await quotaService.TryConsumeAsync(
|
|
new ChatQuotaRequest
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = userId,
|
|
EstimatedTokens = 0,
|
|
ToolCalls = toolPolicy.ToolCallCount
|
|
},
|
|
settings.Quotas,
|
|
ct);
|
|
|
|
if (!quotaDecision.Allowed)
|
|
{
|
|
var quotaCode = quotaDecision.Code ?? "QUOTA_EXCEEDED";
|
|
return Results.Json(
|
|
new ErrorResponse
|
|
{
|
|
Error = quotaDecision.Message ?? "Quota exceeded",
|
|
Code = quotaCode,
|
|
Details = new Dictionary<string, object> { ["quota"] = quotaDecision.Status },
|
|
Doctor = CreateDoctorAction(quotaCode)
|
|
},
|
|
statusCode: StatusCodes.Status429TooManyRequests);
|
|
}
|
|
|
|
var assemblyResult = await evidenceAssembler.AssembleAsync(
|
|
new EvidenceBundleAssemblyRequest
|
|
{
|
|
TenantId = tenantId,
|
|
ArtifactDigest = request.ArtifactDigest ?? "unknown",
|
|
ImageReference = request.ImageReference,
|
|
Environment = request.Environment ?? "unknown",
|
|
FindingId = request.FindingId,
|
|
PackagePurl = request.PackagePurl,
|
|
IncludeSbom = toolPolicy.AllowSbom,
|
|
IncludeVex = toolPolicy.AllowVex,
|
|
IncludePolicy = toolPolicy.AllowPolicy,
|
|
IncludeProvenance = toolPolicy.AllowProvenance,
|
|
IncludeFix = toolPolicy.AllowFix,
|
|
IncludeContext = toolPolicy.AllowContext,
|
|
IncludeReachability = toolPolicy.AllowReachability,
|
|
IncludeBinaryPatch = toolPolicy.AllowBinaryPatch,
|
|
IncludeOpsMemory = toolPolicy.AllowOpsMemory,
|
|
CorrelationId = correlationId
|
|
},
|
|
ct);
|
|
|
|
if (!assemblyResult.Success || assemblyResult.Bundle is null)
|
|
{
|
|
return Results.BadRequest(new ErrorResponse
|
|
{
|
|
Error = assemblyResult.Error ?? "Failed to assemble evidence",
|
|
Code = "EVIDENCE_ASSEMBLY_FAILED"
|
|
});
|
|
}
|
|
|
|
return Results.Ok(new EvidenceBundlePreviewResponse
|
|
{
|
|
BundleId = assemblyResult.Bundle.BundleId,
|
|
FindingId = assemblyResult.Bundle.Finding?.Id,
|
|
HasVexData = assemblyResult.Bundle.Verdicts?.Vex is not null,
|
|
HasReachabilityData = assemblyResult.Bundle.Reachability is not null,
|
|
HasBinaryPatchData = assemblyResult.Bundle.Reachability?.BinaryPatch is not null,
|
|
HasProvenanceData = assemblyResult.Bundle.Provenance is not null,
|
|
HasPolicyData = assemblyResult.Bundle.Verdicts?.Policy.Length > 0,
|
|
HasOpsMemoryData = assemblyResult.Bundle.OpsMemory is not null,
|
|
HasFixData = assemblyResult.Bundle.Fixes is not null,
|
|
EvidenceSummary = new EvidenceSummary
|
|
{
|
|
VexStatus = assemblyResult.Bundle.Verdicts?.Vex?.Status.ToString(),
|
|
ReachabilityStatus = assemblyResult.Bundle.Reachability?.Status.ToString(),
|
|
BinaryPatchDetected = assemblyResult.Bundle.Reachability?.BinaryPatch?.Detected,
|
|
PolicyDecision = assemblyResult.Bundle.Verdicts?.Policy.FirstOrDefault()?.Decision.ToString(),
|
|
FixOptionsCount = assemblyResult.Bundle.Fixes?.Upgrade.Length ?? 0
|
|
}
|
|
});
|
|
}
|
|
|
|
private static async Task<IResult> GetChatSettingsAsync(
|
|
[FromServices] IAdvisoryChatSettingsService settingsService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
[FromHeader(Name = "X-User-Id")] string? userId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
userId ??= "anonymous";
|
|
|
|
var settings = await settingsService.GetEffectiveSettingsAsync(tenantId, userId, ct);
|
|
return Results.Ok(ChatSettingsResponse.FromSettings(settings));
|
|
}
|
|
|
|
private static async Task<IResult> UpdateChatSettingsAsync(
|
|
[FromBody] ChatSettingsUpdateRequest request,
|
|
[FromServices] IAdvisoryChatSettingsService settingsService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
[FromHeader(Name = "X-User-Id")] string? userId,
|
|
[FromQuery(Name = "scope")] string? scope,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
userId ??= "anonymous";
|
|
|
|
if (request is null)
|
|
{
|
|
return Results.BadRequest(new ErrorResponse { Error = "Settings payload is required", Code = "INVALID_SETTINGS" });
|
|
}
|
|
|
|
var overrides = new AdvisoryChatSettingsOverrides
|
|
{
|
|
Quotas = new ChatQuotaOverrides
|
|
{
|
|
RequestsPerMinute = request.Quotas?.RequestsPerMinute,
|
|
RequestsPerDay = request.Quotas?.RequestsPerDay,
|
|
TokensPerDay = request.Quotas?.TokensPerDay,
|
|
ToolCallsPerDay = request.Quotas?.ToolCallsPerDay
|
|
},
|
|
Tools = new ChatToolAccessOverrides
|
|
{
|
|
AllowAll = request.Tools?.AllowAll,
|
|
AllowedTools = request.Tools?.AllowedTools is null
|
|
? null
|
|
: request.Tools.AllowedTools.ToImmutableArray()
|
|
}
|
|
};
|
|
|
|
if (string.Equals(scope, "user", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
await settingsService.SetUserOverridesAsync(tenantId, userId, overrides, ct);
|
|
}
|
|
else
|
|
{
|
|
await settingsService.SetTenantOverridesAsync(tenantId, overrides, ct);
|
|
}
|
|
|
|
var effective = await settingsService.GetEffectiveSettingsAsync(tenantId, userId, ct);
|
|
return Results.Ok(ChatSettingsResponse.FromSettings(effective));
|
|
}
|
|
|
|
private static async Task<IResult> ClearChatSettingsAsync(
|
|
[FromServices] IAdvisoryChatSettingsService settingsService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
[FromHeader(Name = "X-User-Id")] string? userId,
|
|
[FromQuery(Name = "scope")] string? scope,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
userId ??= "anonymous";
|
|
|
|
if (string.Equals(scope, "user", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
await settingsService.ClearUserOverridesAsync(tenantId, userId, ct);
|
|
}
|
|
else
|
|
{
|
|
await settingsService.ClearTenantOverridesAsync(tenantId, ct);
|
|
}
|
|
|
|
return Results.NoContent();
|
|
}
|
|
|
|
private static async Task<IResult> GetChatDoctorAsync(
|
|
[FromServices] IAdvisoryChatSettingsService settingsService,
|
|
[FromServices] IAdvisoryChatQuotaService quotaService,
|
|
[FromServices] IOptions<AdvisoryChatOptions> options,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
[FromHeader(Name = "X-User-Id")] string? userId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
userId ??= "anonymous";
|
|
|
|
var settings = await settingsService.GetEffectiveSettingsAsync(tenantId, userId, ct);
|
|
var toolPolicy = AdvisoryChatToolPolicy.Resolve(
|
|
settings.Tools,
|
|
options.Value.DataProviders,
|
|
includeReachability: true,
|
|
includeBinaryPatch: true,
|
|
includeOpsMemory: true);
|
|
|
|
var quotaStatus = quotaService.GetStatus(tenantId, userId, settings.Quotas);
|
|
|
|
return Results.Ok(new ChatDoctorResponse
|
|
{
|
|
TenantId = tenantId,
|
|
UserId = userId,
|
|
Quotas = ChatQuotaStatusResponse.FromStatus(quotaStatus),
|
|
Tools = ChatToolAccessResponse.FromPolicy(settings.Tools, toolPolicy),
|
|
LastDenied = quotaStatus.LastDenied is null
|
|
? null
|
|
: new ChatDenialResponse
|
|
{
|
|
Code = quotaStatus.LastDenied.Code,
|
|
Message = quotaStatus.LastDenied.Message,
|
|
DeniedAt = quotaStatus.LastDenied.DeniedAt
|
|
}
|
|
});
|
|
}
|
|
|
|
private static Task<IResult> GetChatStatusAsync(
|
|
[FromServices] IOptions<AdvisoryChatOptions> options)
|
|
{
|
|
var opts = options.Value;
|
|
|
|
return Task.FromResult(Results.Ok(new ChatServiceStatusResponse
|
|
{
|
|
Enabled = opts.Enabled,
|
|
InferenceProvider = opts.Inference.Provider.ToString(),
|
|
InferenceModel = opts.Inference.Model,
|
|
MaxTokens = opts.Inference.MaxTokens,
|
|
GuardrailsEnabled = opts.Guardrails.Enabled,
|
|
AuditEnabled = opts.Audit.Enabled
|
|
}));
|
|
}
|
|
|
|
private static ChatDoctorAction CreateDoctorAction(string? reason)
|
|
{
|
|
return new ChatDoctorAction
|
|
{
|
|
Endpoint = "/api/v1/chat/doctor",
|
|
SuggestedCommand = "stella advise doctor",
|
|
Reason = reason
|
|
};
|
|
}
|
|
|
|
private static async Task WriteStreamEventAsync<T>(
|
|
HttpContext context,
|
|
string eventType,
|
|
T data,
|
|
CancellationToken ct)
|
|
{
|
|
var json = JsonSerializer.Serialize(data, StreamJsonOptions);
|
|
await context.Response.WriteAsync($"event: {eventType}\n", ct);
|
|
await context.Response.WriteAsync($"data: {json}\n\n", ct);
|
|
await context.Response.Body.FlushAsync(ct);
|
|
}
|
|
|
|
private static string? ExtractDigestFromImageRef(string? imageRef)
|
|
{
|
|
if (string.IsNullOrEmpty(imageRef))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var atIndex = imageRef.IndexOf('@');
|
|
if (atIndex > 0 && imageRef.Length > atIndex + 1)
|
|
{
|
|
return imageRef[(atIndex + 1)..];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static int CountEvidence(AdvisoryChatEvidenceBundle bundle)
|
|
{
|
|
var count = 0;
|
|
if (bundle.Verdicts?.Vex is not null)
|
|
{
|
|
count++;
|
|
}
|
|
|
|
if (bundle.Reachability is not null)
|
|
{
|
|
count++;
|
|
}
|
|
|
|
if (bundle.Reachability?.BinaryPatch is not null)
|
|
{
|
|
count++;
|
|
}
|
|
|
|
if (bundle.Provenance is not null)
|
|
{
|
|
count++;
|
|
}
|
|
|
|
if (bundle.Verdicts?.Policy.Length > 0)
|
|
{
|
|
count++;
|
|
}
|
|
|
|
if (bundle.OpsMemory is not null)
|
|
{
|
|
count++;
|
|
}
|
|
|
|
if (bundle.Fixes is not null)
|
|
{
|
|
count++;
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
private static AdvisoryChatQueryResponse MapToQueryResponse(AdvisoryChatServiceResult result)
|
|
{
|
|
var response = result.Response!;
|
|
|
|
return new AdvisoryChatQueryResponse
|
|
{
|
|
ResponseId = response.ResponseId,
|
|
BundleId = response.BundleId,
|
|
Intent = response.Intent.ToString(),
|
|
GeneratedAt = response.GeneratedAt,
|
|
Summary = response.Summary,
|
|
Impact = response.Impact is not null ? new ImpactAssessmentResponse
|
|
{
|
|
Artifact = response.Impact.Artifact,
|
|
Environment = response.Impact.Environment,
|
|
AffectedComponent = response.Impact.AffectedComponent,
|
|
AffectedVersion = response.Impact.AffectedVersion,
|
|
Description = response.Impact.Description
|
|
} : null,
|
|
Reachability = response.ReachabilityAssessment is not null ? new ReachabilityAssessmentResponse
|
|
{
|
|
Status = response.ReachabilityAssessment.Status.ToString(),
|
|
CallgraphPaths = response.ReachabilityAssessment.CallgraphPaths,
|
|
PathDescription = response.ReachabilityAssessment.PathDescription,
|
|
BinaryBackportDetected = response.ReachabilityAssessment.BinaryBackport?.Detected
|
|
} : null,
|
|
Mitigations = response.Mitigations.Select(m => new MitigationOptionResponse
|
|
{
|
|
Rank = m.Rank,
|
|
Type = m.Type.ToString(),
|
|
Label = m.Label,
|
|
Description = m.Description,
|
|
Risk = m.Risk.ToString(),
|
|
RequiresApproval = m.RequiresApproval
|
|
}).ToList(),
|
|
EvidenceLinks = response.EvidenceLinks.Select(e => new EvidenceLinkResponse
|
|
{
|
|
Type = e.Type.ToString(),
|
|
Uri = e.Link,
|
|
Label = e.Description,
|
|
Confidence = e.Confidence is not null
|
|
? e.Confidence == ConfidenceLevel.High ? 0.9
|
|
: e.Confidence == ConfidenceLevel.Medium ? 0.7
|
|
: e.Confidence == ConfidenceLevel.Low ? 0.4
|
|
: 0.2
|
|
: null
|
|
}).ToList(),
|
|
Confidence = new ConfidenceResponse
|
|
{
|
|
Level = response.Confidence.Level.ToString(),
|
|
Score = response.Confidence.Score
|
|
},
|
|
ProposedActions = response.ProposedActions.Select(a => new ProposedActionResponse
|
|
{
|
|
ActionType = a.ActionType.ToString(),
|
|
Label = a.Label,
|
|
PolicyGate = a.RiskLevel?.ToString(),
|
|
RequiresConfirmation = a.RequiresApproval ?? false
|
|
}).ToList(),
|
|
FollowUp = response.FollowUp is not null ? new FollowUpResponse
|
|
{
|
|
SuggestedQueries = [.. response.FollowUp.SuggestedQueries],
|
|
NextSteps = [.. response.FollowUp.NextSteps]
|
|
} : null,
|
|
Diagnostics = result.Diagnostics is not null ? new DiagnosticsResponse
|
|
{
|
|
IntentRoutingMs = result.Diagnostics.IntentRoutingMs,
|
|
EvidenceAssemblyMs = result.Diagnostics.EvidenceAssemblyMs,
|
|
InferenceMs = result.Diagnostics.InferenceMs,
|
|
TotalMs = result.Diagnostics.TotalMs,
|
|
PromptTokens = result.Diagnostics.PromptTokens,
|
|
CompletionTokens = result.Diagnostics.CompletionTokens
|
|
} : null
|
|
};
|
|
}
|
|
|
|
private static AdvisoryChatQueryResponse CreateDeterministicFallbackQueryResponse(
|
|
AdvisoryChatQueryRequest request,
|
|
AdvisoryChatServiceResult failedResult)
|
|
{
|
|
var diagnostics = failedResult.Diagnostics is null
|
|
? null
|
|
: new DiagnosticsResponse
|
|
{
|
|
IntentRoutingMs = failedResult.Diagnostics.IntentRoutingMs,
|
|
EvidenceAssemblyMs = failedResult.Diagnostics.EvidenceAssemblyMs,
|
|
InferenceMs = failedResult.Diagnostics.InferenceMs,
|
|
TotalMs = failedResult.Diagnostics.TotalMs,
|
|
PromptTokens = failedResult.Diagnostics.PromptTokens,
|
|
CompletionTokens = failedResult.Diagnostics.CompletionTokens,
|
|
};
|
|
|
|
var reason = string.IsNullOrWhiteSpace(failedResult.Error)
|
|
? "chat runtime unavailable"
|
|
: failedResult.Error.Trim();
|
|
var normalizedQuery = request.Query?.Trim() ?? string.Empty;
|
|
var fallbackId = BuildFallbackResponseId(normalizedQuery, reason);
|
|
|
|
return new AdvisoryChatQueryResponse
|
|
{
|
|
ResponseId = fallbackId,
|
|
BundleId = null,
|
|
Intent = (failedResult.Intent ?? AdvisoryChatIntent.General).ToString(),
|
|
GeneratedAt = DateTimeOffset.UtcNow,
|
|
Summary =
|
|
$"Chat runtime is temporarily unavailable. For \"{normalizedQuery}\", start with unified search evidence, verify VEX status, and confirm active policy gates before acting.",
|
|
Confidence = new ConfidenceResponse { Level = ConfidenceLevel.Low.ToString(), Score = 0.2d },
|
|
EvidenceLinks = [],
|
|
Mitigations = [],
|
|
ProposedActions = [],
|
|
FollowUp = new FollowUpResponse
|
|
{
|
|
SuggestedQueries = [normalizedQuery],
|
|
NextSteps =
|
|
[
|
|
"Retry this chat query after runtime recovery.",
|
|
"Use global search to review findings, VEX, and policy context now."
|
|
]
|
|
},
|
|
Diagnostics = diagnostics,
|
|
};
|
|
}
|
|
|
|
private static string BuildFallbackResponseId(string query, string reason)
|
|
{
|
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{query}|{reason}"));
|
|
var token = Convert.ToHexString(bytes.AsSpan(0, 8)).ToLowerInvariant();
|
|
return $"fallback-{token}";
|
|
}
|
|
}
|
|
|
|
#region Request/Response DTOs
|
|
|
|
/// <summary>
|
|
/// Request to process a chat query.
|
|
/// </summary>
|
|
public sealed record AdvisoryChatQueryRequest
|
|
{
|
|
/// <summary>Gets the user query.</summary>
|
|
public required string Query { get; init; }
|
|
|
|
/// <summary>Gets the artifact digest.</summary>
|
|
public string? ArtifactDigest { get; init; }
|
|
|
|
/// <summary>Gets the image reference.</summary>
|
|
public string? ImageReference { get; init; }
|
|
|
|
/// <summary>Gets the environment.</summary>
|
|
public string? Environment { get; init; }
|
|
|
|
/// <summary>Gets the conversation ID for multi-turn.</summary>
|
|
public string? ConversationId { get; init; }
|
|
|
|
/// <summary>Gets the user roles for policy evaluation.</summary>
|
|
public List<string>? UserRoles { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Response from a chat query.
|
|
/// </summary>
|
|
public sealed record AdvisoryChatQueryResponse
|
|
{
|
|
/// <summary>Gets the response ID.</summary>
|
|
public required string ResponseId { get; init; }
|
|
|
|
/// <summary>Gets the bundle ID.</summary>
|
|
public string? BundleId { get; init; }
|
|
|
|
/// <summary>Gets the detected intent.</summary>
|
|
public required string Intent { get; init; }
|
|
|
|
/// <summary>Gets the generation timestamp.</summary>
|
|
public required DateTimeOffset GeneratedAt { get; init; }
|
|
|
|
/// <summary>Gets the summary.</summary>
|
|
public required string Summary { get; init; }
|
|
|
|
/// <summary>Gets the impact assessment.</summary>
|
|
public ImpactAssessmentResponse? Impact { get; init; }
|
|
|
|
/// <summary>Gets the reachability assessment.</summary>
|
|
public ReachabilityAssessmentResponse? Reachability { get; init; }
|
|
|
|
/// <summary>Gets the mitigation options.</summary>
|
|
public List<MitigationOptionResponse> Mitigations { get; init; } = [];
|
|
|
|
/// <summary>Gets the evidence links.</summary>
|
|
public List<EvidenceLinkResponse> EvidenceLinks { get; init; } = [];
|
|
|
|
/// <summary>Gets the confidence assessment.</summary>
|
|
public required ConfidenceResponse Confidence { get; init; }
|
|
|
|
/// <summary>Gets the proposed actions.</summary>
|
|
public List<ProposedActionResponse> ProposedActions { get; init; } = [];
|
|
|
|
/// <summary>Gets the follow-up suggestions.</summary>
|
|
public FollowUpResponse? FollowUp { get; init; }
|
|
|
|
/// <summary>Gets the diagnostics.</summary>
|
|
public DiagnosticsResponse? Diagnostics { get; init; }
|
|
}
|
|
|
|
/// <summary>Impact assessment response.</summary>
|
|
public sealed record ImpactAssessmentResponse
|
|
{
|
|
public string? Artifact { get; init; }
|
|
public string? Environment { get; init; }
|
|
public string? AffectedComponent { get; init; }
|
|
public string? AffectedVersion { get; init; }
|
|
public string? Description { get; init; }
|
|
}
|
|
|
|
/// <summary>Reachability assessment response.</summary>
|
|
public sealed record ReachabilityAssessmentResponse
|
|
{
|
|
public required string Status { get; init; }
|
|
public int? CallgraphPaths { get; init; }
|
|
public string? PathDescription { get; init; }
|
|
public bool? BinaryBackportDetected { get; init; }
|
|
}
|
|
|
|
/// <summary>Mitigation option response.</summary>
|
|
public sealed record MitigationOptionResponse
|
|
{
|
|
public required int Rank { get; init; }
|
|
public required string Type { get; init; }
|
|
public required string Label { get; init; }
|
|
public string? Description { get; init; }
|
|
public required string Risk { get; init; }
|
|
public bool? RequiresApproval { get; init; }
|
|
}
|
|
|
|
/// <summary>Confidence response.</summary>
|
|
public sealed record ConfidenceResponse
|
|
{
|
|
public required string Level { get; init; }
|
|
public required double Score { get; init; }
|
|
}
|
|
|
|
/// <summary>Follow-up suggestions response.</summary>
|
|
public sealed record FollowUpResponse
|
|
{
|
|
public List<string> SuggestedQueries { get; init; } = [];
|
|
public List<string> NextSteps { get; init; } = [];
|
|
}
|
|
|
|
/// <summary>Diagnostics response.</summary>
|
|
public sealed record DiagnosticsResponse
|
|
{
|
|
public long IntentRoutingMs { get; init; }
|
|
public long EvidenceAssemblyMs { get; init; }
|
|
public long InferenceMs { get; init; }
|
|
public long TotalMs { get; init; }
|
|
public int PromptTokens { get; init; }
|
|
public int CompletionTokens { get; init; }
|
|
}
|
|
|
|
/// <summary>Request for intent detection.</summary>
|
|
public sealed record IntentDetectionRequest
|
|
{
|
|
/// <summary>Gets the query to analyze.</summary>
|
|
public required string Query { get; init; }
|
|
}
|
|
|
|
/// <summary>Response for intent detection.</summary>
|
|
public sealed record IntentDetectionResponse
|
|
{
|
|
public required string Intent { get; init; }
|
|
public required double Confidence { get; init; }
|
|
public required string NormalizedInput { get; init; }
|
|
public bool ExplicitSlashCommand { get; init; }
|
|
public IntentParametersResponse? Parameters { get; init; }
|
|
}
|
|
|
|
/// <summary>Intent parameters response.</summary>
|
|
public sealed record IntentParametersResponse
|
|
{
|
|
public string? FindingId { get; init; }
|
|
public string? Package { get; init; }
|
|
public string? ImageReference { get; init; }
|
|
public string? Environment { get; init; }
|
|
public string? Duration { get; init; }
|
|
public string? Reason { get; init; }
|
|
}
|
|
|
|
/// <summary>Request for evidence preview.</summary>
|
|
public sealed record EvidencePreviewRequest
|
|
{
|
|
public required string FindingId { get; init; }
|
|
public string? ArtifactDigest { get; init; }
|
|
public string? ImageReference { get; init; }
|
|
public string? Environment { get; init; }
|
|
public string? PackagePurl { get; init; }
|
|
}
|
|
|
|
/// <summary>Response for evidence bundle preview.</summary>
|
|
public sealed record EvidenceBundlePreviewResponse
|
|
{
|
|
public required string BundleId { get; init; }
|
|
public string? FindingId { get; init; }
|
|
public bool HasVexData { get; init; }
|
|
public bool HasReachabilityData { get; init; }
|
|
public bool HasBinaryPatchData { get; init; }
|
|
public bool HasProvenanceData { get; init; }
|
|
public bool HasPolicyData { get; init; }
|
|
public bool HasOpsMemoryData { get; init; }
|
|
public bool HasFixData { get; init; }
|
|
public EvidenceSummary? EvidenceSummary { get; init; }
|
|
}
|
|
|
|
/// <summary>Evidence summary.</summary>
|
|
public sealed record EvidenceSummary
|
|
{
|
|
public string? VexStatus { get; init; }
|
|
public string? ReachabilityStatus { get; init; }
|
|
public bool? BinaryPatchDetected { get; init; }
|
|
public string? PolicyDecision { get; init; }
|
|
public int FixOptionsCount { get; init; }
|
|
}
|
|
|
|
/// <summary>Chat service status response.</summary>
|
|
public sealed record ChatServiceStatusResponse
|
|
{
|
|
public required bool Enabled { get; init; }
|
|
public required string InferenceProvider { get; init; }
|
|
public required string InferenceModel { get; init; }
|
|
public required int MaxTokens { get; init; }
|
|
public required bool GuardrailsEnabled { get; init; }
|
|
public required bool AuditEnabled { get; init; }
|
|
}
|
|
|
|
/// <summary>Chat settings update request.</summary>
|
|
public sealed record ChatSettingsUpdateRequest
|
|
{
|
|
public ChatQuotaSettingsUpdateRequest? Quotas { get; init; }
|
|
public ChatToolAccessUpdateRequest? Tools { get; init; }
|
|
}
|
|
|
|
/// <summary>Quota update request.</summary>
|
|
public sealed record ChatQuotaSettingsUpdateRequest
|
|
{
|
|
public int? RequestsPerMinute { get; init; }
|
|
public int? RequestsPerDay { get; init; }
|
|
public int? TokensPerDay { get; init; }
|
|
public int? ToolCallsPerDay { get; init; }
|
|
}
|
|
|
|
/// <summary>Tool access update request.</summary>
|
|
public sealed record ChatToolAccessUpdateRequest
|
|
{
|
|
public bool? AllowAll { get; init; }
|
|
public List<string>? AllowedTools { get; init; }
|
|
}
|
|
|
|
/// <summary>Chat settings response.</summary>
|
|
public sealed record ChatSettingsResponse
|
|
{
|
|
public required ChatQuotaSettingsResponse Quotas { get; init; }
|
|
public required ChatToolAccessResponse Tools { get; init; }
|
|
|
|
public static ChatSettingsResponse FromSettings(AdvisoryChatSettings settings)
|
|
{
|
|
return new ChatSettingsResponse
|
|
{
|
|
Quotas = new ChatQuotaSettingsResponse
|
|
{
|
|
RequestsPerMinute = settings.Quotas.RequestsPerMinute,
|
|
RequestsPerDay = settings.Quotas.RequestsPerDay,
|
|
TokensPerDay = settings.Quotas.TokensPerDay,
|
|
ToolCallsPerDay = settings.Quotas.ToolCallsPerDay
|
|
},
|
|
Tools = new ChatToolAccessResponse
|
|
{
|
|
AllowAll = settings.Tools.AllowAll,
|
|
AllowedTools = settings.Tools.AllowedTools.ToList()
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>Quota settings response.</summary>
|
|
public sealed record ChatQuotaSettingsResponse
|
|
{
|
|
public required int RequestsPerMinute { get; init; }
|
|
public required int RequestsPerDay { get; init; }
|
|
public required int TokensPerDay { get; init; }
|
|
public required int ToolCallsPerDay { get; init; }
|
|
}
|
|
|
|
/// <summary>Tool access response.</summary>
|
|
public sealed record ChatToolAccessResponse
|
|
{
|
|
public required bool AllowAll { get; init; }
|
|
public List<string> AllowedTools { get; init; } = [];
|
|
public ChatToolProviderResponse? Providers { get; init; }
|
|
|
|
public static ChatToolAccessResponse FromPolicy(
|
|
ChatToolAccessSettings settings,
|
|
ChatToolPolicyResult policy)
|
|
{
|
|
return new ChatToolAccessResponse
|
|
{
|
|
AllowAll = settings.AllowAll,
|
|
AllowedTools = policy.AllowedTools.ToList(),
|
|
Providers = new ChatToolProviderResponse
|
|
{
|
|
Sbom = policy.AllowSbom,
|
|
Vex = policy.AllowVex,
|
|
Reachability = policy.AllowReachability,
|
|
BinaryPatch = policy.AllowBinaryPatch,
|
|
OpsMemory = policy.AllowOpsMemory,
|
|
Policy = policy.AllowPolicy,
|
|
Provenance = policy.AllowProvenance,
|
|
Fix = policy.AllowFix,
|
|
Context = policy.AllowContext
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>Tool provider availability response.</summary>
|
|
public sealed record ChatToolProviderResponse
|
|
{
|
|
public bool Sbom { get; init; }
|
|
public bool Vex { get; init; }
|
|
public bool Reachability { get; init; }
|
|
public bool BinaryPatch { get; init; }
|
|
public bool OpsMemory { get; init; }
|
|
public bool Policy { get; init; }
|
|
public bool Provenance { get; init; }
|
|
public bool Fix { get; init; }
|
|
public bool Context { get; init; }
|
|
}
|
|
|
|
/// <summary>Chat doctor response.</summary>
|
|
public sealed record ChatDoctorResponse
|
|
{
|
|
public required string TenantId { get; init; }
|
|
public required string UserId { get; init; }
|
|
public required ChatQuotaStatusResponse Quotas { get; init; }
|
|
public required ChatToolAccessResponse Tools { get; init; }
|
|
public ChatDenialResponse? LastDenied { get; init; }
|
|
}
|
|
|
|
/// <summary>Doctor action hint.</summary>
|
|
public sealed record ChatDoctorAction
|
|
{
|
|
public required string Endpoint { get; init; }
|
|
public required string SuggestedCommand { get; init; }
|
|
public string? Reason { get; init; }
|
|
}
|
|
|
|
/// <summary>Quota status response.</summary>
|
|
public sealed record ChatQuotaStatusResponse
|
|
{
|
|
public required int RequestsPerMinuteLimit { get; init; }
|
|
public required int RequestsPerMinuteRemaining { get; init; }
|
|
public required DateTimeOffset RequestsPerMinuteResetsAt { get; init; }
|
|
public required int RequestsPerDayLimit { get; init; }
|
|
public required int RequestsPerDayRemaining { get; init; }
|
|
public required DateTimeOffset RequestsPerDayResetsAt { get; init; }
|
|
public required int TokensPerDayLimit { get; init; }
|
|
public required int TokensPerDayRemaining { get; init; }
|
|
public required DateTimeOffset TokensPerDayResetsAt { get; init; }
|
|
public required int ToolCallsPerDayLimit { get; init; }
|
|
public required int ToolCallsPerDayRemaining { get; init; }
|
|
public required DateTimeOffset ToolCallsPerDayResetsAt { get; init; }
|
|
|
|
public static ChatQuotaStatusResponse FromStatus(ChatQuotaStatus status)
|
|
{
|
|
return new ChatQuotaStatusResponse
|
|
{
|
|
RequestsPerMinuteLimit = status.RequestsPerMinuteLimit,
|
|
RequestsPerMinuteRemaining = status.RequestsPerMinuteRemaining,
|
|
RequestsPerMinuteResetsAt = status.RequestsPerMinuteResetsAt,
|
|
RequestsPerDayLimit = status.RequestsPerDayLimit,
|
|
RequestsPerDayRemaining = status.RequestsPerDayRemaining,
|
|
RequestsPerDayResetsAt = status.RequestsPerDayResetsAt,
|
|
TokensPerDayLimit = status.TokensPerDayLimit,
|
|
TokensPerDayRemaining = status.TokensPerDayRemaining,
|
|
TokensPerDayResetsAt = status.TokensPerDayResetsAt,
|
|
ToolCallsPerDayLimit = status.ToolCallsPerDayLimit,
|
|
ToolCallsPerDayRemaining = status.ToolCallsPerDayRemaining,
|
|
ToolCallsPerDayResetsAt = status.ToolCallsPerDayResetsAt
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>Quota denial response.</summary>
|
|
public sealed record ChatDenialResponse
|
|
{
|
|
public required string Code { get; init; }
|
|
public required string Message { get; init; }
|
|
public required DateTimeOffset DeniedAt { get; init; }
|
|
}
|
|
|
|
/// <summary>Error response.</summary>
|
|
public sealed record ErrorResponse
|
|
{
|
|
public required string Error { get; init; }
|
|
public string? Code { get; init; }
|
|
public Dictionary<string, object>? Details { get; init; }
|
|
public ChatDoctorAction? Doctor { get; init; }
|
|
}
|
|
|
|
#endregion
|