diff --git a/KernelMemory.sln.DotSettings b/KernelMemory.sln.DotSettings
index 987c4a5d3..c1c45a758 100644
--- a/KernelMemory.sln.DotSettings
+++ b/KernelMemory.sln.DotSettings
@@ -270,6 +270,7 @@ public void It$SOMENAME$()
DO_NOT_SHOW
True
True
+ True
True
True
True
diff --git a/examples/004-dotnet-ServerlessCustomPipeline/Program.cs b/examples/004-dotnet-ServerlessCustomPipeline/Program.cs
index 83d3483d4..b62eb464a 100644
--- a/examples/004-dotnet-ServerlessCustomPipeline/Program.cs
+++ b/examples/004-dotnet-ServerlessCustomPipeline/Program.cs
@@ -1,5 +1,22 @@
// Copyright (c) Microsoft. All rights reserved.
+/*
+ * The ingestion pipeline is composed by a set of default STEPS to process documents:
+ * - extract
+ * - partition
+ * - gen_embeddings
+ * - save_records
+ *
+ * Each step is managed by a HANDLER, see the Core/Handlers for a list of available handlers.
+ *
+ * You can create new handlers, and customize the pipeline in multiple ways:
+ *
+ * - If you are using the Memory Web Service, the list of handlers can be configured in appsettings.json and appsettings..json
+ * - You can create new handlers and load them in the configuration, passing the path to the assembly and the handler class.
+ * - If you are using the Serverless Memory, see the example below.
+ * - You can also remove the default handlers calling .WithoutDefaultHandlers() in the memory builder.
+ */
+
using Microsoft.KernelMemory;
using Microsoft.KernelMemory.Handlers;
diff --git a/examples/202-dotnet-CustomHandlerAsAService/Program.cs b/examples/202-dotnet-CustomHandlerAsAService/Program.cs
index b574bf5ff..60995976b 100644
--- a/examples/202-dotnet-CustomHandlerAsAService/Program.cs
+++ b/examples/202-dotnet-CustomHandlerAsAService/Program.cs
@@ -2,9 +2,9 @@
using Microsoft.KernelMemory;
-/* The following code shows how to create a custom handler, attached
- * to a queue and listening for work to do. You can also add multiple handlers
- * the same way.
+/* The following code shows how to create a custom handler and run it as a standalone service.
+ * The handler will automatically attach to a queue and listen for work to do.
+ * You can also add multiple handlers the same way.
*/
// Usual .NET web app builder
diff --git a/extensions/AzureOpenAI/AzureOpenAIConfig.cs b/extensions/AzureOpenAI/AzureOpenAIConfig.cs
index 25efa3195..d0796d506 100644
--- a/extensions/AzureOpenAI/AzureOpenAIConfig.cs
+++ b/extensions/AzureOpenAI/AzureOpenAIConfig.cs
@@ -84,7 +84,7 @@ public void SetCredential(TokenCredential credential)
public TokenCredential GetTokenCredential()
{
return this._tokenCredential
- ?? throw new ConfigurationException("TokenCredential not defined");
+ ?? throw new ConfigurationException("Azure OpenAI TokenCredential not defined");
}
///
@@ -94,33 +94,33 @@ public void Validate()
{
if (this.Auth == AuthTypes.Unknown)
{
- throw new ArgumentOutOfRangeException(nameof(this.Auth), "The authentication type is not defined");
+ throw new ArgumentOutOfRangeException(nameof(this.Auth), "The Azure OpenAI Authentication Type is not defined");
}
if (this.Auth == AuthTypes.APIKey && string.IsNullOrWhiteSpace(this.APIKey))
{
- throw new ArgumentOutOfRangeException(nameof(this.APIKey), "The API Key is empty");
+ throw new ArgumentOutOfRangeException(nameof(this.APIKey), "The Azure OpenAI API Key is empty");
}
if (string.IsNullOrWhiteSpace(this.Endpoint))
{
- throw new ArgumentOutOfRangeException(nameof(this.Endpoint), "The endpoint value is empty");
+ throw new ArgumentOutOfRangeException(nameof(this.Endpoint), "The Azure OpenAI Endpoint value is empty");
}
if (!this.Endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
- throw new ArgumentOutOfRangeException(nameof(this.Endpoint), "The endpoint value must start with https://");
+ throw new ArgumentOutOfRangeException(nameof(this.Endpoint), "The Azure OpenAI Endpoint value must start with https://");
}
if (string.IsNullOrWhiteSpace(this.Deployment))
{
- throw new ArgumentOutOfRangeException(nameof(this.Deployment), "The deployment value is empty");
+ throw new ArgumentOutOfRangeException(nameof(this.Deployment), "The Azure OpenAI Deployment Name is empty");
}
if (this.MaxTokenTotal < 1)
{
throw new ArgumentOutOfRangeException(nameof(this.MaxTokenTotal),
- $"{nameof(this.MaxTokenTotal)} cannot be less than 1");
+ $"Azure OpenAI: {nameof(this.MaxTokenTotal)} cannot be less than 1");
}
}
}
diff --git a/service/Core/AppBuilders/DependencyInjection.cs b/service/Core/AppBuilders/DependencyInjection.cs
index 8508d06d4..7b564f9d9 100644
--- a/service/Core/AppBuilders/DependencyInjection.cs
+++ b/service/Core/AppBuilders/DependencyInjection.cs
@@ -1,6 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
+using System;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.KernelMemory.Configuration;
+using Microsoft.KernelMemory.Handlers;
using Microsoft.KernelMemory.Pipeline;
namespace Microsoft.KernelMemory;
@@ -16,8 +20,63 @@ public static partial class DependencyInjection
public static void AddHandlerAsHostedService(this IServiceCollection services, string stepName) where THandler : class, IPipelineStepHandler
{
services.AddTransient(serviceProvider => ActivatorUtilities.CreateInstance(serviceProvider, stepName));
-
services.AddHostedService>(
serviceProvider => ActivatorUtilities.CreateInstance>(serviceProvider, stepName));
}
+
+ ///
+ /// Register the handler as a hosted service, passing the step name to the handler ctor
+ ///
+ /// Application builder service collection
+ /// Handler class
+ /// Pipeline step name
+ public static void AddHandlerAsHostedService(this IServiceCollection services, Type tHandler, string stepName)
+ {
+ if (!typeof(IPipelineStepHandler).IsAssignableFrom(tHandler))
+ {
+ throw new ArgumentException($"'{tHandler.FullName}' doesn't implement interface '{nameof(IPipelineStepHandler)}'", nameof(tHandler));
+ }
+
+ if (tHandler == null)
+ {
+ throw new ArgumentNullException(nameof(tHandler), $"Handler type for '{stepName}' is NULL");
+ }
+
+ services.AddTransient(tHandler, serviceProvider => ActivatorUtilities.CreateInstance(serviceProvider, tHandler, stepName));
+
+ // Build generic type: HandlerAsAHostedService
+ Type handlerAsAHostedServiceTHandler = typeof(HandlerAsAHostedService<>).MakeGenericType(tHandler);
+
+ Func implementationFactory =
+ serviceProvider => (IHostedService)ActivatorUtilities.CreateInstance(serviceProvider, handlerAsAHostedServiceTHandler, stepName);
+
+ // See https://github.com/dotnet/runtime/issues/38751 for troubleshooting
+ services.Add(ServiceDescriptor.Singleton(implementationFactory));
+ }
+
+ ///
+ /// Register the handler as a hosted service, passing the step name to the handler ctor
+ ///
+ /// Application builder service collection
+ /// Handler type configuration
+ /// Pipeline step name
+ public static void AddHandlerAsHostedService(this IServiceCollection services, HandlerConfig config, string stepName)
+ {
+ if (HandlerTypeLoader.TryGetHandlerType(config, out var handlerType))
+ {
+ services.AddHandlerAsHostedService(handlerType, stepName);
+ }
+ }
+
+ ///
+ /// Register the handler as a hosted service, passing the step name to the handler ctor
+ ///
+ /// Application builder service collection
+ /// Path to assembly containing handler class
+ /// Handler type, within the assembly
+ /// Pipeline step name
+ public static void AddHandlerAsHostedService(this IServiceCollection services, string assemblyFile, string typeFullName, string stepName)
+ {
+ services.AddHandlerAsHostedService(new HandlerConfig(assemblyFile, typeFullName), stepName);
+ }
}
diff --git a/service/Core/AppBuilders/HandlerTypeLoader.cs b/service/Core/AppBuilders/HandlerTypeLoader.cs
new file mode 100644
index 000000000..7be02abbb
--- /dev/null
+++ b/service/Core/AppBuilders/HandlerTypeLoader.cs
@@ -0,0 +1,60 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Reflection;
+using Microsoft.KernelMemory.Configuration;
+using Microsoft.KernelMemory.Pipeline;
+
+namespace Microsoft.KernelMemory.Handlers;
+
+internal static class HandlerTypeLoader
+{
+ internal static bool TryGetHandlerType(HandlerConfig config, [NotNullWhen(true)] out Type? handlerType)
+ {
+ handlerType = null;
+
+ // If part of the config is empty, the handler is disabled
+ if (string.IsNullOrEmpty(config.Class) || string.IsNullOrEmpty(config.Assembly))
+ {
+ return false;
+ }
+
+ // Search the assembly in a few directories
+ var path = string.Empty;
+ var assemblyFilePaths = new HashSet
+ {
+ config.Assembly,
+ Path.Join(Environment.CurrentDirectory, config.Assembly),
+ Path.Join(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), config.Assembly),
+ };
+
+ foreach (var p in assemblyFilePaths)
+ {
+ if (!File.Exists(p)) { continue; }
+
+ path = p;
+ break;
+ }
+
+ // Check if the assembly exists
+ if (string.IsNullOrEmpty(path))
+ {
+ throw new ConfigurationException($"Handler assembly not found: {config.Assembly}");
+ }
+
+ Assembly assembly = Assembly.LoadFrom(path);
+
+ // IPipelineStepHandler
+ handlerType = assembly.GetType(config.Class);
+
+ if (!typeof(IPipelineStepHandler).IsAssignableFrom(handlerType))
+ {
+ throw new ConfigurationException($"Invalid handler definition: `{config.Class}` class doesn't implement interface {nameof(IPipelineStepHandler)}");
+ }
+
+ return true;
+ }
+}
diff --git a/service/Core/Configuration/HandlerConfig.cs b/service/Core/Configuration/HandlerConfig.cs
new file mode 100644
index 000000000..54cf34d62
--- /dev/null
+++ b/service/Core/Configuration/HandlerConfig.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.KernelMemory.Configuration;
+
+public class HandlerConfig
+{
+ ///
+ /// .NET assembly containing the handler class
+ ///
+ public string Assembly { get; set; }
+
+ ///
+ /// .NET class in the assembly, containing the handler logic
+ ///
+ public string Class { get; set; }
+
+ public HandlerConfig()
+ {
+ this.Assembly = string.Empty;
+ this.Class = string.Empty;
+ }
+
+ public HandlerConfig(string assembly, string className)
+ {
+ this.Assembly = assembly;
+ this.Class = className;
+ }
+}
diff --git a/service/Abstractions/Configuration/KernelMemoryConfig.cs b/service/Core/Configuration/KernelMemoryConfig.cs
similarity index 100%
rename from service/Abstractions/Configuration/KernelMemoryConfig.cs
rename to service/Core/Configuration/KernelMemoryConfig.cs
diff --git a/service/Abstractions/Configuration/ServiceAuthorizationConfig.cs b/service/Core/Configuration/ServiceAuthorizationConfig.cs
similarity index 100%
rename from service/Abstractions/Configuration/ServiceAuthorizationConfig.cs
rename to service/Core/Configuration/ServiceAuthorizationConfig.cs
diff --git a/service/Abstractions/Configuration/ServiceConfig.cs b/service/Core/Configuration/ServiceConfig.cs
similarity index 81%
rename from service/Abstractions/Configuration/ServiceConfig.cs
rename to service/Core/Configuration/ServiceConfig.cs
index 0cdacbd8c..33dd55a08 100644
--- a/service/Abstractions/Configuration/ServiceConfig.cs
+++ b/service/Core/Configuration/ServiceConfig.cs
@@ -1,5 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
+using System.Collections.Generic;
+
namespace Microsoft.KernelMemory.Configuration;
public class ServiceConfig
@@ -20,4 +22,9 @@ public class ServiceConfig
/// Web service settings, e.g. whether to expose OpenAPI swagger docs.
///
public bool OpenApiEnabled { get; set; } = false;
+
+ ///
+ /// List of handlers to enable
+ ///
+ public Dictionary Handlers { get; set; } = new();
}
diff --git a/service/Core/KernelMemoryBuilder.cs b/service/Core/KernelMemoryBuilder.cs
index bef80b15d..1f6a4ceb4 100644
--- a/service/Core/KernelMemoryBuilder.cs
+++ b/service/Core/KernelMemoryBuilder.cs
@@ -37,7 +37,7 @@ private enum ClientTypes
// Services required to build the memory client class
private readonly IServiceCollection _memoryServiceCollection;
- // Services of the host application, when hosting pipeline handlers, e.g. in service mode
+ // Services of the host application
private readonly IServiceCollection? _hostServiceCollection;
// List of all the embedding generators to use during ingestion
@@ -296,29 +296,6 @@ private MemoryService BuildAsyncClient()
var orchestrator = serviceProvider.GetService() ?? throw new ConfigurationException("Unable to build orchestrator");
var searchClient = serviceProvider.GetService() ?? throw new ConfigurationException("Unable to build search client");
- if (this._useDefaultHandlers)
- {
- if (this._hostServiceCollection == null)
- {
- const string ClassName = nameof(KernelMemoryBuilder);
- const string MethodName = nameof(this.WithoutDefaultHandlers);
- throw new ConfigurationException("Host service collection not available: unable to register default handlers. " +
- $"If you'd like using the default handlers use `new {ClassName}()`, " +
- $"otherwise use `new {ClassName}(...).{MethodName}()` to manage the list of handlers manually.");
- }
-
- // Handlers - Register these handlers to run as hosted services in the caller app.
- // At start each hosted handler calls IPipelineOrchestrator.AddHandlerAsync() to register in the orchestrator.
- this._hostServiceCollection.AddHandlerAsHostedService(Constants.PipelineStepsExtract);
- this._hostServiceCollection.AddHandlerAsHostedService(Constants.PipelineStepsPartition);
- this._hostServiceCollection.AddHandlerAsHostedService(Constants.PipelineStepsGenEmbeddings);
- this._hostServiceCollection.AddHandlerAsHostedService(Constants.PipelineStepsSaveRecords);
- this._hostServiceCollection.AddHandlerAsHostedService(Constants.PipelineStepsSummarize);
- this._hostServiceCollection.AddHandlerAsHostedService(Constants.PipelineStepsDeleteDocument);
- this._hostServiceCollection.AddHandlerAsHostedService(Constants.PipelineStepsDeleteIndex);
- this._hostServiceCollection.AddHandlerAsHostedService(Constants.PipelineStepsDeleteGeneratedFiles);
- }
-
this.CheckForMissingDependencies();
return new MemoryService(orchestrator, searchClient);
diff --git a/service/Service/Auth/HttpAuthHandler.cs b/service/Service/Auth/HttpAuthHandler.cs
index c05a6aea0..7ef89655f 100644
--- a/service/Service/Auth/HttpAuthHandler.cs
+++ b/service/Service/Auth/HttpAuthHandler.cs
@@ -13,6 +13,7 @@ public class HttpAuthEndpointFilter : IEndpointFilter
public HttpAuthEndpointFilter(ServiceAuthorizationConfig config)
{
+ config.Validate();
this._config = config;
}
diff --git a/service/Service/ConfigurationBuilderExtensions.cs b/service/Service/ConfigurationBuilderExtensions.cs
new file mode 100644
index 000000000..6d44df131
--- /dev/null
+++ b/service/Service/ConfigurationBuilderExtensions.cs
@@ -0,0 +1,98 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.IO;
+using System.Reflection;
+using Microsoft.Extensions.Configuration;
+using Microsoft.KernelMemory.Configuration;
+
+namespace Microsoft.KernelMemory.Service;
+
+internal static class ConfigurationBuilderExtensions
+{
+ // ASP.NET env var
+ private const string AspnetEnvVar = "ASPNETCORE_ENVIRONMENT";
+
+ public static void AddKMConfigurationSources(
+ this IConfigurationBuilder builder,
+ bool useAppSettingsFiles = true,
+ bool useEnvVars = true,
+ bool useSecretManager = true,
+ string? settingsDirectory = null)
+ {
+ // Load env var name, either Development or Production
+ var env = Environment.GetEnvironmentVariable(AspnetEnvVar) ?? string.Empty;
+
+ // Detect the folder containing configuration files
+ settingsDirectory ??= Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
+ ?? Directory.GetCurrentDirectory();
+ builder.SetBasePath(settingsDirectory);
+
+ // Add configuration files as sources
+ if (useAppSettingsFiles)
+ {
+ // Add appsettings.json, typically used for default settings, without credentials
+ var main = Path.Join(settingsDirectory, "appsettings.json");
+ if (!File.Exists(main))
+ {
+ throw new ConfigurationException($"appsettings.json not found. Directory: {settingsDirectory}");
+ }
+
+ builder.AddJsonFile(main, optional: false);
+
+ // Add appsettings.development.json, used for local overrides and credentials
+ if (env.Equals("development", StringComparison.OrdinalIgnoreCase))
+ {
+ var f1 = Path.Join(settingsDirectory, "appsettings.development.json");
+ var f2 = Path.Join(settingsDirectory, "appsettings.Development.json");
+ if (File.Exists(f1))
+ {
+ builder.AddJsonFile(f1, optional: false);
+ }
+ else if (File.Exists(f2))
+ {
+ builder.AddJsonFile(f2, optional: false);
+ }
+ }
+
+ // Add appsettings.production.json, used for production settings and credentials
+ if (env.Equals("production", StringComparison.OrdinalIgnoreCase))
+ {
+ var f1 = Path.Join(settingsDirectory, "appsettings.production.json");
+ var f2 = Path.Join(settingsDirectory, "appsettings.Production.json");
+ if (File.Exists(f1))
+ {
+ builder.AddJsonFile(f1, optional: false);
+ }
+ else if (File.Exists(f2))
+ {
+ builder.AddJsonFile(f2, optional: false);
+ }
+ }
+ }
+
+ // Add Secret Manager as source
+ if (useSecretManager)
+ {
+ // GetEntryAssembly method can return null if the library is loaded
+ // from an unmanaged application, in which case UserSecrets are not supported.
+ var entryAssembly = Assembly.GetEntryAssembly();
+
+ // Support for user secrets. Secret Manager doesn't encrypt the stored secrets and
+ // shouldn't be treated as a trusted store. It's for development purposes only.
+ // see: https://learn.microsoft.com/aspnet/core/security/app-secrets?#secret-manager
+ if (entryAssembly != null && env.Equals("development", StringComparison.OrdinalIgnoreCase))
+ {
+ builder.AddUserSecrets(entryAssembly, optional: true);
+ }
+ }
+
+ // Add environment variables as source.
+ // Environment variables can override all the settings provided by the previous sources.
+ if (useEnvVars)
+ {
+ // Support for environment variables overriding the config files
+ builder.AddEnvironmentVariables();
+ }
+ }
+}
diff --git a/service/Service/OpenAPI.cs b/service/Service/OpenAPI.cs
new file mode 100644
index 000000000..70df0031d
--- /dev/null
+++ b/service/Service/OpenAPI.cs
@@ -0,0 +1,60 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.OpenApi.Models;
+
+namespace Microsoft.KernelMemory.Service;
+
+internal static class OpenAPI
+{
+ public static void ConfigureSwagger(this WebApplicationBuilder appBuilder, KernelMemoryConfig config)
+ {
+ if (!config.Service.RunWebService || !config.Service.OpenApiEnabled) { return; }
+
+ appBuilder.Services.AddEndpointsApiExplorer();
+
+ // Note: this call is required even if service auth is disabled
+ appBuilder.Services.AddSwaggerGen(c =>
+ {
+ if (!config.ServiceAuthorization.Enabled) { return; }
+
+ const string ReqName = "auth";
+ c.AddSecurityDefinition(ReqName, new OpenApiSecurityScheme
+ {
+ Description = "The API key to access the API",
+ Type = SecuritySchemeType.ApiKey,
+ Scheme = "ApiKeyScheme",
+ Name = config.ServiceAuthorization.HttpHeaderName,
+ In = ParameterLocation.Header,
+ });
+
+ var scheme = new OpenApiSecurityScheme
+ {
+ Reference = new OpenApiReference
+ {
+ Id = ReqName,
+ Type = ReferenceType.SecurityScheme,
+ },
+ In = ParameterLocation.Header
+ };
+
+ var requirement = new OpenApiSecurityRequirement
+ {
+ { scheme, new List() }
+ };
+
+ c.AddSecurityRequirement(requirement);
+ });
+ }
+
+ public static void UseSwagger(this WebApplication app, KernelMemoryConfig config)
+ {
+ if (!config.Service.RunWebService || !config.Service.OpenApiEnabled) { return; }
+
+ // URL: http://localhost:9001/swagger/index.html
+ app.UseSwagger();
+ app.UseSwaggerUI();
+ }
+}
diff --git a/service/Service/Program.cs b/service/Service/Program.cs
index 9f288168f..e8c8b231d 100644
--- a/service/Service/Program.cs
+++ b/service/Service/Program.cs
@@ -3,45 +3,26 @@
using System;
using System.Collections.Generic;
using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
-using Microsoft.KernelMemory;
using Microsoft.KernelMemory.AI;
using Microsoft.KernelMemory.Configuration;
using Microsoft.KernelMemory.ContentStorage;
using Microsoft.KernelMemory.Diagnostics;
-using Microsoft.KernelMemory.InteractiveSetup;
using Microsoft.KernelMemory.MemoryStorage;
-using Microsoft.KernelMemory.Service;
-using Microsoft.KernelMemory.WebService;
-using Microsoft.OpenApi.Models;
-// ********************************************************
-// ************** APP SETTINGS ****************************
-// ********************************************************
-
-// Run `dotnet run setup` to run this code and setup the service
-if (new[] { "setup", "-setup", "config" }.Contains(args.FirstOrDefault(), StringComparer.OrdinalIgnoreCase))
-{
- Main.InteractiveSetup(args.Skip(1).ToArray(), cfgService: true);
-}
-
-// ********************************************************
-// ************** APP BUILD *******************************
-// ********************************************************
-
-// Usual .NET web app builder
-var appBuilder = WebApplication.CreateBuilder();
-appBuilder.Configuration.AddEnvironmentVariables();
-
-// Note:
-// * .NET loads settings from appsettings.json
+// KM Configuration:
+//
+// * Settings are loaded at runtime from multiple sources, merging values.
+// Each configuration source can override settings from the previous source:
+// - appsettings.json (default values)
+// - appsettings.Production.json (only if ASPNETCORE_ENVIRONMENT == "Production", e.g. in the Docker image)
+// - appsettings.Development.json (only if ASPNETCORE_ENVIRONMENT == "Development")
+// - .NET Secret Manager (only if ASPNETCORE_ENVIRONMENT == "Development" - see https://learn.microsoft.com/aspnet/core/security/app-secrets#secret-manager)
+// - environment variables (these can override everything else from the previous sources)
+//
// * You should set ASPNETCORE_ENVIRONMENT env var if you want to use also appsettings..json
// * In production environments:
// Set ASPNETCORE_ENVIRONMENT = Production
@@ -50,336 +31,97 @@
// * In local dev workstations:
// Set ASPNETCORE_ENVIRONMENT = Development
// and the app will try to load appsettings.Development.json
+// In dev mode the app will also look for settings in .NET Secret Manager
+//
+// * The app supports also environment variables, e.g.
+// to set: KernelMemory.Service.RunWebService = true
+// use an env var: KernelMemory__Service__RunWebService = true
-// Read the settings, needed below
-var config = appBuilder.Configuration.GetSection("KernelMemory").Get()
- ?? throw new ConfigurationException("Unable to load configuration");
-config.ServiceAuthorization.Validate();
+namespace Microsoft.KernelMemory.Service;
-// OpenAPI/swagger
-if (config.Service.RunWebService)
+internal sealed class Program
{
- appBuilder.Services.AddEndpointsApiExplorer();
- appBuilder.Services.AddSwaggerGen(c =>
+ public static void Main(string[] args)
{
- if (!config.ServiceAuthorization.Enabled) { return; }
-
- const string ReqName = "auth";
- c.AddSecurityDefinition(ReqName, new OpenApiSecurityScheme
- {
- Description = "The API key to access the API",
- Type = SecuritySchemeType.ApiKey,
- Scheme = "ApiKeyScheme",
- Name = config.ServiceAuthorization.HttpHeaderName,
- In = ParameterLocation.Header,
- });
+ // *************************** CONFIG WIZARD ***************************
- var scheme = new OpenApiSecurityScheme
+ // Run `dotnet run setup` to run this code and setup the service
+ if (new[] { "setup", "-setup", "config" }.Contains(args.FirstOrDefault(), StringComparer.OrdinalIgnoreCase))
{
- Reference = new OpenApiReference
- {
- Id = ReqName,
- Type = ReferenceType.SecurityScheme,
- },
- In = ParameterLocation.Header
- };
-
- var requirement = new OpenApiSecurityRequirement
- {
- { scheme, new List() }
- };
-
- c.AddSecurityRequirement(requirement);
- });
-}
-
-// Inject memory client and its dependencies
-// Note: pass the current service collection to the builder, in order to start the pipeline handlers
-IKernelMemory memory = new KernelMemoryBuilder(appBuilder.Services)
- .FromAppSettings()
- // .With...() // in case you need to set something not already defined by `.FromAppSettings()`
- .Build();
-
-appBuilder.Services.AddSingleton(memory);
-
-// Build .NET web app as usual
-var app = appBuilder.Build();
+ InteractiveSetup.Main.InteractiveSetup(args.Skip(1).ToArray(), cfgService: true);
+ }
-Console.WriteLine("***************************************************************************************************************************");
-Console.WriteLine($"* Environment : " + Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "WARNING: ASPNETCORE_ENVIRONMENT env var not defined");
-Console.WriteLine($"* Web service : " + (config.Service.RunWebService ? "Enabled" : "Disabled"));
-Console.WriteLine($"* Web service auth : " + (config.ServiceAuthorization.Enabled ? "Enabled" : "Disabled"));
-Console.WriteLine($"* Pipeline handlers : " + (config.Service.RunHandlers ? "Enabled" : "Disabled"));
-Console.WriteLine($"* OpenAPI swagger : " + (config.Service.OpenApiEnabled ? "Enabled" : "Disabled"));
-Console.WriteLine($"* Logging level : {app.Logger.GetLogLevelName()}");
-Console.WriteLine($"* Memory Db : {app.Services.GetService()?.GetType().FullName}");
-Console.WriteLine($"* Content storage : {app.Services.GetService()?.GetType().FullName}");
-Console.WriteLine($"* Embedding generation: {app.Services.GetService()?.GetType().FullName}");
-Console.WriteLine($"* Text generation : {app.Services.GetService()?.GetType().FullName}");
-Console.WriteLine("***************************************************************************************************************************");
+ // *************************** APP BUILD *******************************
-// ********************************************************
-// ************** WEB SERVICE ENDPOINTS *******************
-// ********************************************************
+ // Usual .NET web app builder with settings from appsettings.json, appsettings..json, and env vars
+ WebApplicationBuilder appBuilder = WebApplication.CreateBuilder();
+ appBuilder.Configuration.AddKMConfigurationSources();
-// ReSharper disable once TemplateIsNotCompileTimeConstantProblem
-
-if (config.Service.RunWebService)
-{
- if (config.Service.OpenApiEnabled)
- {
- // URL: http://localhost:9001/swagger/index.html
- app.UseSwagger();
- app.UseSwaggerUI();
- }
+ // Read KM settings, needed before building the app.
+ KernelMemoryConfig config = appBuilder.Configuration.GetSection("KernelMemory").Get()
+ ?? throw new ConfigurationException("Unable to load configuration");
- DateTimeOffset start = DateTimeOffset.UtcNow;
- var authFilter = new HttpAuthEndpointFilter(config.ServiceAuthorization);
-
- // Simple ping endpoint
- app.MapGet("/", () => Results.Ok("Ingestion service is running. " +
- "Uptime: " + (DateTimeOffset.UtcNow.ToUnixTimeSeconds()
- - start.ToUnixTimeSeconds()) + " secs " +
- $"- Environment: {Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")}"))
- .AddEndpointFilter(authFilter)
- .Produces(StatusCodes.Status200OK)
- .Produces(StatusCodes.Status401Unauthorized)
- .Produces(StatusCodes.Status403Forbidden);
-
- // File upload endpoint
- app.MapPost(Constants.HttpUploadEndpoint, async Task (
- HttpRequest request,
- IKernelMemory service,
- ILogger log,
- CancellationToken cancellationToken) =>
+ // Register pipeline handlers if enabled
+ if (config.Service.RunHandlers)
{
- log.LogTrace("New upload HTTP request");
+ // You can add handlers in the configuration or manually here using one of these syntaxes:
+ // appBuilder.Services.AddHandlerAsHostedService<...CLASS...>("...STEP NAME...");
+ // appBuilder.Services.AddHandlerAsHostedService("...assembly file name...", "...type full name...", "...STEP NAME...");
- // Note: .NET doesn't yet support binding multipart forms including data and files
- (HttpDocumentUploadRequest input, bool isValid, string errMsg)
- = await HttpDocumentUploadRequest.BindHttpRequestAsync(request, cancellationToken)
- .ConfigureAwait(false);
-
- if (!isValid)
+ // Register all pipeline handlers defined in the configuration to run as hosted services
+ foreach (KeyValuePair handlerConfig in config.Service.Handlers)
{
- log.LogError(errMsg);
- return Results.Problem(detail: errMsg, statusCode: 400);
+ appBuilder.Services.AddHandlerAsHostedService(config: handlerConfig.Value, stepName: handlerConfig.Key);
}
+ }
+
+ // Some OpenAPI Explorer/Swagger dependencies
+ appBuilder.ConfigureSwagger(config);
+
+ // Inject memory client and its dependencies
+ // Note: pass the current service collection to the builder, in order to start the pipeline handlers
+ var memoryBuilder = new KernelMemoryBuilder(appBuilder.Services)
+ .FromAppSettings();
+
+ // Build the memory client and make it available for dependency injection
+ appBuilder.Services.AddSingleton(memoryBuilder.Build());
+
+ // Build .NET web app as usual
+ WebApplication app = appBuilder.Build();
+
+ // Add HTTP endpoints using minimal API (https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis)
+ app.ConfigureMinimalAPI(config);
+
+ // *************************** START ***********************************
+
+ var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
+
+ Console.WriteLine("***************************************************************************************************************************");
+ Console.WriteLine($"* Environment : " + (string.IsNullOrEmpty(env) ? "WARNING: ASPNETCORE_ENVIRONMENT env var not defined" : env));
+ Console.WriteLine($"* Web service : " + (config.Service.RunWebService ? "Enabled" : "Disabled"));
+ Console.WriteLine($"* Web service auth : " + (config.ServiceAuthorization.Enabled ? "Enabled" : "Disabled"));
+ Console.WriteLine($"* Pipeline handlers : " + (config.Service.RunHandlers ? "Enabled" : "Disabled"));
+ Console.WriteLine($"* OpenAPI swagger : " + (config.Service.OpenApiEnabled ? "Enabled" : "Disabled"));
+ Console.WriteLine($"* Logging level : {app.Logger.GetLogLevelName()}");
+ Console.WriteLine($"* Memory Db : {app.Services.GetService()?.GetType().FullName}");
+ Console.WriteLine($"* Content storage : {app.Services.GetService()?.GetType().FullName}");
+ Console.WriteLine($"* Embedding generation: {app.Services.GetService()?.GetType().FullName}");
+ Console.WriteLine($"* Text generation : {app.Services.GetService()?.GetType().FullName}");
+ Console.WriteLine("***************************************************************************************************************************");
+
+ app.Logger.LogInformation(
+ "Starting Kernel Memory service, .NET Env: {0}, Log Level: {1}, Web service: {2}, Auth: {3}, Pipeline handlers: {4}",
+ env,
+ app.Logger.GetLogLevelName(),
+ config.Service.RunWebService,
+ config.ServiceAuthorization.Enabled,
+ config.Service.RunHandlers);
+
+ if (string.IsNullOrEmpty(env))
+ {
+ app.Logger.LogError("ASPNETCORE_ENVIRONMENT env var not defined.");
+ }
- try
- {
- // UploadRequest => Document
- var documentId = await service.ImportDocumentAsync(input.ToDocumentUploadRequest(), cancellationToken)
- .ConfigureAwait(false);
- var url = Constants.HttpUploadStatusEndpointWithParams
- .Replace(Constants.HttpIndexPlaceholder, input.Index, StringComparison.Ordinal)
- .Replace(Constants.HttpDocumentIdPlaceholder, documentId, StringComparison.Ordinal);
- return Results.Accepted(url, new UploadAccepted
- {
- DocumentId = documentId,
- Index = input.Index,
- Message = "Document upload completed, ingestion pipeline started"
- });
- }
- catch (Exception e)
- {
- return Results.Problem(title: "Document upload failed", detail: e.Message, statusCode: 503);
- }
- })
- .AddEndpointFilter(authFilter)
- .Produces(StatusCodes.Status202Accepted)
- .Produces(StatusCodes.Status400BadRequest)
- .Produces(StatusCodes.Status401Unauthorized)
- .Produces(StatusCodes.Status403Forbidden)
- .Produces(StatusCodes.Status503ServiceUnavailable);
-
- // List of indexes endpoint
- app.MapGet(Constants.HttpIndexesEndpoint,
- async Task (
- IKernelMemory service,
- ILogger log,
- CancellationToken cancellationToken) =>
- {
- log.LogTrace("New index list HTTP request");
-
- var result = new IndexCollection();
- IEnumerable list = await service.ListIndexesAsync(cancellationToken)
- .ConfigureAwait(false);
-
- foreach (IndexDetails index in list)
- {
- result.Results.Add(index);
- }
-
- return Results.Ok(result);
- })
- .AddEndpointFilter(authFilter)
- .Produces(StatusCodes.Status200OK)
- .Produces(StatusCodes.Status401Unauthorized)
- .Produces(StatusCodes.Status403Forbidden);
-
- // Delete index endpoint
- app.MapDelete(Constants.HttpIndexesEndpoint,
- async Task (
- [FromQuery(Name = Constants.WebServiceIndexField)]
- string? index,
- IKernelMemory service,
- ILogger log,
- CancellationToken cancellationToken) =>
- {
- log.LogTrace("New delete document HTTP request");
- await service.DeleteIndexAsync(index: index, cancellationToken)
- .ConfigureAwait(false);
- // There's no API to check the index deletion progress, so the URL is empty
- var url = string.Empty;
- return Results.Accepted(url, new DeleteAccepted
- {
- Index = index ?? string.Empty,
- Message = "Index deletion request received, pipeline started"
- });
- })
- .AddEndpointFilter(authFilter)
- .Produces(StatusCodes.Status202Accepted)
- .Produces(StatusCodes.Status401Unauthorized)
- .Produces(StatusCodes.Status403Forbidden);
-
- // Delete document endpoint
- app.MapDelete(Constants.HttpDocumentsEndpoint,
- async Task (
- [FromQuery(Name = Constants.WebServiceIndexField)]
- string? index,
- [FromQuery(Name = Constants.WebServiceDocumentIdField)]
- string documentId,
- IKernelMemory service,
- ILogger log,
- CancellationToken cancellationToken) =>
- {
- log.LogTrace("New delete document HTTP request");
- await service.DeleteDocumentAsync(documentId: documentId, index: index, cancellationToken)
- .ConfigureAwait(false);
- var url = Constants.HttpUploadStatusEndpointWithParams
- .Replace(Constants.HttpIndexPlaceholder, index, StringComparison.Ordinal)
- .Replace(Constants.HttpDocumentIdPlaceholder, documentId, StringComparison.Ordinal);
- return Results.Accepted(url, new DeleteAccepted
- {
- DocumentId = documentId,
- Index = index ?? string.Empty,
- Message = "Document deletion request received, pipeline started"
- });
- })
- .AddEndpointFilter(authFilter)
- .Produces(StatusCodes.Status202Accepted)
- .Produces(StatusCodes.Status401Unauthorized)
- .Produces(StatusCodes.Status403Forbidden);
-
- // Ask endpoint
- app.MapPost(Constants.HttpAskEndpoint,
- async Task (
- MemoryQuery query,
- IKernelMemory service,
- ILogger log,
- CancellationToken cancellationToken) =>
- {
- log.LogTrace("New search request");
- MemoryAnswer answer = await service.AskAsync(
- question: query.Question,
- index: query.Index,
- filters: query.Filters,
- minRelevance: query.MinRelevance,
- cancellationToken: cancellationToken)
- .ConfigureAwait(false);
- return Results.Ok(answer);
- })
- .AddEndpointFilter(authFilter)
- .Produces(StatusCodes.Status200OK)
- .Produces(StatusCodes.Status401Unauthorized)
- .Produces(StatusCodes.Status403Forbidden);
-
- // Search endpoint
- app.MapPost(Constants.HttpSearchEndpoint,
- async Task (
- SearchQuery query,
- IKernelMemory service,
- ILogger log,
- CancellationToken cancellationToken) =>
- {
- log.LogTrace("New search HTTP request");
- SearchResult answer = await service.SearchAsync(
- query: query.Query,
- index: query.Index,
- filters: query.Filters,
- minRelevance: query.MinRelevance,
- limit: query.Limit,
- cancellationToken: cancellationToken)
- .ConfigureAwait(false);
- return Results.Ok(answer);
- })
- .AddEndpointFilter(authFilter)
- .Produces(StatusCodes.Status200OK)
- .Produces(StatusCodes.Status401Unauthorized)
- .Produces(StatusCodes.Status403Forbidden);
-
- // Document status endpoint
- app.MapGet(Constants.HttpUploadStatusEndpoint,
- async Task (
- [FromQuery(Name = Constants.WebServiceIndexField)]
- string? index,
- [FromQuery(Name = Constants.WebServiceDocumentIdField)]
- string documentId,
- IKernelMemory memoryClient,
- ILogger log,
- CancellationToken cancellationToken) =>
- {
- log.LogTrace("New document status HTTP request");
- index = IndexExtensions.CleanName(index);
-
- if (string.IsNullOrEmpty(documentId))
- {
- return Results.Problem(detail: $"'{Constants.WebServiceDocumentIdField}' query parameter is missing or has no value", statusCode: 400);
- }
-
- DataPipelineStatus? pipeline = await memoryClient.GetDocumentStatusAsync(documentId: documentId, index: index, cancellationToken)
- .ConfigureAwait(false);
- if (pipeline == null)
- {
- return Results.Problem(detail: "Document not found", statusCode: 404);
- }
-
- if (pipeline.Empty)
- {
- return Results.Problem(detail: "Empty pipeline", statusCode: 404);
- }
-
- return Results.Ok(pipeline);
- })
- .AddEndpointFilter(authFilter)
- .Produces(StatusCodes.Status200OK)
- .Produces(StatusCodes.Status400BadRequest)
- .Produces(StatusCodes.Status401Unauthorized)
- .Produces(StatusCodes.Status403Forbidden)
- .Produces(StatusCodes.Status404NotFound);
-}
-#pragma warning restore CA1031
-#pragma warning restore CA2254
-
-// ********************************************************
-// ************** START ***********************************
-// ********************************************************
-
-var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? string.Empty;
-app.Logger.LogInformation(
- "Starting Kernel Memory service, .NET Env: {0}, Log Level: {1}, Web service: {2}, Auth: {3}, Pipeline handlers: {4}",
- env,
- app.Logger.GetLogLevelName(),
- config.Service.RunWebService,
- config.ServiceAuthorization.Enabled,
- config.Service.RunHandlers);
-
-if (string.IsNullOrEmpty(env))
-{
- app.Logger.LogError("ASPNETCORE_ENVIRONMENT env var not defined.");
+ app.Run();
+ }
}
-
-app.Run();
-
-
diff --git a/service/Service/README.md b/service/Service/README.md
index 16e9baac9..008cb6182 100644
--- a/service/Service/README.md
+++ b/service/Service/README.md
@@ -5,17 +5,34 @@ settings, ingest data and query for answers.
The service is composed by two main components:
-* A web service to upload files, to ask questions, and to manage settings.
-* A background asynchronous data pipeline to process the files uploaded.
+1. A web service to upload files, to ask questions.
+2. A background asynchronous data pipeline to process the files uploaded.
If you need deploying and scaling the webservice and the pipeline handlers
-separately, you can configure the service to enable/disable them.
+separately, you can enable/disable each of them via configuration.
Once the service is up and running, you can use the **Kernel Memory web
client** or simply interact with the Web API. The API schema is available
at http://127.0.0.1:9001/swagger/index.html when running the service locally
with **OpenAPI** enabled.
+# ▶️ Docker support
+
+If you're looking for a Docker image, we publish a build [here](https://hub.docker.com/r/kernelmemory/service) and
+you can use the [Dockerfile](https://github.com/microsoft/kernel-memory/blob/main/Dockerfile) in the repo for custom builds.
+
+You can test the image in demo mode passing the OPENAI_API_KEY environment variable, otherwise for a full setup
+you will need a configuration file first.
+
+```
+docker run -e OPENAI_API_KEY="..." -p 9001:9001 -it --rm kernelmemory/service
+```
+
+```
+docker run --volume ./appsettings.Development.json:/app/data/appsettings.Production.json \
+ -p 9001:9001 -it --rm kernelmemory/service
+```
+
# ⚙️ Configuration
To quickly set up the service, run the following command and follow the
@@ -25,23 +42,23 @@ questions on screen.
dotnet run setup
```
-The app will create a configuration file `appsettings.Development.json`
+The wizard will create a configuration file `appsettings.Development.json`
that you can customize. Look at the comments in `appsettings.json` for
details and more advanced options.
-Configuration settings can be saved in four places:
+Configuration settings can be saved in multiple places, each source can also override the previous
+(ie environment variables can be used to override settings in the config files):
-1. `appsettings.json`: although possible, it's not recommended, to avoid
- risks of leaking secrets in source code repositories.
-2. `appsettings.Development.json`: this works only when the environment
- variable `ASPNETCORE_ENVIRONMENT` is set to `Development`.
-3. `appsettings.Production.json`: this works only when the environment
- variable `ASPNETCORE_ENVIRONMENT` is set to `Production`.
-4. using **env vars**: preferred method for credentials. Any setting in
- appsettings.json can be overridden by env vars. The env var name correspond
- to the configuration key name, using `__` (double underscore) as a separator.
+1. `appsettings.json`: although possible, it's not recommended, to avoid risks of leaking secrets
+ in source code repositories. The file is mandatory and is used only for default settings.
+2. `appsettings.Development.json`: this is used only when the environment variable `ASPNETCORE_ENVIRONMENT` is set to `Development`.
+3. `appsettings.Production.json`: this is used only when the environment variable `ASPNETCORE_ENVIRONMENT` is set to `Production`.
+4. [.NET Secret Manager](https://learn.microsoft.com/aspnet/core/security/app-secrets#secret-manager)
+5. using **env vars**: preferred method for credentials. Any setting in appsettings.json can be overridden by env vars.
+ The env var name corresponds to the configuration key name, using `__` (double underscore) as a separator instead of `:`.
+ For instance `Logging:LogLevel:Default` is set with `Logging__LogLevel__Default`.
-# ▶️ Start the service
+# ▶️ Start the service from source
To run the Kernel Memory service:
@@ -88,29 +105,18 @@ The service depends on three main components:
get better results with 16k, 32k and bigger models.
-* **Vector storage**: service used to persist embeddings. Currently, the
- service supports **Azure AI Search**, **Qdrant** and a very basic
- in memory vector storage with support for persistence on disk.
- Soon we'll add support for more DBs.
-
- > To use Qdrant locally, install docker and launch Qdrant with:
- >
- > docker run -it --rm --name qdrant -p 6333:6333 qdrant/qdrant
- > or simply use the `run-qdrant.sh` script from the `tools` folder.
+* **Vector storage**: service used to persist embeddings. The
+ service supports **Azure AI Search**, **Qdrant**, **Redis** and other engines,
+ plus a very basic in memory vector storage with support for persistence on disk
+ called **SimpleVectorDb**. Unless configured differently, KM uses SimpleVectorDb
+ storing data in memory only.
* **Data ingestion orchestration**: this can run in memory and in the same
process, e.g. when working with small files, or run as a service, in which
- case it requires persistent queues like **Azure Queues** or **RabbitMQ**
- (corelib includes also a basic in memory queue, that might be useful
- for tests and demos, with support for persistence on disk).
+ case it requires persistent queues like **Azure Queues** or **RabbitMQ**.
+ The Core assembly/package includes also a basic in memory queue called
+ **SimpleQueues** that might be useful for tests and demos.
When running the service, we recommend persistent queues for reliability and
horizontal scaling, like Azure Queues and RabbitMQ.
-
- > To use RabbitMQ locally, install docker and launch RabbitMQ with:
- >
- > docker run -it --rm --name rabbitmq \
- > -p 5672:5672 -e RABBITMQ_DEFAULT_USER=user -e RABBITMQ_DEFAULT_PASS=password \
- > rabbitmq:3
- > or simply use the `run-rabbitmq.sh` script from the `tools` folder.
diff --git a/service/Service/ServiceConfiguration.cs b/service/Service/ServiceConfiguration.cs
index ea5f8f99c..b98812da1 100644
--- a/service/Service/ServiceConfiguration.cs
+++ b/service/Service/ServiceConfiguration.cs
@@ -2,8 +2,6 @@
using System;
using System.Collections.Generic;
-using System.IO;
-using System.Reflection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.KernelMemory.AI;
@@ -79,11 +77,6 @@ private IKernelMemoryBuilder BuildUsingConfiguration(IKernelMemoryBuilder builde
// Required by ctors expecting KernelMemoryConfig via DI
builder.AddSingleton(this._memoryConfiguration);
- if (!this._memoryConfiguration.Service.RunHandlers)
- {
- builder.WithoutDefaultHandlers();
- }
-
this.ConfigureMimeTypeDetectionDependency(builder);
this.ConfigureTextPartitioning(builder);
@@ -122,71 +115,8 @@ private IKernelMemoryBuilder BuildUsingConfiguration(IKernelMemoryBuilder builde
private static IConfiguration ReadAppSettings(string? settingsDirectory)
{
- if (settingsDirectory == null)
- {
- settingsDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? Directory.GetCurrentDirectory();
- }
-
- var env = Environment.GetEnvironmentVariable(AspnetEnvVar) ?? string.Empty;
var builder = new ConfigurationBuilder();
-
- builder.SetBasePath(settingsDirectory);
-
- var main = Path.Join(settingsDirectory, "appsettings.json");
- if (File.Exists(main))
- {
- builder.AddJsonFile(main, optional: false);
- }
- else
- {
- throw new ConfigurationException($"appsettings.json not found. Directory: {settingsDirectory}");
- }
-
- if (env.Equals("development", StringComparison.OrdinalIgnoreCase))
- {
- var f1 = Path.Join(settingsDirectory, "appsettings.development.json");
- var f2 = Path.Join(settingsDirectory, "appsettings.Development.json");
- if (File.Exists(f1))
- {
- builder.AddJsonFile(f1, optional: false);
- }
- else if (File.Exists(f2))
- {
- builder.AddJsonFile(f2, optional: false);
- }
- }
-
- if (env.Equals("production", StringComparison.OrdinalIgnoreCase))
- {
- var f1 = Path.Join(settingsDirectory, "appsettings.production.json");
- var f2 = Path.Join(settingsDirectory, "appsettings.Production.json");
- if (File.Exists(f1))
- {
- builder.AddJsonFile(f1, optional: false);
- }
- else if (File.Exists(f2))
- {
- builder.AddJsonFile(f2, optional: false);
- }
- }
-
- // Support for environment variables overriding the config files
- builder.AddEnvironmentVariables();
-
- // Support for user secrets. Secret Manager doesn't encrypt the stored secrets and
- // shouldn't be treated as a trusted store. It's for development purposes only.
- // see: https://learn.microsoft.com/aspnet/core/security/app-secrets?view=aspnetcore-7.0&tabs=windows#secret-manager
- if (env.Equals("development", StringComparison.OrdinalIgnoreCase))
- {
- // GetEntryAssembly method can return null if this library is loaded
- // from an unmanaged application, in which case UserSecrets are not supported.
- var entryAssembly = Assembly.GetEntryAssembly();
- if (entryAssembly != null)
- {
- builder.AddUserSecrets(entryAssembly, optional: true);
- }
- }
-
+ builder.AddKMConfigurationSources(settingsDirectory: settingsDirectory);
return builder.Build();
}
diff --git a/service/Service/WebAPIEndpoints.cs b/service/Service/WebAPIEndpoints.cs
new file mode 100644
index 000000000..7c30ec826
--- /dev/null
+++ b/service/Service/WebAPIEndpoints.cs
@@ -0,0 +1,299 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+using Microsoft.KernelMemory.WebService;
+
+namespace Microsoft.KernelMemory.Service;
+
+internal static class WebAPIEndpoints
+{
+ private static readonly DateTimeOffset s_start = DateTimeOffset.UtcNow;
+
+ public static void ConfigureMinimalAPI(this WebApplication app, KernelMemoryConfig config)
+ {
+ if (!config.Service.RunWebService) { return; }
+
+ app.UseSwagger(config);
+
+ var authFilter = new HttpAuthEndpointFilter(config.ServiceAuthorization);
+
+ app.UseGetStatusEndpoint(config, authFilter);
+ app.UsePostUploadEndpoint(config, authFilter);
+ app.UseGetIndexesEndpoint(config, authFilter);
+ app.UseDeleteIndexesEndpoint(config, authFilter);
+ app.UseDeleteDocumentsEndpoint(config, authFilter);
+ app.UseAskEndpoint(config, authFilter);
+ app.UseSearchEndpoint(config, authFilter);
+ app.UseUploadStatusEndpoint(config, authFilter);
+ }
+
+ public static void UseGetStatusEndpoint(this WebApplication app, KernelMemoryConfig config, HttpAuthEndpointFilter authFilter)
+ {
+ if (!config.Service.RunWebService) { return; }
+
+ // Simple ping endpoint
+ app.MapGet("/", () => Results.Ok("Ingestion service is running. " +
+ "Uptime: " + (DateTimeOffset.UtcNow.ToUnixTimeSeconds()
+ - s_start.ToUnixTimeSeconds()) + " secs " +
+ $"- Environment: {Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")}"))
+ .AddEndpointFilter(authFilter)
+ .Produces(StatusCodes.Status200OK)
+ .Produces(StatusCodes.Status401Unauthorized)
+ .Produces(StatusCodes.Status403Forbidden);
+ }
+
+ public static void UsePostUploadEndpoint(this WebApplication app, KernelMemoryConfig config, HttpAuthEndpointFilter authFilter)
+ {
+ if (!config.Service.RunWebService) { return; }
+
+ // File upload endpoint
+ app.MapPost(Constants.HttpUploadEndpoint, async Task (
+ HttpRequest request,
+ IKernelMemory service,
+ ILogger log,
+ CancellationToken cancellationToken) =>
+ {
+ log.LogTrace("New upload HTTP request");
+
+ // Note: .NET doesn't yet support binding multipart forms including data and files
+ (HttpDocumentUploadRequest input, bool isValid, string errMsg)
+ = await HttpDocumentUploadRequest.BindHttpRequestAsync(request, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!isValid)
+ {
+ log.LogError(errMsg);
+ return Results.Problem(detail: errMsg, statusCode: 400);
+ }
+
+ try
+ {
+ // UploadRequest => Document
+ var documentId = await service.ImportDocumentAsync(input.ToDocumentUploadRequest(), cancellationToken)
+ .ConfigureAwait(false);
+ var url = Constants.HttpUploadStatusEndpointWithParams
+ .Replace(Constants.HttpIndexPlaceholder, input.Index, StringComparison.Ordinal)
+ .Replace(Constants.HttpDocumentIdPlaceholder, documentId, StringComparison.Ordinal);
+ return Results.Accepted(url, new UploadAccepted
+ {
+ DocumentId = documentId,
+ Index = input.Index,
+ Message = "Document upload completed, ingestion pipeline started"
+ });
+ }
+ catch (Exception e)
+ {
+ return Results.Problem(title: "Document upload failed", detail: e.Message, statusCode: 503);
+ }
+ })
+ .AddEndpointFilter(authFilter)
+ .Produces(StatusCodes.Status202Accepted)
+ .Produces(StatusCodes.Status400BadRequest)
+ .Produces(StatusCodes.Status401Unauthorized)
+ .Produces(StatusCodes.Status403Forbidden)
+ .Produces(StatusCodes.Status503ServiceUnavailable);
+ }
+
+ public static void UseGetIndexesEndpoint(this WebApplication app, KernelMemoryConfig config, HttpAuthEndpointFilter authFilter)
+ {
+ if (!config.Service.RunWebService) { return; }
+
+ // List of indexes endpoint
+ app.MapGet(Constants.HttpIndexesEndpoint,
+ async Task (
+ IKernelMemory service,
+ ILogger log,
+ CancellationToken cancellationToken) =>
+ {
+ log.LogTrace("New index list HTTP request");
+
+ var result = new IndexCollection();
+ IEnumerable list = await service.ListIndexesAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ foreach (IndexDetails index in list)
+ {
+ result.Results.Add(index);
+ }
+
+ return Results.Ok(result);
+ })
+ .AddEndpointFilter(authFilter)
+ .Produces(StatusCodes.Status200OK)
+ .Produces(StatusCodes.Status401Unauthorized)
+ .Produces(StatusCodes.Status403Forbidden);
+ }
+
+ public static void UseDeleteIndexesEndpoint(this WebApplication app, KernelMemoryConfig config, HttpAuthEndpointFilter authFilter)
+ {
+ if (!config.Service.RunWebService) { return; }
+
+ // Delete index endpoint
+ app.MapDelete(Constants.HttpIndexesEndpoint,
+ async Task (
+ [FromQuery(Name = Constants.WebServiceIndexField)]
+ string? index,
+ IKernelMemory service,
+ ILogger log,
+ CancellationToken cancellationToken) =>
+ {
+ log.LogTrace("New delete document HTTP request");
+ await service.DeleteIndexAsync(index: index, cancellationToken)
+ .ConfigureAwait(false);
+ // There's no API to check the index deletion progress, so the URL is empty
+ var url = string.Empty;
+ return Results.Accepted(url, new DeleteAccepted
+ {
+ Index = index ?? string.Empty,
+ Message = "Index deletion request received, pipeline started"
+ });
+ })
+ .AddEndpointFilter(authFilter)
+ .Produces(StatusCodes.Status202Accepted)
+ .Produces(StatusCodes.Status401Unauthorized)
+ .Produces(StatusCodes.Status403Forbidden);
+ }
+
+ public static void UseDeleteDocumentsEndpoint(this WebApplication app, KernelMemoryConfig config, HttpAuthEndpointFilter authFilter)
+ {
+ if (!config.Service.RunWebService) { return; }
+
+ // Delete document endpoint
+ app.MapDelete(Constants.HttpDocumentsEndpoint,
+ async Task (
+ [FromQuery(Name = Constants.WebServiceIndexField)]
+ string? index,
+ [FromQuery(Name = Constants.WebServiceDocumentIdField)]
+ string documentId,
+ IKernelMemory service,
+ ILogger log,
+ CancellationToken cancellationToken) =>
+ {
+ log.LogTrace("New delete document HTTP request");
+ await service.DeleteDocumentAsync(documentId: documentId, index: index, cancellationToken)
+ .ConfigureAwait(false);
+ var url = Constants.HttpUploadStatusEndpointWithParams
+ .Replace(Constants.HttpIndexPlaceholder, index, StringComparison.Ordinal)
+ .Replace(Constants.HttpDocumentIdPlaceholder, documentId, StringComparison.Ordinal);
+ return Results.Accepted(url, new DeleteAccepted
+ {
+ DocumentId = documentId,
+ Index = index ?? string.Empty,
+ Message = "Document deletion request received, pipeline started"
+ });
+ })
+ .AddEndpointFilter(authFilter)
+ .Produces(StatusCodes.Status202Accepted)
+ .Produces(StatusCodes.Status401Unauthorized)
+ .Produces(StatusCodes.Status403Forbidden);
+ }
+
+ public static void UseAskEndpoint(this WebApplication app, KernelMemoryConfig config, HttpAuthEndpointFilter authFilter)
+ {
+ if (!config.Service.RunWebService) { return; }
+
+ // Ask endpoint
+ app.MapPost(Constants.HttpAskEndpoint,
+ async Task (
+ MemoryQuery query,
+ IKernelMemory service,
+ ILogger log,
+ CancellationToken cancellationToken) =>
+ {
+ log.LogTrace("New search request");
+ MemoryAnswer answer = await service.AskAsync(
+ question: query.Question,
+ index: query.Index,
+ filters: query.Filters,
+ minRelevance: query.MinRelevance,
+ cancellationToken: cancellationToken)
+ .ConfigureAwait(false);
+ return Results.Ok(answer);
+ })
+ .AddEndpointFilter(authFilter)
+ .Produces(StatusCodes.Status200OK)
+ .Produces(StatusCodes.Status401Unauthorized)
+ .Produces(StatusCodes.Status403Forbidden);
+ }
+
+ public static void UseSearchEndpoint(this WebApplication app, KernelMemoryConfig config, HttpAuthEndpointFilter authFilter)
+ {
+ if (!config.Service.RunWebService) { return; }
+
+ // Search endpoint
+ app.MapPost(Constants.HttpSearchEndpoint,
+ async Task (
+ SearchQuery query,
+ IKernelMemory service,
+ ILogger log,
+ CancellationToken cancellationToken) =>
+ {
+ log.LogTrace("New search HTTP request");
+ SearchResult answer = await service.SearchAsync(
+ query: query.Query,
+ index: query.Index,
+ filters: query.Filters,
+ minRelevance: query.MinRelevance,
+ limit: query.Limit,
+ cancellationToken: cancellationToken)
+ .ConfigureAwait(false);
+ return Results.Ok(answer);
+ })
+ .AddEndpointFilter(authFilter)
+ .Produces(StatusCodes.Status200OK)
+ .Produces(StatusCodes.Status401Unauthorized)
+ .Produces(StatusCodes.Status403Forbidden);
+ }
+
+ public static void UseUploadStatusEndpoint(this WebApplication app, KernelMemoryConfig config, HttpAuthEndpointFilter authFilter)
+ {
+ if (!config.Service.RunWebService) { return; }
+
+ // Document status endpoint
+ app.MapGet(Constants.HttpUploadStatusEndpoint,
+ async Task (
+ [FromQuery(Name = Constants.WebServiceIndexField)]
+ string? index,
+ [FromQuery(Name = Constants.WebServiceDocumentIdField)]
+ string documentId,
+ IKernelMemory memoryClient,
+ ILogger log,
+ CancellationToken cancellationToken) =>
+ {
+ log.LogTrace("New document status HTTP request");
+ index = IndexExtensions.CleanName(index);
+
+ if (string.IsNullOrEmpty(documentId))
+ {
+ return Results.Problem(detail: $"'{Constants.WebServiceDocumentIdField}' query parameter is missing or has no value", statusCode: 400);
+ }
+
+ DataPipelineStatus? pipeline = await memoryClient.GetDocumentStatusAsync(documentId: documentId, index: index, cancellationToken)
+ .ConfigureAwait(false);
+ if (pipeline == null)
+ {
+ return Results.Problem(detail: "Document not found", statusCode: 404);
+ }
+
+ if (pipeline.Empty)
+ {
+ return Results.Problem(detail: "Empty pipeline", statusCode: 404);
+ }
+
+ return Results.Ok(pipeline);
+ })
+ .AddEndpointFilter(authFilter)
+ .Produces(StatusCodes.Status200OK)
+ .Produces(StatusCodes.Status400BadRequest)
+ .Produces(StatusCodes.Status401Unauthorized)
+ .Produces(StatusCodes.Status403Forbidden)
+ .Produces(StatusCodes.Status404NotFound);
+ }
+}
diff --git a/service/Service/appsettings.json b/service/Service/appsettings.json
index c72ae4d91..018398e2b 100644
--- a/service/Service/appsettings.json
+++ b/service/Service/appsettings.json
@@ -29,11 +29,54 @@
// Whether to run the web service that allows to upload files and search memory
// Use these booleans to deploy the web service and the handlers on same/different VMs
"RunWebService": true,
+ // Whether to expose OpenAPI swagger UI at http://127.0.0.1:9001/swagger/index.html
+ "OpenApiEnabled": false,
// Whether to run the asynchronous pipeline handlers
// Use these booleans to deploy the web service and the handlers on same/different VMs
"RunHandlers": true,
- // Whether to expose OpenAPI swagger UI at http://127.0.0.1:9001/swagger/index.html
- "OpenApiEnabled": false
+ // Handlers to load for callers (use "steps" to choose which handlers
+ // to use to process a document during the ingestion)
+ "Handlers": {
+ // The key, e.g. "extract", is the name used when starting a pipeline with specific steps
+ "extract": {
+ "Assembly": "Microsoft.KernelMemory.Core.dll",
+ "Class": "Microsoft.KernelMemory.Handlers.TextExtractionHandler"
+ },
+ "partition": {
+ "Assembly": "Microsoft.KernelMemory.Core.dll",
+ "Class": "Microsoft.KernelMemory.Handlers.TextPartitioningHandler"
+ },
+ "gen_embeddings": {
+ "Assembly": "Microsoft.KernelMemory.Core.dll",
+ "Class": "Microsoft.KernelMemory.Handlers.GenerateEmbeddingsHandler"
+ },
+ "save_records": {
+ "Assembly": "Microsoft.KernelMemory.Core.dll",
+ "Class": "Microsoft.KernelMemory.Handlers.SaveRecordsHandler"
+ },
+ "summarize": {
+ "Assembly": "Microsoft.KernelMemory.Core.dll",
+ "Class": "Microsoft.KernelMemory.Handlers.SummarizationHandler"
+ },
+ "delete_generated_files": {
+ "Assembly": "Microsoft.KernelMemory.Core.dll",
+ "Class": "Microsoft.KernelMemory.Handlers.DeleteGeneratedFilesHandler"
+ },
+ "private_delete_document": {
+ "Assembly": "Microsoft.KernelMemory.Core.dll",
+ "Class": "Microsoft.KernelMemory.Handlers.DeleteDocumentHandler"
+ },
+ "private_delete_index": {
+ "Assembly": "Microsoft.KernelMemory.Core.dll",
+ "Class": "Microsoft.KernelMemory.Handlers.DeleteIndexHandler"
+ },
+ "disabled_handler_example": {
+ // Setting Class or Assembly to "" in appsettings.Development.json or appsettings.Production.json
+ // allows to remove a handler defined in appsettings.json
+ "Class": "",
+ "Assembly": ""
+ }
+ }
},
"ServiceAuthorization": {
// Whether clients must provide some credentials to interact with the HTTP API
diff --git a/service/Service/run.cmd b/service/Service/run.cmd
index 22eb3fcac..cdaa2038c 100644
--- a/service/Service/run.cmd
+++ b/service/Service/run.cmd
@@ -1,5 +1,5 @@
@echo off
-dotnet restore
-dotnet build
-cmd /C "set ASPNETCORE_ENVIRONMENT=Development && dotnet run"
+dotnet clean
+dotnet build -c Debug -p "SolutionName=KernelMemory"
+cmd /C "set ASPNETCORE_ENVIRONMENT=Development && dotnet run --no-build --no-restore"
diff --git a/service/Service/run.sh b/service/Service/run.sh
index d8c46fb7d..deaa07cb8 100755
--- a/service/Service/run.sh
+++ b/service/Service/run.sh
@@ -2,9 +2,8 @@
set -e
-cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/"
-
-dotnet restore
-dotnet build
-ASPNETCORE_ENVIRONMENT=Development dotnet run
+cd "$(dirname "${BASH_SOURCE[0]:-$0}")"
+dotnet clean
+dotnet build -c Debug -p "SolutionName=KernelMemory"
+ASPNETCORE_ENVIRONMENT=Development dotnet run --no-build --no-restore
diff --git a/service/Service/setup.cmd b/service/Service/setup.cmd
index 75e18a50b..92ab5b799 100644
--- a/service/Service/setup.cmd
+++ b/service/Service/setup.cmd
@@ -1,5 +1,5 @@
@echo off
-dotnet restore
-dotnet build
-dotnet run setup
+dotnet clean
+dotnet build -c Debug -p "SolutionName=KernelMemory"
+cmd /C "set ASPNETCORE_ENVIRONMENT=Development && dotnet run setup --no-build --no-restore"
diff --git a/service/Service/setup.sh b/service/Service/setup.sh
index f2ee1e55a..ee22f4d11 100755
--- a/service/Service/setup.sh
+++ b/service/Service/setup.sh
@@ -1,9 +1,9 @@
-# This script can be used from the repo or from the docker image
+# This script is used also in the Docker image
if [ -f "Microsoft.KernelMemory.ServiceAssembly.dll" ]; then
dotnet Microsoft.KernelMemory.ServiceAssembly.dll setup
else
- dotnet restore
- dotnet build
- dotnet run setup
+ dotnet clean
+ dotnet build -c Debug -p "SolutionName=KernelMemory"
+ ASPNETCORE_ENVIRONMENT=Development dotnet run setup --no-build --no-restore
fi
\ No newline at end of file
diff --git a/service/tests/Core.FunctionalTests/DefaultTestCases/IndexListTest.cs b/service/tests/Core.FunctionalTests/DefaultTestCases/IndexListTest.cs
index 87293c2ce..9275b1eef 100644
--- a/service/tests/Core.FunctionalTests/DefaultTestCases/IndexListTest.cs
+++ b/service/tests/Core.FunctionalTests/DefaultTestCases/IndexListTest.cs
@@ -58,25 +58,25 @@ public static async Task ItListsIndexes(IKernelMemory memory, Action log
while (!await memory.IsDocumentReadyAsync(documentId: id1, index: indexName1))
{
- log("Waiting for memory ingestion to complete...");
+ log($"[id1: {id1}] Waiting for memory ingestion to complete...");
await Task.Delay(TimeSpan.FromSeconds(2));
}
while (!await memory.IsDocumentReadyAsync(documentId: id2, index: indexName2))
{
- log("Waiting for memory ingestion to complete...");
+ log($"[id2: {id2}] Waiting for memory ingestion to complete...");
await Task.Delay(TimeSpan.FromSeconds(2));
}
while (!await memory.IsDocumentReadyAsync(documentId: id3, index: indexNameWithDashes))
{
- log("Waiting for memory ingestion to complete...");
+ log($"[id3: {id3}] Waiting for memory ingestion to complete...");
await Task.Delay(TimeSpan.FromSeconds(2));
}
while (!await memory.IsDocumentReadyAsync(documentId: id4, index: indexNameWithUnderscores))
{
- log("Waiting for memory ingestion to complete...");
+ log($"[id4: {id4}] Waiting for memory ingestion to complete...");
await Task.Delay(TimeSpan.FromSeconds(2));
}
diff --git a/tools/InteractiveSetup/InteractiveSetup.csproj b/tools/InteractiveSetup/InteractiveSetup.csproj
index bd9e04fb8..7c8474a77 100644
--- a/tools/InteractiveSetup/InteractiveSetup.csproj
+++ b/tools/InteractiveSetup/InteractiveSetup.csproj
@@ -9,8 +9,8 @@
-
-
+
+
diff --git a/tools/README.md b/tools/README.md
index 578a563e0..a3865e77d 100644
--- a/tools/README.md
+++ b/tools/README.md
@@ -1,4 +1,6 @@
-# upload-file.sh
+# Kernel memory web service scripts
+
+### upload-file.sh
Simple client for command line uploads to Kernel Memory.
@@ -8,7 +10,7 @@ Instructions:
./upload-file.sh -h
```
-# ask.sh
+### ask.sh
Simple client for asking questions about your documents from the command line.
@@ -18,7 +20,7 @@ Instructions:
./ask.sh -h
```
-# search.sh
+### search.sh
Simple client for searching your indexed documents from the command line.
@@ -28,14 +30,41 @@ Instructions:
./search.sh -h
```
-# run-qdrant.sh
+# Vector DB scripts
+
+### run-elasticsearch.sh
+
+Script to start Elasticsearch using Docker for local development/debugging.
+
+Elasticsearch is used to store and search vectors, as an alternative to
+[Azure AI Search](https://azure.microsoft.com/products/ai-services/ai-search/).
+
+### run-mssql.sh
+
+Script to start MS SQL using Docker for local development/debugging.
+
+MS SQL is used to store and search vectors, as an alternative to
+[Azure AI Search](https://azure.microsoft.com/products/ai-services/ai-search/).
+
+### run-qdrant.sh
Script to start Qdrant using Docker, for local development/debugging.
Qdrant is used to store and search vectors, as an alternative to
[Azure AI Search](https://azure.microsoft.com/products/ai-services/ai-search/).
-# run-rabbitmq.sh
+### run-redis.sh
+
+Script to start Redis using Docker, for local development/debugging.
+This will run Redis on port 6379, as well as running a popular Redis
+GUI, [RedisInsight](https://redis.com/redis-enterprise/redis-insight/), on port 8001.
+
+Redis is used to store and search vectors, as an alternative to
+[Azure AI Search](https://azure.microsoft.com/products/ai-services/ai-search/).
+
+# Orchestration queues scripts
+
+### run-rabbitmq.sh
Script to start RabbitMQ using Docker, for local development/debugging.
@@ -43,10 +72,16 @@ RabbitMQ is used to provides queues for the asynchronous pipelines,
as an alternative to
[Azure Queues](https://learn.microsoft.com/azure/storage/queues/storage-queues-introduction).
-# run-redis.sh
+# Kernel memory runtime scripts
-Script to start Redis using Docker, for local development/debugging.
-This will run Redis on port 6379, as well as running a popular Redis GUI, [RedisInsight](https://redis.com/redis-enterprise/redis-insight/), on port 8001.
+### run-km-service.sh
-Redis is used to store and search vectors, as an alternative to
-[Azure AI Search](https://azure.microsoft.com/products/ai-services/ai-search/).
\ No newline at end of file
+Script to start KM service from source code, using KM nuget packages where configured as such.
+
+### run-km-service-from-source.sh
+
+Script to start KM service from local source code, ignoring KM nuget packages.
+
+### setup-km-service.sh
+
+Script to start KM service configuration wizard and create an appsettings.Development.json file.
diff --git a/service/Service/run-from-source.sh b/tools/run-km-service-from-source.sh
similarity index 81%
rename from service/Service/run-from-source.sh
rename to tools/run-km-service-from-source.sh
index 0fe022143..8f522217b 100755
--- a/service/Service/run-from-source.sh
+++ b/tools/run-km-service-from-source.sh
@@ -1,12 +1,19 @@
+#!/usr/bin/env bash
+
# Use this script to avoid relying on KM published packages,
# and use the local source code instead, e.g. in case changes
# are being made to Abstractions, Core, etc.
+set -e
+
cd "$(dirname "${BASH_SOURCE[0]:-$0}")"
+cd ../service/Service
+
+dotnet clean
# Build using a different solution name. The same can be done using a KernelMemoryDev.sln file.
# Note: dotnet run doesn't support [ -p "SolutionName=KernelMemoryDev" ]
dotnet build -c Debug -p "SolutionName=KernelMemoryDev"
# Run the special build, detached from external KM nugets.
-dotnet run -c Debug --no-build
+dotnet run -c Debug --no-build --no-restore
diff --git a/tools/run-km-service.sh b/tools/run-km-service.sh
new file mode 100755
index 000000000..6769100a5
--- /dev/null
+++ b/tools/run-km-service.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+
+set -e
+
+cd "$(dirname "${BASH_SOURCE[0]:-$0}")"
+cd ../service/Service
+
+dotnet clean
+dotnet build -c Debug -p "SolutionName=KernelMemory"
+ASPNETCORE_ENVIRONMENT=Development dotnet run --no-build --no-restore
diff --git a/tools/run-service.sh b/tools/run-service.sh
deleted file mode 100755
index a44d086bf..000000000
--- a/tools/run-service.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/usr/bin/env bash
-
-set -e
-
-cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/"
-cd ../service/Service
-
-dotnet restore
-dotnet build
-ASPNETCORE_ENVIRONMENT=Development dotnet run
diff --git a/tools/setup-km-service.sh b/tools/setup-km-service.sh
new file mode 100755
index 000000000..b29669a47
--- /dev/null
+++ b/tools/setup-km-service.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+
+set -e
+
+cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/"
+cd ../service/Service
+
+dotnet clean
+dotnet build -c Debug -p "SolutionName=KernelMemory"
+ASPNETCORE_ENVIRONMENT=Development dotnet run setup --no-build --no-restore
diff --git a/tools/setup-service.sh b/tools/setup-service.sh
deleted file mode 100755
index bb7d325b9..000000000
--- a/tools/setup-service.sh
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/usr/bin/env bash
-
-set -e
-
-cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/"
-cd ../service/Service
-
-dotnet restore
-dotnet build
-dotnet run setup
-