stela ops usage fixes roles propagation and timoeut, one account to support multi tenants, migrations consolidation, search to support documentation, doctor and open api vector db search
This commit is contained in:
@@ -377,9 +377,10 @@ public static class EvidencePackEndpoints
|
||||
|
||||
private static string GetUserId(HttpContext context)
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-User", out var user))
|
||||
if (context.Request.Headers.TryGetValue("X-StellaOps-Actor", out var actor)
|
||||
&& !string.IsNullOrWhiteSpace(actor.ToString()))
|
||||
{
|
||||
return user.ToString();
|
||||
return actor.ToString();
|
||||
}
|
||||
|
||||
return context.User?.FindFirst("sub")?.Value ?? "anonymous";
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
|
||||
|
||||
public static class KnowledgeSearchEndpoints
|
||||
{
|
||||
private static readonly HashSet<string> AllowedKinds = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"docs",
|
||||
"api",
|
||||
"doctor"
|
||||
};
|
||||
|
||||
public static RouteGroupBuilder MapKnowledgeSearchEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/v1/advisory-ai")
|
||||
.WithTags("Advisory AI - Knowledge Search");
|
||||
|
||||
group.MapPost("/search", SearchAsync)
|
||||
.WithName("AdvisoryAiKnowledgeSearch")
|
||||
.WithSummary("Searches AdvisoryAI deterministic knowledge index (docs/api/doctor).")
|
||||
.Produces<AdvisoryKnowledgeSearchResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
group.MapPost("/index/rebuild", RebuildIndexAsync)
|
||||
.WithName("AdvisoryAiKnowledgeIndexRebuild")
|
||||
.WithSummary("Rebuilds AdvisoryAI knowledge search index from deterministic local sources.")
|
||||
.Produces<AdvisoryKnowledgeRebuildResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static async Task<IResult> SearchAsync(
|
||||
HttpContext httpContext,
|
||||
AdvisoryKnowledgeSearchRequest request,
|
||||
IKnowledgeSearchService searchService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!EnsureSearchAuthorized(httpContext))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.Q))
|
||||
{
|
||||
return Results.BadRequest(new { error = "q is required." });
|
||||
}
|
||||
|
||||
if (request.Q.Length > 4096)
|
||||
{
|
||||
return Results.BadRequest(new { error = "q must be 4096 characters or fewer." });
|
||||
}
|
||||
|
||||
var normalizedFilter = NormalizeFilter(request.Filters);
|
||||
var domainRequest = new KnowledgeSearchRequest(
|
||||
request.Q.Trim(),
|
||||
request.K,
|
||||
normalizedFilter,
|
||||
request.IncludeDebug);
|
||||
|
||||
var response = await searchService.SearchAsync(domainRequest, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(MapResponse(response));
|
||||
}
|
||||
|
||||
private static async Task<IResult> RebuildIndexAsync(
|
||||
HttpContext httpContext,
|
||||
IKnowledgeIndexer indexer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!EnsureIndexAdminAuthorized(httpContext))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var summary = await indexer.RebuildAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new AdvisoryKnowledgeRebuildResponse
|
||||
{
|
||||
DocumentCount = summary.DocumentCount,
|
||||
ChunkCount = summary.ChunkCount,
|
||||
ApiSpecCount = summary.ApiSpecCount,
|
||||
ApiOperationCount = summary.ApiOperationCount,
|
||||
DoctorProjectionCount = summary.DoctorProjectionCount,
|
||||
DurationMs = summary.DurationMs
|
||||
});
|
||||
}
|
||||
|
||||
private static KnowledgeSearchFilter? NormalizeFilter(AdvisoryKnowledgeSearchFilter? filter)
|
||||
{
|
||||
if (filter is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedKinds = filter.Type is { Count: > 0 }
|
||||
? filter.Type
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim().ToLowerInvariant())
|
||||
.Where(value => AllowedKinds.Contains(value))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static value => value, StringComparer.Ordinal)
|
||||
.ToArray()
|
||||
: null;
|
||||
|
||||
var normalizedTags = filter.Tags is { Count: > 0 }
|
||||
? filter.Tags
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray()
|
||||
: null;
|
||||
|
||||
return new KnowledgeSearchFilter
|
||||
{
|
||||
Type = normalizedKinds,
|
||||
Product = NormalizeOptional(filter.Product),
|
||||
Version = NormalizeOptional(filter.Version),
|
||||
Service = NormalizeOptional(filter.Service),
|
||||
Tags = normalizedTags
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static AdvisoryKnowledgeSearchResponse MapResponse(KnowledgeSearchResponse response)
|
||||
{
|
||||
var results = response.Results
|
||||
.Select(MapResult)
|
||||
.ToArray();
|
||||
|
||||
return new AdvisoryKnowledgeSearchResponse
|
||||
{
|
||||
Query = response.Query,
|
||||
TopK = response.TopK,
|
||||
Results = results,
|
||||
Diagnostics = new AdvisoryKnowledgeSearchDiagnostics
|
||||
{
|
||||
FtsMatches = response.Diagnostics.FtsMatches,
|
||||
VectorMatches = response.Diagnostics.VectorMatches,
|
||||
DurationMs = response.Diagnostics.DurationMs,
|
||||
UsedVector = response.Diagnostics.UsedVector,
|
||||
Mode = response.Diagnostics.Mode
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static AdvisoryKnowledgeSearchResult MapResult(KnowledgeSearchResult result)
|
||||
{
|
||||
var action = new AdvisoryKnowledgeOpenAction
|
||||
{
|
||||
Kind = result.Open.Kind switch
|
||||
{
|
||||
KnowledgeOpenActionType.Api => "api",
|
||||
KnowledgeOpenActionType.Doctor => "doctor",
|
||||
_ => "docs"
|
||||
},
|
||||
Docs = result.Open.Docs is null
|
||||
? null
|
||||
: new AdvisoryKnowledgeOpenDocAction
|
||||
{
|
||||
Path = result.Open.Docs.Path,
|
||||
Anchor = result.Open.Docs.Anchor,
|
||||
SpanStart = result.Open.Docs.SpanStart,
|
||||
SpanEnd = result.Open.Docs.SpanEnd
|
||||
},
|
||||
Api = result.Open.Api is null
|
||||
? null
|
||||
: new AdvisoryKnowledgeOpenApiAction
|
||||
{
|
||||
Service = result.Open.Api.Service,
|
||||
Method = result.Open.Api.Method,
|
||||
Path = result.Open.Api.Path,
|
||||
OperationId = result.Open.Api.OperationId
|
||||
},
|
||||
Doctor = result.Open.Doctor is null
|
||||
? null
|
||||
: new AdvisoryKnowledgeOpenDoctorAction
|
||||
{
|
||||
CheckCode = result.Open.Doctor.CheckCode,
|
||||
Severity = result.Open.Doctor.Severity,
|
||||
CanRun = result.Open.Doctor.CanRun,
|
||||
RunCommand = result.Open.Doctor.RunCommand
|
||||
}
|
||||
};
|
||||
|
||||
return new AdvisoryKnowledgeSearchResult
|
||||
{
|
||||
Type = result.Type,
|
||||
Title = result.Title,
|
||||
Snippet = result.Snippet,
|
||||
Score = result.Score,
|
||||
Open = action,
|
||||
Debug = result.Debug is null
|
||||
? null
|
||||
: new Dictionary<string, string>(result.Debug, StringComparer.Ordinal)
|
||||
};
|
||||
}
|
||||
|
||||
private static bool EnsureSearchAuthorized(HttpContext context)
|
||||
{
|
||||
return HasAnyScope(
|
||||
context,
|
||||
"advisory:run",
|
||||
"advisory:search",
|
||||
"advisory:read");
|
||||
}
|
||||
|
||||
private static bool EnsureIndexAdminAuthorized(HttpContext context)
|
||||
{
|
||||
return HasAnyScope(
|
||||
context,
|
||||
"advisory:run",
|
||||
"advisory:admin",
|
||||
"advisory:index:write");
|
||||
}
|
||||
|
||||
private static bool HasAnyScope(HttpContext context, params string[] expectedScopes)
|
||||
{
|
||||
var scopes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
AddScopeTokens(scopes, context.Request.Headers["X-StellaOps-Scopes"]);
|
||||
AddScopeTokens(scopes, context.Request.Headers["X-Stella-Scopes"]);
|
||||
|
||||
foreach (var expectedScope in expectedScopes)
|
||||
{
|
||||
if (scopes.Contains(expectedScope))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void AddScopeTokens(HashSet<string> scopes, IEnumerable<string> values)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var token in value.Split(
|
||||
[' ', ','],
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
scopes.Add(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AdvisoryKnowledgeSearchRequest
|
||||
{
|
||||
public string Q { get; init; } = string.Empty;
|
||||
|
||||
public int? K { get; init; }
|
||||
|
||||
public AdvisoryKnowledgeSearchFilter? Filters { get; init; }
|
||||
|
||||
public bool IncludeDebug { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdvisoryKnowledgeSearchFilter
|
||||
{
|
||||
public IReadOnlyList<string>? Type { get; init; }
|
||||
|
||||
public string? Product { get; init; }
|
||||
|
||||
public string? Version { get; init; }
|
||||
|
||||
public string? Service { get; init; }
|
||||
|
||||
public IReadOnlyList<string>? Tags { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdvisoryKnowledgeSearchResponse
|
||||
{
|
||||
public string Query { get; init; } = string.Empty;
|
||||
|
||||
public int TopK { get; init; }
|
||||
|
||||
public IReadOnlyList<AdvisoryKnowledgeSearchResult> Results { get; init; } = [];
|
||||
|
||||
public AdvisoryKnowledgeSearchDiagnostics Diagnostics { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed record AdvisoryKnowledgeSearchResult
|
||||
{
|
||||
public string Type { get; init; } = "docs";
|
||||
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
public string Snippet { get; init; } = string.Empty;
|
||||
|
||||
public double Score { get; init; }
|
||||
|
||||
public AdvisoryKnowledgeOpenAction Open { get; init; } = new();
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Debug { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdvisoryKnowledgeOpenAction
|
||||
{
|
||||
public string Kind { get; init; } = "docs";
|
||||
|
||||
public AdvisoryKnowledgeOpenDocAction? Docs { get; init; }
|
||||
|
||||
public AdvisoryKnowledgeOpenApiAction? Api { get; init; }
|
||||
|
||||
public AdvisoryKnowledgeOpenDoctorAction? Doctor { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdvisoryKnowledgeOpenDocAction
|
||||
{
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
public string Anchor { get; init; } = "overview";
|
||||
|
||||
public int SpanStart { get; init; }
|
||||
|
||||
public int SpanEnd { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AdvisoryKnowledgeOpenApiAction
|
||||
{
|
||||
public string Service { get; init; } = string.Empty;
|
||||
|
||||
public string Method { get; init; } = "GET";
|
||||
|
||||
public string Path { get; init; } = "/";
|
||||
|
||||
public string OperationId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed record AdvisoryKnowledgeOpenDoctorAction
|
||||
{
|
||||
public string CheckCode { get; init; } = string.Empty;
|
||||
|
||||
public string Severity { get; init; } = "warn";
|
||||
|
||||
public bool CanRun { get; init; } = true;
|
||||
|
||||
public string RunCommand { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed record AdvisoryKnowledgeSearchDiagnostics
|
||||
{
|
||||
public int FtsMatches { get; init; }
|
||||
|
||||
public int VectorMatches { get; init; }
|
||||
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
public bool UsedVector { get; init; }
|
||||
|
||||
public string Mode { get; init; } = "fts-only";
|
||||
}
|
||||
|
||||
public sealed record AdvisoryKnowledgeRebuildResponse
|
||||
{
|
||||
public int DocumentCount { get; init; }
|
||||
|
||||
public int ChunkCount { get; init; }
|
||||
|
||||
public int ApiSpecCount { get; init; }
|
||||
|
||||
public int ApiOperationCount { get; init; }
|
||||
|
||||
public int DoctorProjectionCount { get; init; }
|
||||
|
||||
public long DurationMs { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.AdvisoryAI.Inference.LlmProviders;
|
||||
using StellaOps.AdvisoryAI.Plugin.Unified;
|
||||
using StellaOps.Plugin.Abstractions.Capabilities;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using PluginLlmCompletionRequest = StellaOps.Plugin.Abstractions.Capabilities.LlmCompletionRequest;
|
||||
using PluginLlmCompletionResult = StellaOps.Plugin.Abstractions.Capabilities.LlmCompletionResult;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Unified LLM adapter exposure endpoints.
|
||||
/// Provides provider discovery and an OpenAI-compatible completion surface.
|
||||
/// </summary>
|
||||
public static class LlmAdapterEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps unified LLM adapter endpoints.
|
||||
/// </summary>
|
||||
/// <param name="builder">Endpoint route builder.</param>
|
||||
/// <returns>Route group builder.</returns>
|
||||
public static RouteGroupBuilder MapLlmAdapterEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/v1/advisory-ai/adapters")
|
||||
.WithTags("Advisory AI - LLM Adapters");
|
||||
|
||||
group.MapGet("/llm/providers", ListProvidersAsync)
|
||||
.WithName("ListLlmProviders")
|
||||
.WithSummary("Lists LLM providers exposed via the unified adapter layer.")
|
||||
.Produces<IReadOnlyList<LlmProviderExposureResponse>>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
group.MapPost("/llm/{providerId}/chat/completions", CompleteWithProviderAsync)
|
||||
.WithName("LlmProviderChatCompletions")
|
||||
.WithSummary("OpenAI-compatible chat completion for a specific unified provider.")
|
||||
.Produces<OpenAiChatCompletionResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
group.MapPost("/openai/v1/chat/completions", CompleteOpenAiCompatAsync)
|
||||
.WithName("OpenAiAdapterChatCompletions")
|
||||
.WithSummary("OpenAI-compatible chat completion alias backed by providerId=openai.")
|
||||
.Produces<OpenAiChatCompletionResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private static IResult ListProvidersAsync(
|
||||
HttpContext context,
|
||||
LlmProviderCatalog catalog,
|
||||
LlmPluginAdapterFactory adapterFactory,
|
||||
IServiceProvider services)
|
||||
{
|
||||
if (!EnsureAdapterReadAuthorized(context))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
var availableProviders = catalog.GetAvailablePlugins(services)
|
||||
.Select(plugin => plugin.ProviderId)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var providers = new List<LlmProviderExposureResponse>();
|
||||
foreach (var plugin in catalog.GetPlugins().OrderBy(p => p.ProviderId, StringComparer.Ordinal))
|
||||
{
|
||||
var config = catalog.GetConfiguration(plugin.ProviderId);
|
||||
var configured = config is not null;
|
||||
var validation = configured
|
||||
? plugin.ValidateConfiguration(config!)
|
||||
: LlmProviderConfigValidation.Failed("Provider configuration file was not found.");
|
||||
|
||||
var exposed = false;
|
||||
var exposureErrors = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
exposed = adapterFactory.GetCapability(plugin.ProviderId) is not null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
exposureErrors.Add(ex.Message);
|
||||
}
|
||||
|
||||
var completionPath = string.Equals(plugin.ProviderId, "openai", StringComparison.OrdinalIgnoreCase)
|
||||
? "/v1/advisory-ai/adapters/openai/v1/chat/completions"
|
||||
: $"/v1/advisory-ai/adapters/llm/{plugin.ProviderId}/chat/completions";
|
||||
|
||||
var errors = validation.Errors.ToList();
|
||||
errors.AddRange(exposureErrors);
|
||||
|
||||
providers.Add(new LlmProviderExposureResponse
|
||||
{
|
||||
ProviderId = plugin.ProviderId,
|
||||
DisplayName = plugin.DisplayName,
|
||||
Description = plugin.Description,
|
||||
Configured = configured,
|
||||
Valid = configured && validation.IsValid,
|
||||
Available = availableProviders.Contains(plugin.ProviderId),
|
||||
Exposed = exposed,
|
||||
CompletionPath = completionPath,
|
||||
Warnings = validation.Warnings,
|
||||
Errors = errors
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(providers);
|
||||
}
|
||||
|
||||
private static Task<IResult> CompleteOpenAiCompatAsync(
|
||||
HttpContext context,
|
||||
[FromBody] OpenAiChatCompletionRequest request,
|
||||
LlmPluginAdapterFactory adapterFactory,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return CompleteWithProviderAsync(
|
||||
context,
|
||||
"openai",
|
||||
request,
|
||||
adapterFactory,
|
||||
timeProvider,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CompleteWithProviderAsync(
|
||||
HttpContext context,
|
||||
string providerId,
|
||||
[FromBody] OpenAiChatCompletionRequest request,
|
||||
LlmPluginAdapterFactory adapterFactory,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!EnsureAdapterInvokeAuthorized(context, providerId))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
if (request.Messages.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "messages must contain at least one item." });
|
||||
}
|
||||
|
||||
if (request.Stream)
|
||||
{
|
||||
return Results.BadRequest(new { error = "stream=true is not supported by the adapter endpoint." });
|
||||
}
|
||||
|
||||
if (!TryBuildPrompts(request.Messages, out var systemPrompt, out var userPrompt))
|
||||
{
|
||||
return Results.BadRequest(new { error = "messages must include at least one non-empty user or assistant content." });
|
||||
}
|
||||
|
||||
var capability = adapterFactory.GetCapability(providerId);
|
||||
if (capability is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Provider '{providerId}' is not configured for adapter exposure." });
|
||||
}
|
||||
|
||||
if (!await capability.IsAvailableAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var completionRequest = new PluginLlmCompletionRequest(
|
||||
UserPrompt: userPrompt,
|
||||
SystemPrompt: systemPrompt,
|
||||
Model: request.Model,
|
||||
Temperature: request.Temperature ?? 0,
|
||||
MaxTokens: request.MaxTokens is > 0 ? request.MaxTokens.Value : 4096,
|
||||
Seed: request.Seed,
|
||||
StopSequences: request.Stop,
|
||||
RequestId: string.IsNullOrWhiteSpace(request.RequestId) ? context.TraceIdentifier : request.RequestId);
|
||||
|
||||
var completion = await capability.CompleteAsync(completionRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = new OpenAiChatCompletionResponse
|
||||
{
|
||||
Id = BuildCompletionId(completion),
|
||||
Object = "chat.completion",
|
||||
Created = timeProvider.GetUtcNow().ToUnixTimeSeconds(),
|
||||
Model = completion.ModelId,
|
||||
Choices =
|
||||
[
|
||||
new OpenAiChatCompletionChoice
|
||||
{
|
||||
Index = 0,
|
||||
Message = new OpenAiChatMessage { Role = "assistant", Content = completion.Content },
|
||||
FinishReason = string.IsNullOrWhiteSpace(completion.FinishReason) ? "stop" : completion.FinishReason
|
||||
}
|
||||
],
|
||||
Usage = new OpenAiUsageInfo
|
||||
{
|
||||
PromptTokens = completion.InputTokens ?? 0,
|
||||
CompletionTokens = completion.OutputTokens ?? 0,
|
||||
TotalTokens = (completion.InputTokens ?? 0) + (completion.OutputTokens ?? 0)
|
||||
}
|
||||
};
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static bool TryBuildPrompts(
|
||||
IReadOnlyList<OpenAiChatMessageRequest> messages,
|
||||
out string? systemPrompt,
|
||||
out string userPrompt)
|
||||
{
|
||||
var systemLines = new List<string>();
|
||||
var userLines = new List<string>();
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message.Content))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var role = message.Role?.Trim() ?? string.Empty;
|
||||
var content = message.Content.Trim();
|
||||
if (role.Equals("system", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
systemLines.Add(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role.Equals("user", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
userLines.Add(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(role))
|
||||
{
|
||||
userLines.Add(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
userLines.Add($"{role.ToLowerInvariant()}: {content}");
|
||||
}
|
||||
|
||||
systemPrompt = systemLines.Count == 0
|
||||
? null
|
||||
: string.Join(Environment.NewLine, systemLines);
|
||||
|
||||
userPrompt = string.Join(Environment.NewLine, userLines);
|
||||
return !string.IsNullOrWhiteSpace(userPrompt);
|
||||
}
|
||||
|
||||
private static string BuildCompletionId(PluginLlmCompletionResult completion)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(completion.RequestId))
|
||||
{
|
||||
return completion.RequestId;
|
||||
}
|
||||
|
||||
var input = $"{completion.ProviderId}|{completion.ModelId}|{completion.Content}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
var hex = Convert.ToHexString(hash).ToLowerInvariant();
|
||||
return $"chatcmpl-{hex[..24]}";
|
||||
}
|
||||
|
||||
private static bool EnsureAdapterReadAuthorized(HttpContext context)
|
||||
{
|
||||
return HasAnyScope(context, "advisory:run", "advisory:adapter:read", "advisory:openai:read");
|
||||
}
|
||||
|
||||
private static bool EnsureAdapterInvokeAuthorized(HttpContext context, string providerId)
|
||||
{
|
||||
var providerScope = $"advisory:{providerId}:invoke";
|
||||
return HasAnyScope(
|
||||
context,
|
||||
"advisory:run",
|
||||
"advisory:adapter:invoke",
|
||||
"advisory:openai:invoke",
|
||||
providerScope);
|
||||
}
|
||||
|
||||
private static bool HasAnyScope(HttpContext context, params string[] expectedScopes)
|
||||
{
|
||||
var scopes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
// Only read from gateway-managed headers that are stripped and rewritten
|
||||
// from validated JWT claims by IdentityHeaderPolicyMiddleware.
|
||||
// Do NOT read from X-Scopes — it is not in the gateway's ReservedHeaders
|
||||
// list and can be spoofed by external clients.
|
||||
AddScopeTokens(scopes, context.Request.Headers["X-StellaOps-Scopes"]);
|
||||
AddScopeTokens(scopes, context.Request.Headers["X-Stella-Scopes"]);
|
||||
|
||||
foreach (var expectedScope in expectedScopes)
|
||||
{
|
||||
if (scopes.Contains(expectedScope))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void AddScopeTokens(HashSet<string> scopes, IEnumerable<string> values)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var token in value.Split(
|
||||
[' ', ','],
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
scopes.Add(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record LlmProviderExposureResponse
|
||||
{
|
||||
[JsonPropertyName("provider_id")]
|
||||
public required string ProviderId { get; init; }
|
||||
|
||||
[JsonPropertyName("display_name")]
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
[JsonPropertyName("configured")]
|
||||
public required bool Configured { get; init; }
|
||||
|
||||
[JsonPropertyName("valid")]
|
||||
public required bool Valid { get; init; }
|
||||
|
||||
[JsonPropertyName("available")]
|
||||
public required bool Available { get; init; }
|
||||
|
||||
[JsonPropertyName("exposed")]
|
||||
public required bool Exposed { get; init; }
|
||||
|
||||
[JsonPropertyName("completion_path")]
|
||||
public required string CompletionPath { get; init; }
|
||||
|
||||
[JsonPropertyName("warnings")]
|
||||
public required IReadOnlyList<string> Warnings { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public required IReadOnlyList<string> Errors { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OpenAiChatCompletionRequest
|
||||
{
|
||||
[JsonPropertyName("model")]
|
||||
public string? Model { get; init; }
|
||||
|
||||
[JsonPropertyName("messages")]
|
||||
public IReadOnlyList<OpenAiChatMessageRequest> Messages { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("temperature")]
|
||||
public double? Temperature { get; init; }
|
||||
|
||||
[JsonPropertyName("max_tokens")]
|
||||
public int? MaxTokens { get; init; }
|
||||
|
||||
[JsonPropertyName("stream")]
|
||||
public bool Stream { get; init; }
|
||||
|
||||
[JsonPropertyName("seed")]
|
||||
public int? Seed { get; init; }
|
||||
|
||||
[JsonPropertyName("stop")]
|
||||
public IReadOnlyList<string>? Stop { get; init; }
|
||||
|
||||
[JsonPropertyName("request_id")]
|
||||
public string? RequestId { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OpenAiChatMessageRequest
|
||||
{
|
||||
[JsonPropertyName("role")]
|
||||
public string Role { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public string? Content { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OpenAiChatCompletionResponse
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("object")]
|
||||
public required string Object { get; init; }
|
||||
|
||||
[JsonPropertyName("created")]
|
||||
public required long Created { get; init; }
|
||||
|
||||
[JsonPropertyName("model")]
|
||||
public required string Model { get; init; }
|
||||
|
||||
[JsonPropertyName("choices")]
|
||||
public required IReadOnlyList<OpenAiChatCompletionChoice> Choices { get; init; }
|
||||
|
||||
[JsonPropertyName("usage")]
|
||||
public required OpenAiUsageInfo Usage { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OpenAiChatCompletionChoice
|
||||
{
|
||||
[JsonPropertyName("index")]
|
||||
public required int Index { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public required OpenAiChatMessage Message { get; init; }
|
||||
|
||||
[JsonPropertyName("finish_reason")]
|
||||
public required string FinishReason { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OpenAiChatMessage
|
||||
{
|
||||
[JsonPropertyName("role")]
|
||||
public required string Role { get; init; }
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public required string Content { get; init; }
|
||||
}
|
||||
|
||||
public sealed record OpenAiUsageInfo
|
||||
{
|
||||
[JsonPropertyName("prompt_tokens")]
|
||||
public required int PromptTokens { get; init; }
|
||||
|
||||
[JsonPropertyName("completion_tokens")]
|
||||
public required int CompletionTokens { get; init; }
|
||||
|
||||
[JsonPropertyName("total_tokens")]
|
||||
public required int TotalTokens { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user