diff --git a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md
index 4c07d9c9..d3feb3ab 100644
--- a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md
+++ b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md
@@ -18,3 +18,7 @@ page will be updated as changes land after 1.0.0.
## Highlights
- upgrades the framework's dependency baseline, including YesSql 6.0 (new `ISession.SaveAsync` signature), the Model Context Protocol 2.0 packages, the GitHub Copilot SDK 1.0.8 (new `PermissionsApi.SetAllowAllAsync` mode-based API), Anthropic 12.39.0, OllamaSharp 5.4.30, the .NET 10.0.10 runtime/extension packages, and the `Microsoft.Extensions.AI` 10.8.3 packages
+
+## Fixes
+
+- fixes AI document citation links throwing `System.InvalidOperationException: The following endpoints with a duplicate endpoint name were found` in multi-tenant hosts. `DocumentAIReferenceLinkResolver` no longer resolves the download link by endpoint name (which forced ASP.NET Core to validate global endpoint-name uniqueness and failed when other modules registered the same named API endpoints across tenants); it now builds the path directly from the static `DownloadAIDocument.RoutePattern`, respecting the request `PathBase`.
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/DownloadAIDocument.cs b/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/DownloadAIDocument.cs
index a5b593d0..1f091385 100644
--- a/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/DownloadAIDocument.cs
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/DownloadAIDocument.cs
@@ -17,6 +17,11 @@ public static class DownloadAIDocument
{
public const string DefaultRouteName = "DownloadAIDocument";
+ ///
+ /// The static route pattern mapped for the AI document download endpoint.
+ ///
+ public const string RoutePattern = "ai/documents/{documentId}/download";
+
///
/// Adds the shared AI document download endpoint used by citation links.
///
@@ -24,7 +29,7 @@ public static class DownloadAIDocument
/// The route name.
public static IEndpointRouteBuilder AddDownloadAIDocumentEndpoint(this IEndpointRouteBuilder builder, string routeName = DefaultRouteName)
{
- var endpoint = builder.MapGet("ai/documents/{documentId}/download", HandleAsync);
+ var endpoint = builder.MapGet(RoutePattern, HandleAsync);
if (!string.IsNullOrEmpty(routeName))
{
diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Services/DocumentAIReferenceLinkResolver.cs b/src/Primitives/CrestApps.Core.AI.Documents/Services/DocumentAIReferenceLinkResolver.cs
index bebf75be..27bf3843 100644
--- a/src/Primitives/CrestApps.Core.AI.Documents/Services/DocumentAIReferenceLinkResolver.cs
+++ b/src/Primitives/CrestApps.Core.AI.Documents/Services/DocumentAIReferenceLinkResolver.cs
@@ -1,7 +1,6 @@
using CrestApps.Core.AI.Documents.Endpoints;
using CrestApps.Core.AI.Profiles;
using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Routing;
namespace CrestApps.Core.AI.Documents.Services;
@@ -10,19 +9,14 @@ namespace CrestApps.Core.AI.Documents.Services;
///
public sealed class DocumentAIReferenceLinkResolver : IAIReferenceLinkResolver
{
- private readonly LinkGenerator _linkGenerator;
private readonly IHttpContextAccessor _httpContextAccessor;
///
/// Initializes a new instance of the class.
///
- /// The link generator.
/// The http context accessor.
- public DocumentAIReferenceLinkResolver(
- LinkGenerator linkGenerator,
- IHttpContextAccessor httpContextAccessor)
+ public DocumentAIReferenceLinkResolver(IHttpContextAccessor httpContextAccessor)
{
- _linkGenerator = linkGenerator;
_httpContextAccessor = httpContextAccessor;
}
@@ -38,12 +32,17 @@ public string ResolveLink(string referenceId, IDictionary metada
return null;
}
- return _linkGenerator.GetPathByName(
- _httpContextAccessor.HttpContext,
- DownloadAIDocument.DefaultRouteName,
- new RouteValueDictionary
- {
- ["documentId"] = referenceId,
- });
+ // Build the path directly from the static route pattern instead of resolving by endpoint
+ // name. Name-based link generation forces ASP.NET Core to validate that every endpoint name
+ // is globally unique, which throws in multi-tenant hosts where other modules register the
+ // same named API endpoints across tenants.
+ var relativePath = "/" + DownloadAIDocument.RoutePattern.Replace(
+ "{documentId}",
+ Uri.EscapeDataString(referenceId),
+ StringComparison.Ordinal);
+
+ var pathBase = _httpContextAccessor.HttpContext?.Request.PathBase ?? PathString.Empty;
+
+ return pathBase.Add(relativePath).Value;
}
}