diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/Dtos/ChatResponseDto.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/Dtos/ChatResponseDto.cs index 277fa3fb9..ddeed555e 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/Dtos/ChatResponseDto.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/Dtos/ChatResponseDto.cs @@ -46,6 +46,10 @@ public class ChatResponseDto : InstructResult [JsonPropertyName("is_streaming")] public bool IsStreaming { get; set; } + [JsonPropertyName("thought")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Thought { get; set; } + [JsonPropertyName("meta_data")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Dictionary? MetaData { get; set; } diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs index 411ca0842..cc6c229bd 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/Conversation.cs @@ -114,6 +114,10 @@ public class DialogMetaData [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ToolCallId { get; set; } + [JsonPropertyName("thought")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Thought { get; set; } + [JsonPropertyName("meta_data")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Dictionary? MetaData { get; set; } diff --git a/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/RoleDialogModel.cs b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/RoleDialogModel.cs index 796ee93d7..c7f26591a 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/RoleDialogModel.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Conversations/Models/RoleDialogModel.cs @@ -71,6 +71,9 @@ public class RoleDialogModel : ITrackableMessage [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ToolCallId { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Thought { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Dictionary? MetaData { get; set; } @@ -136,7 +139,6 @@ public class RoleDialogModel : ITrackableMessage [JsonIgnore(Condition = JsonIgnoreCondition.Always)] public bool IsStreaming { get; set; } - [JsonIgnore(Condition = JsonIgnoreCondition.Always)] public bool IsFromUser => Role == AgentRole.User; @@ -204,6 +206,7 @@ public static RoleDialogModel From(RoleDialogModel source, Data = source.Data, IsStreaming = source.IsStreaming, Annotations = source.Annotations, + Thought = source.Thought != null ? new(source.Thought) : null, MetaData = source.MetaData != null ? new(source.MetaData) : null }; } diff --git a/src/Infrastructure/BotSharp.Abstraction/MLTasks/Settings/LlmModelSetting.cs b/src/Infrastructure/BotSharp.Abstraction/MLTasks/Settings/LlmModelSetting.cs index e22729ec0..3915f9f6e 100644 --- a/src/Infrastructure/BotSharp.Abstraction/MLTasks/Settings/LlmModelSetting.cs +++ b/src/Infrastructure/BotSharp.Abstraction/MLTasks/Settings/LlmModelSetting.cs @@ -99,7 +99,10 @@ public class ReasoningSetting public class WebSearchSetting { public bool IsDefault { get; set; } + + [Obsolete("Set SearchContextSize in Parameters")] public string? SearchContextSize { get; set; } + public Dictionary? Parameters { get; set; } } #endregion diff --git a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStorage.cs b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStorage.cs index d2dc3a99d..154fdd1de 100644 --- a/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStorage.cs +++ b/src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationStorage.cs @@ -81,6 +81,7 @@ public async Task> GetDialogs(string conversationId, Conve ToolCallId = meta?.ToolCallId, FunctionName = meta?.FunctionName, FunctionArgs = meta?.FunctionArgs, + Thought = meta?.Thought, MetaData = meta?.MetaData, RichContent = richContent, SecondaryContent = secondaryContent, @@ -119,6 +120,7 @@ public async Task> GetDialogs(string conversationId, Conve ToolCallId = dialog.ToolCallId, FunctionName = dialog.FunctionName, FunctionArgs = dialog.FunctionArgs, + Thought = dialog.Thought, MetaData = dialog.MetaData, CreatedTime = dialog.CreatedAt }; @@ -146,6 +148,7 @@ public async Task> GetDialogs(string conversationId, Conve MessageLabel = dialog.MessageLabel, SenderId = dialog.SenderId, FunctionName = dialog.FunctionName, + Thought = dialog.Thought, MetaData = dialog.MetaData, CreatedTime = dialog.CreatedAt }; diff --git a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs index b2f246fb0..e193e08c3 100644 --- a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs +++ b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs @@ -54,6 +54,7 @@ public async Task InvokeAgent( message.ToolCallId = response.ToolCallId; message.FunctionName = response.FunctionName; message.FunctionArgs = response.FunctionArgs; + message.Thought = response.Thought != null ? new(response.Thought) : null; message.MetaData = response.MetaData != null ? new(response.MetaData) : null; message.Indication = response.Indication; message.CurrentAgentId = agent.Id; @@ -73,6 +74,7 @@ public async Task InvokeAgent( message = RoleDialogModel.From(message, role: AgentRole.Assistant, content: response.Content); message.CurrentAgentId = agent.Id; + message.Thought = response.Thought != null ? new(response.Thought) : null; message.MetaData = response.MetaData != null ? new(response.MetaData) : null; message.IsStreaming = response.IsStreaming; message.MessageLabel = response.MessageLabel; diff --git a/src/Infrastructure/BotSharp.OpenAPI/Controllers/Conversation/ConversationController.cs b/src/Infrastructure/BotSharp.OpenAPI/Controllers/Conversation/ConversationController.cs index 30961ef84..ed8c86b17 100644 --- a/src/Infrastructure/BotSharp.OpenAPI/Controllers/Conversation/ConversationController.cs +++ b/src/Infrastructure/BotSharp.OpenAPI/Controllers/Conversation/ConversationController.cs @@ -131,6 +131,7 @@ public async Task> GetDialogs( Data = message.Data, Sender = UserDto.FromUser(user), Payload = message.Payload, + Thought = message.Thought, MetaData = message.MetaData, HasMessageFiles = files.Any(x => x.MessageId.IsEqualTo(message.MessageId) && x.FileSource == FileSource.User) }); @@ -147,6 +148,7 @@ public async Task> GetDialogs( Text = !string.IsNullOrEmpty(message.SecondaryContent) ? message.SecondaryContent : message.Content, Function = message.FunctionName, Data = message.Data, + Thought = message.Thought, MetaData = message.MetaData, Sender = new() { @@ -419,6 +421,8 @@ await conv.SendMessage(agentId, inputMsg, response.RichContent = msg.SecondaryRichContent ?? msg.RichContent; response.Instruction = msg.Instruction; response.Data = msg.Data; + response.Thought = msg.Thought; + response.MetaData = msg.MetaData; }); } catch (OperationCanceledException) when (input.IsStreamingMessage) @@ -485,6 +489,8 @@ await conv.SendMessage(agentId, inputMsg, response.RichContent = msg.SecondaryRichContent ?? msg.RichContent; response.Instruction = msg.Instruction; response.Data = msg.Data; + response.Thought = msg.Thought; + response.MetaData = msg.MetaData; response.States = state.GetStates(); await OnChunkReceived(Response, response); diff --git a/src/Plugins/BotSharp.Plugin.ChatHub/Hooks/ChatHubConversationHook.cs b/src/Plugins/BotSharp.Plugin.ChatHub/Hooks/ChatHubConversationHook.cs index bd57f5f9f..18a3990c5 100644 --- a/src/Plugins/BotSharp.Plugin.ChatHub/Hooks/ChatHubConversationHook.cs +++ b/src/Plugins/BotSharp.Plugin.ChatHub/Hooks/ChatHubConversationHook.cs @@ -112,6 +112,7 @@ public override async Task OnResponseGenerated(RoleDialogModel message) Function = message.FunctionName, RichContent = message.SecondaryRichContent ?? message.RichContent, Data = message.Data, + Thought = message.Thought, MetaData = message.MetaData, States = state.GetStates(), IsStreaming = message.IsStreaming, diff --git a/src/Plugins/BotSharp.Plugin.ChatHub/Observers/ChatHubObserver.cs b/src/Plugins/BotSharp.Plugin.ChatHub/Observers/ChatHubObserver.cs index 6bd9209a3..614340cfd 100644 --- a/src/Plugins/BotSharp.Plugin.ChatHub/Observers/ChatHubObserver.cs +++ b/src/Plugins/BotSharp.Plugin.ChatHub/Observers/ChatHubObserver.cs @@ -80,6 +80,7 @@ public override void OnNext(HubObserveData value) Function = message.FunctionName, RichContent = message.SecondaryRichContent ?? message.RichContent, Data = message.Data, + Thought = message.Thought, MetaData = message.MetaData, Sender = new() { diff --git a/src/Plugins/BotSharp.Plugin.FileHandler/Functions/ReadPdfFn.cs b/src/Plugins/BotSharp.Plugin.FileHandler/Functions/ReadPdfFn.cs index 3edc4afb8..198ed4e89 100644 --- a/src/Plugins/BotSharp.Plugin.FileHandler/Functions/ReadPdfFn.cs +++ b/src/Plugins/BotSharp.Plugin.FileHandler/Functions/ReadPdfFn.cs @@ -108,7 +108,9 @@ private async Task> AssembleFiles(string conversationId, L { ContentType = x.ContentType, FileUrl = x.FileUrl, - FileStorageUrl = x.FileStorageUrl + FileStorageUrl = x.FileStorageUrl, + FileName = x.FileName, + FileExtension = x.FileExtension }).ToList(); } diff --git a/src/Plugins/BotSharp.Plugin.GoogleAI/Constants/Constants.cs b/src/Plugins/BotSharp.Plugin.GoogleAI/Constants/Constants.cs index c1300c0a5..cb523f517 100644 --- a/src/Plugins/BotSharp.Plugin.GoogleAI/Constants/Constants.cs +++ b/src/Plugins/BotSharp.Plugin.GoogleAI/Constants/Constants.cs @@ -1,4 +1,4 @@ -namespace BotSharp.Plugin.GoogleAI.Constants; +namespace BotSharp.Plugin.GoogleAi; internal static class Constants { diff --git a/src/Plugins/BotSharp.Plugin.GoogleAI/Providers/Chat/ChatCompletionProvider.cs b/src/Plugins/BotSharp.Plugin.GoogleAI/Providers/Chat/ChatCompletionProvider.cs index c7df1f6bb..8dda0edaa 100644 --- a/src/Plugins/BotSharp.Plugin.GoogleAI/Providers/Chat/ChatCompletionProvider.cs +++ b/src/Plugins/BotSharp.Plugin.GoogleAI/Providers/Chat/ChatCompletionProvider.cs @@ -18,6 +18,8 @@ public class ChatCompletionProvider : IChatCompletion private readonly IServiceProvider _services; private readonly ILogger _logger; private readonly IConversationStateService _state; + private readonly IFileStorageService _fileStorage; + private List renderedInstructions = []; private string _model; @@ -31,12 +33,14 @@ public ChatCompletionProvider( IServiceProvider services, GoogleAiSettings googleSettings, ILogger logger, - IConversationStateService state) + IConversationStateService state, + IFileStorageService fileStorage) { _settings = googleSettings; _services = services; _logger = logger; _state = state; + _fileStorage = fileStorage; } public async Task GetChatCompletions(Agent agent, List conversations) @@ -75,7 +79,7 @@ public async Task GetChatCompletions(Agent agent, List + Thought = new Dictionary { [Constants.ThoughtSignature] = thoughtSignature }, @@ -99,7 +103,7 @@ public async Task GetChatCompletions(Agent agent, List + Thought = new Dictionary { [Constants.ThoughtSignature] = thoughtSignature }, @@ -107,10 +111,10 @@ public async Task GetChatCompletions(Agent agent, List GetChatCompletionsAsync(Agent agent, List + Thought = new Dictionary { [Constants.ThoughtSignature] = thoughtSignature }, @@ -167,7 +171,7 @@ public async Task GetChatCompletionsAsync(Agent agent, List GetChatCompletionsAsync(Agent agent, List + Thought = new Dictionary { [Constants.ThoughtSignature] = thoughtSignature }, @@ -217,7 +221,7 @@ public async Task GetChatCompletionsAsync(Agent agent, List + Thought = new Dictionary { [Constants.ThoughtSignature] = thoughtSignature }, @@ -263,7 +267,7 @@ public async Task GetChatCompletionsStreamingAsync(Agent agent, }); using var textStream = new RealtimeTextStream(); - using var thinkingTextStream = new RealtimeTextStream(); + using var thinkingStream = new RealtimeTextStream(); ChatThoughtModel? thoughtModel = null; UsageMetadata? tokenUsage = null; @@ -301,7 +305,7 @@ public async Task GetChatCompletionsStreamingAsync(Agent agent, if (!string.IsNullOrEmpty(thoughtPart?.Text)) { var text = thoughtPart.Text; - thinkingTextStream.Collect(text); + thinkingStream.Collect(text); hub.Push(new() { EventName = ChatEvent.OnReceiveLlmStreamMessage, @@ -310,7 +314,7 @@ public async Task GetChatCompletionsStreamingAsync(Agent agent, { CurrentAgentId = agent.Id, MessageId = messageId, - MetaData = new() + Thought = new() { [Constants.ThinkingText] = text } @@ -352,7 +356,7 @@ public async Task GetChatCompletionsStreamingAsync(Agent agent, ToolCallId = functionCall.Id, FunctionName = functionCall.Name, FunctionArgs = functionCall.Args?.ToJsonString(), - MetaData = new Dictionary + Thought = new Dictionary { [Constants.ThoughtSignature] = thought?.ThoughtSignature } @@ -374,7 +378,7 @@ public async Task GetChatCompletionsStreamingAsync(Agent agent, CurrentAgentId = agent.Id, MessageId = messageId, IsStreaming = true, - MetaData = new Dictionary + Thought = new Dictionary { [Constants.ThoughtSignature] = thoughtSignature } @@ -391,7 +395,7 @@ public async Task GetChatCompletionsStreamingAsync(Agent agent, CurrentAgentId = agent.Id, MessageId = messageId, IsStreaming = true, - MetaData = new Dictionary + Thought = new Dictionary { [Constants.ThoughtSignature] = part?.ThoughtSignature } @@ -418,12 +422,12 @@ public async Task GetChatCompletionsStreamingAsync(Agent agent, }; } - // Set thinking text in metadata - var thinkingText = thinkingTextStream.GetText(); + // Set thinking text in thought and metadata + var thinkingText = thinkingStream.GetText(); if (!string.IsNullOrEmpty(thinkingText)) { - responseMessage.MetaData ??= []; - responseMessage.MetaData[Constants.ThinkingText] = thinkingText; + responseMessage.Thought ??= []; + responseMessage.Thought[Constants.ThinkingText] = thinkingText; } hub.Push(new() @@ -458,7 +462,6 @@ public void SetModelName(string model) { var agentService = _services.GetRequiredService(); var googleSettings = _services.GetRequiredService(); - var fileStorage = _services.GetRequiredService(); var settingsService = _services.GetRequiredService(); var settings = settingsService.GetSetting(Provider, _model); var allowMultiModal = settings != null && settings.MultiModal; @@ -528,7 +531,7 @@ public void SetModelName(string model) contents.Add(new Content([ new Part() { - ThoughtSignature = message.MetaData?.GetValueOrDefault(Constants.ThoughtSignature, null), + ThoughtSignature = message.Thought?.GetValueOrDefault(Constants.ThoughtSignature, null), FunctionCall = new FunctionCall { Id = message.ToolCallId, @@ -541,7 +544,7 @@ public void SetModelName(string model) contents.Add(new Content([ new Part() { - ThoughtSignature = message.MetaData?.GetValueOrDefault(Constants.ThoughtSignature, null), + ThoughtSignature = message.Thought?.GetValueOrDefault(Constants.ThoughtSignature, null), FunctionResponse = new FunctionResponse { Id = message.ToolCallId, @@ -564,7 +567,7 @@ public void SetModelName(string model) new() { Text = text, - ThoughtSignature = message.MetaData?.GetValueOrDefault(Constants.ThoughtSignature, null) + ThoughtSignature = message.Thought?.GetValueOrDefault(Constants.ThoughtSignature, null) } }; @@ -583,7 +586,7 @@ public void SetModelName(string model) new() { Text = text, - ThoughtSignature = message.MetaData?.GetValueOrDefault(Constants.ThoughtSignature, null) + ThoughtSignature = message.Thought?.GetValueOrDefault(Constants.ThoughtSignature, null) } }; @@ -627,8 +630,6 @@ public void SetModelName(string model) private void CollectMessageContentParts(List contentParts, List files) { - var fileStorage = _services.GetRequiredService(); - foreach (var file in files) { if (!string.IsNullOrEmpty(file.FileData)) @@ -646,7 +647,7 @@ private void CollectMessageContentParts(List contentParts, List AssembleFiles(string conversationId, IEnumerable? Thought { get; set; } public Dictionary? MetaData { get; set; } public string? SenderId { get; set; } public DateTime CreateTime { get; set; } @@ -66,6 +67,7 @@ public static DialogMetaData ToDomainElement(DialogMetaDataMongoElement meta) ToolCallId = meta.ToolCallId, FunctionName = meta.FunctionName, FunctionArgs = meta.FunctionArgs, + Thought = meta.Thought, MetaData = meta.MetaData, SenderId = meta.SenderId, CreatedTime = meta.CreateTime @@ -75,7 +77,7 @@ public static DialogMetaData ToDomainElement(DialogMetaDataMongoElement meta) public static DialogMetaDataMongoElement ToMongoElement(DialogMetaData meta) { return new DialogMetaDataMongoElement - { + { Role = meta.Role, AgentId = meta.AgentId, MessageId = meta.MessageId, @@ -84,6 +86,7 @@ public static DialogMetaDataMongoElement ToMongoElement(DialogMetaData meta) ToolCallId = meta.ToolCallId, FunctionName = meta.FunctionName, FunctionArgs = meta.FunctionArgs, + Thought = meta.Thought, MetaData = meta.MetaData, SenderId = meta.SenderId, CreateTime = meta.CreatedTime diff --git a/src/Plugins/BotSharp.Plugin.OpenAI/BotSharp.Plugin.OpenAI.csproj b/src/Plugins/BotSharp.Plugin.OpenAI/BotSharp.Plugin.OpenAI.csproj index 534d66df7..7fc22b57a 100644 --- a/src/Plugins/BotSharp.Plugin.OpenAI/BotSharp.Plugin.OpenAI.csproj +++ b/src/Plugins/BotSharp.Plugin.OpenAI/BotSharp.Plugin.OpenAI.csproj @@ -10,6 +10,10 @@ $(SolutionDir)packages + + + + diff --git a/src/Plugins/BotSharp.Plugin.OpenAI/Constants/Constants.cs b/src/Plugins/BotSharp.Plugin.OpenAI/Constants/Constants.cs new file mode 100644 index 000000000..558882875 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.OpenAI/Constants/Constants.cs @@ -0,0 +1,6 @@ +namespace BotSharp.Plugin.OpenAI; + +internal static class Constants +{ + internal const string ThinkingText = "thinking_text"; +} diff --git a/src/Plugins/BotSharp.Plugin.OpenAI/Models/Web/WebSearchUserLocation.cs b/src/Plugins/BotSharp.Plugin.OpenAI/Models/Web/WebSearchUserLocation.cs new file mode 100644 index 000000000..1591f62ef --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.OpenAI/Models/Web/WebSearchUserLocation.cs @@ -0,0 +1,48 @@ +namespace BotSharp.Plugin.OpenAI.Models.Web; + +/// +/// Approximate user location hint passed to the OpenAI web search tool. +/// Mirrors the shape of OpenAI's user_location payload (country, region, city, timezone). +/// +public class WebSearchUserLocation +{ + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public string? Country { get; set; } + public string? Region { get; set; } + public string? City { get; set; } + public string? Timezone { get; set; } + + [JsonIgnore] + public bool HasAnyValue => + !string.IsNullOrEmpty(Country) + || !string.IsNullOrEmpty(Region) + || !string.IsNullOrEmpty(City) + || !string.IsNullOrEmpty(Timezone); + + public static WebSearchUserLocation? FromJson(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(json, _jsonOptions); + } + catch (JsonException) + { + return null; + } + } + +} diff --git a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Chat.cs b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Chat.cs new file mode 100644 index 000000000..45682e619 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Chat.cs @@ -0,0 +1,704 @@ +#pragma warning disable OPENAI001 +using BotSharp.Abstraction.MessageHub.Models; +using BotSharp.Core.Infrastructures.Streams; +using BotSharp.Core.MessageHub; +using OpenAI.Chat; +using System.Net.Http; + +namespace BotSharp.Plugin.OpenAI.Providers.Chat; + +public partial class ChatCompletionProvider +{ + private async Task InnerGetChatCompletions(Agent agent, List conversations) + { + var contentHooks = _services.GetHooks(agent.Id); + + // Before chat completion hook + foreach (var hook in contentHooks) + { + await hook.BeforeGenerating(agent, conversations); + } + + var client = ProviderHelper.GetClient(Provider, _model, apiKey: _apiKey, _services); + var chatClient = client.GetChatClient(_model); + var (prompt, messages, options) = PrepareOptions(agent, conversations); + + var response = chatClient.CompleteChat(messages, options); + var value = response.Value; + var reason = value.FinishReason; + var content = value.Content; + var text = content.FirstOrDefault()?.Text ?? string.Empty; + + RoleDialogModel responseMessage; + if (reason == ChatFinishReason.FunctionCall || reason == ChatFinishReason.ToolCalls) + { + _logger.LogInformation($"Action: {nameof(InnerGetChatCompletions)}, Reason: {reason}, Agent: {agent.Name}, ToolCalls: {string.Join(",", value.ToolCalls.Select(x => x.FunctionName))}"); + + var toolCall = value.ToolCalls.FirstOrDefault(); + responseMessage = new RoleDialogModel(AgentRole.Function, text) + { + CurrentAgentId = agent.Id, + MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, + ToolCallId = toolCall?.Id, + FunctionName = toolCall?.FunctionName, + FunctionArgs = toolCall?.FunctionArguments?.ToString(), + RenderedInstruction = string.Join("\r\n", renderedInstructions) + }; + + // Somethings LLM will generate a function name with agent name. + responseMessage.FunctionName = responseMessage.FunctionName.NormalizeFunctionName(); + } + else if (reason == ChatFinishReason.Length) + { + _logger.LogWarning($"Action: {nameof(InnerGetChatCompletions)}, Reason: {reason}, Agent: {agent.Name}, MaxOutputTokens: {options.MaxOutputTokenCount}, Content:{text}"); + + responseMessage = new RoleDialogModel(AgentRole.Assistant, $"AI response exceeded max output length") + { + CurrentAgentId = agent.Id, + MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, + StopCompletion = true + }; + } + else + { + responseMessage = new RoleDialogModel(AgentRole.Assistant, text) + { + CurrentAgentId = agent.Id, + MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, + RenderedInstruction = string.Join("\r\n", renderedInstructions), + Annotations = value.Annotations?.Select(x => new ChatAnnotation + { + Title = x.WebResourceTitle, + Url = x.WebResourceUri.AbsoluteUri, + StartIndex = x.StartIndex, + EndIndex = x.EndIndex + })?.ToList() + }; + } + + var tokenUsage = response.Value?.Usage; + var inputTokenDetails = response.Value?.Usage?.InputTokenDetails; + + // After chat completion hook + foreach (var hook in contentHooks) + { + await hook.AfterGenerated(responseMessage, new TokenStatsModel + { + Prompt = prompt, + Provider = Provider, + Model = _model, + TextInputTokens = (tokenUsage?.InputTokenCount ?? 0) - (inputTokenDetails?.CachedTokenCount ?? 0), + CachedTextInputTokens = inputTokenDetails?.CachedTokenCount ?? 0, + TextOutputTokens = tokenUsage?.OutputTokenCount ?? 0 + }); + } + + return responseMessage; + } + + + private async Task InnerGetChatCompletionsAsync(Agent agent, + List conversations, + Func onMessageReceived, + Func onFunctionExecuting) + { + var hooks = _services.GetHooks(agent.Id); + + // Before chat completion hook + foreach (var hook in hooks) + { + await hook.BeforeGenerating(agent, conversations); + } + + var client = ProviderHelper.GetClient(Provider, _model, apiKey: _apiKey, _services); + var chatClient = client.GetChatClient(_model); + var (prompt, messages, options) = PrepareOptions(agent, conversations); + + var response = await chatClient.CompleteChatAsync(messages, options); + var value = response.Value; + var reason = value.FinishReason; + var content = value.Content; + var text = content.FirstOrDefault()?.Text ?? string.Empty; + + var msg = new RoleDialogModel(AgentRole.Assistant, text) + { + CurrentAgentId = agent.Id, + RenderedInstruction = string.Join("\r\n", renderedInstructions) + }; + + var tokenUsage = response?.Value?.Usage; + var inputTokenDetails = response?.Value?.Usage?.InputTokenDetails; + + // After chat completion hook + foreach (var hook in hooks) + { + await hook.AfterGenerated(msg, new TokenStatsModel + { + Prompt = prompt, + Provider = Provider, + Model = _model, + TextInputTokens = (tokenUsage?.InputTokenCount ?? 0) - (inputTokenDetails?.CachedTokenCount ?? 0), + CachedTextInputTokens = inputTokenDetails?.CachedTokenCount ?? 0, + TextOutputTokens = tokenUsage?.OutputTokenCount ?? 0 + }); + } + + if (reason == ChatFinishReason.FunctionCall || reason == ChatFinishReason.ToolCalls) + { + var toolCall = value.ToolCalls?.FirstOrDefault(); + _logger.LogInformation($"[{agent.Name}]: {toolCall?.FunctionName}({toolCall?.FunctionArguments})"); + + var funcContextIn = new RoleDialogModel(AgentRole.Function, text) + { + CurrentAgentId = agent.Id, + MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, + ToolCallId = toolCall?.Id, + FunctionName = toolCall?.FunctionName, + FunctionArgs = toolCall?.FunctionArguments?.ToString(), + RenderedInstruction = string.Join("\r\n", renderedInstructions) + }; + + // Somethings LLM will generate a function name with agent name. + funcContextIn.FunctionName = funcContextIn.FunctionName.NormalizeFunctionName(); + + // Execute functions + await onFunctionExecuting(funcContextIn); + } + else if (reason == ChatFinishReason.Length) + { + _logger.LogWarning($"Action: {nameof(InnerGetChatCompletionsAsync)}, Reason: {reason}, Agent: {agent.Name}, MaxOutputTokens: {options.MaxOutputTokenCount}, Content:{text}"); + + msg = new RoleDialogModel(AgentRole.Assistant, $"AI response exceeded max output length") + { + CurrentAgentId = agent.Id, + MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, + StopCompletion = true, + RenderedInstruction = string.Join("\r\n", renderedInstructions) + }; + await onMessageReceived(msg); + } + else + { + // Text response received + msg = new RoleDialogModel(AgentRole.Assistant, text) + { + CurrentAgentId = agent.Id, + MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, + RenderedInstruction = string.Join("\r\n", renderedInstructions), + Annotations = value.Annotations?.Select(x => new ChatAnnotation + { + Title = x.WebResourceTitle, + Url = x.WebResourceUri.AbsoluteUri, + StartIndex = x.StartIndex, + EndIndex = x.EndIndex + })?.ToList() + }; + await onMessageReceived(msg); + } + + return true; + } + + private async Task InnerGetChatCompletionsStreamingAsync(Agent agent, List conversations) + { + var client = ProviderHelper.GetClient(Provider, _model, apiKey: _apiKey, _services); + var chatClient = client.GetChatClient(_model); + var (prompt, messages, options) = PrepareOptions(agent, conversations); + + var hub = _services.GetRequiredService>>(); + var conv = _services.GetRequiredService(); + var messageId = conversations.LastOrDefault()?.MessageId ?? string.Empty; + + var contentHooks = _services.GetHooks(agent.Id); + // Before chat completion hook + foreach (var hook in contentHooks) + { + await hook.BeforeGenerating(agent, conversations); + } + + hub.Push(new() + { + EventName = ChatEvent.BeforeReceiveLlmStreamMessage, + RefId = conv.ConversationId, + Data = new RoleDialogModel(AgentRole.Assistant, string.Empty) + { + CurrentAgentId = agent.Id, + MessageId = messageId + } + }); + + using var textStream = new RealtimeTextStream(); + var toolCalls = new List(); + ChatTokenUsage? tokenUsage = null; + + var responseMessage = new RoleDialogModel(AgentRole.Assistant, string.Empty) + { + CurrentAgentId = agent.Id, + MessageId = messageId + }; + + var streamingCancellation = _services.GetRequiredService(); + var cancellationToken = streamingCancellation.GetToken(conv.ConversationId); + + try + { + await foreach (var choice in chatClient.CompleteChatStreamingAsync(messages, options, cancellationToken)) + { + tokenUsage = choice.Usage; + + if (!choice.ToolCallUpdates.IsNullOrEmpty()) + { + toolCalls.AddRange(choice.ToolCallUpdates); + } + + if (!choice.ContentUpdate.IsNullOrEmpty()) + { + var text = choice.ContentUpdate[0]?.Text ?? string.Empty; + textStream.Collect(text); +#if DEBUG + _logger.LogDebug($"Stream Content update: {text}"); +#endif + + hub.Push(new() + { + EventName = ChatEvent.OnReceiveLlmStreamMessage, + RefId = conv.ConversationId, + Data = new RoleDialogModel(AgentRole.Assistant, text) + { + CurrentAgentId = agent.Id, + MessageId = messageId + } + }); + } + + if (choice.FinishReason == ChatFinishReason.ToolCalls || choice.FinishReason == ChatFinishReason.FunctionCall) + { + var meta = toolCalls.FirstOrDefault(x => !string.IsNullOrEmpty(x.FunctionName)); + var functionName = meta?.FunctionName; + var toolCallId = meta?.ToolCallId; + var args = toolCalls.Where(x => x.FunctionArgumentsUpdate != null).Select(x => x.FunctionArgumentsUpdate.ToString()).ToList(); + var functionArguments = string.Join(string.Empty, args); + +#if DEBUG + _logger.LogDebug($"Tool Call (id: {toolCallId}) => {functionName}({functionArguments})"); +#endif + + responseMessage = new RoleDialogModel(AgentRole.Function, string.Empty) + { + CurrentAgentId = agent.Id, + MessageId = messageId, + ToolCallId = toolCallId, + FunctionName = functionName, + FunctionArgs = functionArguments + }; + } + else if (choice.FinishReason == ChatFinishReason.Stop) + { + var allText = textStream.GetText(); +#if DEBUG + _logger.LogDebug($"Stream text Content: {allText}"); +#endif + + responseMessage = new RoleDialogModel(AgentRole.Assistant, allText) + { + CurrentAgentId = agent.Id, + MessageId = messageId, + IsStreaming = true + }; + } + else if (choice.FinishReason.HasValue) + { + var text = choice.FinishReason == ChatFinishReason.Length ? "Model reached the maximum number of tokens allowed." + : choice.FinishReason == ChatFinishReason.ContentFilter ? "Content is omitted due to content filter rule." + : choice.FinishReason.Value.ToString(); + responseMessage = new RoleDialogModel(AgentRole.Assistant, text) + { + CurrentAgentId = agent.Id, + MessageId = messageId, + IsStreaming = true + }; + } + } + } + catch (OperationCanceledException) + { + _logger.LogWarning("Streaming was cancelled for conversation {ConversationId}", conv.ConversationId); + } + + // Build responseMessage from collected text when cancelled before FinishReason + if (cancellationToken.IsCancellationRequested && string.IsNullOrEmpty(responseMessage.Content)) + { + var allText = textStream.GetText(); + responseMessage = new RoleDialogModel(AgentRole.Assistant, allText) + { + CurrentAgentId = agent.Id, + MessageId = messageId, + IsStreaming = true + }; + } + + hub.Push(new() + { + EventName = ChatEvent.AfterReceiveLlmStreamMessage, + RefId = conv.ConversationId, + Data = responseMessage + }); + + + var inputTokenDetails = tokenUsage?.InputTokenDetails; + // After chat completion hook + foreach (var hook in contentHooks) + { + await hook.AfterGenerated(responseMessage, new TokenStatsModel + { + Prompt = prompt, + Provider = Provider, + Model = _model, + TextInputTokens = (tokenUsage?.InputTokenCount ?? 0) - (inputTokenDetails?.CachedTokenCount ?? 0), + CachedTextInputTokens = inputTokenDetails?.CachedTokenCount ?? 0, + TextOutputTokens = tokenUsage?.OutputTokenCount ?? 0 + }); + } + + return responseMessage; + } + + + + #region Private methods + protected (string, IEnumerable, ChatCompletionOptions) PrepareOptions(Agent agent, List conversations) + { + var agentService = _services.GetRequiredService(); + var settingsService = _services.GetRequiredService(); + var settings = settingsService.GetSetting(Provider, _model); + var allowMultiModal = settings != null && settings.MultiModal; + renderedInstructions = []; + + var messages = new List(); + var options = InitChatCompletionOption(agent); + + // Prepare instruction and functions + var renderData = agentService.CollectRenderData(agent); + var (instruction, functions) = agentService.PrepareInstructionAndFunctions(agent, renderData); + if (!string.IsNullOrWhiteSpace(instruction)) + { + renderedInstructions.Add(instruction); + messages.Add(new SystemChatMessage(instruction)); + } + + // Render functions + if (options.WebSearchOptions == null) + { + foreach (var function in functions) + { + if (!agentService.RenderFunction(agent, function, renderData)) + { + continue; + } + + var property = agentService.RenderFunctionProperty(agent, function, renderData); + + options.Tools.Add(ChatTool.CreateFunctionTool( + functionName: function.Name, + functionDescription: function.Description, + functionParameters: BinaryData.FromObjectAsJson(property))); + } + } + + if (!string.IsNullOrEmpty(agent.Knowledges)) + { + messages.Add(new SystemChatMessage(agent.Knowledges)); + } + + var samples = ProviderHelper.GetChatSamples(agent.Samples); + foreach (var sample in samples) + { + messages.Add(sample.Role == AgentRole.User ? new UserChatMessage(sample.Content) : new AssistantChatMessage(sample.Content)); + } + + var filteredMessages = conversations.Select(x => x).ToList(); + var firstUserMsgIdx = filteredMessages.FindIndex(x => x.Role == AgentRole.User); + if (firstUserMsgIdx > 0) + { + filteredMessages = filteredMessages.Where((_, idx) => idx >= firstUserMsgIdx).ToList(); + } + + var imageDetailLevel = ChatImageDetailLevel.Auto; + if (allowMultiModal) + { + imageDetailLevel = ParseChatImageDetailLevel(_state.GetState("chat_image_detail_level")); + } + + foreach (var message in filteredMessages) + { + if (message.Role == AgentRole.Function) + { + messages.Add(new AssistantChatMessage(new List + { + ChatToolCall.CreateFunctionToolCall(message.ToolCallId.IfNullOrEmptyAs(message.FunctionName), message.FunctionName, BinaryData.FromString(message.FunctionArgs ?? "{}")) + })); + + messages.Add(new ToolChatMessage(message.ToolCallId.IfNullOrEmptyAs(message.FunctionName), message.LlmContent)); + } + else if (message.Role == AgentRole.User) + { + var text = message.LlmContent; + var textPart = ChatMessageContentPart.CreateTextPart(text); + var contentParts = new List { textPart }; + + if (allowMultiModal && !message.Files.IsNullOrEmpty()) + { + CollectMessageContentParts(contentParts, message.Files, imageDetailLevel); + } + messages.Add(new UserChatMessage(contentParts) { ParticipantName = message.FunctionName }); + } + else if (message.Role == AgentRole.Assistant) + { + var text = message.LlmContent; + var textPart = ChatMessageContentPart.CreateTextPart(text); + var contentParts = new List { textPart }; + + if (allowMultiModal && !message.Files.IsNullOrEmpty()) + { + CollectMessageContentParts(contentParts, message.Files, imageDetailLevel); + } + messages.Add(new AssistantChatMessage(contentParts)); + } + } + + var prompt = GetPrompt(messages, options); + return (prompt, messages, options); + } + + private void CollectMessageContentParts(List contentParts, List files, ChatImageDetailLevel imageDetailLevel) + { + foreach (var file in files) + { + if (!string.IsNullOrEmpty(file.FileData)) + { + var (contentType, binary) = FileUtility.GetFileInfoFromData(file.FileData); + contentType = contentType.IfNullOrEmptyAs(file.ContentType); + var contentPart = IsImageContentType(contentType) + ? ChatMessageContentPart.CreateImagePart(binary, contentType, imageDetailLevel) + : ChatMessageContentPart.CreateFilePart(binary, contentType, file.FileFullName); + contentParts.Add(contentPart); + } + else if (!string.IsNullOrEmpty(file.FileStorageUrl)) + { + var fileStorage = _services.GetRequiredService(); + var binary = fileStorage.GetFileBytes(file.FileStorageUrl); + var contentType = FileUtility.GetFileContentType(file.FileStorageUrl).IfNullOrEmptyAs(file.ContentType); + var contentPart = IsImageContentType(contentType) + ? ChatMessageContentPart.CreateImagePart(binary, contentType, imageDetailLevel) + : ChatMessageContentPart.CreateFilePart(binary, contentType, file.FileFullName); + contentParts.Add(contentPart); + } + else if (!string.IsNullOrEmpty(file.FileUrl)) + { + var contentType = FileUtility.GetFileContentType(file.FileUrl).IfNullOrEmptyAs(file.ContentType); + if (IsImageContentType(contentType)) + { + var uri = new Uri(file.FileUrl); + var contentPart = ChatMessageContentPart.CreateImagePart(uri, imageDetailLevel); + contentParts.Add(contentPart); + } + else + { + try + { + var http = _services.GetRequiredService(); + using var client = http.CreateClient(); + var bytes = client.GetByteArrayAsync(file.FileUrl).GetAwaiter().GetResult(); + var binary = BinaryData.FromBytes(bytes); + var contentPart = ChatMessageContentPart.CreateFilePart(binary, contentType, file.FileFullName); + contentParts.Add(contentPart); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to download FileUrl for chat file part (url: {file.FileUrl})."); + } + } + } + } + } + + private string GetPrompt(IEnumerable messages, ChatCompletionOptions options) + { + var prompt = string.Empty; + + if (!messages.IsNullOrEmpty()) + { + // System instruction + var verbose = string.Join("\r\n", messages + .Select(x => x as SystemChatMessage) + .Where(x => x != null) + .Select(x => + { + if (!string.IsNullOrEmpty(x.ParticipantName)) + { + // To display Agent name in log + return $"[{x.ParticipantName}]: {x.Content.FirstOrDefault()?.Text ?? string.Empty}"; + } + return $"{AgentRole.System}: {x.Content.FirstOrDefault()?.Text ?? string.Empty}"; + })); + prompt += $"{verbose}\r\n"; + + prompt += "\r\n[CONVERSATION]"; + verbose = string.Join("\r\n", messages + .Where(x => x as SystemChatMessage == null) + .Select(x => + { + var fnMessage = x as ToolChatMessage; + if (fnMessage != null) + { + return $"{AgentRole.Function}: {fnMessage.Content.FirstOrDefault()?.Text ?? string.Empty}"; + } + + var userMessage = x as UserChatMessage; + if (userMessage != null) + { + var content = x.Content.FirstOrDefault()?.Text ?? string.Empty; + return !string.IsNullOrEmpty(userMessage.ParticipantName) && userMessage.ParticipantName != "route_to_agent" ? + $"{userMessage.ParticipantName}: {content}" : + $"{AgentRole.User}: {content}"; + } + + var assistMessage = x as AssistantChatMessage; + if (assistMessage != null) + { + var toolCall = assistMessage.ToolCalls?.FirstOrDefault(); + return toolCall != null ? + $"{AgentRole.Assistant}: Call function {toolCall?.FunctionName}({toolCall?.FunctionArguments})" : + $"{AgentRole.Assistant}: {assistMessage.Content.FirstOrDefault()?.Text ?? string.Empty}"; + } + + return string.Empty; + })); + prompt += $"\r\n{verbose}\r\n"; + } + + if (!options.Tools.IsNullOrEmpty()) + { + var functions = string.Join("\r\n", options.Tools.Select(fn => + { + return $"\r\n{fn.FunctionName}: {fn.FunctionDescription}\r\n{fn.FunctionParameters}"; + })); + prompt += $"\r\n[FUNCTIONS]{functions}\r\n"; + } + + return prompt; + } + + private ChatCompletionOptions InitChatCompletionOption(Agent agent) + { + var settingsService = _services.GetRequiredService(); + var settings = settingsService.GetSetting(Provider, _model); + + // Reasoning + float? temperature = float.Parse(_state.GetState("temperature", "0.0")); + var (reasoningTemp, reasoningEffortLevel) = ParseReasoning(settings?.Reasoning, agent); + if (reasoningTemp.HasValue) + { + temperature = reasoningTemp.Value; + } + + // Web search + ChatWebSearchOptions? webSearchOptions = null; + if (settings?.WebSearch != null) + { + temperature = null; + reasoningEffortLevel = null; + webSearchOptions = new(); + } + + var maxTokens = int.TryParse(_state.GetState("max_tokens"), out var tokens) + ? tokens + : agent.LlmConfig?.MaxOutputTokens ?? LlmConstant.DEFAULT_MAX_OUTPUT_TOKEN; + + return new ChatCompletionOptions() + { + Temperature = temperature, + MaxOutputTokenCount = maxTokens, + ReasoningEffortLevel = reasoningEffortLevel, + WebSearchOptions = webSearchOptions + }; + } + + /// + /// Parse reasoning setting: returns (temperature, reasoning effort level) + /// + /// + /// + /// + private (float?, ChatReasoningEffortLevel?) ParseReasoning(ReasoningSetting? settings, Agent agent) + { + float? temperature = null; + ChatReasoningEffortLevel? reasoningEffortLevel = null; + + var level = _state.GetState("reasoning_effort_level"); + + if (string.IsNullOrEmpty(level) && _model == agent?.LlmConfig?.Model) + { + level = agent?.LlmConfig?.ReasoningEffortLevel; + } + + if (settings == null) + { + reasoningEffortLevel = ParseReasoningEffortLevel(level); + return (temperature, reasoningEffortLevel); + } + + if (settings.Temperature.HasValue) + { + temperature = settings.Temperature; + } + + if (string.IsNullOrEmpty(level)) + { + level = settings?.EffortLevel; + if (settings?.Parameters != null + && settings.Parameters.TryGetValue("EffortLevel", out var settingValue) + && !string.IsNullOrEmpty(settingValue?.Default)) + { + level = settingValue.Default; + } + } + + reasoningEffortLevel = ParseReasoningEffortLevel(level); + return (temperature, reasoningEffortLevel); + } + + private ChatReasoningEffortLevel? ParseReasoningEffortLevel(string? level) + { + if (string.IsNullOrWhiteSpace(level)) + { + return null; + } + + return new ChatReasoningEffortLevel(level.ToLower()); + } + + private ChatImageDetailLevel ParseChatImageDetailLevel(string? level) + { + if (string.IsNullOrWhiteSpace(level)) + { + return ChatImageDetailLevel.Auto; + } + + var imageLevel = ChatImageDetailLevel.Auto; + switch (level.ToLower()) + { + case "low": + imageLevel = ChatImageDetailLevel.Low; + break; + case "high": + imageLevel = ChatImageDetailLevel.High; + break; + default: + break; + } + + return imageLevel; + } + #endregion +} diff --git a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Response.cs b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Response.cs new file mode 100644 index 000000000..705cdf5ba --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.Response.cs @@ -0,0 +1,755 @@ +#pragma warning disable OPENAI001 +using BotSharp.Abstraction.MessageHub.Models; +using BotSharp.Core.Infrastructures.Streams; +using BotSharp.Core.MessageHub; +using OpenAI.Responses; + +namespace BotSharp.Plugin.OpenAI.Providers.Chat; + +public partial class ChatCompletionProvider +{ + private async Task InnerCreateResponse(Agent agent, List conversations) + { + var contentHooks = _services.GetHooks(agent.Id); + + // Before chat completion hook + foreach (var hook in contentHooks) + { + await hook.BeforeGenerating(agent, conversations); + } + + var client = ProviderHelper.GetClient(Provider, _model, apiKey: _apiKey, _services); + var responsesClient = client.GetResponsesClient(); + var (prompt, options) = PrepareResponseOptions(agent, conversations); + + var response = await responsesClient.CreateResponseAsync(options); + var value = response.Value; + + var functionCall = value.OutputItems.OfType().FirstOrDefault(); + var reasoningItem = value.OutputItems.OfType().FirstOrDefault(); + var text = value.GetOutputText() ?? string.Empty; + var thinkingText = reasoningItem?.GetSummaryText(); + + RoleDialogModel responseMessage; + if (functionCall != null) + { + _logger.LogInformation($"Action: {nameof(InnerCreateResponse)}, Agent: {agent.Name}, ToolCall: {functionCall.FunctionName}"); + + responseMessage = new RoleDialogModel(AgentRole.Function, text) + { + CurrentAgentId = agent.Id, + MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, + ToolCallId = functionCall.CallId, + FunctionName = functionCall.FunctionName.NormalizeFunctionName(), + FunctionArgs = functionCall.FunctionArguments?.ToString(), + RenderedInstruction = string.Join("\r\n", renderedInstructions) + }; + } + else if (value.IncompleteStatusDetails?.Reason == ResponseIncompleteStatusReason.MaxOutputTokens + || value?.IncompleteStatusDetails?.Reason == ResponseIncompleteStatusReason.ContentFilter) + { + _logger.LogWarning($"Action: {nameof(InnerCreateResponse)}, Reason: {value.IncompleteStatusDetails.Reason}, Agent: {agent.Name}, MaxOutputTokens: {options.MaxOutputTokenCount}, Content:{text}"); + + responseMessage = new RoleDialogModel(AgentRole.Assistant, $"AI response exceeded max output length") + { + CurrentAgentId = agent.Id, + MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, + StopCompletion = true + }; + } + else + { + responseMessage = new RoleDialogModel(AgentRole.Assistant, text) + { + CurrentAgentId = agent.Id, + MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, + RenderedInstruction = string.Join("\r\n", renderedInstructions) + }; + } + + if (!string.IsNullOrEmpty(thinkingText)) + { + responseMessage.Thought ??= []; + responseMessage.Thought[Constants.ThinkingText] = thinkingText; + } + + var tokenUsage = value?.Usage; + var inputTokenDetails = tokenUsage?.InputTokenDetails; + + // After chat completion hook + foreach (var hook in contentHooks) + { + await hook.AfterGenerated(responseMessage, new TokenStatsModel + { + Prompt = prompt, + Provider = Provider, + Model = _model, + TextInputTokens = (tokenUsage?.InputTokenCount ?? 0) - (inputTokenDetails?.CachedTokenCount ?? 0), + CachedTextInputTokens = inputTokenDetails?.CachedTokenCount ?? 0, + TextOutputTokens = tokenUsage?.OutputTokenCount ?? 0 + }); + } + + return responseMessage; + } + + private async Task InnerCreateResponseAsync(Agent agent, + List conversations, + Func onMessageReceived, + Func onFunctionExecuting) + { + var contentHooks = _services.GetHooks(agent.Id); + + // Before chat completion hook + foreach (var hook in contentHooks) + { + await hook.BeforeGenerating(agent, conversations); + } + + var client = ProviderHelper.GetClient(Provider, _model, apiKey: _apiKey, _services); + var responsesClient = client.GetResponsesClient(); + var (prompt, options) = PrepareResponseOptions(agent, conversations); + + var response = await responsesClient.CreateResponseAsync(options); + var value = response.Value; + + var functionCall = value.OutputItems.OfType().FirstOrDefault(); + var reasoningItem = value.OutputItems.OfType().FirstOrDefault(); + var text = value.GetOutputText() ?? string.Empty; + var thinkingText = reasoningItem?.GetSummaryText(); + + RoleDialogModel responseMessage; + if (functionCall != null) + { + _logger.LogInformation($"Action: {nameof(InnerCreateResponseAsync)}, Agent: {agent.Name}, ToolCall: {functionCall.FunctionName}"); + + responseMessage = new RoleDialogModel(AgentRole.Function, text) + { + CurrentAgentId = agent.Id, + MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, + ToolCallId = functionCall.CallId, + FunctionName = functionCall.FunctionName.NormalizeFunctionName(), + FunctionArgs = functionCall.FunctionArguments?.ToString(), + RenderedInstruction = string.Join("\r\n", renderedInstructions) + }; + } + else if (value.IncompleteStatusDetails?.Reason == ResponseIncompleteStatusReason.MaxOutputTokens + || value?.IncompleteStatusDetails?.Reason == ResponseIncompleteStatusReason.ContentFilter) + { + _logger.LogWarning($"Action: {nameof(InnerCreateResponseAsync)}, Reason: {value.IncompleteStatusDetails.Reason}, Agent: {agent.Name}, MaxOutputTokens: {options.MaxOutputTokenCount}, Content:{text}"); + + responseMessage = new RoleDialogModel(AgentRole.Assistant, $"AI response exceeded max output length") + { + CurrentAgentId = agent.Id, + MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, + StopCompletion = true + }; + } + else + { + responseMessage = new RoleDialogModel(AgentRole.Assistant, text) + { + CurrentAgentId = agent.Id, + MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, + RenderedInstruction = string.Join("\r\n", renderedInstructions) + }; + } + + if (!string.IsNullOrEmpty(thinkingText)) + { + responseMessage.Thought ??= []; + responseMessage.Thought[Constants.ThinkingText] = thinkingText; + } + + var tokenUsage = value?.Usage; + var inputTokenDetails = tokenUsage?.InputTokenDetails; + + // After chat completion hook + foreach (var hook in contentHooks) + { + await hook.AfterGenerated(responseMessage, new TokenStatsModel + { + Prompt = prompt, + Provider = Provider, + Model = _model, + TextInputTokens = (tokenUsage?.InputTokenCount ?? 0) - (inputTokenDetails?.CachedTokenCount ?? 0), + CachedTextInputTokens = inputTokenDetails?.CachedTokenCount ?? 0, + TextOutputTokens = tokenUsage?.OutputTokenCount ?? 0 + }); + } + + if (functionCall != null) + { + await onFunctionExecuting(responseMessage); + } + else + { + await onMessageReceived(responseMessage); + } + + return true; + } + + private async Task InnerCreateResponseStreamingAsync(Agent agent, List conversations) + { + var client = ProviderHelper.GetClient(Provider, _model, apiKey: _apiKey, _services); + var responsesClient = client.GetResponsesClient(); + var (prompt, options) = PrepareResponseOptions(agent, conversations); + options.StreamingEnabled = true; + + var hub = _services.GetRequiredService>>(); + var conv = _services.GetRequiredService(); + var messageId = conversations.LastOrDefault()?.MessageId ?? string.Empty; + + var contentHooks = _services.GetHooks(agent.Id); + // Before chat completion hook + foreach (var hook in contentHooks) + { + await hook.BeforeGenerating(agent, conversations); + } + + hub.Push(new() + { + EventName = ChatEvent.BeforeReceiveLlmStreamMessage, + RefId = conv.ConversationId, + Data = new RoleDialogModel(AgentRole.Assistant, string.Empty) + { + CurrentAgentId = agent.Id, + MessageId = messageId + } + }); + + using var textStream = new RealtimeTextStream(); + using var thinkingStream = new RealtimeTextStream(); + FunctionCallResponseItem? functionCall = null; + ResponseResult? finalResult = null; + ResponseTokenUsage? tokenUsage = null; + + var responseMessage = new RoleDialogModel(AgentRole.Assistant, string.Empty) + { + CurrentAgentId = agent.Id, + MessageId = messageId + }; + + var streamingCancellation = _services.GetRequiredService(); + var cancellationToken = streamingCancellation.GetToken(conv.ConversationId); + + try + { + await foreach (var update in responsesClient.CreateResponseStreamingAsync(options, cancellationToken)) + { + switch (update) + { + case StreamingResponseOutputTextDeltaUpdate textDelta: + { + var text = textDelta.Delta ?? string.Empty; + if (!string.IsNullOrEmpty(text)) + { + textStream.Collect(text); +#if DEBUG + _logger.LogDebug($"Stream Content update: {text}"); +#endif + hub.Push(new() + { + EventName = ChatEvent.OnReceiveLlmStreamMessage, + RefId = conv.ConversationId, + Data = new RoleDialogModel(AgentRole.Assistant, text) + { + CurrentAgentId = agent.Id, + MessageId = messageId + } + }); + } + break; + } + case StreamingResponseReasoningSummaryTextDeltaUpdate reasoningSummaryDelta: + { + var text = reasoningSummaryDelta.Delta ?? string.Empty; + if (!string.IsNullOrEmpty(text)) + { + thinkingStream.Collect(text); + hub.Push(new() + { + EventName = ChatEvent.OnReceiveLlmStreamMessage, + RefId = conv.ConversationId, + Data = new RoleDialogModel(AgentRole.Assistant, string.Empty) + { + CurrentAgentId = agent.Id, + MessageId = messageId, + Thought = new Dictionary + { + [Constants.ThinkingText] = text + } + } + }); + } + break; + } + case StreamingResponseReasoningTextDeltaUpdate reasoningTextDelta: + { + var text = reasoningTextDelta.Delta ?? string.Empty; + if (!string.IsNullOrEmpty(text)) + { + thinkingStream.Collect(text); + hub.Push(new() + { + EventName = ChatEvent.OnReceiveLlmStreamMessage, + RefId = conv.ConversationId, + Data = new RoleDialogModel(AgentRole.Assistant, string.Empty) + { + CurrentAgentId = agent.Id, + MessageId = messageId, + Thought = new Dictionary + { + [Constants.ThinkingText] = text + } + } + }); + } + break; + } + case StreamingResponseOutputItemDoneUpdate itemDone: + { + if (itemDone.Item is FunctionCallResponseItem fc) + { + functionCall = fc; +#if DEBUG + _logger.LogDebug($"Tool Call (id: {fc.CallId}) => {fc.FunctionName}({fc.FunctionArguments})"); +#endif + } + break; + } + case StreamingResponseCompletedUpdate completed: + finalResult = completed.Response; + tokenUsage = finalResult?.Usage; + break; + case StreamingResponseIncompleteUpdate incomplete: + finalResult = incomplete.Response; + tokenUsage = finalResult?.Usage; + break; + case StreamingResponseFailedUpdate failed: + finalResult = failed.Response; + tokenUsage = finalResult?.Usage; + break; + } + } + } + catch (OperationCanceledException) + { + _logger.LogWarning("Streaming was cancelled for conversation {ConversationId}", conv.ConversationId); + } + + var allText = textStream.GetText(); + var thinkingText = thinkingStream.GetText(); + + if (functionCall != null) + { + _logger.LogInformation($"Action: {nameof(InnerCreateResponseStreamingAsync)}, Agent: {agent.Name}, ToolCall: {functionCall.FunctionName}"); + + responseMessage = new RoleDialogModel(AgentRole.Function, allText) + { + CurrentAgentId = agent.Id, + MessageId = messageId, + ToolCallId = functionCall.CallId, + FunctionName = functionCall.FunctionName.NormalizeFunctionName(), + FunctionArgs = functionCall.FunctionArguments?.ToString(), + RenderedInstruction = string.Join("\r\n", renderedInstructions) + }; + } + else if (finalResult?.IncompleteStatusDetails?.Reason == ResponseIncompleteStatusReason.MaxOutputTokens + || finalResult?.IncompleteStatusDetails?.Reason == ResponseIncompleteStatusReason.ContentFilter) + { + _logger.LogWarning($"Action: {nameof(InnerCreateResponseStreamingAsync)}, Reason: {finalResult.IncompleteStatusDetails.Reason}, Agent: {agent.Name}, MaxOutputTokens: {options.MaxOutputTokenCount}, Content:{allText}"); + + responseMessage = new RoleDialogModel(AgentRole.Assistant, $"AI response exceeded max output length") + { + CurrentAgentId = agent.Id, + MessageId = messageId, + StopCompletion = true, + IsStreaming = true + }; + } + else + { +#if DEBUG + _logger.LogDebug($"Stream text Content: {allText}"); +#endif + responseMessage = new RoleDialogModel(AgentRole.Assistant, allText) + { + CurrentAgentId = agent.Id, + MessageId = messageId, + IsStreaming = true, + RenderedInstruction = string.Join("\r\n", renderedInstructions) + }; + } + + if (!string.IsNullOrEmpty(thinkingText)) + { + responseMessage.Thought ??= []; + responseMessage.Thought[Constants.ThinkingText] = thinkingText; + } + + hub.Push(new() + { + EventName = ChatEvent.AfterReceiveLlmStreamMessage, + RefId = conv.ConversationId, + Data = responseMessage + }); + + + var inputTokenDetails = tokenUsage?.InputTokenDetails; + // After chat completion hook + foreach (var hook in contentHooks) + { + await hook.AfterGenerated(responseMessage, new TokenStatsModel + { + Prompt = prompt, + Provider = Provider, + Model = _model, + TextInputTokens = (tokenUsage?.InputTokenCount ?? 0) - (inputTokenDetails?.CachedTokenCount ?? 0), + CachedTextInputTokens = inputTokenDetails?.CachedTokenCount ?? 0, + TextOutputTokens = tokenUsage?.OutputTokenCount ?? 0 + }); + } + + return responseMessage; + } + + #region Private methods + private (string, CreateResponseOptions) PrepareResponseOptions(Agent agent, List conversations) + { + var agentService = _services.GetRequiredService(); + var settingsService = _services.GetRequiredService(); + var settings = settingsService.GetSetting(Provider, _model); + var allowMultiModal = settings != null && settings.MultiModal; + renderedInstructions = []; + + var maxTokens = int.TryParse(_state.GetState("max_tokens"), out var tokens) + ? tokens + : agent.LlmConfig?.MaxOutputTokens ?? LlmConstant.DEFAULT_MAX_OUTPUT_TOKEN; + + var options = new CreateResponseOptions(_model, new List()) + { + MaxOutputTokenCount = maxTokens + }; + + var (_, reasoningEffortLevel) = ParseReasoning(settings?.Reasoning, agent); + var responseReasoningLevel = ParseResponseReasoningEffortLevel(reasoningEffortLevel?.ToString()); + if (responseReasoningLevel.HasValue) + { + options.ReasoningOptions = new ResponseReasoningOptions + { + ReasoningEffortLevel = responseReasoningLevel.Value, + ReasoningSummaryVerbosity = ResponseReasoningSummaryVerbosity.Auto + }; + } + + // Prepare instruction and functions + var renderData = agentService.CollectRenderData(agent); + var (instruction, functions) = agentService.PrepareInstructionAndFunctions(agent, renderData); + if (!string.IsNullOrWhiteSpace(instruction)) + { + renderedInstructions.Add(instruction); + options.InputItems.Add(ResponseItem.CreateSystemMessageItem(instruction)); + } + + // Render functions as tools + foreach (var function in functions) + { + if (!agentService.RenderFunction(agent, function, renderData)) + { + continue; + } + + var property = agentService.RenderFunctionProperty(agent, function, renderData); + + options.Tools.Add(ResponseTool.CreateFunctionTool( + function.Name, + BinaryData.FromObjectAsJson(property), + strictModeEnabled: false, + function.Description)); + } + + AddBuiltInTools(options.Tools, settings); + + if (!string.IsNullOrEmpty(agent.Knowledges)) + { + options.InputItems.Add(ResponseItem.CreateSystemMessageItem(agent.Knowledges)); + } + + var samples = ProviderHelper.GetChatSamples(agent.Samples); + foreach (var sample in samples) + { + options.InputItems.Add(sample.Role == AgentRole.User + ? ResponseItem.CreateUserMessageItem(sample.Content) + : ResponseItem.CreateAssistantMessageItem(sample.Content)); + } + + var filteredMessages = conversations.Select(x => x).ToList(); + var firstUserMsgIdx = filteredMessages.FindIndex(x => x.Role == AgentRole.User); + if (firstUserMsgIdx > 0) + { + filteredMessages = filteredMessages.Where((_, idx) => idx >= firstUserMsgIdx).ToList(); + } + + var imageDetailLevel = ResponseImageDetailLevel.Auto; + if (allowMultiModal) + { + imageDetailLevel = ParseResponseImageDetailLevel(_state.GetState("chat_image_detail_level")); + } + + foreach (var message in filteredMessages) + { + if (message.Role == AgentRole.Function) + { + var toolCallId = message.ToolCallId.IfNullOrEmptyAs(message.FunctionName); + options.InputItems.Add(ResponseItem.CreateFunctionCallItem( + toolCallId, + message.FunctionName, + BinaryData.FromString(message.FunctionArgs ?? "{}"))); + options.InputItems.Add(ResponseItem.CreateFunctionCallOutputItem( + toolCallId, + message.LlmContent)); + } + else if (message.Role == AgentRole.User) + { + var text = message.LlmContent; + var textPart = ResponseContentPart.CreateInputTextPart(text); + var contentParts = new List { textPart }; + + if (allowMultiModal && !message.Files.IsNullOrEmpty()) + { + CollectResponseContentParts(contentParts, message.Files!, imageDetailLevel); + } + options.InputItems.Add(ResponseItem.CreateUserMessageItem(contentParts)); + } + else if (message.Role == AgentRole.Assistant) + { + var text = message.LlmContent; + var textPart = ResponseContentPart.CreateOutputTextPart(text, []); + var contentParts = new List { textPart }; + + if (allowMultiModal && !message.Files.IsNullOrEmpty()) + { + CollectResponseContentParts(contentParts, message.Files!, imageDetailLevel); + } + options.InputItems.Add(ResponseItem.CreateAssistantMessageItem(contentParts)); + } + } + + var prompt = GetResponseApiPrompt(options); + return (prompt, options); + } + + private string GetResponseApiPrompt(CreateResponseOptions options) + { + var sb = new StringBuilder(); + foreach (var item in options.InputItems) + { + if (item is MessageResponseItem msg) + { + var text = msg.Content?.FirstOrDefault()?.Text ?? string.Empty; + sb.AppendLine($"{msg.Role}: {text}"); + } + else if (item is FunctionCallResponseItem fc) + { + sb.AppendLine($"{AgentRole.Assistant}: Call function {fc.FunctionName}({fc.FunctionArguments})"); + } + else if (item is FunctionCallOutputResponseItem fco) + { + sb.AppendLine($"{AgentRole.Function}: {fco.FunctionOutput}"); + } + } + + if (!options.Tools.IsNullOrEmpty()) + { + var functions = string.Join("\r\n", options.Tools.OfType().Select(fn => + { + return $"\r\n{fn.FunctionName}: {fn.FunctionDescription}\r\n{fn.FunctionParameters}"; + })); + sb.AppendLine($"\r\n[FUNCTIONS]{functions}\r\n"); + } + + return sb.ToString(); + } + + private ResponseReasoningEffortLevel? ParseResponseReasoningEffortLevel(string? level) + { + if (string.IsNullOrWhiteSpace(level)) + { + return null; + } + + return new ResponseReasoningEffortLevel(level.ToLower()); + } + + private WebSearchToolContextSize? ParseWebSearchContextSize(string? size) + { + if (string.IsNullOrWhiteSpace(size)) + { + return null; + } + + return size.ToLower() switch + { + "low" => WebSearchToolContextSize.Low, + "medium" => WebSearchToolContextSize.Medium, + "high" => WebSearchToolContextSize.High, + _ => null + }; + } + + private void CollectResponseContentParts(List contentParts, List files, ResponseImageDetailLevel imageDetailLevel) + { + ResponseContentPart? contentPart; + + foreach (var file in files) + { + contentPart = null; + + if (!string.IsNullOrEmpty(file.FileData)) + { + var (contentType, binary) = FileUtility.GetFileInfoFromData(file.FileData); + contentType = contentType.IfNullOrEmptyAs(file.ContentType); + if (IsImageContentType(contentType)) + { + var typedBinary = BinaryData.FromBytes(binary.ToArray(), contentType); + contentPart = ResponseContentPart.CreateInputImagePart(typedBinary, imageDetailLevel); + } + else + { + contentPart = ResponseContentPart.CreateInputFilePart(binary, contentType, file.FileFullName); + } + } + else if (!string.IsNullOrEmpty(file.FileStorageUrl)) + { + var binary = _fileStorage.GetFileBytes(file.FileStorageUrl); + var contentType = FileUtility.GetFileContentType(file.FileStorageUrl).IfNullOrEmptyAs(file.ContentType); + if (IsImageContentType(contentType)) + { + var typedBinary = BinaryData.FromBytes(binary.ToArray(), contentType); + contentPart = ResponseContentPart.CreateInputImagePart(typedBinary, imageDetailLevel); + } + else + { + contentPart = ResponseContentPart.CreateInputFilePart(binary, contentType, file.FileFullName); + } + } + else if (!string.IsNullOrEmpty(file.FileUrl)) + { + var uri = new Uri(file.FileUrl); + var contentType = FileUtility.GetFileContentType(file.FileUrl).IfNullOrEmptyAs(file.ContentType); + if (IsImageContentType(contentType)) + { + contentPart = ResponseContentPart.CreateInputImagePart(uri, imageDetailLevel); + } + else + { + contentPart = ResponseContentPart.CreateInputFilePart(uri); + } + } + + if (contentPart != null) + { + contentParts.Add(contentPart); + } + } + } + + private ResponseImageDetailLevel ParseResponseImageDetailLevel(string? level) + { + if (string.IsNullOrWhiteSpace(level)) + { + return ResponseImageDetailLevel.Auto; + } + + var imageLevel = ResponseImageDetailLevel.Auto; + var levelLower = level.ToLower(); + switch (levelLower) + { + case "low": + imageLevel = ResponseImageDetailLevel.Low; + break; + case "high": + imageLevel = ResponseImageDetailLevel.High; + break; + case "original": + imageLevel = new ResponseImageDetailLevel(levelLower); + break; + default: + break; + } + + return imageLevel; + } + + #region Built-in tools + private void AddBuiltInTools(IList tools, LlmModelSetting? modelSettings) + { + if (bool.TryParse(_state.GetState("enable_web_search"), out var webSearchEnabled) && webSearchEnabled) + { + var (location, contextSize) = ParseWebSearchContext(modelSettings?.WebSearch); + tools.Add(ResponseTool.CreateWebSearchTool(userLocation: location, searchContextSize: contextSize)); + } + else if (bool.TryParse(_state.GetState("enable_web_search_preview"), out var webSearchPreviewEnabled) && webSearchPreviewEnabled) + { + var (location, contextSize) = ParseWebSearchContext(modelSettings?.WebSearch); + tools.Add(ResponseTool.CreateWebSearchPreviewTool(userLocation: location, searchContextSize: contextSize)); + } + } + + private (WebSearchToolLocation?, WebSearchToolContextSize?) ParseWebSearchContext(WebSearchSetting? settings) + { + WebSearchToolLocation? webSearchLocation = null; + WebSearchToolContextSize? webSearchContextSize = null; + + var contextSize = _state.GetState("web_search_context_size"); + if (string.IsNullOrEmpty(contextSize) + && settings?.Parameters != null + && settings.Parameters.TryGetValue("SearchContextSize", out var settingValue) + && !string.IsNullOrEmpty(settingValue?.Default)) + { + contextSize = settingValue.Default; + } + if (string.IsNullOrEmpty(contextSize)) + { + contextSize = _settings.WebSearch?.SearchContextSize; + } + if (string.IsNullOrEmpty(contextSize)) + { + contextSize = settings?.SearchContextSize; + } + webSearchContextSize = ParseWebSearchContextSize(contextSize); + + var userLocation = ResolveWebSearchUserLocation(); + if (userLocation?.HasAnyValue == true) + { + webSearchLocation = WebSearchToolLocation.CreateApproximateLocation( + userLocation.Country, + userLocation.Region, + userLocation.City, + userLocation.Timezone); + } + + return (webSearchLocation, webSearchContextSize); + } + + private WebSearchUserLocation? ResolveWebSearchUserLocation() + { + var location = WebSearchUserLocation.FromJson(_state.GetState("web_search_user_location")); + if (location?.HasAnyValue == true) + { + return location; + } + + location = _settings.WebSearch?.UserLocation; + if (location?.HasAnyValue == true) + { + return location; + } + + return null; + } + #endregion + #endregion +} diff --git a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.cs b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.cs index 93e9201e2..98c89980b 100644 --- a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.cs +++ b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.cs @@ -1,17 +1,12 @@ -#pragma warning disable OPENAI001 -using BotSharp.Abstraction.MessageHub.Models; -using BotSharp.Core.Infrastructures.Streams; -using BotSharp.Core.MessageHub; -using OpenAI.Chat; - namespace BotSharp.Plugin.OpenAI.Providers.Chat; -public class ChatCompletionProvider : IChatCompletion +public partial class ChatCompletionProvider : IChatCompletion { protected readonly OpenAiSettings _settings; protected readonly IServiceProvider _services; protected readonly ILogger _logger; protected readonly IConversationStateService _state; + protected readonly IFileStorageService _fileStorage; protected string _model; protected string? _apiKey; @@ -25,99 +20,27 @@ public ChatCompletionProvider( OpenAiSettings settings, ILogger logger, IServiceProvider services, - IConversationStateService state) + IConversationStateService state, + IFileStorageService fileStorage) { _settings = settings; _logger = logger; _services = services; _state = state; + _fileStorage = fileStorage; } + public async Task GetChatCompletions(Agent agent, List conversations) { - var contentHooks = _services.GetHooks(agent.Id); - - // Before chat completion hook - foreach (var hook in contentHooks) + if (_settings.UseResponseApi) { - await hook.BeforeGenerating(agent, conversations); - } - - var client = ProviderHelper.GetClient(Provider, _model, apiKey: _apiKey, _services); - var chatClient = client.GetChatClient(_model); - var (prompt, messages, options) = PrepareOptions(agent, conversations); - - var response = chatClient.CompleteChat(messages, options); - var value = response.Value; - var reason = value.FinishReason; - var content = value.Content; - var text = content.FirstOrDefault()?.Text ?? string.Empty; - - RoleDialogModel responseMessage; - if (reason == ChatFinishReason.FunctionCall || reason == ChatFinishReason.ToolCalls) - { - _logger.LogInformation($"Action: {nameof(GetChatCompletions)}, Reason: {reason}, Agent: {agent.Name}, ToolCalls: {string.Join(",", value.ToolCalls.Select(x => x.FunctionName))}"); - - var toolCall = value.ToolCalls.FirstOrDefault(); - responseMessage = new RoleDialogModel(AgentRole.Function, text) - { - CurrentAgentId = agent.Id, - MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, - ToolCallId = toolCall?.Id, - FunctionName = toolCall?.FunctionName, - FunctionArgs = toolCall?.FunctionArguments?.ToString(), - RenderedInstruction = string.Join("\r\n", renderedInstructions) - }; - - // Somethings LLM will generate a function name with agent name. - responseMessage.FunctionName = responseMessage.FunctionName.NormalizeFunctionName(); - } - else if (reason == ChatFinishReason.Length) - { - _logger.LogWarning($"Action: {nameof(GetChatCompletions)}, Reason: {reason}, Agent: {agent.Name}, MaxOutputTokens: {options.MaxOutputTokenCount}, Content:{text}"); - - responseMessage = new RoleDialogModel(AgentRole.Assistant, $"AI response exceeded max output length") - { - CurrentAgentId = agent.Id, - MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, - StopCompletion = true - }; + return await InnerCreateResponse(agent, conversations); } else { - responseMessage = new RoleDialogModel(AgentRole.Assistant, text) - { - CurrentAgentId = agent.Id, - MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, - RenderedInstruction = string.Join("\r\n", renderedInstructions), - Annotations = value.Annotations?.Select(x => new ChatAnnotation - { - Title = x.WebResourceTitle, - Url = x.WebResourceUri.AbsoluteUri, - StartIndex = x.StartIndex, - EndIndex = x.EndIndex - })?.ToList() - }; - } - - var tokenUsage = response.Value?.Usage; - var inputTokenDetails = response.Value?.Usage?.InputTokenDetails; - - // After chat completion hook - foreach (var hook in contentHooks) - { - await hook.AfterGenerated(responseMessage, new TokenStatsModel - { - Prompt = prompt, - Provider = Provider, - Model = _model, - TextInputTokens = (tokenUsage?.InputTokenCount ?? 0) - (inputTokenDetails?.CachedTokenCount ?? 0), - CachedTextInputTokens = inputTokenDetails?.CachedTokenCount ?? 0, - TextOutputTokens = tokenUsage?.OutputTokenCount ?? 0 - }); + return await InnerGetChatCompletions(agent, conversations); } - - return responseMessage; } public async Task GetChatCompletionsAsync(Agent agent, @@ -125,602 +48,28 @@ public async Task GetChatCompletionsAsync(Agent agent, Func onMessageReceived, Func onFunctionExecuting) { - var hooks = _services.GetHooks(agent.Id); - - // Before chat completion hook - foreach (var hook in hooks) + if (_settings.UseResponseApi) { - await hook.BeforeGenerating(agent, conversations); - } - - var client = ProviderHelper.GetClient(Provider, _model, apiKey: _apiKey, _services); - var chatClient = client.GetChatClient(_model); - var (prompt, messages, options) = PrepareOptions(agent, conversations); - - var response = await chatClient.CompleteChatAsync(messages, options); - var value = response.Value; - var reason = value.FinishReason; - var content = value.Content; - var text = content.FirstOrDefault()?.Text ?? string.Empty; - - var msg = new RoleDialogModel(AgentRole.Assistant, text) - { - CurrentAgentId = agent.Id, - RenderedInstruction = string.Join("\r\n", renderedInstructions) - }; - - var tokenUsage = response?.Value?.Usage; - var inputTokenDetails = response?.Value?.Usage?.InputTokenDetails; - - // After chat completion hook - foreach (var hook in hooks) - { - await hook.AfterGenerated(msg, new TokenStatsModel - { - Prompt = prompt, - Provider = Provider, - Model = _model, - TextInputTokens = (tokenUsage?.InputTokenCount ?? 0) - (inputTokenDetails?.CachedTokenCount ?? 0), - CachedTextInputTokens = inputTokenDetails?.CachedTokenCount ?? 0, - TextOutputTokens = tokenUsage?.OutputTokenCount ?? 0 - }); - } - - if (reason == ChatFinishReason.FunctionCall || reason == ChatFinishReason.ToolCalls) - { - var toolCall = value.ToolCalls?.FirstOrDefault(); - _logger.LogInformation($"[{agent.Name}]: {toolCall?.FunctionName}({toolCall?.FunctionArguments})"); - - var funcContextIn = new RoleDialogModel(AgentRole.Function, text) - { - CurrentAgentId = agent.Id, - MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, - ToolCallId = toolCall?.Id, - FunctionName = toolCall?.FunctionName, - FunctionArgs = toolCall?.FunctionArguments?.ToString(), - RenderedInstruction = string.Join("\r\n", renderedInstructions) - }; - - // Somethings LLM will generate a function name with agent name. - funcContextIn.FunctionName = funcContextIn.FunctionName.NormalizeFunctionName(); - - // Execute functions - await onFunctionExecuting(funcContextIn); - } - else if (reason == ChatFinishReason.Length) - { - _logger.LogWarning($"Action: {nameof(GetChatCompletionsAsync)}, Reason: {reason}, Agent: {agent.Name}, MaxOutputTokens: {options.MaxOutputTokenCount}, Content:{text}"); - - msg = new RoleDialogModel(AgentRole.Assistant, $"AI response exceeded max output length") - { - CurrentAgentId = agent.Id, - MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, - StopCompletion = true, - RenderedInstruction = string.Join("\r\n", renderedInstructions) - }; - await onMessageReceived(msg); + return await InnerCreateResponseAsync(agent, conversations, onMessageReceived, onFunctionExecuting); } else { - // Text response received - msg = new RoleDialogModel(AgentRole.Assistant, text) - { - CurrentAgentId = agent.Id, - MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, - RenderedInstruction = string.Join("\r\n", renderedInstructions), - Annotations = value.Annotations?.Select(x => new ChatAnnotation - { - Title = x.WebResourceTitle, - Url = x.WebResourceUri.AbsoluteUri, - StartIndex = x.StartIndex, - EndIndex = x.EndIndex - })?.ToList() - }; - await onMessageReceived(msg); + return await InnerGetChatCompletionsAsync(agent, conversations, onMessageReceived, onFunctionExecuting); } - - return true; } public async Task GetChatCompletionsStreamingAsync(Agent agent, List conversations) { - var client = ProviderHelper.GetClient(Provider, _model, apiKey: _apiKey, _services); - var chatClient = client.GetChatClient(_model); - var (prompt, messages, options) = PrepareOptions(agent, conversations); - - var hub = _services.GetRequiredService>>(); - var conv = _services.GetRequiredService(); - var messageId = conversations.LastOrDefault()?.MessageId ?? string.Empty; - - var contentHooks = _services.GetHooks(agent.Id); - // Before chat completion hook - foreach (var hook in contentHooks) - { - await hook.BeforeGenerating(agent, conversations); - } - - hub.Push(new() - { - EventName = ChatEvent.BeforeReceiveLlmStreamMessage, - RefId = conv.ConversationId, - Data = new RoleDialogModel(AgentRole.Assistant, string.Empty) - { - CurrentAgentId = agent.Id, - MessageId = messageId - } - }); - - using var textStream = new RealtimeTextStream(); - var toolCalls = new List(); - ChatTokenUsage? tokenUsage = null; - - var responseMessage = new RoleDialogModel(AgentRole.Assistant, string.Empty) - { - CurrentAgentId = agent.Id, - MessageId = messageId - }; - - var streamingCancellation = _services.GetRequiredService(); - var cancellationToken = streamingCancellation.GetToken(conv.ConversationId); - - try - { - await foreach (var choice in chatClient.CompleteChatStreamingAsync(messages, options, cancellationToken)) - { - tokenUsage = choice.Usage; - - if (!choice.ToolCallUpdates.IsNullOrEmpty()) - { - toolCalls.AddRange(choice.ToolCallUpdates); - } - - if (!choice.ContentUpdate.IsNullOrEmpty()) - { - var text = choice.ContentUpdate[0]?.Text ?? string.Empty; - textStream.Collect(text); -#if DEBUG - _logger.LogDebug($"Stream Content update: {text}"); -#endif - - hub.Push(new() - { - EventName = ChatEvent.OnReceiveLlmStreamMessage, - RefId = conv.ConversationId, - Data = new RoleDialogModel(AgentRole.Assistant, text) - { - CurrentAgentId = agent.Id, - MessageId = messageId - } - }); - } - - if (choice.FinishReason == ChatFinishReason.ToolCalls || choice.FinishReason == ChatFinishReason.FunctionCall) - { - var meta = toolCalls.FirstOrDefault(x => !string.IsNullOrEmpty(x.FunctionName)); - var functionName = meta?.FunctionName; - var toolCallId = meta?.ToolCallId; - var args = toolCalls.Where(x => x.FunctionArgumentsUpdate != null).Select(x => x.FunctionArgumentsUpdate.ToString()).ToList(); - var functionArguments = string.Join(string.Empty, args); - -#if DEBUG - _logger.LogDebug($"Tool Call (id: {toolCallId}) => {functionName}({functionArguments})"); -#endif - - responseMessage = new RoleDialogModel(AgentRole.Function, string.Empty) - { - CurrentAgentId = agent.Id, - MessageId = messageId, - ToolCallId = toolCallId, - FunctionName = functionName, - FunctionArgs = functionArguments - }; - } - else if (choice.FinishReason == ChatFinishReason.Stop) - { - var allText = textStream.GetText(); -#if DEBUG - _logger.LogDebug($"Stream text Content: {allText}"); -#endif - - responseMessage = new RoleDialogModel(AgentRole.Assistant, allText) - { - CurrentAgentId = agent.Id, - MessageId = messageId, - IsStreaming = true - }; - } - else if (choice.FinishReason.HasValue) - { - var text = choice.FinishReason == ChatFinishReason.Length ? "Model reached the maximum number of tokens allowed." - : choice.FinishReason == ChatFinishReason.ContentFilter ? "Content is omitted due to content filter rule." - : choice.FinishReason.Value.ToString(); - responseMessage = new RoleDialogModel(AgentRole.Assistant, text) - { - CurrentAgentId = agent.Id, - MessageId = messageId, - IsStreaming = true - }; - } - } - } - catch (OperationCanceledException) - { - _logger.LogWarning("Streaming was cancelled for conversation {ConversationId}", conv.ConversationId); - } - - // Build responseMessage from collected text when cancelled before FinishReason - if (cancellationToken.IsCancellationRequested && string.IsNullOrEmpty(responseMessage.Content)) - { - var allText = textStream.GetText(); - responseMessage = new RoleDialogModel(AgentRole.Assistant, allText) - { - CurrentAgentId = agent.Id, - MessageId = messageId, - IsStreaming = true - }; - } - - hub.Push(new() - { - EventName = ChatEvent.AfterReceiveLlmStreamMessage, - RefId = conv.ConversationId, - Data = responseMessage - }); - - - var inputTokenDetails = tokenUsage?.InputTokenDetails; - // After chat completion hook - foreach (var hook in contentHooks) - { - await hook.AfterGenerated(responseMessage, new TokenStatsModel - { - Prompt = prompt, - Provider = Provider, - Model = _model, - TextInputTokens = (tokenUsage?.InputTokenCount ?? 0) - (inputTokenDetails?.CachedTokenCount ?? 0), - CachedTextInputTokens = inputTokenDetails?.CachedTokenCount ?? 0, - TextOutputTokens = tokenUsage?.OutputTokenCount ?? 0 - }); - } - - return responseMessage; - } - - - protected (string, IEnumerable, ChatCompletionOptions) PrepareOptions(Agent agent, List conversations) - { - var agentService = _services.GetRequiredService(); - var settingsService = _services.GetRequiredService(); - var settings = settingsService.GetSetting(Provider, _model); - var allowMultiModal = settings != null && settings.MultiModal; - renderedInstructions = []; - - var messages = new List(); - var options = InitChatCompletionOption(agent); - - // Prepare instruction and functions - var renderData = agentService.CollectRenderData(agent); - var (instruction, functions) = agentService.PrepareInstructionAndFunctions(agent, renderData); - if (!string.IsNullOrWhiteSpace(instruction)) - { - renderedInstructions.Add(instruction); - messages.Add(new SystemChatMessage(instruction)); - } - - // Render functions - if (options.WebSearchOptions == null) - { - foreach (var function in functions) - { - if (!agentService.RenderFunction(agent, function, renderData)) - { - continue; - } - - var property = agentService.RenderFunctionProperty(agent, function, renderData); - - options.Tools.Add(ChatTool.CreateFunctionTool( - functionName: function.Name, - functionDescription: function.Description, - functionParameters: BinaryData.FromObjectAsJson(property))); - } - } - - if (!string.IsNullOrEmpty(agent.Knowledges)) - { - messages.Add(new SystemChatMessage(agent.Knowledges)); - } - - var samples = ProviderHelper.GetChatSamples(agent.Samples); - foreach (var sample in samples) - { - messages.Add(sample.Role == AgentRole.User ? new UserChatMessage(sample.Content) : new AssistantChatMessage(sample.Content)); - } - - var filteredMessages = conversations.Select(x => x).ToList(); - var firstUserMsgIdx = filteredMessages.FindIndex(x => x.Role == AgentRole.User); - if (firstUserMsgIdx > 0) - { - filteredMessages = filteredMessages.Where((_, idx) => idx >= firstUserMsgIdx).ToList(); - } - - var imageDetailLevel = ChatImageDetailLevel.Auto; - if (allowMultiModal) - { - imageDetailLevel = ParseChatImageDetailLevel(_state.GetState("chat_image_detail_level")); - } - - foreach (var message in filteredMessages) - { - if (message.Role == AgentRole.Function) - { - messages.Add(new AssistantChatMessage(new List - { - ChatToolCall.CreateFunctionToolCall(message.ToolCallId.IfNullOrEmptyAs(message.FunctionName), message.FunctionName, BinaryData.FromString(message.FunctionArgs ?? "{}")) - })); - - messages.Add(new ToolChatMessage(message.ToolCallId.IfNullOrEmptyAs(message.FunctionName), message.LlmContent)); - } - else if (message.Role == AgentRole.User) - { - var text = message.LlmContent; - var textPart = ChatMessageContentPart.CreateTextPart(text); - var contentParts = new List { textPart }; - - if (allowMultiModal && !message.Files.IsNullOrEmpty()) - { - CollectMessageContentParts(contentParts, message.Files, imageDetailLevel); - } - messages.Add(new UserChatMessage(contentParts) { ParticipantName = message.FunctionName }); - } - else if (message.Role == AgentRole.Assistant) - { - var text = message.LlmContent; - var textPart = ChatMessageContentPart.CreateTextPart(text); - var contentParts = new List { textPart }; - - if (allowMultiModal && !message.Files.IsNullOrEmpty()) - { - CollectMessageContentParts(contentParts, message.Files, imageDetailLevel); - } - messages.Add(new AssistantChatMessage(contentParts)); - } - } - - var prompt = GetPrompt(messages, options); - return (prompt, messages, options); - } - - private void CollectMessageContentParts(List contentParts, List files, ChatImageDetailLevel imageDetailLevel) - { - foreach (var file in files) - { - if (!string.IsNullOrEmpty(file.FileData)) - { - var (contentType, binary) = FileUtility.GetFileInfoFromData(file.FileData); - var contentPart = ChatMessageContentPart.CreateImagePart(binary, contentType.IfNullOrEmptyAs(file.ContentType), imageDetailLevel); - contentParts.Add(contentPart); - } - else if (!string.IsNullOrEmpty(file.FileStorageUrl)) - { - var fileStorage = _services.GetRequiredService(); - var binary = fileStorage.GetFileBytes(file.FileStorageUrl); - var contentType = FileUtility.GetFileContentType(file.FileStorageUrl); - var contentPart = ChatMessageContentPart.CreateImagePart(binary, contentType.IfNullOrEmptyAs(file.ContentType), imageDetailLevel); - contentParts.Add(contentPart); - } - else if (!string.IsNullOrEmpty(file.FileUrl)) - { - var uri = new Uri(file.FileUrl); - var contentPart = ChatMessageContentPart.CreateImagePart(uri, imageDetailLevel); - contentParts.Add(contentPart); - } - } - } - - private string GetPrompt(IEnumerable messages, ChatCompletionOptions options) - { - var prompt = string.Empty; - - if (!messages.IsNullOrEmpty()) - { - // System instruction - var verbose = string.Join("\r\n", messages - .Select(x => x as SystemChatMessage) - .Where(x => x != null) - .Select(x => - { - if (!string.IsNullOrEmpty(x.ParticipantName)) - { - // To display Agent name in log - return $"[{x.ParticipantName}]: {x.Content.FirstOrDefault()?.Text ?? string.Empty}"; - } - return $"{AgentRole.System}: {x.Content.FirstOrDefault()?.Text ?? string.Empty}"; - })); - prompt += $"{verbose}\r\n"; - - prompt += "\r\n[CONVERSATION]"; - verbose = string.Join("\r\n", messages - .Where(x => x as SystemChatMessage == null) - .Select(x => - { - var fnMessage = x as ToolChatMessage; - if (fnMessage != null) - { - return $"{AgentRole.Function}: {fnMessage.Content.FirstOrDefault()?.Text ?? string.Empty}"; - } - - var userMessage = x as UserChatMessage; - if (userMessage != null) - { - var content = x.Content.FirstOrDefault()?.Text ?? string.Empty; - return !string.IsNullOrEmpty(userMessage.ParticipantName) && userMessage.ParticipantName != "route_to_agent" ? - $"{userMessage.ParticipantName}: {content}" : - $"{AgentRole.User}: {content}"; - } - - var assistMessage = x as AssistantChatMessage; - if (assistMessage != null) - { - var toolCall = assistMessage.ToolCalls?.FirstOrDefault(); - return toolCall != null ? - $"{AgentRole.Assistant}: Call function {toolCall?.FunctionName}({toolCall?.FunctionArguments})" : - $"{AgentRole.Assistant}: {assistMessage.Content.FirstOrDefault()?.Text ?? string.Empty}"; - } - - return string.Empty; - })); - prompt += $"\r\n{verbose}\r\n"; - } - - if (!options.Tools.IsNullOrEmpty()) - { - var functions = string.Join("\r\n", options.Tools.Select(fn => - { - return $"\r\n{fn.FunctionName}: {fn.FunctionDescription}\r\n{fn.FunctionParameters}"; - })); - prompt += $"\r\n[FUNCTIONS]{functions}\r\n"; - } - - return prompt; - } - - private ChatCompletionOptions InitChatCompletionOption(Agent agent) - { - var settingsService = _services.GetRequiredService(); - var settings = settingsService.GetSetting(Provider, _model); - - // Reasoning - float? temperature = float.Parse(_state.GetState("temperature", "0.0")); - var (reasoningTemp, reasoningEffortLevel) = ParseReasoning(settings?.Reasoning, agent); - if (reasoningTemp.HasValue) - { - temperature = reasoningTemp.Value; - } - - // Web search - ChatWebSearchOptions? webSearchOptions = null; - if (settings?.WebSearch != null) - { - temperature = null; - reasoningEffortLevel = null; - webSearchOptions = new(); - } - - var maxTokens = int.TryParse(_state.GetState("max_tokens"), out var tokens) - ? tokens - : agent.LlmConfig?.MaxOutputTokens ?? LlmConstant.DEFAULT_MAX_OUTPUT_TOKEN; - - return new ChatCompletionOptions() - { - Temperature = temperature, - MaxOutputTokenCount = maxTokens, - ReasoningEffortLevel = reasoningEffortLevel, - WebSearchOptions = webSearchOptions - }; - } - - /// - /// Parse reasoning setting: returns (temperature, reasoning effort level) - /// - /// - /// - /// - private (float?, ChatReasoningEffortLevel?) ParseReasoning(ReasoningSetting? settings, Agent agent) - { - float? temperature = null; - ChatReasoningEffortLevel? reasoningEffortLevel = null; - - var level = _state.GetState("reasoning_effort_level"); - - if (string.IsNullOrEmpty(level) && _model == agent?.LlmConfig?.Model) - { - level = agent?.LlmConfig?.ReasoningEffortLevel; - } - - if (settings == null) - { - reasoningEffortLevel = ParseReasoningEffortLevel(level); - return (temperature, reasoningEffortLevel); - } - - if (settings.Temperature.HasValue) - { - temperature = settings.Temperature; - } - - if (string.IsNullOrEmpty(level)) + if (_settings.UseResponseApi) { - level = settings?.EffortLevel; - if (settings?.Parameters != null - && settings.Parameters.TryGetValue("EffortLevel", out var settingValue) - && !string.IsNullOrEmpty(settingValue?.Default)) - { - level = settingValue.Default; - } + return await InnerCreateResponseStreamingAsync(agent, conversations); } - - reasoningEffortLevel = ParseReasoningEffortLevel(level); - return (temperature, reasoningEffortLevel); - } - - private ChatReasoningEffortLevel? ParseReasoningEffortLevel(string? level) - { - if (string.IsNullOrWhiteSpace(level)) - { - return null; - } - - var effortLevel = new ChatReasoningEffortLevel("low"); - level = level.ToLower(); - switch (level) + else { - case "minimal": - effortLevel = ChatReasoningEffortLevel.Minimal; - break; - case "low": - effortLevel = ChatReasoningEffortLevel.Low; - break; - case "medium": - effortLevel = ChatReasoningEffortLevel.Medium; - break; - case "high": - effortLevel = ChatReasoningEffortLevel.High; - break; - case "none": - case "xhigh": - effortLevel = new ChatReasoningEffortLevel(level); - break; - default: - effortLevel = new ChatReasoningEffortLevel(level); - break; + return await InnerGetChatCompletionsStreamingAsync(agent, conversations); } - - return effortLevel; } - private ChatImageDetailLevel ParseChatImageDetailLevel(string? level) - { - if (string.IsNullOrWhiteSpace(level)) - { - return ChatImageDetailLevel.Auto; - } - - var imageLevel = ChatImageDetailLevel.Auto; - switch (level.ToLower()) - { - case "low": - imageLevel = ChatImageDetailLevel.Low; - break; - case "high": - imageLevel = ChatImageDetailLevel.High; - break; - default: - break; - } - - return imageLevel; - } public void SetModelName(string model) { @@ -731,4 +80,10 @@ public void SetApiKey(string apiKey) { _apiKey = apiKey; } + + private static bool IsImageContentType(string? contentType) + { + return !string.IsNullOrEmpty(contentType) + && contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase); + } } \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.OpenAI/Settings/OpenAiSettings.cs b/src/Plugins/BotSharp.Plugin.OpenAI/Settings/OpenAiSettings.cs index 3d0fbf18d..7f9ddeeb3 100644 --- a/src/Plugins/BotSharp.Plugin.OpenAI/Settings/OpenAiSettings.cs +++ b/src/Plugins/BotSharp.Plugin.OpenAI/Settings/OpenAiSettings.cs @@ -2,4 +2,14 @@ namespace BotSharp.Plugin.OpenAI.Settings; public class OpenAiSettings { + /// + /// Switch on to use response api; Switch off to use legacy chat completion + /// + public bool UseResponseApi { get; set; } + + /// + /// Defaults for the OpenAI web search tool (context size, approximate user location). + /// Conversation state keys take precedence over these values at runtime. + /// + public WebSearchSettings? WebSearch { get; set; } } diff --git a/src/Plugins/BotSharp.Plugin.OpenAI/Settings/WebSearchSettings.cs b/src/Plugins/BotSharp.Plugin.OpenAI/Settings/WebSearchSettings.cs new file mode 100644 index 000000000..d0f1e00d4 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.OpenAI/Settings/WebSearchSettings.cs @@ -0,0 +1,18 @@ +namespace BotSharp.Plugin.OpenAI.Settings; + +/// +/// Plugin-level configuration for the OpenAI web search tool. +/// Bound from the "OpenAi:WebSearch" configuration section via . +/// +public class WebSearchSettings +{ + /// + /// Default context size ("low", "medium", "high") used when no conversation state override is set. + /// + public string? SearchContextSize { get; set; } + + /// + /// Default approximate user location used when no conversation state override is set. + /// + public WebSearchUserLocation? UserLocation { get; set; } +} diff --git a/src/Plugins/BotSharp.Plugin.OpenAI/Using.cs b/src/Plugins/BotSharp.Plugin.OpenAI/Using.cs index aa1f3b57c..4cb466d09 100644 --- a/src/Plugins/BotSharp.Plugin.OpenAI/Using.cs +++ b/src/Plugins/BotSharp.Plugin.OpenAI/Using.cs @@ -39,4 +39,5 @@ global using BotSharp.Plugin.OpenAI.Models.Text; global using BotSharp.Plugin.OpenAI.Models.Realtime; global using BotSharp.Plugin.OpenAI.Models.Image; +global using BotSharp.Plugin.OpenAI.Models.Web; global using BotSharp.Plugin.OpenAI.Settings; \ No newline at end of file