diff --git a/.editorconfig b/.editorconfig index d6c74b153..578ce1883 100644 --- a/.editorconfig +++ b/.editorconfig @@ -246,7 +246,9 @@ dotnet_diagnostic.IDE0305.severity = none # CS8509 already warns dotnet_diagnostic.IDE0072.severity = none - +[src/api/Elastic.Documentation.Api.Lambda/**.cs] +dotnet_diagnostic.IL3050.severity = none +dotnet_diagnostic.IL2026.severity = none [DocumentationWebHost.cs] dotnet_diagnostic.IL3050.severity = none diff --git a/.github/workflows/build-api-lambda.yml b/.github/workflows/build-api-lambda.yml new file mode 100644 index 000000000..acd39b6cc --- /dev/null +++ b/.github/workflows/build-api-lambda.yml @@ -0,0 +1,41 @@ +--- +# This workflow is used to build the API lambda +# lambda function bootstrap binary that can be deployed to AWS Lambda. +name: Build API Lambda + +on: + workflow_dispatch: + workflow_call: + inputs: + ref: + required: false + type: string + default: ${{ github.ref }} + +jobs: + build: + runs-on: ubuntu-latest + env: + BINARY_PATH: .artifacts/Elastic.Documentation.Api.Lambda/release_linux-x64/bootstrap + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + - name: Amazon Linux 2023 build + run: | + docker build . -t api-lambda:latest -f src/api/Elastic.Documentation.Api.Lambda/Dockerfile + - name: Get bootstrap binary + run: | + docker cp $(docker create --name tc api-lambda:latest):/app/.artifacts/publish ./.artifacts && docker rm tc + - name: Inspect bootstrap binary + run: | + tree .artifacts + stat "${BINARY_PATH}" + - name: Archive artifact + id: upload-artifact + uses: actions/upload-artifact@v4 + with: + name: api-lambda-binary + retention-days: 1 + if-no-files-found: error + path: ${{ env.BINARY_PATH }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d3fd09d5..599f464e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,9 +32,12 @@ jobs: - name: Validate Content Sources run: dotnet run --project src/tooling/docs-assembler -c release -- content-source validate - build-lambda: + build-link-index-updater-lambda: uses: ./.github/workflows/build-link-index-updater-lambda.yml - + + build-api-lambda: + uses: ./.github/workflows/build-api-lambda.yml + npm: runs-on: ubuntu-latest defaults: diff --git a/Directory.Packages.props b/Directory.Packages.props index 4a9060035..e0e2235df 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + @@ -18,6 +19,7 @@ + @@ -70,4 +72,4 @@ - + \ No newline at end of file diff --git a/docs-builder.sln b/docs-builder.sln index 03a3c206d..bc53345b7 100644 --- a/docs-builder.sln +++ b/docs-builder.sln @@ -119,6 +119,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "config", "config", "{6FAB56 config\navigation.yml = config\navigation.yml EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "api", "api", "{B042CC78-5060-4091-B95A-79C71BA3908A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Api.Core", "src\api\Elastic.Documentation.Api.Core\Elastic.Documentation.Api.Core.csproj", "{F30B90AD-1A01-4A6F-9699-809FA6875B22}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Api.Infrastructure", "src\api\Elastic.Documentation.Api.Infrastructure\Elastic.Documentation.Api.Infrastructure.csproj", "{AE3FC78E-167F-4B6E-88EC-84743EB748B7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elastic.Documentation.Api.Lambda", "src\api\Elastic.Documentation.Api.Lambda\Elastic.Documentation.Api.Lambda.csproj", "{C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -204,6 +212,18 @@ Global {164F55EC-9412-4CD4-81AD-3598B57632A6}.Debug|Any CPU.Build.0 = Debug|Any CPU {164F55EC-9412-4CD4-81AD-3598B57632A6}.Release|Any CPU.ActiveCfg = Release|Any CPU {164F55EC-9412-4CD4-81AD-3598B57632A6}.Release|Any CPU.Build.0 = Release|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F30B90AD-1A01-4A6F-9699-809FA6875B22}.Release|Any CPU.Build.0 = Release|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE3FC78E-167F-4B6E-88EC-84743EB748B7}.Release|Any CPU.Build.0 = Release|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {4D198E25-C211-41DC-9E84-B15E89BD7048} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} @@ -234,5 +254,9 @@ Global {89B83007-71E6-4B57-BA78-2544BFA476DB} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} {111E7029-BB29-4039-9B45-04776798A8DD} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} {164F55EC-9412-4CD4-81AD-3598B57632A6} = {67B576EE-02FA-4F9B-94BC-3630BC09ECE5} + {B042CC78-5060-4091-B95A-79C71BA3908A} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} + {F30B90AD-1A01-4A6F-9699-809FA6875B22} = {B042CC78-5060-4091-B95A-79C71BA3908A} + {AE3FC78E-167F-4B6E-88EC-84743EB748B7} = {B042CC78-5060-4091-B95A-79C71BA3908A} + {C6A121C5-DEB1-4FCE-9140-AF144EA98EEE} = {B042CC78-5060-4091-B95A-79C71BA3908A} EndGlobalSection EndGlobal diff --git a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useLlmGateway.ts b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useLlmGateway.ts index 2c9e4c2ff..1e819efbe 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useLlmGateway.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/SearchOrAskAi/useLlmGateway.ts @@ -4,25 +4,12 @@ import { EventSourceMessage } from '@microsoft/fetch-event-source' import { useEffect, useState, useRef, useCallback } from 'react' import * as z from 'zod' -export const LlmGatewayRequestSchema = z.object({ - userContext: z.object({ - userEmail: z.string(), - }), - platformContext: z.object({ - origin: z.literal('support_portal'), - useCase: z.literal('support_assistant'), - metadata: z.any(), - }), - input: z.array( - z.object({ - role: z.string(), - message: z.string(), - }) - ), - threadId: z.string(), +export const AskAiRequestSchema = z.object({ + message: z.string(), + threadId: z.string().optional(), }) -export type LlmGatewayRequest = z.infer +export type AskAiRequest = z.infer const sharedAttributes = { timestamp: z.number(), @@ -154,8 +141,8 @@ export const useLlmGateway = (props: Props): UseLlmGatewayResponse => { [processMessage] ) - const { sendMessage, abort } = useFetchEventSource({ - apiEndpoint: '/chat', + const { sendMessage, abort } = useFetchEventSource({ + apiEndpoint: '/_api/v1/ask-ai/stream', onMessage, onError: (error) => { setError(error) @@ -221,64 +208,12 @@ export const useLlmGateway = (props: Props): UseLlmGatewayResponse => { } } -function createLlmGatewayRequest(question: string, threadId?: string) { - // TODO: we should move this to the backend so that the use cannot change this - // Right now, the backend is a pure proxy to the LLM gateway - return LlmGatewayRequestSchema.parse({ - userContext: { - userEmail: `elastic-docs-v3@invalid`, // Random email (will be optional in the future) - }, - platformContext: { - origin: 'support_portal', - useCase: 'support_assistant', - metadata: {}, - }, - input: [ - { - role: 'user', - message: ` - # ROLE AND GOAL - You are an expert AI assistant for the Elastic Stack (Elasticsearch, Kibana, Beats, Logstash, etc.). Your sole purpose is to answer user questions based *exclusively* on the provided context from the official Elastic Documentation. - - # CRITICAL INSTRUCTION: SINGLE-SHOT INTERACTION - This is a single-turn interaction. The user cannot reply to your answer for clarification. Therefore, your response MUST be final, self-contained, and as comprehensive as possible based on the provided context. - Also, keep the response as short as possible, but do not truncate the context. - - # RULES - 1. **Facts** Always do RAG search to find the relevant Elastic documentation. - 2. **Strictly Grounded Answers:** You MUST base your answer 100% on the information from the search results. Do not use any of your pre-trained knowledge or any information outside of this context. - 3. **Handle Ambiguity Gracefully:** Since you cannot ask clarifying questions, if the question is broad or ambiguous (e.g., "how to improve performance"), structure your answer to cover the different interpretations supported by the context. - * Acknowledge the ambiguity. For example: "Your question about 'performance' can cover several areas. Based on the documentation, here are the key aspects:" - * Organize the answer with clear headings for each aspect (e.g., "Indexing Performance," "Query Performance"). - * But if there is a similar or related topic in the docs you can mention it and link to it. - 4. **Direct Answer First:** If the context directly and sufficiently answers a specific question, provide a clear, comprehensive, and well-structured answer. - * Use Markdown for formatting (e.g., code blocks for configurations, bullet points for lists). - * Use LaTeX for mathematical or scientific notations where appropriate (e.g., \`$E = mc^2$\`). - * Make the answer as complete as possible, as this is the user's only response. - * Keep the answer short and concise. We want to link users to the Elastic Documentation to find more information. - 5. **Handling Incomplete Answers:** If the context contains relevant information but does not fully answer the question, you MUST follow this procedure: - * Start by explicitly stating that you could not find a complete answer. - * Then, summarize the related information you *did* find in the context, explaining how it might be helpful. - 6. **Handling No Answer:** If the context is empty or completely irrelevant to the question, you MUST respond with the following, and nothing else: - I was unable to find an answer to your question in the Elastic Documentation. - - For further assistance, you may want to: - * Ask the community of experts at **discuss.elastic.co**. - * If you have an Elastic subscription, contact our support engineers at **support.elastic.co**." - 7. If you are 100% sure that something is not supported by Elastic, then say so. - 8. **Tone:** Your tone should be helpful, professional, and confident. It is better to provide no answer (Rule #5) than an incorrect one. - * Assume that the user is using Elastic for the first time. - * Assume that the user is a beginner. - * Assume that the user has a limited knowledge of Elastic - * Explain unusual terminology, abbreviations, or acronyms. - * Always try to cite relevant Elastic documentation. - `, - }, - { - role: 'user', - message: question, - }, - ], +function createLlmGatewayRequest( + message: string, + threadId?: string +): AskAiRequest { + return AskAiRequestSchema.parse({ + message, threadId, }) } diff --git a/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs new file mode 100644 index 000000000..2c5f3e4ad --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs @@ -0,0 +1,38 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Microsoft.Extensions.Logging; + +namespace Elastic.Documentation.Api.Core.AskAi; + +public class AskAiUsecase(IAskAiGateway askAiGateway, ILogger logger) +{ + public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx) + { + logger.LogDebug("Processing AskAiRequest: {Request}", askAiRequest); + return await askAiGateway.AskAi(askAiRequest, ctx); + } +} + +public record AskAiRequest(string Message, string? ThreadId) +{ + public static string SystemPrompt => + """ + Role: You are a specialized AI assistant designed to answer user questions exclusively from a set of provided documentation. Your primary purpose is to retrieve, synthesize, and present information directly from these documents. + + ## Core Directives: + + - Source of Truth: Your only source of information is the document content provided to you for each user query. You must not use any pre-trained knowledge or external information. + - Answering Style: Answer the user's question directly and comprehensively. As the user cannot ask follow-up questions, your response must be a complete, self-contained answer to their query. Do not start with phrases like "Based on the documents..."—simply provide the answer. + - Handling Unknowns: If the information required to answer the question is not present in the provided documents, you must explicitly state that the answer cannot be found. Do not attempt to guess, infer, or provide a general response. + - Helpful Fallback: If you cannot find a direct answer, you may suggest and link to a few related or similar topics that are present in the documentation. This provides value even when a direct answer is unavailable. + - Output Format: Your final response should be a single, coherent block of text. + + ## Negative Constraints: + + - Do not mention that you are a language model or AI. + - Do not provide answers based on your general knowledge. + - Do not ask the user for clarification. + """; +} diff --git a/src/api/Elastic.Documentation.Api.Core/AskAi/IAskAiGateway.cs b/src/api/Elastic.Documentation.Api.Core/AskAi/IAskAiGateway.cs new file mode 100644 index 000000000..236bf94b1 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/AskAi/IAskAiGateway.cs @@ -0,0 +1,10 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.Api.Core.AskAi; + +public interface IAskAiGateway +{ + Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default); +} diff --git a/src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj b/src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj new file mode 100644 index 000000000..a4ad652a2 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + Elastic.Documentation.Api.Core + Elastic.Documentation.Api.Core + + + + + + + diff --git a/src/api/Elastic.Documentation.Api.Core/SerializationContext.cs b/src/api/Elastic.Documentation.Api.Core/SerializationContext.cs new file mode 100644 index 000000000..07a92d26a --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/SerializationContext.cs @@ -0,0 +1,13 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text.Json.Serialization; +using Elastic.Documentation.Api.Core.AskAi; + +namespace Elastic.Documentation.Api.Core; + + +[JsonSerializable(typeof(AskAiRequest))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +public partial class ApiJsonContext : JsonSerializerContext; diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs new file mode 100644 index 000000000..79155b654 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs @@ -0,0 +1,66 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Elastic.Documentation.Api.Core.AskAi; +using Elastic.Documentation.Api.Infrastructure.Gcp; +using Microsoft.Extensions.Options; + +namespace Elastic.Documentation.Api.Infrastructure.Adapters.AskAi; + +public class LlmGatewayAskAiGateway(HttpClient httpClient, GcpIdTokenProvider tokenProvider, IOptionsSnapshot options) : IAskAiGateway +{ + public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) + { + var llmGatewayRequest = LlmGatewayRequest.CreateFromRequest(askAiRequest); + var requestBody = JsonSerializer.Serialize(llmGatewayRequest, LlmGatewayContext.Default.LlmGatewayRequest); + var request = new HttpRequestMessage(HttpMethod.Post, options.Value.FunctionUrl) + { + Content = new StringContent(requestBody, Encoding.UTF8, "application/json") + }; + var authToken = await tokenProvider.GenerateIdTokenAsync(ctx); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", authToken); + request.Headers.Add("User-Agent", "elastic-docs-proxy/1.0"); + request.Headers.Add("Accept", "text/event-stream"); + request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx); + return await response.Content.ReadAsStreamAsync(ctx); + } +} + +public record LlmGatewayRequest( + UserContext UserContext, + PlatformContext PlatformContext, + ChatInput[] Input, + string ThreadId +) +{ + public static LlmGatewayRequest CreateFromRequest(AskAiRequest request) => + new( + UserContext: new UserContext("elastic-docs-v3@invalid"), + PlatformContext: new PlatformContext("support_portal", "support_assistant", []), + Input: + [ + new ChatInput("system", AskAiRequest.SystemPrompt), + new ChatInput("user", request.Message) + ], + ThreadId: request.ThreadId ?? "elastic-docs-" + Guid.NewGuid() + ); +} + +public record UserContext(string UserEmail); + +public record PlatformContext( + string Origin, + string UseCase, + Dictionary? Metadata = null +); + +public record ChatInput(string Role, string Message); + +[JsonSerializable(typeof(LlmGatewayRequest))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal sealed partial class LlmGatewayContext : JsonSerializerContext; diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Aws/IParameterProvider.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Aws/IParameterProvider.cs new file mode 100644 index 000000000..c35dac454 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Aws/IParameterProvider.cs @@ -0,0 +1,10 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.Api.Infrastructure.Aws; + +public interface IParameterProvider +{ + Task GetParam(string name, bool withDecryption = true, Cancel ctx = default); +} diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Aws/LambdaExtensionParameterProvider.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Aws/LambdaExtensionParameterProvider.cs new file mode 100644 index 000000000..651e72e19 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Aws/LambdaExtensionParameterProvider.cs @@ -0,0 +1,64 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace Elastic.Documentation.Api.Infrastructure.Aws; + +public class LambdaExtensionParameterProvider(IHttpClientFactory httpClientFactory, ILogger logger) : IParameterProvider +{ + public const string HttpClientName = "AwsParametersAndSecretsLambdaExtensionClient"; + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); + + public async Task GetParam(string name, bool withDecryption = true, Cancel ctx = default) + { + try + { + logger.LogInformation("Retrieving parameter '{Name}' from Lambda Extension (SSM Parameter Store).", name); + var response = await _httpClient.GetFromJsonAsync($"/systemsmanager/parameters/get?name={Uri.EscapeDataString(name)}&withDecryption={withDecryption.ToString().ToLowerInvariant()}", AwsJsonContext.Default.ParameterResponse, ctx); + return response?.Parameter?.Value ?? throw new InvalidOperationException($"Parameter value for '{name}' is null."); + } + catch (HttpRequestException httpEx) + { + logger.LogError(httpEx, "HTTP request failed for parameter '{Name}'. Status: {StatusCode}.", name, httpEx.StatusCode); + throw; + } + catch (JsonException jsonEx) + { + logger.LogError(jsonEx, "JSON deserialization failed for parameter '{Name}'.", name); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "An unexpected error occurred while retrieving parameter '{Name}'.", name); + throw; + } + } +} + +internal sealed class ParameterResponse +{ + public Parameter? Parameter { get; set; } +} + +internal sealed class Parameter +{ + public string? Arn { get; set; } + public string? Name { get; set; } + public string? Type { get; set; } + public string? Value { get; set; } + public string? Version { get; set; } + public string? Selector { get; set; } + public string? LastModifiedDate { get; set; } + public string? LastModifiedUser { get; set; } + public string? DataType { get; set; } +} + + +[JsonSerializable(typeof(ParameterResponse))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal sealed partial class AwsJsonContext : JsonSerializerContext; diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Aws/LocalParameterProvider.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Aws/LocalParameterProvider.cs new file mode 100644 index 000000000..c8974fb63 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Aws/LocalParameterProvider.cs @@ -0,0 +1,38 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.Api.Infrastructure.Aws; + +public class LocalParameterProvider : IParameterProvider +{ + public async Task GetParam(string name, bool withDecryption = true, Cancel ctx = default) + { + switch (name) + { + case "/elastic-docs-v3/dev/llm-gateway-service-account": + { + const string envName = "LLM_GATEWAY_SERVICE_ACCOUNT_KEY_PATH"; + var serviceAccountKeyPath = Environment.GetEnvironmentVariable(envName); + if (string.IsNullOrEmpty(serviceAccountKeyPath)) + throw new ArgumentException($"Environment variable '{envName}' not found."); + if (!File.Exists(serviceAccountKeyPath)) + throw new ArgumentException($"Service account key file not found at '{serviceAccountKeyPath}'."); + var serviceAccountKey = await File.ReadAllTextAsync(serviceAccountKeyPath, ctx); + return serviceAccountKey; + } + case "/elastic-docs-v3/dev/llm-gateway-function-url": + { + const string envName = "LLM_GATEWAY_FUNCTION_URL"; + var value = Environment.GetEnvironmentVariable(envName); + if (string.IsNullOrEmpty(value)) + throw new ArgumentException($"Environment variable '{envName}' not found."); + return value; + } + default: + { + throw new ArgumentException($"Parameter '{name}' not found in {nameof(LocalParameterProvider)}"); + } + } + } +} diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Elastic.Documentation.Api.Infrastructure.csproj b/src/api/Elastic.Documentation.Api.Infrastructure/Elastic.Documentation.Api.Infrastructure.csproj new file mode 100644 index 000000000..009c0da0a --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Elastic.Documentation.Api.Infrastructure.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + true + $(InterceptorsPreviewNamespaces);Microsoft.AspNetCore.Http.Generated + Elastic.Documentation.Api.Infrastructure + Elastic.Documentation.Api.Infrastructure + + + + + + + + + + + + + + ..\..\..\..\..\..\..\usr\local\share\dotnet\shared\Microsoft.AspNetCore.App\9.0.5\Microsoft.Extensions.Configuration.Abstractions.dll + + + + diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Gcp/GcpIdTokenProvider.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Gcp/GcpIdTokenProvider.cs new file mode 100644 index 000000000..3c06e6345 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Gcp/GcpIdTokenProvider.cs @@ -0,0 +1,125 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; + +namespace Elastic.Documentation.Api.Infrastructure.Gcp; + +// This is a custom implementation to create an ID token for GCP. +// Because Google.Api.Auth.OAuth2 is not compatible with AOT +public class GcpIdTokenProvider(HttpClient httpClient, IOptionsSnapshot options) +{ + public async Task GenerateIdTokenAsync(Cancel cancellationToken = default) + { + // Read and parse service account key file using System.Text.Json source generation (AOT compatible) + var serviceAccount = JsonSerializer.Deserialize(options.Value.ServiceAccount, GcpJsonContext.Default.ServiceAccountKey); + + // Create JWT header + var header = new JwtHeader("RS256", "JWT", serviceAccount.PrivateKeyId); + var headerJson = JsonSerializer.Serialize(header, JwtHeaderJsonContext.Default.JwtHeader); + var headerBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(headerJson)); + + // Create JWT payload + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var payload = new JwtPayload( + serviceAccount.ClientEmail, + serviceAccount.ClientEmail, + "https://oauth2.googleapis.com/token", + now, + now + 300, // 5 minutes + options.Value.TargetAudience + ); + + var payloadJson = JsonSerializer.Serialize(payload, GcpJsonContext.Default.JwtPayload); + var payloadBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadJson)); + + // Create signature + var message = $"{headerBase64}.{payloadBase64}"; + var messageBytes = Encoding.UTF8.GetBytes(message); + + // Parse the private key (removing PEM headers/footers and decoding) + var privateKeyPem = serviceAccount.PrivateKey + .Replace("-----BEGIN PRIVATE KEY-----", "") + .Replace("-----END PRIVATE KEY-----", "") + .Replace("\n", "") + .Replace("\r", ""); + var privateKeyBytes = Convert.FromBase64String(privateKeyPem); + + // Create RSA instance and sign + using var rsa = RSA.Create(); + rsa.ImportPkcs8PrivateKey(privateKeyBytes, out _); + var signature = rsa.SignData(messageBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var signatureBase64 = Base64UrlEncode(signature); + + var jwt = $"{message}.{signatureBase64}"; + + // Exchange JWT for ID token + return await ExchangeJwtForIdToken(jwt, options.Value.TargetAudience, cancellationToken); + } + + + private async Task ExchangeJwtForIdToken(string jwt, string targetAudience, Cancel cancellationToken) + { + var requestContent = new FormUrlEncodedContent([ + new KeyValuePair("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + new KeyValuePair("assertion", jwt), + new KeyValuePair("target_audience", targetAudience) + ]); + + var response = await httpClient.PostAsync("https://oauth2.googleapis.com/token", requestContent, cancellationToken); + _ = response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); + using var document = JsonDocument.Parse(responseJson); + + if (document.RootElement.TryGetProperty("id_token", out var idTokenElement)) + return idTokenElement.GetString() ?? throw new InvalidOperationException("ID token is null"); + + throw new InvalidOperationException("No id_token found in response"); + } + + private static string Base64UrlEncode(byte[] input) + { + var base64 = Convert.ToBase64String(input); + // Convert base64 to base64url encoding + return base64.Replace('+', '-').Replace('/', '_').TrimEnd('='); + } +} + +internal readonly record struct ServiceAccountKey( + string Type, + string ProjectId, + string PrivateKeyId, + string PrivateKey, + string ClientEmail, + string ClientId, + string AuthUri, + string TokenUri, + string AuthProviderX509CertUrl, + string ClientX509CertUrl +); + +internal readonly record struct JwtHeader(string Alg, string Typ, string Kid); + +internal readonly record struct JwtPayload( + string Iss, + string Sub, + string Aud, + long Iat, + long Exp, + string TargetAudience +); + +[JsonSerializable(typeof(ServiceAccountKey))] +[JsonSerializable(typeof(JwtPayload))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] +internal sealed partial class GcpJsonContext : JsonSerializerContext; + +[JsonSerializable(typeof(JwtHeader))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal sealed partial class JwtHeaderJsonContext : JsonSerializerContext; diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExstension.cs b/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExstension.cs new file mode 100644 index 000000000..92e9daccf --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExstension.cs @@ -0,0 +1,26 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.Documentation.Api.Core.AskAi; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Elastic.Documentation.Api.Infrastructure; + +public static class MappingsExtension +{ + public static void MapElasticDocsApiEndpoints(this IEndpointRouteBuilder group) => + MapAskAiEndpoint(group); + + private static void MapAskAiEndpoint(IEndpointRouteBuilder group) + { + var askAiGroup = group.MapGroup("/ask-ai"); + _ = askAiGroup.MapPost("/stream", async (AskAiRequest askAiRequest, AskAiUsecase askAiUsecase, Cancel ctx) => + { + var stream = await askAiUsecase.AskAi(askAiRequest, ctx); + return Results.Stream(stream, "text/event-stream"); + }); + } +} diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs new file mode 100644 index 000000000..3bb7ecf15 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs @@ -0,0 +1,123 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.ComponentModel.DataAnnotations; +using Elastic.Documentation.Api.Core; +using Elastic.Documentation.Api.Core.AskAi; +using Elastic.Documentation.Api.Infrastructure.Adapters.AskAi; +using Elastic.Documentation.Api.Infrastructure.Aws; +using Elastic.Documentation.Api.Infrastructure.Gcp; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NetEscapades.EnumGenerators; + +namespace Elastic.Documentation.Api.Infrastructure; + +[EnumExtensions] +public enum AppEnvironment +{ + [Display(Name = "dev")] Dev, + [Display(Name = "staging")] Staging, + [Display(Name = "edge")] Edge, + [Display(Name = "prod")] Prod +} + +public class LlmGatewayOptions +{ + public string ServiceAccount { get; set; } = string.Empty; + public string FunctionUrl { get; set; } = string.Empty; + public string TargetAudience { get; set; } = string.Empty; +} + +public static class ServicesExtension +{ + private static ILogger? GetLogger(IServiceCollection services) + { + using var serviceProvider = services.BuildServiceProvider(); + var loggerFactory = serviceProvider.GetService(); + return loggerFactory?.CreateLogger(typeof(ServicesExtension)); + } + + public static void AddElasticDocsApiUsecases(this IServiceCollection services, string? appEnvironment) + { + if (AppEnvironmentExtensions.TryParse(appEnvironment, out var parsedEnvironment, true)) + { + AddElasticDocsApiUsecases(services, parsedEnvironment); + } + else + { + var logger = GetLogger(services); + logger?.LogWarning("Unable to parse environment {AppEnvironment} into AppEnvironment. Using default AppEnvironment.Dev", appEnvironment); + AddElasticDocsApiUsecases(services, AppEnvironment.Dev); + } + } + + + private static void AddElasticDocsApiUsecases(this IServiceCollection services, AppEnvironment appEnvironment) + { + _ = services.ConfigureHttpJsonOptions(options => + { + options.SerializerOptions.TypeInfoResolverChain.Insert(0, ApiJsonContext.Default); + }); + _ = services.AddHttpClient(); + AddParameterProvider(services, appEnvironment); + AddAskAiUsecase(services, appEnvironment); + } + + // https://docs.aws.amazon.com/systems-manager/latest/userguide/ps-integration-lambda-extensions.html + private static void AddParameterProvider(IServiceCollection services, AppEnvironment appEnvironment) + { + var logger = GetLogger(services); + + switch (appEnvironment) + { + case AppEnvironment.Prod: + case AppEnvironment.Staging: + case AppEnvironment.Edge: + { + logger?.LogInformation("Configuring LambdaExtensionParameterProvider for environment {AppEnvironment}", appEnvironment); + _ = services.AddHttpClient(LambdaExtensionParameterProvider.HttpClientName, client => + { + client.BaseAddress = new Uri("http://localhost:2773"); + client.DefaultRequestHeaders.Add("X-Aws-Parameters-Secrets-Token", Environment.GetEnvironmentVariable("AWS_SESSION_TOKEN")); + }); + _ = services.AddSingleton(); + break; + } + case AppEnvironment.Dev: + { + logger?.LogInformation("Configuring LocalParameterProvider for environment {AppEnvironment}", appEnvironment); + _ = services.AddSingleton(); + break; + } + default: + { + throw new ArgumentOutOfRangeException(nameof(appEnvironment), appEnvironment, + "Unsupported environment for parameter provider."); + } + } + } + + private static void AddAskAiUsecase(IServiceCollection services, AppEnvironment appEnvironment) + { + var logger = GetLogger(services); + logger?.LogInformation("Configuring AskAi use case for environment {AppEnvironment}", appEnvironment); + + _ = services.Configure(options => + { + var serviceProvider = services.BuildServiceProvider(); + var parameterProvider = serviceProvider.GetRequiredService(); + var appEnvString = appEnvironment.ToStringFast(true); + + options.ServiceAccount = parameterProvider.GetParam($"/elastic-docs-v3/{appEnvString}/llm-gateway-service-account").GetAwaiter().GetResult(); + options.FunctionUrl = parameterProvider.GetParam($"/elastic-docs-v3/{appEnvString}/llm-gateway-function-url").GetAwaiter().GetResult(); + + var functionUri = new Uri(options.FunctionUrl); + options.TargetAudience = $"{functionUri.Scheme}://{functionUri.Host}"; + }); + _ = services.AddScoped(); + _ = services.AddScoped, LlmGatewayAskAiGateway>(); + _ = services.AddScoped(); + } +} diff --git a/src/api/Elastic.Documentation.Api.Lambda/Dockerfile b/src/api/Elastic.Documentation.Api.Lambda/Dockerfile new file mode 100644 index 000000000..07ac09d4a --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Lambda/Dockerfile @@ -0,0 +1,27 @@ +FROM public.ecr.aws/amazonlinux/amazonlinux:2023 AS base + +ARG TARGETARCH +ARG TARGETOS + +WORKDIR /app + +RUN rpm --import https://packages.microsoft.com/keys/microsoft.asc + +RUN curl -o /etc/yum.repos.d/microsoft-prod.repo https://packages.microsoft.com/config/fedora/39/prod.repo + +RUN dnf update -y +RUN dnf install -y dotnet-sdk-9.0 +RUN dnf install -y npm +RUN dnf install -y git +RUN dnf install -y clang + +COPY . . + +ENV DOTNET_NOLOGO=true \ + DOTNET_CLI_TELEMETRY_OPTOUT=true + +RUN arch=$TARGETARCH \ + && if [ "$arch" = "amd64" ]; then arch="x64"; fi \ + && echo $TARGETOS-$arch > /tmp/rid + +RUN dotnet publish src/api/Elastic.Documentation.Api.Lambda -r linux-x64 -c Release diff --git a/src/api/Elastic.Documentation.Api.Lambda/Elastic.Documentation.Api.Lambda.csproj b/src/api/Elastic.Documentation.Api.Lambda/Elastic.Documentation.Api.Lambda.csproj new file mode 100644 index 000000000..5d49a07a4 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Lambda/Elastic.Documentation.Api.Lambda.csproj @@ -0,0 +1,33 @@ + + + + Exe + net9.0 + enable + enable + true + + bootstrap + Lambda + + true + true + true + + true + false + Linux + true + $(InterceptorsPreviewNamespaces);Microsoft.AspNetCore.Http.Generated + + + + + + + + + + + + diff --git a/src/api/Elastic.Documentation.Api.Lambda/Program.cs b/src/api/Elastic.Documentation.Api.Lambda/Program.cs new file mode 100644 index 000000000..8ea055886 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Lambda/Program.cs @@ -0,0 +1,24 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Text.Json.Serialization; +using Amazon.Lambda.APIGatewayEvents; +using Amazon.Lambda.Serialization.SystemTextJson; +using Elastic.Documentation.Api.Infrastructure; + +var builder = WebApplication.CreateSlimBuilder(args); + +builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi, new SourceGeneratorLambdaJsonSerializer()); +builder.Services.AddElasticDocsApiUsecases(Environment.GetEnvironmentVariable("APP_ENVIRONMENT")); + +var app = builder.Build(); + +var v1 = app.MapGroup("/v1"); +v1.MapElasticDocsApiEndpoints(); + +app.Run(); + +[JsonSerializable(typeof(APIGatewayHttpApiV2ProxyRequest), GenerationMode = JsonSourceGenerationMode.Metadata)] +[JsonSerializable(typeof(APIGatewayHttpApiV2ProxyResponse), GenerationMode = JsonSourceGenerationMode.Default)] +internal sealed partial class LambdaJsonSerializerContext : JsonSerializerContext; diff --git a/src/api/Elastic.Documentation.Api.Lambda/Properties/launchSettings.json b/src/api/Elastic.Documentation.Api.Lambda/Properties/launchSettings.json new file mode 100644 index 000000000..b3c490b26 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Lambda/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5020", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7192;http://localhost:5020", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/api/Elastic.Documentation.Api.Lambda/appsettings.Development.json b/src/api/Elastic.Documentation.Api.Lambda/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Lambda/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/api/Elastic.Documentation.Api.Lambda/appsettings.json b/src/api/Elastic.Documentation.Api.Lambda/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Lambda/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/tooling/docs-builder/Http/DocumentationWebHost.cs b/src/tooling/docs-builder/Http/DocumentationWebHost.cs index 8469d1431..bcca91319 100644 --- a/src/tooling/docs-builder/Http/DocumentationWebHost.cs +++ b/src/tooling/docs-builder/Http/DocumentationWebHost.cs @@ -6,7 +6,13 @@ using System.Net; using System.Runtime.InteropServices; using System.Text; +using System.Text.Json; using Documentation.Builder.Diagnostics.LiveMode; +using Elastic.Documentation.Api.Core; +using Elastic.Documentation.Api.Core.AskAi; +using Elastic.Documentation.Api.Infrastructure; +using Elastic.Documentation.Api.Infrastructure.Adapters.AskAi; +using Elastic.Documentation.Api.Infrastructure.Gcp; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Versions; using Elastic.Documentation.Site.FileProviders; @@ -34,8 +40,8 @@ public DocumentationWebHost(ILoggerFactory logFactory, string? path, int port, I { _writeFileSystem = writeFs; var builder = WebApplication.CreateSlimBuilder(); + builder.Services.AddElasticDocsApiUsecases("dev"); DocumentationTooling.CreateServiceCollection(builder.Services, LogLevel.Information); - _ = builder.Logging .AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Error) .AddFilter("Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware", LogLevel.Error) @@ -94,6 +100,23 @@ private void SetUpRoutes() _ = _webApplication .UseLiveReloadWithManualScriptInjection(_webApplication.Lifetime) .UseDeveloperExceptionPage(new DeveloperExceptionPageOptions()) + .Use(async (context, next) => + { + try + { + await next(context); + } + catch (Exception ex) + { + Console.WriteLine($"[UNHANDLED EXCEPTION] {ex.GetType().Name}: {ex.Message}"); + Console.WriteLine($"[STACK TRACE] {ex.StackTrace}"); + if (ex.InnerException != null) + { + Console.WriteLine($"[INNER EXCEPTION] {ex.InnerException.GetType().Name}: {ex.InnerException.Message}"); + } + throw; // Re-throw to let ASP.NET Core handle it + } + }) .UseStaticFiles( new StaticFileOptions { @@ -111,8 +134,9 @@ private void SetUpRoutes() _ = _webApplication.MapGet("/api/{**slug}", (string slug, ReloadableGeneratorState holder, Cancel ctx) => ServeApiFile(holder, slug, ctx)); - _ = _webApplication.MapPost("/chat", async (HttpContext context, Cancel ctx) => - await ProxyChatRequest(context, ctx)); + + var apiV1 = _webApplication.MapGroup("/_api/v1"); + apiV1.MapElasticDocsApiEndpoints(); _ = _webApplication.MapGet("{**slug}", (string slug, ReloadableGeneratorState holder, Cancel ctx) => ServeDocumentationFile(holder, slug, ctx)); @@ -219,106 +243,4 @@ private static IResult LiveReloadHtml(string content, Encoding? encoding = null, return Results.Content(content, "text/html", encoding, statusCode); } - - private static async Task ProxyChatRequest(HttpContext context, CancellationToken ctx) - { - try - { - // Read the frontend request body - var requestBody = await new StreamReader(context.Request.Body).ReadToEndAsync(ctx); - - // Load GCP service account credentials - var serviceAccountKeyPath = Environment.GetEnvironmentVariable("GCP_SERVICE_ACCOUNT_KEY_PATH") - ?? "service-account-key.json"; - - if (!File.Exists(serviceAccountKeyPath)) - { - context.Response.StatusCode = 500; - await context.Response.WriteAsync("GCP credentials not configured", cancellationToken: ctx); - return Results.Empty; - } - - // Get GCP function URL - var gcpFunctionUrl = Environment.GetEnvironmentVariable("GCP_CHAT_FUNCTION_URL"); - if (string.IsNullOrEmpty(gcpFunctionUrl)) - { - context.Response.StatusCode = 500; - await context.Response.WriteAsync("GCP function URL not configured", cancellationToken: ctx); - return Results.Empty; - } - - // Extract base URL for ID token audience (service URL without path) - var functionUri = new Uri(gcpFunctionUrl); - var audienceUrl = $"{functionUri.Scheme}://{functionUri.Host}"; - - // Generate ID token using AOT-compatible approach - var idToken = await GcpIdTokenGenerator.GenerateIdTokenAsync(serviceAccountKeyPath, audienceUrl, ctx); - - // Make request to GCP function - using var httpClient = new HttpClient(); - var request = new HttpRequestMessage(HttpMethod.Post, gcpFunctionUrl) - { - Content = new StringContent(requestBody, Encoding.UTF8, "application/json") - }; - - // Add authorization header with ID token - request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", idToken); - - // Add additional headers that GCP functions commonly require - request.Headers.Add("User-Agent", "docs-builder-proxy/1.0"); - request.Headers.Add("Accept", "text/event-stream, application/json"); - - // Ensure Content-Type is set properly for the request body - request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); - - var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx); - - if (!response.IsSuccessStatusCode) - { - var errorContent = await response.Content.ReadAsStringAsync(ctx); - Console.WriteLine($"[CHAT PROXY] Error response: {errorContent}"); - context.Response.StatusCode = (int)response.StatusCode; - await context.Response.WriteAsync(errorContent, cancellationToken: ctx); - return Results.Empty; - } - - // Forward the response - context.Response.StatusCode = (int)response.StatusCode; - context.Response.ContentType = response.Content.Headers.ContentType?.ToString(); - - // // Copy response headers (but skip headers that shouldn't be forwarded) - // foreach (var header in response.Headers) - // { - // // Skip headers that can cause issues in proxy scenarios - // if (header.Key.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase) - // || header.Key.Equals("Connection", StringComparison.OrdinalIgnoreCase) - // || header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) - // { - // continue; - // } - // context.Response.Headers[header.Key] = header.Value.ToArray(); - // } - // foreach (var header in response.Content.Headers) - // { - // // Skip Content-Length as it may conflict with chunked streaming - // if (header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase)) - // { - // continue; - // } - // context.Response.Headers[header.Key] = header.Value.ToArray(); - // } - - // Stream the response - await using var responseStream = await response.Content.ReadAsStreamAsync(ctx); - await responseStream.CopyToAsync(context.Response.Body, ctx); - } - catch (Exception ex) - { - Console.WriteLine($"[CHAT PROXY] Exception: {ex.Message}"); - context.Response.StatusCode = 500; - await context.Response.WriteAsync($"Error proxying request: {ex.Message}", cancellationToken: ctx); - } - - return Results.Empty; - } } diff --git a/src/tooling/docs-builder/docs-builder.csproj b/src/tooling/docs-builder/docs-builder.csproj index 9eb1616ce..d0b7388a4 100644 --- a/src/tooling/docs-builder/docs-builder.csproj +++ b/src/tooling/docs-builder/docs-builder.csproj @@ -26,6 +26,8 @@ + +