Skip to content

Commit 26ce7c3

Browse files
committed
Update AI Feature, add initial support for dynamic Admin AI Chat Page
1 parent b286e74 commit 26ce7c3

17 files changed

Lines changed: 2944 additions & 100 deletions

File tree

Lines changed: 130 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,153 @@
11
using ServiceStack.Configuration;
22
using ServiceStack.DataAnnotations;
3+
using ServiceStack.OrmLite;
34

45
namespace ServiceStack.AI;
56

67
[ExcludeMetadata, Tag(TagNames.Admin), ExplicitAutoQuery]
78
public class AdminQueryChatCompletionLogs : QueryDb<ChatCompletionLog>
89
{
9-
1010
public DateTime? Month { get; set; }
1111
}
1212

13+
[ExcludeMetadata, Tag(TagNames.Admin)]
14+
public class AdminMonthlyChatCompletionAnalytics : IGet, IReturn<AdminMonthlyChatCompletionAnalyticsResponse>
15+
{
16+
public DateTime? Month { get; set; }
17+
}
18+
public class AdminMonthlyChatCompletionAnalyticsResponse
19+
{
20+
public string Month { get; set; }
21+
public List<ChatCompletionStat> ModelStats { get; set; }
22+
public List<ChatCompletionStat> ProviderStats { get; set; }
23+
public List<ChatCompletionStat> DailyStats { get; set; }
24+
}
25+
26+
[ExcludeMetadata, Tag(TagNames.Admin)]
27+
public class AdminDailyChatCompletionAnalytics : IGet, IReturn<AdminDailyChatCompletionAnalyticsResponse>
28+
{
29+
public DateTime? Day { get; set; }
30+
}
31+
public class AdminDailyChatCompletionAnalyticsResponse
32+
{
33+
public List<ChatCompletionStat> ModelStats { get; set; }
34+
public List<ChatCompletionStat> ProviderStats { get; set; }
35+
}
36+
37+
public class ChatCompletionStat
38+
{
39+
public string Name { get; set; }
40+
public int Requests { get; set; }
41+
public int InputTokens { get; set; }
42+
public int OutputTokens { get; set; }
43+
public decimal Cost { get; set; }
44+
}
45+
1346
public class AdminChatServices(IAutoQueryDb autoQuery)
1447
: Service
1548
{
16-
private ChatFeature AssertRequiredRole()
49+
private (ChatFeature,IChatStore) AssertRequiredRole()
1750
{
1851
var feature = AssertPlugin<ChatFeature>();
1952
RequiredRoleAttribute.AssertRequiredRoles(Request, RoleNames.Admin);
20-
return feature;
53+
var chatStore = feature.ChatStore
54+
?? throw new Exception("ChatStore is not configured");
55+
return (feature, chatStore);
2156
}
2257

23-
public object Any(AdminQueryChatCompletionLogs request)
58+
public async Task<object> Any(AdminQueryChatCompletionLogs request)
2459
{
25-
var feature = AssertRequiredRole();
26-
var chatStore = feature.ChatStore
27-
?? throw new Exception("ChatStore is not configured");
28-
var month = request.Month ?? DateTime.UtcNow;
29-
using var monthDb = chatStore.OpenMonthDb(month);
60+
var (feature, chatStore) = AssertRequiredRole();
61+
var monthDate = request.Month ?? DateTime.UtcNow;
62+
var monthStart = new DateTime(monthDate.Year, monthDate.Month, 1);
63+
using var monthDb = chatStore.OpenMonthDb(monthDate);
3064
var q = autoQuery.CreateQuery(request, base.Request, monthDb);
31-
return autoQuery.Execute(request, q, base.Request, monthDb);
65+
q.Ensure(x => x.CreatedDate >= monthStart && x.CreatedDate < monthStart.AddMonths(1));
66+
return await autoQuery.ExecuteAsync(request, q, base.Request, monthDb);
67+
}
68+
69+
public async Task<object> Any(AdminMonthlyChatCompletionAnalytics request)
70+
{
71+
var (feature, chatStore) = AssertRequiredRole();
72+
var monthDate = (request.Month ?? DateTime.UtcNow);
73+
var month = new DateTime(monthDate.Year, monthDate.Month, 1);
74+
using var monthDb = chatStore.OpenMonthDb(month);
75+
76+
var modelStats = await monthDb.SelectAsync<ChatCompletionStat>(monthDb.From<ChatCompletionLog>()
77+
.Where(x => x.CreatedDate >= month && x.CreatedDate < month.AddMonths(1))
78+
.GroupBy(x => x.Model)
79+
.Select(x => new {
80+
Name = x.Model,
81+
Requests = Sql.Count("*"),
82+
InputTokens = Sql.Sum(x.PromptTokens ?? 0),
83+
OutputTokens = Sql.Sum(x.CompletionTokens ?? 0),
84+
Cost = Sql.Sum(x.Cost),
85+
}));
86+
var providerStats = await monthDb.SelectAsync<ChatCompletionStat>(monthDb.From<ChatCompletionLog>()
87+
.Where(x => x.CreatedDate >= month && x.CreatedDate < month.AddMonths(1))
88+
.GroupBy(x => x.Provider)
89+
.Select(x => new {
90+
Name = x.Provider,
91+
Requests = Sql.Count("*"),
92+
InputTokens = Sql.Sum(x.PromptTokens ?? 0),
93+
OutputTokens = Sql.Sum(x.CompletionTokens ?? 0),
94+
Cost = Sql.Sum(x.Cost),
95+
}));
96+
97+
var q = monthDb.From<ChatCompletionLog>();
98+
var createdDate = q.Column<ChatCompletionLog>(c => c.CreatedDate);
99+
var dailyStatsForMonth = await monthDb.SelectAsync<ChatCompletionStat>(q
100+
.Where(x => x.CreatedDate >= month && x.CreatedDate < month.AddMonths(1))
101+
.GroupBy(x => q.sql.DateFormat(createdDate, "%d"))
102+
.Select(x => new {
103+
Name = Sql.As(q.sql.DateFormat(createdDate, "%d"), "'Name'"),
104+
Requests = Sql.Count("*"),
105+
InputTokens = Sql.Sum(x.PromptTokens ?? 0),
106+
OutputTokens = Sql.Sum(x.CompletionTokens ?? 0),
107+
Cost = Sql.Sum(x.Cost),
108+
}));
109+
110+
return new AdminMonthlyChatCompletionAnalyticsResponse
111+
{
112+
Month = month.ToString("yyyy-MM"),
113+
ModelStats = modelStats,
114+
ProviderStats = providerStats,
115+
DailyStats = dailyStatsForMonth,
116+
};
117+
}
118+
119+
public async Task<object> Any(AdminDailyChatCompletionAnalytics request)
120+
{
121+
var (feature, chatStore) = AssertRequiredRole();
122+
var dayDate = (request.Day ?? DateTime.UtcNow);
123+
var month = new DateTime(dayDate.Year, dayDate.Month, dayDate.Day);
124+
using var monthDb = chatStore.OpenMonthDb(month);
125+
126+
var modelStats = await monthDb.SelectAsync<ChatCompletionStat>(monthDb.From<ChatCompletionLog>()
127+
.Where(x => x.CreatedDate >= month && x.CreatedDate < month.AddDays(1))
128+
.GroupBy(x => x.Model)
129+
.Select(x => new {
130+
Name = x.Model,
131+
Requests = Sql.Count("*"),
132+
InputTokens = Sql.Sum(x.PromptTokens ?? 0),
133+
OutputTokens = Sql.Sum(x.CompletionTokens ?? 0),
134+
Cost = Sql.Sum(x.Cost),
135+
}));
136+
var providerStats = await monthDb.SelectAsync<ChatCompletionStat>(monthDb.From<ChatCompletionLog>()
137+
.Where(x => x.CreatedDate >= month && x.CreatedDate < month.AddDays(1))
138+
.GroupBy(x => x.Provider)
139+
.Select(x => new {
140+
Name = x.Provider,
141+
Requests = Sql.Count("*"),
142+
InputTokens = Sql.Sum(x.PromptTokens ?? 0),
143+
OutputTokens = Sql.Sum(x.CompletionTokens ?? 0),
144+
Cost = Sql.Sum(x.Cost),
145+
}));
146+
147+
return new AdminDailyChatCompletionAnalyticsResponse
148+
{
149+
ModelStats = modelStats,
150+
ProviderStats = providerStats,
151+
};
32152
}
33153
}

