diff --git a/BotSharp.sln b/BotSharp.sln index f6a6f5d93..e502b4b12 100644 --- a/BotSharp.sln +++ b/BotSharp.sln @@ -1,4 +1,3 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 VisualStudioVersion = 18.0.11217.181 @@ -145,12 +144,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.ChartHandle EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.ExcelHandler", "src\Plugins\BotSharp.Plugin.ExcelHandler\BotSharp.Plugin.ExcelHandler.csproj", "{FC63C875-E880-D8BB-B8B5-978AB7B62983}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.ImageHandler", "src\Plugins\BotSharp.Plugin.ImageHandler\BotSharp.Plugin.ImageHandler.csproj", "{242F2D93-FCCE-4982-8075-F3052ECCA92C}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.GiteeAI", "src\Plugins\BotSharp.Plugin.GiteeAI\BotSharp.Plugin.GiteeAI.csproj", "{50B57066-3267-1D10-0F72-D2F5CC494F2C}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.FuzzySharp", "src\Plugins\BotSharp.Plugin.FuzzySharp\BotSharp.Plugin.FuzzySharp.csproj", "{E7C243B9-E751-B3B4-8F16-95C76CA90D31}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.Membase", "src\Plugins\BotSharp.Plugin.Membase\BotSharp.Plugin.Membase.csproj", "{13223C71-9EAC-9835-28ED-5A4833E6F915}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.ImageHandler", "src\Plugins\BotSharp.Plugin.ImageHandler\BotSharp.Plugin.ImageHandler.csproj", "{C548FDFF-B882-B552-D428-5C8EC4478187}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -615,14 +616,14 @@ Global {FC63C875-E880-D8BB-B8B5-978AB7B62983}.Release|Any CPU.Build.0 = Release|Any CPU {FC63C875-E880-D8BB-B8B5-978AB7B62983}.Release|x64.ActiveCfg = Release|Any CPU {FC63C875-E880-D8BB-B8B5-978AB7B62983}.Release|x64.Build.0 = Release|Any CPU - {242F2D93-FCCE-4982-8075-F3052ECCA92C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {242F2D93-FCCE-4982-8075-F3052ECCA92C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {242F2D93-FCCE-4982-8075-F3052ECCA92C}.Debug|x64.ActiveCfg = Debug|Any CPU - {242F2D93-FCCE-4982-8075-F3052ECCA92C}.Debug|x64.Build.0 = Debug|Any CPU - {242F2D93-FCCE-4982-8075-F3052ECCA92C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {242F2D93-FCCE-4982-8075-F3052ECCA92C}.Release|Any CPU.Build.0 = Release|Any CPU - {242F2D93-FCCE-4982-8075-F3052ECCA92C}.Release|x64.ActiveCfg = Release|Any CPU - {242F2D93-FCCE-4982-8075-F3052ECCA92C}.Release|x64.Build.0 = Release|Any CPU + {50B57066-3267-1D10-0F72-D2F5CC494F2C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50B57066-3267-1D10-0F72-D2F5CC494F2C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50B57066-3267-1D10-0F72-D2F5CC494F2C}.Debug|x64.ActiveCfg = Debug|Any CPU + {50B57066-3267-1D10-0F72-D2F5CC494F2C}.Debug|x64.Build.0 = Debug|Any CPU + {50B57066-3267-1D10-0F72-D2F5CC494F2C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50B57066-3267-1D10-0F72-D2F5CC494F2C}.Release|Any CPU.Build.0 = Release|Any CPU + {50B57066-3267-1D10-0F72-D2F5CC494F2C}.Release|x64.ActiveCfg = Release|Any CPU + {50B57066-3267-1D10-0F72-D2F5CC494F2C}.Release|x64.Build.0 = Release|Any CPU {E7C243B9-E751-B3B4-8F16-95C76CA90D31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E7C243B9-E751-B3B4-8F16-95C76CA90D31}.Debug|Any CPU.Build.0 = Debug|Any CPU {E7C243B9-E751-B3B4-8F16-95C76CA90D31}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -639,6 +640,14 @@ Global {13223C71-9EAC-9835-28ED-5A4833E6F915}.Release|Any CPU.Build.0 = Release|Any CPU {13223C71-9EAC-9835-28ED-5A4833E6F915}.Release|x64.ActiveCfg = Release|Any CPU {13223C71-9EAC-9835-28ED-5A4833E6F915}.Release|x64.Build.0 = Release|Any CPU + {C548FDFF-B882-B552-D428-5C8EC4478187}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C548FDFF-B882-B552-D428-5C8EC4478187}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C548FDFF-B882-B552-D428-5C8EC4478187}.Debug|x64.ActiveCfg = Debug|Any CPU + {C548FDFF-B882-B552-D428-5C8EC4478187}.Debug|x64.Build.0 = Debug|Any CPU + {C548FDFF-B882-B552-D428-5C8EC4478187}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C548FDFF-B882-B552-D428-5C8EC4478187}.Release|Any CPU.Build.0 = Release|Any CPU + {C548FDFF-B882-B552-D428-5C8EC4478187}.Release|x64.ActiveCfg = Release|Any CPU + {C548FDFF-B882-B552-D428-5C8EC4478187}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -709,9 +718,10 @@ Global {B067B126-88CD-4282-BEEF-7369B64423EF} = {32FAFFFE-A4CB-4FEE-BF7C-84518BBC6DCC} {0428DEAA-E4FE-4259-A6D8-6EDD1A9D0702} = {51AFE054-AE99-497D-A593-69BAEFB5106F} {FC63C875-E880-D8BB-B8B5-978AB7B62983} = {51AFE054-AE99-497D-A593-69BAEFB5106F} - {242F2D93-FCCE-4982-8075-F3052ECCA92C} = {51AFE054-AE99-497D-A593-69BAEFB5106F} + {50B57066-3267-1D10-0F72-D2F5CC494F2C} = {D5293208-2BEF-42FC-A64C-5954F61720BA} {E7C243B9-E751-B3B4-8F16-95C76CA90D31} = {51AFE054-AE99-497D-A593-69BAEFB5106F} {13223C71-9EAC-9835-28ED-5A4833E6F915} = {53E7CD86-0D19-40D9-A0FA-AB4613837E89} + {C548FDFF-B882-B552-D428-5C8EC4478187} = {51AFE054-AE99-497D-A593-69BAEFB5106F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A9969D89-C98B-40A5-A12B-FC87E55B3A19} diff --git a/Directory.Packages.props b/Directory.Packages.props index aae7921bf..84e09a1fa 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,11 @@ true + + + + + @@ -33,7 +38,7 @@ - + @@ -100,7 +105,8 @@ - + + @@ -165,7 +171,7 @@ - + @@ -190,4 +196,4 @@ - + \ No newline at end of file diff --git a/src/BotSharp.AppHost/Program.cs b/src/BotSharp.AppHost/Program.cs index 4c54ed11b..444e2ecf3 100644 --- a/src/BotSharp.AppHost/Program.cs +++ b/src/BotSharp.AppHost/Program.cs @@ -2,8 +2,8 @@ var apiService = builder.AddProject("apiservice") .WithExternalHttpEndpoints(); -var mcpService = builder.AddProject("mcpservice") - .WithExternalHttpEndpoints(); +//var mcpService = builder.AddProject("mcpservice") +// .WithExternalHttpEndpoints(); builder.AddNpmApp("BotSharpUI", "../../../BotSharp-UI") .WithReference(apiService) diff --git a/src/BotSharp.AppHost/Properties/launchSettings.json b/src/BotSharp.AppHost/Properties/launchSettings.json index c315179c6..e4b685d27 100644 --- a/src/BotSharp.AppHost/Properties/launchSettings.json +++ b/src/BotSharp.AppHost/Properties/launchSettings.json @@ -8,6 +8,7 @@ "applicationUrl": "https://localhost:17248;http://localhost:15140", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true", "DOTNET_ENVIRONMENT": "Development", "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21247", "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22140" @@ -20,6 +21,7 @@ "applicationUrl": "http://localhost:15140", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true", "DOTNET_ENVIRONMENT": "Development", "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19185", "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20069" diff --git a/src/BotSharp.ServiceDefaults/BotSharp.ServiceDefaults.csproj b/src/BotSharp.ServiceDefaults/BotSharp.ServiceDefaults.csproj index 78ccdc056..f64f850b0 100644 --- a/src/BotSharp.ServiceDefaults/BotSharp.ServiceDefaults.csproj +++ b/src/BotSharp.ServiceDefaults/BotSharp.ServiceDefaults.csproj @@ -1,4 +1,4 @@ - + $(TargetFramework) @@ -21,6 +21,7 @@ + diff --git a/src/BotSharp.ServiceDefaults/Extensions.cs b/src/BotSharp.ServiceDefaults/Extensions.cs index bfc0bb687..b4790f3e7 100644 --- a/src/BotSharp.ServiceDefaults/Extensions.cs +++ b/src/BotSharp.ServiceDefaults/Extensions.cs @@ -1,12 +1,12 @@ +using Langfuse.OpenTelemetry; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.ServiceDiscovery; -using OpenTelemetry; using OpenTelemetry.Logs; using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; using OpenTelemetry.Trace; using Serilog; @@ -45,6 +45,10 @@ public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBu public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) { + // Enable model diagnostics with sensitive data. + AppContext.SetSwitch("BotSharp.Experimental.GenAI.EnableOTelDiagnostics", true); + AppContext.SetSwitch("BotSharp.Experimental.GenAI.EnableOTelDiagnosticsSensitive", true); + builder.Logging.AddOpenTelemetry(logging => { // Use Serilog Log.Logger = new LoggerConfiguration() @@ -87,10 +91,29 @@ public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicati }) .WithTracing(tracing => { - tracing.AddAspNetCoreInstrumentation() + tracing.SetResourceBuilder( + ResourceBuilder.CreateDefault() + .AddService("apiservice", serviceVersion: "1.0.0") + ) + .AddSource("BotSharp") + .AddSource("BotSharp.Server") + .AddSource("BotSharp.Abstraction.Diagnostics") + .AddSource("BotSharp.Core.Routing.Executor") + .AddLangfuseExporter(builder.Configuration) + .AddAspNetCoreInstrumentation() // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) //.AddGrpcClientInstrumentation() - .AddHttpClientInstrumentation(); + .AddHttpClientInstrumentation() + //.AddOtlpExporter(options => + //{ + // //options.Endpoint = new Uri(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"] ?? "http://localhost:4317"); + // options.Endpoint = new Uri(host); + // options.Protocol = OtlpExportProtocol.HttpProtobuf; + // options.Headers = $"Authorization=Basic {base64EncodedAuth}"; + //}) + ; + + }); builder.AddOpenTelemetryExporters(); @@ -100,6 +123,8 @@ public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicati private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) { + var langfuseSection = builder.Configuration.GetSection("Langfuse"); + var useLangfuse = langfuseSection != null; var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); if (useOtlpExporter) @@ -107,15 +132,9 @@ private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostAppli builder.Services.Configure(logging => logging.AddOtlpExporter()); builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); - + } - - // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) - //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) - //{ - // builder.Services.AddOpenTelemetry() - // .UseAzureMonitor(); - //} + return builder; } diff --git a/src/Infrastructure/BotSharp.Abstraction/BotSharp.Abstraction.csproj b/src/Infrastructure/BotSharp.Abstraction/BotSharp.Abstraction.csproj index 37f1f2520..761d60372 100644 --- a/src/Infrastructure/BotSharp.Abstraction/BotSharp.Abstraction.csproj +++ b/src/Infrastructure/BotSharp.Abstraction/BotSharp.Abstraction.csproj @@ -33,6 +33,11 @@ + + + + + + - diff --git a/src/Infrastructure/BotSharp.Abstraction/Diagnostics/ActivityExtensions.cs b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/ActivityExtensions.cs new file mode 100644 index 000000000..11c44a987 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/ActivityExtensions.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using BotSharp.Abstraction.Diagnostics.Telemetry; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace BotSharp.Abstraction.Diagnostics; + +/// +/// Model diagnostics helper class that provides a set of methods to trace model activities with the OTel semantic conventions. +/// This class contains experimental features and may change in the future. +/// To enable these features, set one of the following switches to true: +/// `BotSharp.Experimental.GenAI.EnableOTelDiagnostics` +/// `BotSharp.Experimental.GenAI.EnableOTelDiagnosticsSensitive` +/// Or set the following environment variables to true: +/// `BOTSHARP_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS` +/// `BOTSHARP_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE` +/// +[ExcludeFromCodeCoverage] +public static class ActivityExtensions +{ + private const string EnableDiagnosticsSwitch = "BotSharp.Experimental.GenAI.EnableOTelDiagnostics"; + private const string EnableSensitiveEventsSwitch = "BotSharp.Experimental.GenAI.EnableOTelDiagnosticsSensitive"; + private const string EnableDiagnosticsEnvVar = "BOTSHARP_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS"; + private const string EnableSensitiveEventsEnvVar = "BOTSHARP_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE"; + + public static readonly bool s_enableDiagnostics = AppContextSwitchHelper.GetConfigValue(EnableDiagnosticsSwitch, EnableDiagnosticsEnvVar); + public static readonly bool s_enableSensitiveEvents = AppContextSwitchHelper.GetConfigValue(EnableSensitiveEventsSwitch, EnableSensitiveEventsEnvVar); + + + /// + /// Starts an activity with the appropriate tags for a kernel function execution. + /// + public static Activity? StartFunctionActivity(this ActivitySource source, string functionName, string functionDescription) + { + const string OperationName = "execute_tool"; + + return source.StartActivityWithTags($"{OperationName} {functionName}", [ + new KeyValuePair(TelemetryConstants.ModelDiagnosticsTags.Operation, OperationName), + new KeyValuePair(TelemetryConstants.ModelDiagnosticsTags.ToolName, functionName), + new KeyValuePair(TelemetryConstants.ModelDiagnosticsTags.ToolDescription, functionDescription) + ], ActivityKind.Internal); + } + + /// + /// Starts an activity with the specified name and tags. + /// + public static Activity? StartActivityWithTags(this ActivitySource source, string name, IEnumerable> tags, ActivityKind kind = ActivityKind.Internal) + => source.StartActivity(name, kind, default(ActivityContext), tags); + + /// + /// Adds tags to the activity. + /// + public static Activity SetTags(this Activity activity, ReadOnlySpan> tags) + { + foreach (var tag in tags) + { + activity.SetTag(tag.Key, tag.Value); + } + return activity; + } + + /// + /// Adds an event to the activity. Should only be used for events that contain sensitive data. + /// + public static Activity AttachSensitiveDataAsEvent(this Activity activity, string name, IEnumerable> tags) + { + activity.AddEvent(new ActivityEvent( + name, + tags: [.. tags] + )); + + return activity; + } + + /// + /// Sets the error status and type on the activity. + /// + public static Activity SetError(this Activity activity, Exception exception) + { + activity.SetTag("error.type", exception.GetType().FullName); + activity.SetStatus(ActivityStatusCode.Error, exception.Message); + return activity; + } +} diff --git a/src/Infrastructure/BotSharp.Abstraction/Diagnostics/AppContextSwitchHelper.cs b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/AppContextSwitchHelper.cs new file mode 100644 index 000000000..2add23728 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/AppContextSwitchHelper.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace BotSharp.Abstraction.Diagnostics; + +/// +/// Helper class to get app context switch value +/// +[ExcludeFromCodeCoverage] +internal static class AppContextSwitchHelper +{ + /// + /// Returns the value of the specified app switch or environment variable if it is set. + /// If the switch or environment variable is not set, return false. + /// The app switch value takes precedence over the environment variable. + /// + /// The name of the app switch. + /// The name of the environment variable. + /// The value of the app switch or environment variable if it is set; otherwise, false. + public static bool GetConfigValue(string appContextSwitchName, string envVarName) + { + if (AppContext.TryGetSwitch(appContextSwitchName, out bool value)) + { + return value; + } + + string? envVarValue = Environment.GetEnvironmentVariable(envVarName); + if (envVarValue != null && bool.TryParse(envVarValue, out value)) + { + return value; + } + + return false; + } +} diff --git a/src/Infrastructure/BotSharp.Abstraction/Diagnostics/BotSharpOTelOptions.cs b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/BotSharpOTelOptions.cs new file mode 100644 index 000000000..72c1a03d2 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/BotSharpOTelOptions.cs @@ -0,0 +1,12 @@ +namespace BotSharp.Abstraction.Diagnostics; + +public class BotSharpOTelOptions +{ + public const string DefaultName = "BotSharp.Server"; + + public string Name { get; set; } = DefaultName; + + public string Version { get; set; } = "4.0.0"; + + public bool IsTelemetryEnabled { get; set; } = true; +} diff --git a/src/Infrastructure/BotSharp.Abstraction/Diagnostics/EnvironmentConfigLoader.cs b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/EnvironmentConfigLoader.cs new file mode 100644 index 000000000..39fba4d40 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/EnvironmentConfigLoader.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.Configuration; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BotSharp.Abstraction.Diagnostics; + +internal static class EnvironmentConfigLoader +{ + private const string DefaultBaseUrl = "https://cloud.langfuse.com"; + + private const string EnvTelemetry = "BOTSHARP_COLLECT_TELEMETRY"; + + + /// + /// Loads configuration from environment variables and applies defaults. + /// + public static BotSharpOTelOptions LoadFromEnvironment(IConfiguration? configuration = null) + { + var options = new BotSharpOTelOptions(); + + // Try configuration first (appsettings.json, etc.) + if (configuration != null) + { + if (bool.TryParse(configuration["Otel:IsTelemetryEnabled"], out bool istelemetryEnabled)) + { + options.IsTelemetryEnabled = istelemetryEnabled; + } + } + + var collectTelemetry = Environment.GetEnvironmentVariable(EnvTelemetry); + if (!string.IsNullOrWhiteSpace(collectTelemetry)) + { + options.IsTelemetryEnabled = bool.TryParse(collectTelemetry, out var shouldCollect) && shouldCollect; + } + + + return options; + } + + /// + /// Validates that required options are set. + /// + public static void Validate(BotSharpOTelOptions options) + { + if (string.IsNullOrWhiteSpace(options.Name)) + { + throw new InvalidOperationException( + $"Otel name is required. Set it via code or configuration."); + } + + } + +} \ No newline at end of file diff --git a/src/Infrastructure/BotSharp.Abstraction/Diagnostics/OpenTelemetryExtensions.cs b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/OpenTelemetryExtensions.cs new file mode 100644 index 000000000..390c0e1ae --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/OpenTelemetryExtensions.cs @@ -0,0 +1,55 @@ +using BotSharp.Abstraction.Diagnostics.Telemetry; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; + +namespace BotSharp.Abstraction.Diagnostics; + +public static class OpenTelemetryExtensions +{ + public static void AddOpenTelemetry(this IServiceCollection services, + IConfiguration configure) + { + // Load from environment first + var options = EnvironmentConfigLoader.LoadFromEnvironment(configure); + + // Validate configuration + EnvironmentConfigLoader.Validate(options); + + services.Configure(cfg => + { + cfg.Name = options.Name; + cfg.Version = _assemblyVersion.Value; + cfg.IsTelemetryEnabled = options.IsTelemetryEnabled; + }); + + services.AddSingleton(); + services.AddSingleton(); + + } + + /// + /// Align with --version command. + /// https://github.com/dotnet/command-line-api/blob/bcdd4b9b424f0ff6ec855d08665569061a5d741f/src/System.CommandLine/Builder/CommandLineBuilderExtensions.cs#L23-L39 + /// + private static readonly Lazy _assemblyVersion = new(() => + { + var assembly = Assembly.GetEntryAssembly(); + + if (assembly == null) + { + throw new InvalidOperationException("Should be able to get entry assembly."); + } + + var assemblyVersionAttribute = assembly.GetCustomAttribute(); + + if (assemblyVersionAttribute is null) + { + return assembly.GetName().Version?.ToString() ?? ""; + } + else + { + return assemblyVersionAttribute.InformationalVersion; + } + }); +} \ No newline at end of file diff --git a/src/Infrastructure/BotSharp.Abstraction/Diagnostics/Telemetry/IMachineInformationProvider.cs b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/Telemetry/IMachineInformationProvider.cs new file mode 100644 index 000000000..74ee63621 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/Telemetry/IMachineInformationProvider.cs @@ -0,0 +1,15 @@ +namespace BotSharp.Abstraction.Diagnostics.Telemetry; + +public interface IMachineInformationProvider +{ + /// + /// Gets existing or creates the device id. In case the cached id cannot be retrieved, or the + /// newly generated id cannot be cached, a value of null is returned. + /// + Task GetOrCreateDeviceId(); + + /// + /// Gets a hash of the machine's MAC address. + /// + Task GetMacAddressHash(); +} diff --git a/src/Infrastructure/BotSharp.Abstraction/Diagnostics/Telemetry/ITelemetryService.cs b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/Telemetry/ITelemetryService.cs new file mode 100644 index 000000000..89e3f5966 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/Telemetry/ITelemetryService.cs @@ -0,0 +1,66 @@ +using BotSharp.Abstraction.Conversations; +using ModelContextProtocol.Protocol; +using System.Diagnostics; + +namespace BotSharp.Abstraction.Diagnostics.Telemetry; + +public interface ITelemetryService : IDisposable +{ + ActivitySource Parent { get; } + + /// + /// Creates and starts a new telemetry activity. + /// + /// Name of the activity. + /// An Activity object or null if there are no active listeners or telemetry is disabled. + /// If the service is not in an operational state or was not invoked. + Activity? StartActivity(string activityName); + + /// + /// Creates and starts a new telemetry activity. + /// + /// Name of the activity. + /// MCP client information to add to the activity. + /// An Activity object or null if there are no active listeners or telemetry is disabled. + /// If the service is not in an operational state or was not invoked. + Activity? StartActivity(string activityName, Implementation? clientInfo); + + /// + /// Creates and starts a new telemetry activity + /// + /// + /// + /// + /// + /// + /// + Activity? StartTextCompletionActivity(Uri? endpoint, string modelName, string modelProvider, string prompt, IConversationStateService services); + + /// + /// Creates and starts a new telemetry activity + /// + /// + /// + /// + /// + /// + /// + Activity? StartCompletionActivity(Uri? endpoint, string modelName, string modelProvider, List chatHistory, IConversationStateService conversationStateService); + + /// + /// Creates and starts a new telemetry activity + /// + /// + /// + /// + /// + /// + /// + Activity? StartAgentInvocationActivity(string agentId, string agentName, string? agentDescription, Agent? agents, List messages); + + /// + /// Performs any initialization operations before telemetry service is ready. + /// + /// A task that completes when initialization is complete. + Task InitializeAsync(); +} \ No newline at end of file diff --git a/src/Infrastructure/BotSharp.Abstraction/Diagnostics/Telemetry/MachineInformationProvider.cs b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/Telemetry/MachineInformationProvider.cs new file mode 100644 index 000000000..9cd954f56 --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/Telemetry/MachineInformationProvider.cs @@ -0,0 +1,83 @@ +using DeviceId; +using Microsoft.Extensions.Logging; +using System.Net.NetworkInformation; +using System.Security.Cryptography; + +namespace BotSharp.Abstraction.Diagnostics.Telemetry; + +public class MachineInformationProvider(ILogger logger) + : IMachineInformationProvider +{ + protected const string NotAvailable = "N/A"; + + private static readonly SHA256 s_sHA256 = SHA256.Create(); + + private readonly ILogger _logger = logger; + + /// + /// + /// + public async Task GetOrCreateDeviceId() + { + string deviceId = new DeviceIdBuilder() + .AddMachineName() + .AddOsVersion() + .OnWindows(windows => windows + .AddProcessorId() + .AddMotherboardSerialNumber() + .AddSystemDriveSerialNumber()) + .OnLinux(linux => linux + .AddMotherboardSerialNumber() + .AddSystemDriveSerialNumber()) + .OnMac(mac => mac + .AddSystemDriveSerialNumber() + .AddPlatformSerialNumber()) + .ToString(); + + return deviceId; + } + + /// + /// + /// + public virtual Task GetMacAddressHash() + { + return Task.Run(() => + { + try + { + var address = GetMacAddress(); + + return address != null + ? HashValue(address) + : NotAvailable; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to calculate MAC address hash."); + return NotAvailable; + } + }); + } + + /// + /// Searches for first network interface card that is up and has a physical address. + /// + /// Hash of the MAC address or if none can be found. + protected virtual string? GetMacAddress() + { + return NetworkInterface.GetAllNetworkInterfaces() + .Where(x => x.OperationalStatus == OperationalStatus.Up && x.NetworkInterfaceType != NetworkInterfaceType.Loopback) + .Select(x => x.GetPhysicalAddress().ToString()) + .FirstOrDefault(x => !string.IsNullOrEmpty(x)); + } + + /// + /// Generates a SHA-256 of the given value. + /// + protected string HashValue(string value) + { + var hashInput = s_sHA256.ComputeHash(Encoding.UTF8.GetBytes(value)); + return BitConverter.ToString(hashInput).Replace("-", string.Empty).ToLowerInvariant(); + } +} diff --git a/src/Infrastructure/BotSharp.Abstraction/Diagnostics/Telemetry/TelemetryConstants.cs b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/Telemetry/TelemetryConstants.cs new file mode 100644 index 000000000..88433d8ce --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/Telemetry/TelemetryConstants.cs @@ -0,0 +1,98 @@ +namespace BotSharp.Abstraction.Diagnostics.Telemetry; + +public static class TelemetryConstants +{ + /// + /// Name of tags published. + /// + public static class TagName + { + public const string BotSharpVersion = "Version"; + public const string ClientName = "ClientName"; + public const string ClientVersion = "ClientVersion"; + public const string DevDeviceId = "DevDeviceId"; + public const string ErrorDetails = "ErrorDetails"; + public const string EventId = "EventId"; + public const string MacAddressHash = "MacAddressHash"; + public const string ToolName = "ToolName"; + public const string ToolArea = "ToolArea"; + public const string ServerMode = "ServerMode"; + public const string IsServerCommandInvoked = "IsServerCommandInvoked"; + public const string Transport = "Transport"; + public const string IsReadOnly = "IsReadOnly"; + public const string Namespace = "Namespace"; + public const string ToolCount = "ToolCount"; + public const string InsecureDisableElicitation = "InsecureDisableElicitation"; + public const string IsDebug = "IsDebug"; + public const string EnableInsecureTransports = "EnableInsecureTransports"; + public const string Tool = "Tool"; + } + + public static class ActivityName + { + public const string ListToolsHandler = "ListToolsHandler"; + public const string ToolExecuted = "ToolExecuted"; + public const string ServerStarted = "ServerStarted"; + } + + /// + /// 工具输入输出参数键常量类 + /// + public static class ToolParameterKeys + { + /// + /// 输入参数键 + /// + public const string Input = "input"; + + /// + /// 输出参数键 + /// + public const string Output = "output"; + } + + /// + /// Tags used in model diagnostics + /// + public static class ModelDiagnosticsTags + { + // Activity tags + public const string System = "gen_ai.system"; + public const string Operation = "gen_ai.operation.name"; + public const string Model = "gen_ai.request.model"; + public const string MaxToken = "gen_ai.request.max_tokens"; + public const string Temperature = "gen_ai.request.temperature"; + public const string TopP = "gen_ai.request.top_p"; + public const string ResponseId = "gen_ai.response.id"; + public const string ResponseModel = "gen_ai.response.model"; + public const string FinishReason = "gen_ai.response.finish_reason"; + public const string InputTokens = "gen_ai.usage.input_tokens"; + public const string OutputTokens = "gen_ai.usage.output_tokens"; + public const string Address = "server.address"; + public const string Port = "server.port"; + public const string AgentId = "gen_ai.agent.id"; + public const string AgentName = "gen_ai.agent.name"; + public const string AgentDescription = "gen_ai.agent.description"; + public const string AgentInvocationInput = "gen_ai.input.messages"; + public const string AgentInvocationOutput = "gen_ai.output.messages"; + public const string AgentToolDefinitions = "gen_ai.tool.definitions"; + + // Activity events + public const string EventName = "gen_ai.event.content"; + public const string SystemMessage = "gen_ai.system.message"; + public const string UserMessage = "gen_ai.user.message"; + public const string AssistantMessage = "gen_ai.assistant.message"; + public const string ToolName = "gen_ai.tool.name"; + public const string ToolMessage = "gen_ai.tool.message"; + public const string ToolDescription = "gen_ai.tool.description"; + public const string Choice = "gen_ai.choice"; + + public static readonly Dictionary RoleToEventMap = new() + { + { AgentRole.System, SystemMessage }, + { AgentRole.User, UserMessage }, + { AgentRole.Assistant, AssistantMessage }, + { AgentRole.Function, ToolMessage } + }; + } +} diff --git a/src/Infrastructure/BotSharp.Abstraction/Diagnostics/Telemetry/TelemetryService.cs b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/Telemetry/TelemetryService.cs new file mode 100644 index 000000000..f1414daef --- /dev/null +++ b/src/Infrastructure/BotSharp.Abstraction/Diagnostics/Telemetry/TelemetryService.cs @@ -0,0 +1,378 @@ +using BotSharp.Abstraction.Conversations; +using BotSharp.Abstraction.Functions.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using System.Diagnostics; +using System.Text.Json; +using System.Threading; + +namespace BotSharp.Abstraction.Diagnostics.Telemetry; + +public class TelemetryService : ITelemetryService +{ + private readonly IMachineInformationProvider _informationProvider; + private readonly bool _isEnabled; + private readonly ILogger _logger; + private readonly List> _tagsList; + private readonly SemaphoreSlim _initalizeLock = new(1); + + /// + /// Task created on the first invocation of . + /// This is saved so that repeated invocations will see the same exception + /// as the first invocation. + /// + private Task? _initalizationTask = null; + + private bool _initializationSuccessful; + private bool _isInitialized; + + public ActivitySource Parent { get; } + + public TelemetryService(IMachineInformationProvider informationProvider, + IOptions options, + ILogger logger) + { + _isEnabled = options.Value.IsTelemetryEnabled; + _tagsList = + [ + new(TelemetryConstants.TagName.BotSharpVersion, options.Value.Version), + ]; + + + Parent = new ActivitySource(options.Value.Name, options.Value.Version); + _informationProvider = informationProvider; + _logger = logger; + } + + /// + /// TESTING PURPOSES ONLY: Gets the default tags used for telemetry. + /// + internal IReadOnlyList> GetDefaultTags() + { + if (!_isEnabled) + { + return []; + } + + CheckInitialization(); + return [.. _tagsList]; + } + + /// + /// + /// + public Activity? StartActivity(string activityId) => StartActivity(activityId, null); + + /// + /// + /// + public Activity? StartActivity(string activityId, Implementation? clientInfo) + { + if (!_isEnabled) + { + return null; + } + + CheckInitialization(); + + var activity = Parent.StartActivity(activityId); + + if (activity == null) + { + return activity; + } + + if (clientInfo != null) + { + activity.AddTag(TelemetryConstants.TagName.ClientName, clientInfo.Name) + .AddTag(TelemetryConstants.TagName.ClientVersion, clientInfo.Version); + } + + activity.AddTag(TelemetryConstants.TagName.EventId, Guid.NewGuid().ToString()); + + _tagsList.ForEach(kvp => activity.AddTag(kvp.Key, kvp.Value)); + + return activity; + } + + public Activity? StartTextCompletionActivity(Uri? endpoint, string modelName, string modelProvider, string prompt, IConversationStateService services) + { + if (!IsModelDiagnosticsEnabled()) + { + return null; + } + + const string OperationName = "text.completions"; + var activity = Parent.StartActivityWithTags( + $"{OperationName} {modelName}", + [ + new(TelemetryConstants.ModelDiagnosticsTags.Operation, OperationName), + new(TelemetryConstants.ModelDiagnosticsTags.System, modelProvider), + new(TelemetryConstants.ModelDiagnosticsTags.Model, modelName), + ], + ActivityKind.Client); + + if (endpoint is not null) + { + activity?.SetTags([ + // Skip the query string in the uri as it may contain keys + new(TelemetryConstants.ModelDiagnosticsTags.Address, endpoint.GetLeftPart(UriPartial.Path)), + new(TelemetryConstants.ModelDiagnosticsTags.Port, endpoint.Port), + ]); + } + + AddOptionalTags(activity, services); + + if (ActivityExtensions.s_enableSensitiveEvents) + { + activity?.AttachSensitiveDataAsEvent( + TelemetryConstants.ModelDiagnosticsTags.UserMessage, + [ + new(TelemetryConstants.ModelDiagnosticsTags.EventName, prompt), + new(TelemetryConstants.ModelDiagnosticsTags.System, modelProvider), + ]); + } + + return activity; + } + + public Activity? StartCompletionActivity(Uri? endpoint, string modelName, string modelProvider, List chatHistory, IConversationStateService conversationStateService) + { + if (!IsModelDiagnosticsEnabled()) + { + return null; + } + + const string OperationName = "chat.completions"; + var activity = Parent.StartActivityWithTags( + $"{OperationName} {modelName}", + [ + new(TelemetryConstants.ModelDiagnosticsTags.Operation, OperationName), + new(TelemetryConstants.ModelDiagnosticsTags.System, modelProvider), + new(TelemetryConstants.ModelDiagnosticsTags.Model, modelName), + ], + ActivityKind.Client); + + if (endpoint is not null) + { + activity?.SetTags([ + // Skip the query string in the uri as it may contain keys + new(TelemetryConstants.ModelDiagnosticsTags.Address, endpoint.GetLeftPart(UriPartial.Path)), + new(TelemetryConstants.ModelDiagnosticsTags.Port, endpoint.Port), + ]); + } + + AddOptionalTags(activity, conversationStateService); + + if (ActivityExtensions.s_enableSensitiveEvents) + { + foreach (var message in chatHistory) + { + var formattedContent = JsonSerializer.Serialize(ToGenAIConventionsFormat(message)); + activity?.AttachSensitiveDataAsEvent( + TelemetryConstants.ModelDiagnosticsTags.RoleToEventMap[message.Role], + [ + new(TelemetryConstants.ModelDiagnosticsTags.EventName, formattedContent), + new(TelemetryConstants.ModelDiagnosticsTags.System, modelProvider), + ]); + } + } + + return activity; + } + + public Activity? StartAgentInvocationActivity(string agentId, string agentName, string? agentDescription, Agent? agents, List messages) + { + if (!IsModelDiagnosticsEnabled()) + { + return null; + } + + const string OperationName = "invoke_agent"; + + var activity = Parent.StartActivityWithTags( + $"{OperationName} {agentName}", + [ + new(TelemetryConstants.ModelDiagnosticsTags.Operation, OperationName), + new(TelemetryConstants.ModelDiagnosticsTags.AgentId, agentId), + new(TelemetryConstants.ModelDiagnosticsTags.AgentName, agentName) + ], + ActivityKind.Internal); + + if (!string.IsNullOrWhiteSpace(agentDescription)) + { + activity?.SetTag(TelemetryConstants.ModelDiagnosticsTags.AgentDescription, agentDescription); + } + + if (agents is not null && (agents.Functions.Count > 0 || agents.SecondaryFunctions.Count > 0)) + { + List allFunctions = []; + allFunctions.AddRange(agents.Functions); + allFunctions.AddRange(agents.SecondaryFunctions); + + activity?.SetTag( + TelemetryConstants.ModelDiagnosticsTags.AgentToolDefinitions, + JsonSerializer.Serialize(messages.Select(m => ToGenAIConventionsFormat(m)))); + } + + if (IsSensitiveEventsEnabled()) + { + activity?.SetTag( + TelemetryConstants.ModelDiagnosticsTags.AgentInvocationInput, + JsonSerializer.Serialize(messages.Select(m => ToGenAIConventionsFormat(m)))); + } + + return activity; + } + + + public void Dispose() + { + + } + + /// + /// + /// + public async Task InitializeAsync() + { + if (!_isEnabled) + { + return; + } + + // Quick check if initialization already happened. Avoids + // trying to get the lock. + if (_initalizationTask == null) + { + // Get async lock for starting initialization + await _initalizeLock.WaitAsync(); + + try + { + // Check after acquiring lock to ensure we honor work + // started while we were waiting. + if (_initalizationTask == null) + { + _initalizationTask = InnerInitializeAsync(); + } + } + finally + { + _initalizeLock.Release(); + } + } + + // Await the response of the initialization work regardless of if + // we or another invocation created the Task representing it. All + // awaiting on this will give the same result to ensure idempotency. + await _initalizationTask; + + async Task InnerInitializeAsync() + { + try + { + var macAddressHash = await _informationProvider.GetMacAddressHash(); + var deviceId = await _informationProvider.GetOrCreateDeviceId(); + + _tagsList.Add(new(TelemetryConstants.TagName.MacAddressHash, macAddressHash)); + _tagsList.Add(new(TelemetryConstants.TagName.DevDeviceId, deviceId)); + + _initializationSuccessful = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred initializing telemetry service."); + throw; + } + finally + { + _isInitialized = true; + } + } + } + + private void CheckInitialization() + { + if (!_isInitialized) + { + throw new InvalidOperationException( + $"Telemetry service has not been initialized. Use {nameof(InitializeAsync)}() before any other operations."); + } + + if (!_initializationSuccessful) + { + throw new InvalidOperationException("Telemetry service was not successfully initialized. Check logs for initialization errors."); + } + + } + + internal bool IsModelDiagnosticsEnabled() + { + return (ActivityExtensions.s_enableDiagnostics || ActivityExtensions.s_enableSensitiveEvents) && Parent.HasListeners(); + } + + /// + /// Check if sensitive events are enabled. + /// Sensitive events are enabled if EnableSensitiveEvents is set to true and there are listeners. + /// + internal bool IsSensitiveEventsEnabled() => ActivityExtensions.s_enableSensitiveEvents && Parent.HasListeners(); + + private static void AddOptionalTags(Activity? activity, IConversationStateService conversationStateService) + { + if (activity is null) + { + return; + } + + void TryAddTag(string key, string tag) + { + var value = conversationStateService.GetState(key); + if (!string.IsNullOrEmpty(value)) + { + activity.SetTag(tag, value); + } + } + + TryAddTag("max_tokens", TelemetryConstants.ModelDiagnosticsTags.MaxToken); + TryAddTag("temperature", TelemetryConstants.ModelDiagnosticsTags.Temperature); + TryAddTag("top_p", TelemetryConstants.ModelDiagnosticsTags.TopP); + } + + /// + /// Convert a chat message to a JSON object based on the OTel GenAI Semantic Conventions format + /// + private static object ToGenAIConventionsFormat(RoleDialogModel chatMessage) + { + return new + { + role = chatMessage.Role.ToString(), + name = chatMessage.MessageId, + content = chatMessage.Content, + tool_calls = ToGenAIConventionsToolCallFormat(chatMessage), + }; + } + + /// + /// Helper method to convert tool calls to a list of JSON object based on the OTel GenAI Semantic Conventions format + /// + private static List ToGenAIConventionsToolCallFormat(RoleDialogModel chatMessage) + { + List toolCalls = []; + if (chatMessage.Instruction is not null) + { + toolCalls.Add(new + { + id = chatMessage.ToolCallId, + function = new + { + name = chatMessage.Instruction.Function, + arguments = chatMessage.Instruction.Arguments + }, + type = "function" + }); + } + return toolCalls; + } +} \ No newline at end of file diff --git a/src/Infrastructure/BotSharp.Core/BotSharpCoreExtensions.cs b/src/Infrastructure/BotSharp.Core/BotSharpCoreExtensions.cs index 9c34de599..eaa76bef3 100644 --- a/src/Infrastructure/BotSharp.Core/BotSharpCoreExtensions.cs +++ b/src/Infrastructure/BotSharp.Core/BotSharpCoreExtensions.cs @@ -16,6 +16,7 @@ using BotSharp.Abstraction.Infrastructures.Enums; using BotSharp.Abstraction.Realtime; using BotSharp.Abstraction.Repositories.Settings; +using BotSharp.Abstraction.Diagnostics; namespace BotSharp.Core; @@ -31,7 +32,7 @@ public static IServiceCollection AddBotSharpCore(this IServiceCollection service services.AddScoped(); services.AddScoped(); services.AddScoped(); - + services.AddOpenTelemetry(config); AddRedisEvents(services, config); // Register cache service AddCacheServices(services, config); diff --git a/src/Infrastructure/BotSharp.Core/MCP/Managers/McpClientManager.cs b/src/Infrastructure/BotSharp.Core/MCP/Managers/McpClientManager.cs index 9a06a85b8..c0a46c15d 100644 --- a/src/Infrastructure/BotSharp.Core/MCP/Managers/McpClientManager.cs +++ b/src/Infrastructure/BotSharp.Core/MCP/Managers/McpClientManager.cs @@ -30,13 +30,15 @@ public McpClientManager( IClientTransport? transport = null; if (config.SseConfig != null) { - transport = new HttpClientTransport(new HttpClientTransportOptions - { - Name = config.Name, - Endpoint = new Uri(config.SseConfig.EndPoint), - AdditionalHeaders = config.SseConfig.AdditionalHeaders, - ConnectionTimeout = config.SseConfig.ConnectionTimeout - }); + transport = new HttpClientTransport( + new HttpClientTransportOptions + { + Endpoint = new Uri(config.SseConfig.EndPoint), + TransportMode = HttpTransportMode.AutoDetect, + Name = config.Name, + ConnectionTimeout = config.SseConfig.ConnectionTimeout, + AdditionalHeaders = config.SseConfig.AdditionalHeaders + }); } else if (config.StdioConfig != null) { @@ -62,7 +64,7 @@ public McpClientManager( _logger.LogWarning(ex, $"Error when loading mcp client {serverId}"); return null; } - } + } public void Dispose() { diff --git a/src/Infrastructure/BotSharp.Core/Routing/Executor/FunctionCallbackExecutor.cs b/src/Infrastructure/BotSharp.Core/Routing/Executor/FunctionCallbackExecutor.cs index 4b208374f..63490d12f 100644 --- a/src/Infrastructure/BotSharp.Core/Routing/Executor/FunctionCallbackExecutor.cs +++ b/src/Infrastructure/BotSharp.Core/Routing/Executor/FunctionCallbackExecutor.cs @@ -1,20 +1,31 @@ -using BotSharp.Abstraction.Routing.Executor; +using BotSharp.Abstraction.Diagnostics; +using BotSharp.Abstraction.Diagnostics.Telemetry; using BotSharp.Abstraction.Functions; +using BotSharp.Abstraction.Routing.Executor; +using System.Diagnostics; +using static BotSharp.Abstraction.Diagnostics.Telemetry.TelemetryConstants; namespace BotSharp.Core.Routing.Executor; public class FunctionCallbackExecutor : IFunctionExecutor { private readonly IFunctionCallback _functionCallback; + private readonly ITelemetryService _telemetryService; - public FunctionCallbackExecutor(IFunctionCallback functionCallback) + public FunctionCallbackExecutor(ITelemetryService telemetryService, IFunctionCallback functionCallback) { _functionCallback = functionCallback; + _telemetryService = telemetryService; } public async Task ExecuteAsync(RoleDialogModel message) { - return await _functionCallback.Execute(message); + using var activity = _telemetryService.Parent.StartFunctionActivity(this._functionCallback.Name, this._functionCallback.Indication); + { + activity?.SetTag("input", message.FunctionArgs); + activity?.SetTag(ModelDiagnosticsTags.AgentId, message.CurrentAgentId); + return await _functionCallback.Execute(message); + } } public async Task GetIndicatorAsync(RoleDialogModel message) diff --git a/src/Infrastructure/BotSharp.Core/Routing/Executor/FunctionExecutorFactory.cs b/src/Infrastructure/BotSharp.Core/Routing/Executor/FunctionExecutorFactory.cs index 8a4a54865..516317c2f 100644 --- a/src/Infrastructure/BotSharp.Core/Routing/Executor/FunctionExecutorFactory.cs +++ b/src/Infrastructure/BotSharp.Core/Routing/Executor/FunctionExecutorFactory.cs @@ -1,3 +1,4 @@ +using BotSharp.Abstraction.Diagnostics.Telemetry; using BotSharp.Abstraction.Functions; using BotSharp.Abstraction.Routing.Executor; @@ -7,10 +8,12 @@ internal class FunctionExecutorFactory { public static IFunctionExecutor? Create(IServiceProvider services, string functionName, Agent agent) { + ITelemetryService telemetryService = services.GetRequiredService(); + var functionCall = services.GetServices().FirstOrDefault(x => x.Name == functionName); if (functionCall != null) { - return new FunctionCallbackExecutor(functionCall); + return new FunctionCallbackExecutor( telemetryService,functionCall); } var functions = (agent?.Functions ?? []).Concat(agent?.SecondaryFunctions ?? []); @@ -23,7 +26,7 @@ internal class FunctionExecutorFactory var mcpServerId = agent?.McpTools?.Where(x => x.Functions.Any(y => y.Name == funcDef?.Name))?.FirstOrDefault()?.ServerId; if (!string.IsNullOrWhiteSpace(mcpServerId)) { - return new McpToolExecutor(services, mcpServerId, functionName); + return new McpToolExecutor(services, telemetryService, mcpServerId, functionName); } return null; diff --git a/src/Infrastructure/BotSharp.Core/Routing/Executor/MCPToolExecutor.cs b/src/Infrastructure/BotSharp.Core/Routing/Executor/MCPToolExecutor.cs index 8cf7d18e5..36e7d70df 100644 --- a/src/Infrastructure/BotSharp.Core/Routing/Executor/MCPToolExecutor.cs +++ b/src/Infrastructure/BotSharp.Core/Routing/Executor/MCPToolExecutor.cs @@ -1,6 +1,11 @@ +using BotSharp.Abstraction.Diagnostics; +using BotSharp.Abstraction.Diagnostics.Telemetry; using BotSharp.Abstraction.Routing.Executor; using BotSharp.Core.MCP.Managers; +using ModelContextProtocol; using ModelContextProtocol.Protocol; +using System.Diagnostics; +using static BotSharp.Abstraction.Diagnostics.Telemetry.TelemetryConstants; namespace BotSharp.Core.Routing.Executor; @@ -9,44 +14,51 @@ public class McpToolExecutor : IFunctionExecutor private readonly IServiceProvider _services; private readonly string _mcpServerId; private readonly string _functionName; + private readonly ITelemetryService _telemetryService; + - public McpToolExecutor(IServiceProvider services, string mcpServerId, string functionName) - { + public McpToolExecutor(IServiceProvider services, ITelemetryService telemetryService, string mcpServerId, string functionName) + { _services = services; + _telemetryService = telemetryService; _mcpServerId = mcpServerId; _functionName = functionName; } public async Task ExecuteAsync(RoleDialogModel message) { - try + using var activity = _telemetryService.Parent.StartFunctionActivity(this._functionName, $"calling tool {_functionName} of MCP server {_mcpServerId}"); { - // Convert arguments to dictionary format expected by mcpdotnet - Dictionary argDict = JsonToDictionary(message.FunctionArgs); - - var clientManager = _services.GetRequiredService(); - var client = await clientManager.GetMcpClientAsync(_mcpServerId); - - if (client == null) + try + { + activity?.SetTag("input", message.FunctionArgs); + activity?.SetTag(ModelDiagnosticsTags.AgentId, message.CurrentAgentId); + + // Convert arguments to dictionary format expected by mcpdotnet + Dictionary argDict = JsonToDictionary(message.FunctionArgs); + + var clientManager = _services.GetRequiredService(); + var client = await clientManager.GetMcpClientAsync(_mcpServerId); + + // Call the tool through mcpdotnet + var result = await client.CallToolAsync(_functionName, !argDict.IsNullOrEmpty() ? argDict : []); + + // Extract the text content from the result + var json = string.Join("\n", result.Content + .OfType() + .Where(c => c.Type == "text") + .Select(c => c.Text)); + + message.Content = json; + message.Data = json.JsonContent(); + return true; + } + catch (Exception ex) { - message.Content = $"MCP client for server {_mcpServerId} not found."; + message.Content = $"Error when calling tool {_functionName} of MCP server {_mcpServerId}. {ex.Message}"; + activity?.SetError(ex); return false; } - - // Call the tool through mcpdotnet - var result = await client.CallToolAsync(_functionName, !argDict.IsNullOrEmpty() ? argDict : []); - - // Extract the text content from the result - var json = string.Join("\n", result.Content.Where(c => c is TextContentBlock).Select(c => ((TextContentBlock)c).Text)); - - message.Content = json; - message.Data = json.JsonContent(); - return true; - } - catch (Exception ex) - { - message.Content = $"Error when calling tool {_functionName} of MCP server {_mcpServerId}. {ex.Message}"; - return false; } } diff --git a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs index e0175a70d..6132ee534 100644 --- a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs +++ b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeAgent.cs @@ -1,3 +1,4 @@ +using BotSharp.Abstraction.Diagnostics; using BotSharp.Abstraction.Routing.Models; using BotSharp.Abstraction.Templating; @@ -14,6 +15,8 @@ public async Task InvokeAgent( var agentService = _services.GetRequiredService(); var agent = await agentService.LoadAgent(agentId); + using var activity = _telemetryService.StartAgentInvocationActivity(agentId, agent.Name, agent.Description, agent, dialogs); + Context.IncreaseRecursiveCounter(); if (Context.CurrentRecursionDepth > agent.LlmConfig.MaxRecursionDepth) { @@ -79,7 +82,7 @@ public async Task InvokeAgent( dialogs.Add(message); Context.AddDialogs([message]); } - + return true; } diff --git a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeFunction.cs b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeFunction.cs index 3850dcc13..17cd180a3 100644 --- a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeFunction.cs +++ b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.InvokeFunction.cs @@ -1,3 +1,4 @@ +using BotSharp.Abstraction.Diagnostics; using BotSharp.Abstraction.Routing.Models; using BotSharp.Core.MessageHub; using BotSharp.Core.Routing.Executor; diff --git a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.cs b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.cs index 4e43cbd52..b327daff1 100644 --- a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.cs +++ b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.cs @@ -1,5 +1,7 @@ +using BotSharp.Abstraction.Diagnostics.Telemetry; using BotSharp.Abstraction.Routing.Models; using BotSharp.Abstraction.Routing.Settings; +using System.Diagnostics; namespace BotSharp.Core.Routing; @@ -8,6 +10,7 @@ public partial class RoutingService : IRoutingService private readonly IServiceProvider _services; private readonly RoutingSettings _settings; private readonly IRoutingContext _context; + private readonly ITelemetryService _telemetryService; private readonly ILogger _logger; private Agent _router; @@ -18,11 +21,13 @@ public RoutingService( IServiceProvider services, RoutingSettings settings, IRoutingContext context, + ITelemetryService telemetryService, ILogger logger) { _services = services; _settings = settings; _context = context; + _telemetryService = telemetryService; _logger = logger; } diff --git a/src/Infrastructure/BotSharp.OpenAPI/BotSharpOpenApiExtensions.cs b/src/Infrastructure/BotSharp.OpenAPI/BotSharpOpenApiExtensions.cs index 2a63ae3f7..cc3699891 100644 --- a/src/Infrastructure/BotSharp.OpenAPI/BotSharpOpenApiExtensions.cs +++ b/src/Infrastructure/BotSharp.OpenAPI/BotSharpOpenApiExtensions.cs @@ -1,17 +1,20 @@ +using BotSharp.Abstraction.Diagnostics.Telemetry; using BotSharp.Abstraction.Messaging.JsonConverters; using BotSharp.Core.Users.Services; +using BotSharp.OpenAPI.BackgroundServices; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; +using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Logging; using Microsoft.IdentityModel.Tokens; using Microsoft.Net.Http.Headers; -using Microsoft.IdentityModel.JsonWebTokens; using BotSharp.OpenAPI.BackgroundServices; +using Microsoft.OpenApi.Models; using System.Text.Json.Serialization; -using Microsoft.AspNetCore.Authentication; namespace BotSharp.OpenAPI; @@ -217,6 +220,7 @@ public static IApplicationBuilder UseBotSharpOpenAPI(this IApplicationBuilder ap app.UseAuthorization(); + app.UseOtelInitialize(); app.UseEndpoints( endpoints => { @@ -260,5 +264,17 @@ public static IApplicationBuilder UseBotSharpUI(this IApplicationBuilder app, bo return app; } + + internal static void UseOtelInitialize(this IApplicationBuilder app) + { + // Perform any initialization before starting the service. + // If the initialization operation fails, do not continue because we do not want + // invalid telemetry published. + var telemetryService = app.ApplicationServices.GetRequiredService(); + telemetryService.InitializeAsync() + .ConfigureAwait(false) + .GetAwaiter() + .GetResult(); + } } diff --git a/src/Infrastructure/BotSharp.OpenAPI/Controllers/Conversation/ConversationController.cs b/src/Infrastructure/BotSharp.OpenAPI/Controllers/Conversation/ConversationController.cs index 0931466ac..794ec6775 100644 --- a/src/Infrastructure/BotSharp.OpenAPI/Controllers/Conversation/ConversationController.cs +++ b/src/Infrastructure/BotSharp.OpenAPI/Controllers/Conversation/ConversationController.cs @@ -6,6 +6,8 @@ using BotSharp.Abstraction.Routing; using BotSharp.Abstraction.Users.Dtos; using BotSharp.Core.Infrastructures; +using System.Diagnostics; +using static BotSharp.Abstraction.Diagnostics.Telemetry.TelemetryConstants; namespace BotSharp.OpenAPI.Controllers; @@ -42,8 +44,12 @@ public async Task NewConversation([FromRoute] string agen }; conv = await service.NewConversation(conv); service.SetConversationId(conv.Id, config.States); - - return ConversationViewModel.FromSession(conv); + using (var trace = new ActivitySource("BotSharp").StartActivity("NewUserSession", ActivityKind.Internal)) + { + trace?.SetTag("user_id", _user.FullName); + trace?.SetTag("conversation_id", conv.Id); + return ConversationViewModel.FromSession(conv); + } } [HttpGet("/conversations")] @@ -363,25 +369,34 @@ public async Task SendMessage( conv.SetConversationId(conversationId, input.States); SetStates(conv, input); - var response = new ChatResponseModel(); - await conv.SendMessage(agentId, inputMsg, - replyMessage: input.Postback, - async msg => - { - response.Text = !string.IsNullOrEmpty(msg.SecondaryContent) ? msg.SecondaryContent : msg.Content; - response.Function = msg.FunctionName; - response.MessageLabel = msg.MessageLabel; - response.RichContent = msg.SecondaryRichContent ?? msg.RichContent; - response.Instruction = msg.Instruction; - response.Data = msg.Data; - }); + using (var trace = new ActivitySource("BotSharp").StartActivity("UserSession", ActivityKind.Internal)) + { + trace?.SetTag("user.id", _user.FullName); + trace?.SetTag("session.id", conversationId); + trace?.SetTag("input", inputMsg.Content); + trace?.SetTag(ModelDiagnosticsTags.AgentId, agentId); + + var response = new ChatResponseModel(); + await conv.SendMessage(agentId, inputMsg, + replyMessage: input.Postback, + async msg => + { + response.Text = !string.IsNullOrEmpty(msg.SecondaryContent) ? msg.SecondaryContent : msg.Content; + response.Function = msg.FunctionName; + response.MessageLabel = msg.MessageLabel; + response.RichContent = msg.SecondaryRichContent ?? msg.RichContent; + response.Instruction = msg.Instruction; + response.Data = msg.Data; + }); - var state = _services.GetRequiredService(); - response.States = state.GetStates(); - response.MessageId = inputMsg.MessageId; - response.ConversationId = conversationId; + var state = _services.GetRequiredService(); + response.States = state.GetStates(); + response.MessageId = inputMsg.MessageId; + response.ConversationId = conversationId; - return response; + trace?.SetTag("output", response.Data); + return response; + } } @@ -432,7 +447,7 @@ await conv.SendMessage(agentId, inputMsg, response.Instruction = msg.Instruction; response.Data = msg.Data; response.States = state.GetStates(); - + await OnChunkReceived(Response, response); }); diff --git a/src/Plugins/BotSharp.Plugin.AnthropicAI/Providers/ChatCompletionProvider.cs b/src/Plugins/BotSharp.Plugin.AnthropicAI/Providers/ChatCompletionProvider.cs index d6b4c4107..571f01232 100644 --- a/src/Plugins/BotSharp.Plugin.AnthropicAI/Providers/ChatCompletionProvider.cs +++ b/src/Plugins/BotSharp.Plugin.AnthropicAI/Providers/ChatCompletionProvider.cs @@ -1,11 +1,13 @@ using Anthropic.SDK.Common; using BotSharp.Abstraction.Conversations; +using BotSharp.Abstraction.Diagnostics.Telemetry; using BotSharp.Abstraction.Files; using BotSharp.Abstraction.Files.Models; using BotSharp.Abstraction.Files.Utilities; using BotSharp.Abstraction.Hooks; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using static BotSharp.Abstraction.Diagnostics.Telemetry.TelemetryConstants; namespace BotSharp.Plugin.AnthropicAI.Providers; @@ -17,22 +19,26 @@ public class ChatCompletionProvider : IChatCompletion protected readonly AnthropicSettings _settings; protected readonly IServiceProvider _services; protected readonly ILogger _logger; + protected readonly ITelemetryService _telemetryService; private List renderedInstructions = []; protected string _model; public ChatCompletionProvider(AnthropicSettings settings, ILogger logger, + ITelemetryService telemetryService, IServiceProvider services) { _settings = settings; _logger = logger; _services = services; + _telemetryService = telemetryService; } public async Task GetChatCompletions(Agent agent, List conversations) { var contentHooks = _services.GetHooks(agent.Id); + var convService = _services.GetRequiredService(); // Before chat completion hook foreach (var hook in contentHooks) @@ -45,53 +51,61 @@ public async Task GetChatCompletions(Agent agent, List().FirstOrDefault(); - var toolResult = response.Content.OfType().First(); - responseMessage = new RoleDialogModel(AgentRole.Function, content?.Text ?? string.Empty) + var response = await client.Messages.GetClaudeMessageAsync(parameters); + + RoleDialogModel responseMessage; + activity?.SetTag(ModelDiagnosticsTags.FinishReason, response.StopReason); + if (response.StopReason == "tool_use") { - CurrentAgentId = agent.Id, - MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, - ToolCallId = toolResult.Id, - FunctionName = toolResult.Name, - FunctionArgs = JsonSerializer.Serialize(toolResult.Input), - RenderedInstruction = string.Join("\r\n", renderedInstructions) - }; - } - else - { - var message = response.FirstMessage; - responseMessage = new RoleDialogModel(AgentRole.Assistant, message?.Text ?? string.Empty) + var content = response.Content.OfType().FirstOrDefault(); + var toolResult = response.Content.OfType().First(); + + responseMessage = new RoleDialogModel(AgentRole.Function, content?.Text ?? string.Empty) + { + CurrentAgentId = agent.Id, + MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, + ToolCallId = toolResult.Id, + FunctionName = toolResult.Name, + FunctionArgs = JsonSerializer.Serialize(toolResult.Input), + RenderedInstruction = string.Join("\r\n", renderedInstructions) + }; + } + else { - CurrentAgentId = agent.Id, - MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, - RenderedInstruction = string.Join("\r\n", renderedInstructions) - }; - } + var message = response.FirstMessage; + responseMessage = new RoleDialogModel(AgentRole.Assistant, message?.Text ?? string.Empty) + { + CurrentAgentId = agent.Id, + MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, + RenderedInstruction = string.Join("\r\n", renderedInstructions) + }; + } - var tokenUsage = response.Usage; + var tokenUsage = response.Usage; + var inputTokenDetails = tokenUsage?.InputTokens ?? 0; + var outputTokenDetails = tokenUsage?.OutputTokens ?? 0; + var cachedInputTokens = tokenUsage?.CacheReadInputTokens ; - // After chat completion hook - foreach (var hook in contentHooks) - { - await hook.AfterGenerated(responseMessage, new TokenStatsModel + activity?.SetTag(ModelDiagnosticsTags.InputTokens, (inputTokenDetails - cachedInputTokens)); + activity?.SetTag(ModelDiagnosticsTags.OutputTokens, outputTokenDetails); + // After chat completion hook + foreach (var hook in contentHooks) { - Prompt = prompt, - Provider = Provider, - Model = _model, - TextInputTokens = tokenUsage?.InputTokens ?? 0, - TextOutputTokens = tokenUsage?.OutputTokens ?? 0 - }); + await hook.AfterGenerated(responseMessage, new TokenStatsModel + { + Prompt = prompt, + Provider = Provider, + Model = _model, + TextInputTokens = tokenUsage?.InputTokens ?? 0, + TextOutputTokens = tokenUsage?.OutputTokens ?? 0 + }); + } + activity?.SetTag("output", responseMessage.Content); + return responseMessage; } - - return responseMessage; } public Task GetChatCompletionsAsync(Agent agent, List conversations, diff --git a/src/Plugins/BotSharp.Plugin.AzureOpenAI/Providers/Chat/ChatCompletionProvider.cs b/src/Plugins/BotSharp.Plugin.AzureOpenAI/Providers/Chat/ChatCompletionProvider.cs index dc9a0fbc5..8c1c4d7d9 100644 --- a/src/Plugins/BotSharp.Plugin.AzureOpenAI/Providers/Chat/ChatCompletionProvider.cs +++ b/src/Plugins/BotSharp.Plugin.AzureOpenAI/Providers/Chat/ChatCompletionProvider.cs @@ -1,5 +1,6 @@ #pragma warning disable OPENAI001 using BotSharp.Abstraction.Conversations.Enums; +using BotSharp.Abstraction.Diagnostics.Telemetry; using BotSharp.Abstraction.Files.Utilities; using BotSharp.Abstraction.Hooks; using BotSharp.Abstraction.MessageHub.Models; @@ -7,6 +8,8 @@ using BotSharp.Core.MessageHub; using OpenAI.Chat; using System.ClientModel; +using BotSharp.Abstraction.Diagnostics; +using static BotSharp.Abstraction.Diagnostics.Telemetry.TelemetryConstants; namespace BotSharp.Plugin.AzureOpenAI.Providers.Chat; @@ -15,6 +18,7 @@ public class ChatCompletionProvider : IChatCompletion protected readonly AzureOpenAiSettings _settings; protected readonly IServiceProvider _services; protected readonly ILogger _logger; + protected readonly ITelemetryService _telemetryService; private List renderedInstructions = []; protected string _model; @@ -25,16 +29,19 @@ public class ChatCompletionProvider : IChatCompletion public ChatCompletionProvider( AzureOpenAiSettings settings, ILogger logger, + ITelemetryService telemetryService, IServiceProvider services) { _settings = settings; _logger = logger; _services = services; + _telemetryService = telemetryService; } public async Task GetChatCompletions(Agent agent, List conversations) { var contentHooks = _services.GetHooks(agent.Id); + var convService = _services.GetService(); // Before chat completion hook foreach (var hook in contentHooks) @@ -49,91 +56,100 @@ public async Task GetChatCompletions(Agent agent, List? response = null; ChatCompletion value = default; RoleDialogModel responseMessage; - - try + using (var activity = _telemetryService.StartCompletionActivity(null, _model, Provider, conversations, convService)) { - response = chatClient.CompleteChat(messages, options); - value = response.Value; + try + { + response = chatClient.CompleteChat(messages, options); + value = response.Value; - var reason = value.FinishReason; - var content = value.Content; - var text = content.FirstOrDefault()?.Text ?? string.Empty; + var reason = value.FinishReason; + var content = value.Content; + var text = content.FirstOrDefault()?.Text ?? string.Empty; - if (reason == ChatFinishReason.FunctionCall || reason == ChatFinishReason.ToolCalls) + activity?.SetTag(ModelDiagnosticsTags.FinishReason, reason); + if (reason == ChatFinishReason.FunctionCall || reason == ChatFinishReason.ToolCalls) + { + 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. + if (!string.IsNullOrEmpty(responseMessage.FunctionName)) + { + responseMessage.FunctionName = responseMessage.FunctionName.Split('.').Last(); + } + } + 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() + }; + } + } + catch (ClientResultException ex) { - var toolCall = value.ToolCalls.FirstOrDefault(); - responseMessage = new RoleDialogModel(AgentRole.Function, text) + _logger.LogError(ex, ex.Message); + responseMessage = new RoleDialogModel(AgentRole.Assistant, "The response was filtered due to the prompt triggering our content management policy. Please modify your prompt and retry.") { 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. - if (!string.IsNullOrEmpty(responseMessage.FunctionName)) - { - responseMessage.FunctionName = responseMessage.FunctionName.Split('.').Last(); - } + activity?.SetError(ex); } - else + catch (Exception ex) { - responseMessage = new RoleDialogModel(AgentRole.Assistant, text) + _logger.LogError(ex, ex.Message); + responseMessage = new RoleDialogModel(AgentRole.Assistant, ex.Message) { 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() + RenderedInstruction = string.Join("\r\n", renderedInstructions) }; + activity?.SetError(ex); } - } - catch (ClientResultException ex) - { - _logger.LogError(ex, ex.Message); - responseMessage = new RoleDialogModel(AgentRole.Assistant, "The response was filtered due to the prompt triggering our content management policy. Please modify your prompt and retry.") - { - CurrentAgentId = agent.Id, - MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, - RenderedInstruction = string.Join("\r\n", renderedInstructions) - }; - } - catch (Exception ex) - { - _logger.LogError(ex, ex.Message); - responseMessage = new RoleDialogModel(AgentRole.Assistant, ex.Message) - { - CurrentAgentId = agent.Id, - MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, - RenderedInstruction = string.Join("\r\n", renderedInstructions) - }; - } - var tokenUsage = response?.Value?.Usage; - var inputTokenDetails = response?.Value?.Usage?.InputTokenDetails; + 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 + activity?.SetTag(ModelDiagnosticsTags.InputTokens, (tokenUsage?.InputTokenCount ?? 0) - (inputTokenDetails?.CachedTokenCount ?? 0)); + activity?.SetTag(ModelDiagnosticsTags.OutputTokens, tokenUsage?.OutputTokenCount ?? 0); + + // After chat completion hook + foreach (var hook in contentHooks) { - Prompt = prompt, - Provider = Provider, - Model = _model, - TextInputTokens = (tokenUsage?.InputTokenCount ?? 0) - (inputTokenDetails?.CachedTokenCount ?? 0), - CachedTextInputTokens = inputTokenDetails?.CachedTokenCount ?? 0, - TextOutputTokens = tokenUsage?.OutputTokenCount ?? 0 - }); - } + 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 + }); + } + activity?.SetTag("output", responseMessage.Content); - return responseMessage; + return responseMessage; + } } public async Task GetChatCompletionsAsync(Agent agent, @@ -167,7 +183,7 @@ public async Task GetChatCompletionsAsync(Agent agent, var tokenUsage = response?.Value?.Usage; var inputTokenDetails = response?.Value?.Usage?.InputTokenDetails; - + // After chat completion hook foreach (var hook in hooks) { diff --git a/src/Plugins/BotSharp.Plugin.DeepSeekAI/Providers/Chat/ChatCompletionProvider.cs b/src/Plugins/BotSharp.Plugin.DeepSeekAI/Providers/Chat/ChatCompletionProvider.cs index 6349b1ed0..4323118ac 100644 --- a/src/Plugins/BotSharp.Plugin.DeepSeekAI/Providers/Chat/ChatCompletionProvider.cs +++ b/src/Plugins/BotSharp.Plugin.DeepSeekAI/Providers/Chat/ChatCompletionProvider.cs @@ -1,5 +1,6 @@ #pragma warning disable OPENAI001 using BotSharp.Abstraction.Conversations.Enums; +using BotSharp.Abstraction.Diagnostics.Telemetry; using BotSharp.Abstraction.Files; using BotSharp.Abstraction.Files.Models; using BotSharp.Abstraction.Files.Utilities; @@ -10,6 +11,7 @@ using BotSharp.Plugin.DeepSeek.Providers; using Microsoft.Extensions.Logging; using OpenAI.Chat; +using static BotSharp.Abstraction.Diagnostics.Telemetry.TelemetryConstants; namespace BotSharp.Plugin.DeepSeekAI.Providers.Chat; @@ -17,6 +19,7 @@ public class ChatCompletionProvider : IChatCompletion { protected readonly IServiceProvider _services; protected readonly ILogger _logger; + protected readonly ITelemetryService _telemetryService; private List renderedInstructions = []; protected string _model; @@ -25,15 +28,18 @@ public class ChatCompletionProvider : IChatCompletion public ChatCompletionProvider( IServiceProvider services, + ITelemetryService telemetryService, ILogger logger) { _services = services; + _telemetryService = telemetryService; _logger = logger; } public async Task GetChatCompletions(Agent agent, List conversations) { var contentHooks = _services.GetHooks(agent.Id); + var convService = _services.GetRequiredService(); // Before chat completion hook foreach (var hook in contentHooks) @@ -44,68 +50,75 @@ public async Task GetChatCompletions(Agent agent, List new ChatAnnotation + responseMessage = new RoleDialogModel(AgentRole.Assistant, text) { - Title = x.WebResourceTitle, - Url = x.WebResourceUri.AbsoluteUri, - StartIndex = x.StartIndex, - EndIndex = x.EndIndex - })?.ToList() - }; - } + 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; + 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 + activity?.SetTag(ModelDiagnosticsTags.InputTokens, (tokenUsage?.InputTokenCount ?? 0) - (inputTokenDetails?.CachedTokenCount ?? 0)); + activity?.SetTag(ModelDiagnosticsTags.OutputTokens, tokenUsage?.OutputTokenCount ?? 0); + activity?.SetTag(ModelDiagnosticsTags.OutputTokens, tokenUsage?.OutputTokenCount ?? 0); + + // After chat completion hook + foreach (var hook in contentHooks) { - Prompt = prompt, - Provider = Provider, - Model = _model, - TextInputTokens = (tokenUsage?.InputTokenCount ?? 0) - (inputTokenDetails?.CachedTokenCount ?? 0), - CachedTextInputTokens = inputTokenDetails?.CachedTokenCount ?? 0, - TextOutputTokens = tokenUsage?.OutputTokenCount ?? 0 - }); + 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 + }); + } + activity?.SetTag("output", responseMessage.Content); + return responseMessage; } - - return responseMessage; } public async Task GetChatCompletionsAsync(Agent agent, List conversations, Func onMessageReceived, Func onFunctionExecuting) diff --git a/src/Plugins/BotSharp.Plugin.GiteeAI/BotSharp.Plugin.GiteeAI.csproj b/src/Plugins/BotSharp.Plugin.GiteeAI/BotSharp.Plugin.GiteeAI.csproj new file mode 100644 index 000000000..8d73c6489 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.GiteeAI/BotSharp.Plugin.GiteeAI.csproj @@ -0,0 +1,31 @@ + + + $(TargetFramework) + enable + enable + $(LangVersion) + true + $(BotSharpVersion) + $(GeneratePackageOnBuild) + $(GenerateDocumentationFile) + true + $(SolutionDir)packages + + + + + false + runtime + + + + + + PreserveNewest + + + + + + + diff --git a/src/Plugins/BotSharp.Plugin.GiteeAI/GiteeAiPlugin.cs b/src/Plugins/BotSharp.Plugin.GiteeAI/GiteeAiPlugin.cs new file mode 100644 index 000000000..ef9686482 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.GiteeAI/GiteeAiPlugin.cs @@ -0,0 +1,19 @@ +using BotSharp.Abstraction.Plugins; +using BotSharp.Plugin.GiteeAI.Providers.Chat; +using BotSharp.Plugin.GiteeAI.Providers.Embedding; + +namespace BotSharp.Plugin.GiteeAI; + +public class GiteeAiPlugin : IBotSharpPlugin +{ + public string Id => "59ad4c3c-0b88-3344-ba99-5245ec015938"; + public string Name => "GiteeAI"; + public string Description => "Gitee AI"; + public string IconUrl => "https://ai-assets.gitee.com/_next/static/media/gitee-ai.622edfb0.ico"; + + public void RegisterDI(IServiceCollection services, IConfiguration config) + { + services.AddScoped(); + services.AddScoped(); + } +} diff --git a/src/Plugins/BotSharp.Plugin.GiteeAI/Providers/Chat/ChatCompletionProvider.cs b/src/Plugins/BotSharp.Plugin.GiteeAI/Providers/Chat/ChatCompletionProvider.cs new file mode 100644 index 000000000..96152c053 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.GiteeAI/Providers/Chat/ChatCompletionProvider.cs @@ -0,0 +1,497 @@ +using BotSharp.Abstraction.Agents.Constants; +using BotSharp.Abstraction.Diagnostics.Telemetry; +using BotSharp.Abstraction.Files; +using Microsoft.Extensions.Logging; +using OpenAI.Chat; +using System.Diagnostics; +using static BotSharp.Abstraction.Diagnostics.Telemetry.TelemetryConstants; + +namespace BotSharp.Plugin.GiteeAI.Providers.Chat; + +/// +/// 模力方舟的文本对话 +/// +public class ChatCompletionProvider( + ILogger logger, + ITelemetryService telemetryService, + IServiceProvider services) : IChatCompletion +{ + protected string _model = string.Empty; + + public virtual string Provider => "gitee-ai"; + + public string Model => _model; + + + public async Task GetChatCompletions(Agent agent, List conversations) + { + var contentHooks = services.GetServices().ToList(); + var convService = services.GetService(); + + // Before chat completion hook + foreach (var hook in contentHooks) + { + await hook.BeforeGenerating(agent, conversations); + } + + var client = ProviderHelper.GetClient(Provider, _model, services); + var chatClient = client.GetChatClient(_model); + var (prompt, messages, options) = PrepareOptions(agent, conversations); + + using (var activity = telemetryService.StartCompletionActivity(null, _model, Provider, conversations, convService)) + { + 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; + + activity?.SetTag(ModelDiagnosticsTags.FinishReason, reason); + + RoleDialogModel responseMessage; + if (reason == ChatFinishReason.FunctionCall || reason == ChatFinishReason.ToolCalls) + { + 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() + }; + + // Somethings LLM will generate a function name with agent name. + if (!string.IsNullOrEmpty(responseMessage.FunctionName)) + { + responseMessage.FunctionName = responseMessage.FunctionName.Split('.').Last(); + } + } + else + { + responseMessage = new RoleDialogModel(AgentRole.Assistant, text) + { + CurrentAgentId = agent.Id, + MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty, + }; + } + + var tokenUsage = response?.Value?.Usage; + var inputTokenDetails = response?.Value?.Usage?.InputTokenDetails; + + activity?.SetTag(ModelDiagnosticsTags.InputTokens, (tokenUsage?.InputTokenCount ?? 0) - (inputTokenDetails?.CachedTokenCount ?? 0)); + activity?.SetTag(ModelDiagnosticsTags.OutputTokens, tokenUsage?.OutputTokenCount ?? 0); + activity?.SetTag(ModelDiagnosticsTags.OutputTokens, tokenUsage?.OutputTokenCount ?? 0); + + // After chat completion hook + foreach (var hook in contentHooks) + { + await hook.AfterGenerated(responseMessage, new TokenStatsModel + { + Prompt = prompt, + Provider = Provider, + Model = _model, + TextInputTokens = response.Value?.Usage?.InputTokenCount ?? 0, + TextOutputTokens = response.Value?.Usage?.OutputTokenCount ?? 0 + }); + } + activity?.SetTag("output", responseMessage.Content); + return responseMessage; + } + } + + public async Task GetChatCompletionsAsync(Agent agent, List conversations, Func onStreamResponseReceived) + { + var contentHooks = services.GetServices().ToList(); + + // Before chat completion hook + foreach (var hook in contentHooks) + { + await hook.BeforeGenerating(agent, conversations); + } + + StringBuilder? contentBuilder = null; + Dictionary? toolCallIdsByIndex = null; + Dictionary? functionNamesByIndex = null; + Dictionary? functionArgumentBuildersByIndex = null; + + var client = ProviderHelper.GetClient(Provider, _model, services); + var chatClient = client.GetChatClient(_model); + var (prompt, messages, options) = PrepareOptions(agent, conversations); + + var response = chatClient.CompleteChatStreamingAsync(messages, options); + + await foreach (var choice in response) + { + TrackStreamingToolingUpdate(choice.ToolCallUpdates, ref toolCallIdsByIndex, ref functionNamesByIndex, ref functionArgumentBuildersByIndex); + + if (!choice.ContentUpdate.IsNullOrEmpty() && choice.ContentUpdate[0] != null) + { + foreach (var contentPart in choice.ContentUpdate) + { + if (contentPart.Kind == ChatMessageContentPartKind.Text) + { + (contentBuilder ??= new()).Append(contentPart.Text); + } + } + + logger.LogInformation(choice.ContentUpdate[0]?.Text); + + if (!string.IsNullOrEmpty(choice.ContentUpdate[0]?.Text)) + { + var msg = new RoleDialogModel(choice.Role?.ToString() ?? ChatMessageRole.Assistant.ToString(), choice.ContentUpdate[0]?.Text ?? string.Empty); + + await onStreamResponseReceived(msg); + } + } + } + + // Get any response content that was streamed. + string content = contentBuilder?.ToString() ?? string.Empty; + + RoleDialogModel responseMessage = new(ChatMessageRole.Assistant.ToString(), content); + + var tools = ConvertToolCallUpdatesToFunctionToolCalls(ref toolCallIdsByIndex, ref functionNamesByIndex, ref functionArgumentBuildersByIndex); + + foreach (var tool in tools) + { + tool.CurrentAgentId = agent.Id; + tool.MessageId = conversations.LastOrDefault()?.MessageId ?? string.Empty; + await onStreamResponseReceived(tool); + } + + if (tools.Length > 0) + { + responseMessage = tools[0]; + } + + return responseMessage; + } + + public async Task GetChatCompletionsAsync(Agent agent, List conversations, Func onMessageReceived, Func onFunctionExecuting) + { + var hooks = services.GetServices().ToList(); + + // Before chat completion hook + foreach (var hook in hooks) + { + await hook.BeforeGenerating(agent, conversations); + } + + var client = ProviderHelper.GetClient(Provider, _model, 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 + }; + + // After chat completion hook + foreach (var hook in hooks) + { + await hook.AfterGenerated(msg, new TokenStatsModel + { + Prompt = prompt, + Provider = Provider, + Model = _model, + TextInputTokens = response.Value?.Usage?.InputTokenCount ?? 0, + TextOutputTokens = response.Value?.Usage?.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() + }; + + // Somethings LLM will generate a function name with agent name. + if (!string.IsNullOrEmpty(funcContextIn.FunctionName)) + { + funcContextIn.FunctionName = funcContextIn.FunctionName.Split('.').Last(); + } + + // Execute functions + await onFunctionExecuting(funcContextIn); + } + else + { + // Text response received + await onMessageReceived(msg); + } + + return true; + } + + public async Task GetChatCompletionsStreamingAsync(Agent agent, List conversations, Func onMessageReceived) + { + var client = ProviderHelper.GetClient(Provider, _model, services); + var chatClient = client.GetChatClient(_model); + var (prompt, messages, options) = PrepareOptions(agent, conversations); + + var response = chatClient.CompleteChatStreamingAsync(messages, options); + + await foreach (var choice in response) + { + if (choice.FinishReason == ChatFinishReason.FunctionCall || choice.FinishReason == ChatFinishReason.ToolCalls) + { + var update = choice.ToolCallUpdates?.FirstOrDefault()?.FunctionArgumentsUpdate?.ToString() ?? string.Empty; + logger.LogInformation(update); + + await onMessageReceived(new RoleDialogModel(AgentRole.Assistant, update)); + continue; + } + + if (choice.ContentUpdate.IsNullOrEmpty()) continue; + + logger.LogInformation(choice.ContentUpdate[0]?.Text); + + await onMessageReceived(new RoleDialogModel(choice.Role?.ToString() ?? ChatMessageRole.Assistant.ToString(), choice.ContentUpdate[0]?.Text ?? string.Empty)); + } + + return true; + } + + public void SetModelName(string model) + { + _model = model; + } + + protected (string, IEnumerable, ChatCompletionOptions) PrepareOptions(Agent agent, List conversations) + { + var agentService = services.GetRequiredService(); + var state = services.GetRequiredService(); + var fileStorage = services.GetRequiredService(); + var settingsService = services.GetRequiredService(); + var settings = settingsService.GetSetting(Provider, _model); + var allowMultiModal = settings != null && settings.MultiModal; + + var messages = new List(); + float? temperature = float.Parse(state.GetState("temperature", "0.0")); + var maxTokens = int.TryParse(state.GetState("max_tokens"), out var tokens) + ? tokens + : agent.LlmConfig?.MaxOutputTokens ?? LlmConstant.DEFAULT_MAX_OUTPUT_TOKEN; + + + state.SetState("temperature", temperature.ToString()); + state.SetState("max_tokens", maxTokens.ToString()); + + var options = new ChatCompletionOptions() + { + Temperature = temperature, + MaxOutputTokenCount = maxTokens + }; + + var functions = agent.Functions.Concat(agent.SecondaryFunctions ?? []); + foreach (var function in functions) + { + if (!agentService.RenderFunction(agent, function)) continue; + + var property = agentService.RenderFunctionProperty(agent, function); + + options.Tools.Add(ChatTool.CreateFunctionTool( + functionName: function.Name, + functionDescription: function.Description, + functionParameters: BinaryData.FromObjectAsJson(property))); + } + + if (!string.IsNullOrEmpty(agent.Instruction) || !agent.SecondaryInstructions.IsNullOrEmpty()) + { + var text = agentService.RenderInstruction(agent); + messages.Add(new SystemChatMessage(text)); + } + + if (!string.IsNullOrEmpty(agent.Knowledges)) + { + messages.Add(new SystemChatMessage(agent.Knowledges)); + } + + 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(); + } + + foreach (var message in filteredMessages) + { + if (message.Role == AgentRole.Function) + { + messages.Add(new AssistantChatMessage(new List + { + ChatToolCall.CreateFunctionToolCall(message.FunctionName, message.FunctionName, BinaryData.FromString(message.FunctionArgs ?? string.Empty)) + })); + + messages.Add(new ToolChatMessage(message.FunctionName, message.Content)); + } + else if (message.Role == AgentRole.User) + { + var text = !string.IsNullOrWhiteSpace(message.Payload) ? message.Payload : message.Content; + messages.Add(new UserChatMessage(text)); + } + else if (message.Role == AgentRole.Assistant) + { + messages.Add(new AssistantChatMessage(message.Content)); + } + } + + var prompt = GetPrompt(messages, options); + return (prompt, messages, options); + } + + 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 static void TrackStreamingToolingUpdate( + IReadOnlyList? updates, + ref Dictionary? toolCallIdsByIndex, + ref Dictionary? functionNamesByIndex, + ref Dictionary? functionArgumentBuildersByIndex) + { + if (updates is null) + { + // Nothing to track. + return; + } + + foreach (var update in updates) + { + // If we have an ID, ensure the index is being tracked. Even if it's not a function update, + // we want to keep track of it so we can send back an error. + if (!string.IsNullOrWhiteSpace(update.ToolCallId)) + { + (toolCallIdsByIndex ??= [])[update.Index] = update.ToolCallId; + } + + // Ensure we're tracking the function's name. + if (!string.IsNullOrWhiteSpace(update.FunctionName)) + { + (functionNamesByIndex ??= [])[update.Index] = update.FunctionName; + } + + // Ensure we're tracking the function's arguments. + if (update.FunctionArgumentsUpdate is not null && !update.FunctionArgumentsUpdate.ToMemory().IsEmpty) + { + if (!(functionArgumentBuildersByIndex ??= []).TryGetValue(update.Index, out StringBuilder? arguments)) + { + functionArgumentBuildersByIndex[update.Index] = arguments = new(); + } + + arguments.Append(update.FunctionArgumentsUpdate.ToString()); + } + } + } + + private static RoleDialogModel[] ConvertToolCallUpdatesToFunctionToolCalls( + ref Dictionary? toolCallIdsByIndex, + ref Dictionary? functionNamesByIndex, + ref Dictionary? functionArgumentBuildersByIndex) + { + RoleDialogModel[] toolCalls = []; + if (toolCallIdsByIndex is { Count: > 0 }) + { + toolCalls = new RoleDialogModel[toolCallIdsByIndex.Count]; + + int i = 0; + foreach (KeyValuePair toolCallIndexAndId in toolCallIdsByIndex) + { + string? functionName = null; + StringBuilder? functionArguments = null; + + functionNamesByIndex?.TryGetValue(toolCallIndexAndId.Key, out functionName); + functionArgumentBuildersByIndex?.TryGetValue(toolCallIndexAndId.Key, out functionArguments); + + toolCalls[i] = new RoleDialogModel(AgentRole.Function, string.Empty) + { + FunctionName = functionName ?? string.Empty, + FunctionArgs = functionArguments?.ToString() ?? string.Empty, + }; + i++; + } + + Debug.Assert(i == toolCalls.Length); + } + + return toolCalls; + } + +} diff --git a/src/Plugins/BotSharp.Plugin.GiteeAI/Providers/Embedding/TextEmbeddingProvider.cs b/src/Plugins/BotSharp.Plugin.GiteeAI/Providers/Embedding/TextEmbeddingProvider.cs new file mode 100644 index 000000000..80a8dbd71 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.GiteeAI/Providers/Embedding/TextEmbeddingProvider.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Logging; +using OpenAI.Embeddings; + +namespace BotSharp.Plugin.GiteeAI.Providers.Embedding; + +public class TextEmbeddingProvider( + ILogger logger, + IServiceProvider services) : ITextEmbedding +{ + protected readonly IServiceProvider _services = services; + protected readonly ILogger _logger = logger; + + private const int DEFAULT_DIMENSION = 1024; + protected string _model = "bge-m3"; + + public virtual string Provider => "gitee-ai"; + + public string Model => _model; + + protected int _dimension; + + public async Task GetVectorAsync(string text) + { + var client = ProviderHelper.GetClient(Provider, _model, _services); + var embeddingClient = client.GetEmbeddingClient(_model); + var options = PrepareOptions(); + var response = await embeddingClient.GenerateEmbeddingAsync(text, options); + var value = response.Value; + return value.ToFloats().ToArray(); + } + + public async Task> GetVectorsAsync(List texts) + { + var client = ProviderHelper.GetClient(Provider, _model, _services); + var embeddingClient = client.GetEmbeddingClient(_model); + var options = PrepareOptions(); + var response = await embeddingClient.GenerateEmbeddingsAsync(texts, options); + var value = response.Value; + return value.Select(x => x.ToFloats().ToArray()).ToList(); + } + + public void SetModelName(string model) + { + _model = model; + } + + private EmbeddingGenerationOptions PrepareOptions() + { + return new EmbeddingGenerationOptions + { + Dimensions = GetDimension() + }; + } + + public int GetDimension() + { + var state = _services.GetRequiredService(); + var stateDimension = state.GetState("embedding_dimension"); + var defaultDimension = _dimension > 0 ? _dimension : DEFAULT_DIMENSION; + + if (int.TryParse(stateDimension, out var dimension)) + { + return dimension > 0 ? dimension : defaultDimension; + } + return defaultDimension; + } + + public void SetDimension(int dimension) + { + _dimension = dimension > 0 ? dimension : DEFAULT_DIMENSION; + } + +} \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.GiteeAI/Providers/ProviderHelper.cs b/src/Plugins/BotSharp.Plugin.GiteeAI/Providers/ProviderHelper.cs new file mode 100644 index 000000000..b532e834c --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.GiteeAI/Providers/ProviderHelper.cs @@ -0,0 +1,16 @@ +using OpenAI; +using System.ClientModel; + +namespace BotSharp.Plugin.GiteeAI.Providers; + +public static class ProviderHelper +{ + public static OpenAIClient GetClient(string provider, string model, IServiceProvider services) + { + var settingsService = services.GetRequiredService(); + var settings = settingsService.GetSetting(provider, model); + var options = !string.IsNullOrEmpty(settings.Endpoint) ? + new OpenAIClientOptions { Endpoint = new Uri(settings.Endpoint) } : null; + return new OpenAIClient(new ApiKeyCredential(settings.ApiKey), options); + } +} diff --git a/src/Plugins/BotSharp.Plugin.GiteeAI/README.md b/src/Plugins/BotSharp.Plugin.GiteeAI/README.md new file mode 100644 index 000000000..5b4d00ff4 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.GiteeAI/README.md @@ -0,0 +1,8 @@ +Model Ark (Gitee AI) , hereinafter referred to as Gitee AI, aggregates the latest and most popular AI models, providing a one-stop service for model experience, inference, fine-tuning, and application deployment . We offer a diverse range of computing power options, aiming to help enterprises and developers build AI applications more easily . +ChatCompletions Interface: + +- https://ai.gitee.com/docs/openapi/v1#tag/%E6%96%87%E6%9C%AC%E7%94%9F%E6%88%90/post/chat/completions + +Signature Authentication Method: + +- https://ai.gitee.com/docs/organization/access-token \ No newline at end of file diff --git a/src/Plugins/BotSharp.Plugin.GiteeAI/Using.cs b/src/Plugins/BotSharp.Plugin.GiteeAI/Using.cs new file mode 100644 index 000000000..aa44ad1e2 --- /dev/null +++ b/src/Plugins/BotSharp.Plugin.GiteeAI/Using.cs @@ -0,0 +1,15 @@ +global using BotSharp.Abstraction.Agents; +global using BotSharp.Abstraction.Agents.Enums; +global using BotSharp.Abstraction.Agents.Models; +global using BotSharp.Abstraction.Conversations; +global using BotSharp.Abstraction.Conversations.Models; +global using BotSharp.Abstraction.Loggers; +global using BotSharp.Abstraction.MLTasks; +global using BotSharp.Abstraction.Utilities; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using System; +global using System.Collections.Generic; +global using System.Linq; +global using System.Text; +global using System.Threading.Tasks; diff --git a/src/Plugins/BotSharp.Plugin.GoogleAI/Providers/Chat/ChatCompletionProvider.cs b/src/Plugins/BotSharp.Plugin.GoogleAI/Providers/Chat/ChatCompletionProvider.cs index d224fb122..44563fa44 100644 --- a/src/Plugins/BotSharp.Plugin.GoogleAI/Providers/Chat/ChatCompletionProvider.cs +++ b/src/Plugins/BotSharp.Plugin.GoogleAI/Providers/Chat/ChatCompletionProvider.cs @@ -1,3 +1,4 @@ +using BotSharp.Abstraction.Diagnostics.Telemetry; using BotSharp.Abstraction.Files; using BotSharp.Abstraction.Files.Models; using BotSharp.Abstraction.Files.Utilities; @@ -5,6 +6,7 @@ using GenerativeAI; using GenerativeAI.Core; using GenerativeAI.Types; +using static BotSharp.Abstraction.Diagnostics.Telemetry.TelemetryConstants; namespace BotSharp.Plugin.GoogleAi.Providers.Chat; @@ -12,6 +14,8 @@ public class ChatCompletionProvider : IChatCompletion { private readonly IServiceProvider _services; private readonly ILogger _logger; + + protected readonly ITelemetryService _telemetryService; private List renderedInstructions = []; private string _model; @@ -22,10 +26,12 @@ public class ChatCompletionProvider : IChatCompletion private GoogleAiSettings _settings; public ChatCompletionProvider( IServiceProvider services, + ITelemetryService telemetryService, GoogleAiSettings googleSettings, ILogger logger) { _settings = googleSettings; + _telemetryService = telemetryService; _services = services; _logger = logger; } @@ -33,6 +39,7 @@ public ChatCompletionProvider( public async Task GetChatCompletions(Agent agent, List conversations) { var contentHooks = _services.GetHooks(agent.Id); + var convService = _services.GetRequiredService(); // Before chat completion hook foreach (var hook in contentHooks) @@ -43,49 +50,58 @@ public async Task GetChatCompletions(Agent agent, List GetChatCompletionsAsync(Agent agent, List conversations, Func onMessageReceived, Func onFunctionExecuting) diff --git a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.cs b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.cs index 7ab149e26..c7a9cd36c 100644 --- a/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.cs +++ b/src/Plugins/BotSharp.Plugin.OpenAI/Providers/Chat/ChatCompletionProvider.cs @@ -1,9 +1,12 @@ #pragma warning disable OPENAI001 +using BotSharp.Abstraction.Diagnostics.Telemetry; using BotSharp.Abstraction.Hooks; using BotSharp.Abstraction.MessageHub.Models; using BotSharp.Core.Infrastructures.Streams; using BotSharp.Core.MessageHub; +using Microsoft.AspNetCore.Cors.Infrastructure; using OpenAI.Chat; +using static BotSharp.Abstraction.Diagnostics.Telemetry.TelemetryConstants; namespace BotSharp.Plugin.OpenAI.Providers.Chat; @@ -12,6 +15,7 @@ public class ChatCompletionProvider : IChatCompletion protected readonly OpenAiSettings _settings; protected readonly IServiceProvider _services; protected readonly ILogger _logger; + protected readonly ITelemetryService _telemetryService; protected string _model; private List renderedInstructions = []; @@ -22,16 +26,19 @@ public class ChatCompletionProvider : IChatCompletion public ChatCompletionProvider( OpenAiSettings settings, ILogger logger, + ITelemetryService telemetryService, IServiceProvider services) { _settings = settings; _logger = logger; _services = services; + _telemetryService = telemetryService; } public async Task GetChatCompletions(Agent agent, List conversations) { var contentHooks = _services.GetHooks(agent.Id); + var convService = _services.GetRequiredService(); // Before chat completion hook foreach (var hook in contentHooks) @@ -42,68 +49,77 @@ public async Task GetChatCompletions(Agent agent, List new ChatAnnotation + responseMessage = new RoleDialogModel(AgentRole.Assistant, text) { - Title = x.WebResourceTitle, - Url = x.WebResourceUri.AbsoluteUri, - StartIndex = x.StartIndex, - EndIndex = x.EndIndex - })?.ToList() - }; - } + 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; + 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 + activity?.SetTag(ModelDiagnosticsTags.InputTokens, (tokenUsage?.InputTokenCount ?? 0) - (inputTokenDetails?.CachedTokenCount ?? 0)); + activity?.SetTag(ModelDiagnosticsTags.OutputTokens, tokenUsage?.OutputTokenCount ?? 0); + activity?.SetTag(ModelDiagnosticsTags.OutputTokens, tokenUsage?.OutputTokenCount ?? 0); + + + + // After chat completion hook + foreach (var hook in contentHooks) { - Prompt = prompt, - Provider = Provider, - Model = _model, - TextInputTokens = (tokenUsage?.InputTokenCount ?? 0) - (inputTokenDetails?.CachedTokenCount ?? 0), - CachedTextInputTokens = inputTokenDetails?.CachedTokenCount ?? 0, - TextOutputTokens = tokenUsage?.OutputTokenCount ?? 0 - }); - } + 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 + }); + } + activity?.SetTag("output", responseMessage.Content); - return responseMessage; + return responseMessage; + } } public async Task GetChatCompletionsAsync(Agent agent, diff --git a/src/WebStarter/Program.cs b/src/WebStarter/Program.cs index 2c9c073c2..087d915fc 100644 --- a/src/WebStarter/Program.cs +++ b/src/WebStarter/Program.cs @@ -1,11 +1,10 @@ +using BotSharp.Abstraction.Messaging.JsonConverters; using BotSharp.Core; using BotSharp.Core.MCP; -using BotSharp.OpenAPI; using BotSharp.Logger; +using BotSharp.OpenAPI; using BotSharp.Plugin.ChatHub; using Serilog; -using BotSharp.Abstraction.Messaging.JsonConverters; -using StackExchange.Redis; var builder = WebApplication.CreateBuilder(args); @@ -31,7 +30,7 @@ }).AddBotSharpOpenAPI(builder.Configuration, allowedOrigins, builder.Environment, true) .AddBotSharpMCP(builder.Configuration) .AddBotSharpLogger(builder.Configuration); - + // Add service defaults & Aspire components. builder.AddServiceDefaults(); diff --git a/src/WebStarter/WebStarter.csproj b/src/WebStarter/WebStarter.csproj index c49e28cfc..dc95144b9 100644 --- a/src/WebStarter/WebStarter.csproj +++ b/src/WebStarter/WebStarter.csproj @@ -36,6 +36,7 @@ + diff --git a/src/WebStarter/appsettings.json b/src/WebStarter/appsettings.json index a97667e9e..5abab2aca 100644 --- a/src/WebStarter/appsettings.json +++ b/src/WebStarter/appsettings.json @@ -6,6 +6,8 @@ } }, "AllowedHosts": "*", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317", + "OTEL_SERVICE_NAME": "apiservice", "AllowedOrigins": [ "http://localhost:5015", "http://0.0.0.0:5015", @@ -493,6 +495,43 @@ } } ] + }, + { + "Provider": "gitee-ai", + "Models": [ + { + "Name": "DeepSeek-V3_1", + "ApiKey": " ", + "Endpoint": "https://ai.gitee.com/v1/", + "Type": "chat", + "PromptCost": 0.0015, + "CompletionCost": 0.002, + "MaxTokens": 1024, + "Temperature": 0.6 + }, + { + "Name": "GLM-4_5", + "ApiKey": " ", + "Endpoint": "https://ai.gitee.com/v1/", + "Type": "chat", + "PromptCost": 0.0015, + "CompletionCost": 0.002, + "MaxTokens": 1024, + "Temperature": 0.6 + }, + { + "Id": "bge-m3", + "Name": "bge-m3", + "ApiKey": " ", + "Endpoint": "https://ai.gitee.com/v1/embeddings/", + "Type": "embedding", + "Dimension": 1024, + "PromptCost": 0.0015, + "CompletionCost": 0.002, + "MaxTokens": null, + "Temperature": 1.0 + } + ] } ], @@ -509,8 +548,8 @@ "HostAgentId": "01e2fc5c-2c89-4ec7-8470-7688608b496c", "EnableTranslator": false, "LlmConfig": { - "Provider": "openai", - "Model": "gpt-4.1-nano" + "Provider": "azure-openai", + "Model": "gpt-4.1" } }, @@ -855,7 +894,11 @@ "Language": "en" } }, - + "Langfuse": { + "PublicKey": "pk-lf- ", + "SecretKey": "sk-lf- ", + "BaseUrl": "https://us.cloud.langfuse.com/" + }, "PluginLoader": { "Assemblies": [ "BotSharp.Core", @@ -871,6 +914,7 @@ "BotSharp.Plugin.GoogleAI", "BotSharp.Plugin.MetaAI", "BotSharp.Plugin.DeepSeekAI", + "BotSharp.Plugin.GiteeAI", "BotSharp.Plugin.MetaMessenger", "BotSharp.Plugin.HuggingFace", "BotSharp.Plugin.KnowledgeBase", diff --git a/tests/BotSharp.PizzaBot.MCPServer/BotSharp.PizzaBot.MCPServer.csproj b/tests/BotSharp.PizzaBot.MCPServer/BotSharp.PizzaBot.MCPServer.csproj index 4e335c45b..a686c8ed6 100644 --- a/tests/BotSharp.PizzaBot.MCPServer/BotSharp.PizzaBot.MCPServer.csproj +++ b/tests/BotSharp.PizzaBot.MCPServer/BotSharp.PizzaBot.MCPServer.csproj @@ -1,4 +1,4 @@ - + $(TargetFramework) @@ -8,10 +8,7 @@ - - - diff --git a/tests/UnitTest/UnitTest.csproj b/tests/UnitTest/UnitTest.csproj index 401b2e5e9..444869f2c 100644 --- a/tests/UnitTest/UnitTest.csproj +++ b/tests/UnitTest/UnitTest.csproj @@ -13,7 +13,6 @@ - all