ServiceStack/src/ServiceStack.AI.Chat/ChatCompletion.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,10 @@ public class ChatResponse
352352
[Description("Usage statistics for the completion request.")]
353353
[DataMember(Name = "usage")]
354354
public AiUsage Usage { get; set; }
355+
356+
[Description("The provider used for the chat completion.")]
357+
[DataMember(Name = "provider")]
358+
public string? Provider { get; set; }
355359

356360
[Description("Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object in a structured format.")]
357361
[DataMember(Name = "metadata")]

ServiceStack/src/ServiceStack.AI.Chat/ChatCompletionLog.cs

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public class ChatCompletionLog : IMeta
2525
public virtual string? ApiKey { get; set; }
2626

2727
public string Model { get; set; }
28+
public string Provider { get; set; }
2829
public string? UserPrompt { get; set; }
2930

3031
public string? Answer { get; set; }
@@ -51,14 +52,9 @@ public class ChatCompletionLog : IMeta
5152
/// Associate Request with a tag group
5253
/// </summary>
5354
public virtual string? Tag { get; set; }
54-
55-
public virtual string? ThreadId { get; set; }
56-
5755
public virtual int? DurationMs { get; set; }
58-
5956
public virtual int? PromptTokens { get; set; }
6057
public virtual int? CompletionTokens { get; set; }
61-
6258
public virtual decimal Cost { get; set; }
6359

6460
public virtual string? ProviderRef { get; set; }
@@ -68,7 +64,9 @@ public class ChatCompletionLog : IMeta
6864
public virtual string? FinishReason { get; set; }
6965

7066
public virtual ModelUsage? Usage { get; set; }
71-
67+
public virtual string? ThreadId { get; set; }
68+
69+
public string? Title { get; set; }
7270
public virtual Dictionary<string, string>? Meta { get; set; }
7371
}
7472

@@ -100,15 +98,15 @@ public class ModelUsage
10098

10199
public static class ChatCompletionLogUtils
102100
{
103-
public static ChatCompletionLog ToChatCompletionLog(this IRequest req, ChatCompletion request, ChatResponse response, string? refId = null)
101+
public static ChatCompletionLog ToChatCompletionLog(this IRequest req, OpenAiProviderBase provider, ChatCompletion request, ChatResponse response, string? refId = null)
104102
{
105103
ArgumentNullException.ThrowIfNull(request);
106104
ArgumentNullException.ThrowIfNull(response);
107105

108106
var userId = req.GetClaimsPrincipal()?.GetUserId();
109107
var apiKey = req.GetApiKey();
110108
userId ??= apiKey?.UserAuthId;
111-
userId ??= req.GetSession()?.UserAuthId;
109+
userId ??= req.GetClaimsPrincipal().GetUserId();
112110
var duration = req.GetElapsed();
113111

114112
var usage = response.Usage;
@@ -117,7 +115,8 @@ public static ChatCompletionLog ToChatCompletionLog(this IRequest req, ChatCompl
117115
RefId = refId ?? req.GetTraceId() ?? Guid.NewGuid().ToString("N"),
118116
UserId = userId,
119117
ApiKey = apiKey?.Key,
120-
Model = request.Model,
118+
Model = provider.GetModelId(request.Model) ?? request.Model,
119+
Provider = provider.Id,
121120
UserPrompt = request.GetUserPrompt(),
122121
Answer = response.GetAnswer(),
123122
RequestBody = request.ToJson(),
@@ -129,6 +128,9 @@ public static ChatCompletionLog ToChatCompletionLog(this IRequest req, ChatCompl
129128
ThreadId = request.Metadata?.TryGetValue("threadId", out var threadId) == true
130129
? threadId
131130
: null,
131+
ProviderRef = response.Provider,
132+
ProviderModel = response.Model,
133+
FinishReason = response.Choices?.FirstOrDefault()?.FinishReason?.ToLower(),
132134
};
133135
ret.Usage = new()
134136
{
@@ -154,11 +156,21 @@ public static ChatCompletionLog ToChatCompletionLog(this IRequest req, ChatCompl
154156
ret.Cost = (usage.PromptTokens * decimal.Parse(ret.Usage.Input) +
155157
usage.CompletionTokens * decimal.Parse(ret.Usage.Output));
156158
}
159+
160+
// store any additional response.Metadata other than duration,pricing
161+
var keys = response.Metadata.Keys.ToList();
162+
string[] ignoreKeys = ["duration", "pricing"];
163+
if (keys.Any(x => !ignoreKeys.Contains(x)))
164+
{
165+
ret.Meta = response.Metadata
166+
.Where(x => !ignoreKeys.Contains(x.Key))
167+
.ToDictionary(x => x.Key, x => x.Value);
168+
}
157169
}
158170
return ret;
159171
}
160172

161-
public static ChatCompletionLog ToChatCompletionLog(this IRequest req, ChatCompletion request, Exception ex, string? refId = null)
173+
public static ChatCompletionLog ToChatCompletionLog(this IRequest req, OpenAiProviderBase provider, ChatCompletion request, Exception ex, string? refId = null)
162174
{
163175
ArgumentNullException.ThrowIfNull(request);
164176
ArgumentNullException.ThrowIfNull(ex);
@@ -176,13 +188,17 @@ public static ChatCompletionLog ToChatCompletionLog(this IRequest req, ChatCompl
176188
RefId = refId ?? req.GetTraceId() ?? Guid.NewGuid().ToString("N"),
177189
UserId = userId,
178190
ApiKey = apiKey?.Key,
179-
Model = request.Model,
191+
Model = provider.GetModelId(request.Model) ?? request.Model,
192+
Provider = provider.Id,
180193
UserPrompt = request.GetUserPrompt(),
181194
RequestBody = request.ToJson(),
182-
ErrorCode = status.ErrorCode,
183-
Error = status,
184195
CreatedDate = DateTime.UtcNow,
185196
DurationMs = duration != TimeSpan.Zero ? (int)duration.TotalMilliseconds : null,
197+
ThreadId = request.Metadata?.TryGetValue("threadId", out var threadId) == true
198+
? threadId
199+
: null,
200+
ErrorCode = status.ErrorCode,
201+
Error = status,
186202
};
187203
}
188204
}

ServiceStack/src/ServiceStack.AI.Chat/ChatFeature.cs

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Microsoft.Extensions.DependencyInjection;
22
using Microsoft.Extensions.DependencyInjection.Extensions;
33
using Microsoft.Extensions.Logging;
4+
using Microsoft.Extensions.Logging.Abstractions;
45
using ServiceStack.Configuration;
56
using ServiceStack.IO;
67
using ServiceStack.Text;
@@ -82,12 +83,16 @@ public T GetRequiredProvider<T>(string providerId) where T : OpenAiProviderBase
8283
var provider = Providers.GetValueOrDefault(providerId);
8384
if (provider == null)
8485
throw new ArgumentException($"Chat Provider '{providerId}' is not available");
86+
provider.Id = providerId;
8587
return (T)provider;
8688
}
8789
public OpenAiProvider GetOpenAiProvider(string providerId) => GetRequiredProvider<OpenAiProvider>(providerId);
8890
public OllamaProvider GetOllamaProvider(string providerId) => GetRequiredProvider<OllamaProvider>(providerId);
8991
public GoogleProvider GetGoogleProvider(string providerId) => GetRequiredProvider<GoogleProvider>(providerId);
9092

93+
public static string SvgIcon =
94+
"<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><path fill='currentColor' d='M14 5H4v13.385L5.763 17H20v-6h2v7a1 1 0 0 1-1 1H6.454L2 22.5V4a1 1 0 0 1 1-1h11zm5.53-3.68a.507.507 0 0 1 .94 0l.254.61a4.37 4.37 0 0 0 2.25 2.327l.717.32a.53.53 0 0 1 0 .962l-.758.338a4.36 4.36 0 0 0-2.22 2.25l-.246.566a.506.506 0 0 1-.934 0l-.247-.565a4.36 4.36 0 0 0-2.219-2.251l-.76-.338a.53.53 0 0 1 0-.963l.718-.32a4.37 4.37 0 0 0 2.251-2.325z'/></svg>";
95+
9196
public void Configure(IServiceCollection services)
9297
{
9398
services.RegisterService<ChatServices>();
@@ -99,6 +104,16 @@ public void Configure(IServiceCollection services)
99104
if (!DisableAdminUi)
100105
{
101106
services.RegisterService<AdminChatServices>();
107+
services.ConfigurePlugin<UiFeature>(feature =>
108+
{
109+
feature.AddAdminLink(AdminUiFeature.Dynamic, new LinkInfo {
110+
Id = "chat",
111+
Label = "AI Chat",
112+
Icon = Svg.ImageSvg(SvgIcon),
113+
Show = $"role:{RoleNames.Admin}",
114+
});
115+
feature.AddAdminComponent("chat", "AdminChat");
116+
});
102117
}
103118
if (ExcludeRequestDtoTypes.Count > 0)
104119
{
@@ -238,6 +253,7 @@ public void CreateProviders(IServiceProvider services)
238253
};
239254
if (p != null)
240255
{
256+
p.Id = entry.Key;
241257
p.VirtualFiles = VirtualFiles;
242258
p.DownloadUrlAsBase64Async = DownloadUrlAsBase64Async;
243259
Providers[entry.Key] = p;
@@ -346,8 +362,8 @@ public async Task DisableProviderAsync(string requestId)
346362
}
347363

348364
public Func<ChatCompletion, IRequest, Task<ChatResponse>> ChatCompletionAsync { get; set; }
349-
public Func<ChatCompletion, ChatResponse, IRequest, Task>? OnChatCompletionSuccessAsync { get; set; }
350-
public Func<ChatCompletion, Exception, IRequest, Task>? OnChatCompletionFailedAsync { get; set; }
365+
public Func<OpenAiProviderBase, ChatCompletion, ChatResponse, IRequest, Task>? OnChatCompletionSuccessAsync { get; set; }
366+
public Func<OpenAiProviderBase, ChatCompletion, Exception, IRequest, Task>? OnChatCompletionFailedAsync { get; set; }
351367

352368
public async Task<ChatResponse> DefaultChatCompletionAsync(ChatCompletion request, IRequest req)
353369
{
@@ -360,6 +376,7 @@ public async Task<ChatResponse> DefaultChatCompletionAsync(ChatCompletion reques
360376
}
361377

362378
Exception? firstEx = null;
379+
OpenAiProviderBase? firstProvider = null;
363380
var i = 0;
364381
var chatRequest = request;
365382
foreach (var entry in candidateProviders)
@@ -371,26 +388,29 @@ public async Task<ChatResponse> DefaultChatCompletionAsync(ChatCompletion reques
371388
chatRequest.Model = request.Model;
372389
var ret = await provider.ChatAsync(chatRequest).ConfigAwait();
373390
if (ChatStore != null)
374-
await ChatStore.ChatCompletedAsync(chatRequest, ret, req).ConfigAwait();
391+
await ChatStore.ChatCompletedAsync(provider, chatRequest, ret, req).ConfigAwait();
375392
var onSuccess = OnChatCompletionSuccessAsync;
376393
if (onSuccess != null)
377-
await onSuccess(chatRequest, ret, req).ConfigAwait();
394+
await onSuccess(provider, chatRequest, ret, req).ConfigAwait();
378395
return ret;
379396
}
380397
catch (Exception ex)
381398
{
382399
Log.LogError(ex, "Error calling {Name} ({CandidateIndex}/{CandidatesTotal}): {Message}",
383400
i, candidateProviders.Count, entry.Key, ex.Message);
384401
firstEx ??= ex;
402+
firstProvider ??= entry.Value;
385403
}
386404
}
387405

388406
firstEx ??= HttpError.NotFound($"Model {request.Model} not found");
407+
firstProvider ??= new OpenAiProvider(NullLogger.Instance, HttpClientFactory) { Id = "unknown" };
408+
389409
if (ChatStore != null)
390-
await ChatStore.ChatFailedAsync(chatRequest, firstEx, req).ConfigAwait();
410+
await ChatStore.ChatFailedAsync(firstProvider, chatRequest, firstEx, req).ConfigAwait();
391411
var onFailed = OnChatCompletionFailedAsync;
392412
if (onFailed != null)
393-
await onFailed(request, firstEx, req).ConfigAwait();
413+
await onFailed(firstProvider, request, firstEx, req).ConfigAwait();
394414
throw firstEx;
395415
}
396416

0 commit comments

Comments
 (0)