diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.csproj b/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.csproj
new file mode 100644
index 00000000000..de798bd94d9
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.csproj
@@ -0,0 +1,36 @@
+
+
+
+ Microsoft.Extensions.AI
+ Deterministic mock implementations for testing applications built on Microsoft.Extensions.AI.
+ AI
+ Testing
+ $(PackageTags);Testing;AI;Chat
+ true
+
+
+
+ preview
+ false
+ n/a
+ n/a
+
+
+
+ $(TargetFrameworks);netstandard2.0
+ $(NoWarn);MEAI001
+ true
+ true
+
+
+
+ true
+ true
+ true
+
+
+
+
+
+
+
diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.json b/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.json
new file mode 100644
index 00000000000..52e7a513563
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.json
@@ -0,0 +1,189 @@
+{
+ "Name": "Microsoft.Extensions.AI.Testing, Version=10.9.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35",
+ "Types": [
+ {
+ "Type": "class Microsoft.Extensions.AI.MockChatClient : Microsoft.Extensions.AI.IChatClient, System.IDisposable",
+ "Stage": "Experimental",
+ "Methods": [
+ {
+ "Member": "Microsoft.Extensions.AI.MockChatClient.MockChatClient();",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddException(System.Func requestPredicate, System.Func exceptionFactory, bool singleUse = false);",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponse(System.Func requestPredicate, System.Func> getResponse, bool singleUse = false);",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponse(System.Func requestPredicate, System.Func> getResponse, System.Func> getStreamingResponse, bool singleUse = false);",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponses(System.Collections.Generic.Dictionary responses, System.Func? requestPredicate = null, bool singleUse = false, System.Func>? getResponse = null);",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponses(System.Collections.Generic.IEnumerable> responses, System.Func? requestPredicate = null, bool singleUse = false, System.Func>? getResponse = null);",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponsesFromDictionary(System.Collections.Generic.Dictionary responses, System.Func? requestPredicate, bool singleUse, System.Func>? getResponse);",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponsesFromEnumerable(System.Collections.Generic.IEnumerable> responses, System.Func? requestPredicate, bool singleUse, System.Func>? getResponse);",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddSeed(System.Func requestPredicate, System.Func> getResponse, System.Func> getStreamingResponse, bool singleUse);",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddStreamingResponse(System.Func requestPredicate, System.Func> getResponse, bool singleUse = false);",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.ClearResponses();",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "void Microsoft.Extensions.AI.MockChatClient.Dispose();",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual void Microsoft.Extensions.AI.MockChatClient.Dispose(bool disposing);",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual System.Threading.Tasks.Task Microsoft.Extensions.AI.MockChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual object? Microsoft.Extensions.AI.MockChatClient.GetService(System.Type serviceType, object? serviceKey = null);",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.MockChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual Microsoft.Extensions.AI.MockChatClient.SeededResponse Microsoft.Extensions.AI.MockChatClient.MatchSeededResponse(Microsoft.Extensions.AI.MockChatClientRequest request);",
+ "Stage": "Experimental"
+ }
+ ],
+ "Properties": [
+ {
+ "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.MockChatClient.Requests { get; }",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "System.IServiceProvider? Microsoft.Extensions.AI.MockChatClient.Services { get; set; }",
+ "Stage": "Experimental"
+ }
+ ]
+ },
+ {
+ "Type": "sealed class Microsoft.Extensions.AI.MockChatClient.SeededResponse",
+ "Stage": "Experimental",
+ "Methods": [
+ {
+ "Member": "Microsoft.Extensions.AI.MockChatClient.SeededResponse.SeededResponse();",
+ "Stage": "Experimental"
+ }
+ ],
+ "Properties": [
+ {
+ "Member": "System.Func Microsoft.Extensions.AI.MockChatClient.SeededResponse.RequestPredicate { get; init; }",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "System.Func> Microsoft.Extensions.AI.MockChatClient.SeededResponse.ResponseFactory { get; init; }",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "bool Microsoft.Extensions.AI.MockChatClient.SeededResponse.SingleUse { get; init; }",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "System.Func> Microsoft.Extensions.AI.MockChatClient.SeededResponse.StreamingFactory { get; init; }",
+ "Stage": "Experimental"
+ }
+ ]
+ },
+ {
+ "Type": "sealed class Microsoft.Extensions.AI.MockChatClientRequest",
+ "Stage": "Experimental",
+ "Methods": [
+ {
+ "Member": "Microsoft.Extensions.AI.MockChatClientRequest.MockChatClientRequest(System.Collections.Generic.IReadOnlyList messages, Microsoft.Extensions.AI.ChatOptions? options, bool isStreaming);",
+ "Stage": "Experimental"
+ }
+ ],
+ "Properties": [
+ {
+ "Member": "bool Microsoft.Extensions.AI.MockChatClientRequest.IsStreaming { get; }",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "string? Microsoft.Extensions.AI.MockChatClientRequest.LastUserText { get; }",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.MockChatClientRequest.Messages { get; }",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "Microsoft.Extensions.AI.ChatOptions? Microsoft.Extensions.AI.MockChatClientRequest.Options { get; }",
+ "Stage": "Experimental"
+ }
+ ]
+ },
+ {
+ "Type": "class Microsoft.Extensions.AI.MockEmbeddingGenerator : Microsoft.Extensions.AI.IEmbeddingGenerator>, Microsoft.Extensions.AI.IEmbeddingGenerator, System.IDisposable",
+ "Stage": "Experimental",
+ "Methods": [
+ {
+ "Member": "Microsoft.Extensions.AI.MockEmbeddingGenerator.MockEmbeddingGenerator();",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "void Microsoft.Extensions.AI.MockEmbeddingGenerator.Dispose();",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual void Microsoft.Extensions.AI.MockEmbeddingGenerator.Dispose(bool disposing);",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "System.Threading.Tasks.Task>> Microsoft.Extensions.AI.MockEmbeddingGenerator.GenerateAsync(System.Collections.Generic.IEnumerable values, Microsoft.Extensions.AI.EmbeddingGenerationOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual System.Threading.Tasks.Task>> Microsoft.Extensions.AI.MockEmbeddingGenerator.GenerateCoreAsync(System.Collections.Generic.IEnumerable values, Microsoft.Extensions.AI.EmbeddingGenerationOptions? options, System.Threading.CancellationToken cancellationToken);",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "virtual object? Microsoft.Extensions.AI.MockEmbeddingGenerator.GetService(System.Type serviceType, object? serviceKey = null);",
+ "Stage": "Experimental"
+ }
+ ],
+ "Properties": [
+ {
+ "Member": "int Microsoft.Extensions.AI.MockEmbeddingGenerator.CallCount { get; }",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "System.Func, Microsoft.Extensions.AI.EmbeddingGenerationOptions?, System.Threading.CancellationToken, System.Threading.Tasks.Task>>>? Microsoft.Extensions.AI.MockEmbeddingGenerator.GenerateAsyncCallback { get; set; }",
+ "Stage": "Experimental"
+ },
+ {
+ "Member": "System.Func Microsoft.Extensions.AI.MockEmbeddingGenerator.GetServiceCallback { get; set; }",
+ "Stage": "Experimental"
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.AddResponses.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.AddResponses.cs
new file mode 100644
index 00000000000..8638e53e822
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.AddResponses.cs
@@ -0,0 +1,123 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+public partial class MockChatClient
+{
+ /// Seeds text responses from a dictionary for matching requests.
+ /// The response text keyed by request match values.
+ ///
+ /// An optional predicate used to select whether a response key applies to a request. By default, this uses
+ /// string.Equals(request.LastUserText, key, StringComparison.OrdinalIgnoreCase).
+ ///
+ ///
+ /// to remove every response after its first matching request; otherwise the
+ /// responses remain reusable.
+ ///
+ ///
+ /// An optional asynchronous function applied to each selected response. The function receives the response and the
+ /// cancellation token from the matching chat-client call.
+ ///
+ /// The same instance for chaining.
+ ///
+ /// Dictionary entries are seeded in enumeration order. When multiple entries match the same request, the last
+ /// entry wins. Add more specific matches last.
+ ///
+ /// is .
+ /// This instance has been disposed.
+ public virtual MockChatClient AddResponses(
+ Dictionary responses,
+ Func? requestPredicate = null,
+ bool singleUse = false,
+ Func>? getResponse = null) =>
+ AddResponsesFromDictionary(responses, requestPredicate, singleUse, getResponse);
+
+ /// Seeds text responses from an enumerable collection for matching requests.
+ /// The response text keyed by request match values.
+ ///
+ /// An optional predicate used to select whether a response key applies to a request. By default, this uses
+ /// string.Equals(request.LastUserText, key, StringComparison.OrdinalIgnoreCase).
+ ///
+ ///
+ /// to remove every response after its first matching request; otherwise the
+ /// responses remain reusable.
+ ///
+ ///
+ /// An optional asynchronous function applied to each selected response. The function receives the response and the
+ /// cancellation token from the matching chat-client call.
+ ///
+ /// The same instance for chaining.
+ ///
+ /// Entries are seeded in enumeration order. When multiple entries match the same request, the last entry wins.
+ /// Unlike dictionaries, enumerable collections can contain repeated keys.
+ ///
+ /// is .
+ /// This instance has been disposed.
+ public virtual MockChatClient AddResponses(
+ IEnumerable> responses,
+ Func? requestPredicate = null,
+ bool singleUse = false,
+ Func>? getResponse = null) =>
+ AddResponsesFromEnumerable(responses, requestPredicate, singleUse, getResponse);
+
+ /// Adds text response seeds from a dictionary.
+ /// The response text keyed by request match values.
+ /// Predicate used to select whether a response key applies to a request.
+ ///
+ /// to remove every response after its first matching request; otherwise the
+ /// responses remain reusable.
+ ///
+ /// An asynchronous function applied to each selected response.
+ /// The same instance for chaining.
+ protected virtual MockChatClient AddResponsesFromDictionary(
+ Dictionary responses,
+ Func? requestPredicate,
+ bool singleUse,
+ Func>? getResponse)
+ => AddResponsesFromEnumerable(responses, requestPredicate, singleUse, getResponse);
+
+ /// Adds text response seeds from an enumerable collection.
+ /// The response text keyed by request match values.
+ /// Predicate used to select whether a response key applies to a request.
+ ///
+ /// to remove every response after its first matching request; otherwise the
+ /// responses remain reusable.
+ ///
+ /// An asynchronous function applied to each selected response.
+ /// The same instance for chaining.
+ protected virtual MockChatClient AddResponsesFromEnumerable(
+ IEnumerable> responses,
+ Func? requestPredicate,
+ bool singleUse,
+ Func>? getResponse)
+ {
+ _ = Throw.IfNull(responses);
+ ThrowIfDisposed();
+
+ requestPredicate ??= static (request, key) =>
+ string.Equals(request.LastUserText, key, StringComparison.OrdinalIgnoreCase);
+ getResponse ??= static (response, _) => Task.FromResult(response);
+
+ foreach (KeyValuePair response in responses)
+ {
+ string key = Throw.IfNull(response.Key);
+ string value = Throw.IfNull(response.Value);
+
+ _ = AddResponse(
+ request => requestPredicate(request, key),
+ (_, cancellationToken) => getResponse(
+ new ChatResponse(new ChatMessage(ChatRole.Assistant, value)),
+ cancellationToken),
+ singleUse);
+ }
+
+ return this;
+ }
+}
diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs
new file mode 100644
index 00000000000..236158685e8
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs
@@ -0,0 +1,459 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+///
+/// A deterministic implementation for tests and local mock scenarios,
+/// using seeded responses based on request predicates.
+///
+///
+///
+/// This client starts with no seeded responses. Each request is matched against seeded responses
+/// in reverse insertion order, and the most recently added match is used.
+///
+///
+/// Seeds are reusable by default. Set singleUse: true to remove a seed after its first match.
+///
+///
+/// If no seed matches a request, an is thrown. Response factories
+/// can return fully populated and payloads to model
+/// citations, reasoning, tool calls, usage, metadata, errors, and other chat-client behavior.
+///
+///
+[Experimental(DiagnosticIds.Experiments.AITesting, UrlFormat = DiagnosticIds.UrlFormat)]
+public partial class MockChatClient : IChatClient
+{
+ private static ChatResponse CloneResponse(ChatResponse response)
+ {
+ var clone = new ChatResponse(response.Messages.Select(message => message.Clone()).ToList())
+ {
+ AdditionalProperties = response.AdditionalProperties?.Clone(),
+ ContinuationToken = response.ContinuationToken,
+ ConversationId = response.ConversationId,
+ CreatedAt = response.CreatedAt,
+ FinishReason = response.FinishReason,
+ ModelId = response.ModelId,
+ RawRepresentation = response.RawRepresentation,
+ ResponseId = response.ResponseId,
+ Usage = response.Usage,
+ };
+
+ return clone;
+ }
+
+ private static async IAsyncEnumerable EnumerateUpdatesAsync(
+ IEnumerable updates,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ foreach (ChatResponseUpdate update in updates)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ yield return Throw.IfNull(update).Clone();
+ }
+ }
+
+ private static async IAsyncEnumerable EnumerateUpdatesAsync(
+ IAsyncEnumerable updates,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await foreach (ChatResponseUpdate update in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
+ {
+ yield return Throw.IfNull(update).Clone();
+ }
+ }
+
+ private static async IAsyncEnumerable GetResponseUpdatesAsync(
+ Func> getResponse,
+ MockChatClientRequest request,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ ChatResponse response = Throw.IfNull(await getResponse(request, cancellationToken).ConfigureAwait(false));
+ await foreach (ChatResponseUpdate update in EnumerateUpdatesAsync(response.ToChatResponseUpdates(), cancellationToken).ConfigureAwait(false))
+ {
+ yield return update;
+ }
+ }
+
+ private static Task GetResponseFromUpdatesAsync(
+ Func> getResponse,
+ MockChatClientRequest request,
+ CancellationToken cancellationToken) =>
+ EnumerateUpdatesAsync(Throw.IfNull(getResponse(request, cancellationToken)), cancellationToken)
+ .ToChatResponseAsync(cancellationToken);
+
+ private static async IAsyncEnumerable ThrowExceptionAsync(
+ Func exceptionFactory,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await Task.FromException(Throw.IfNull(exceptionFactory())).ConfigureAwait(false);
+ yield break;
+ }
+
+ private readonly object _sync = new();
+ private readonly List _seededResponses = [];
+ private readonly List _requests = [];
+ private bool _disposed;
+
+ ///
+ /// Gets or sets an optional service provider surfaced through .
+ ///
+ ///
+ /// This provider is queried only for non-keyed lookups when the requested service type is not
+ /// itself.
+ ///
+ public IServiceProvider? Services { get; set; }
+
+ ///
+ /// Gets a snapshot of requests previously sent to this instance.
+ ///
+ ///
+ /// Requests are recorded in call order across both streaming and non-streaming APIs.
+ ///
+ public IReadOnlyList Requests
+ {
+ get
+ {
+ lock (_sync)
+ {
+ return _requests.ToArray();
+ }
+ }
+ }
+
+ /// Seeds a response factory for matching requests.
+ /// Predicate used to select whether this seed applies to a request.
+ ///
+ /// Asynchronously creates the response for a matching request. The supplied cancellation token is the one passed
+ /// to the matching chat-client call.
+ ///
+ ///
+ /// to remove this seed after its first matching request; otherwise it remains reusable.
+ ///
+ /// The same instance for chaining.
+ ///
+ /// If is called
+ /// for a matching request, the response is converted to updates by using the chat response update conversion helpers.
+ ///
+ ///
+ /// or is .
+ ///
+ /// This instance has been disposed.
+ public virtual MockChatClient AddResponse(
+ Func requestPredicate,
+ Func> getResponse,
+ bool singleUse = false) =>
+ AddSeed(
+ requestPredicate,
+ getResponse,
+ (request, cancellationToken) => GetResponseUpdatesAsync(getResponse, request, cancellationToken),
+ singleUse);
+
+ /// Seeds non-streaming and streaming response factories for matching requests.
+ /// Predicate used to select whether this seed applies to a request.
+ ///
+ /// Asynchronously creates the response for a matching non-streaming request. The supplied cancellation token is
+ /// the one passed to the matching chat-client call.
+ ///
+ ///
+ /// Asynchronously creates the updates for a matching streaming request. The supplied cancellation token is the
+ /// one passed to the matching chat-client call.
+ ///
+ ///
+ /// to remove this seed after its first matching request; otherwise it remains reusable.
+ ///
+ /// The same instance for chaining.
+ ///
+ /// , , or
+ /// is .
+ ///
+ /// This instance has been disposed.
+ public virtual MockChatClient AddResponse(
+ Func requestPredicate,
+ Func> getResponse,
+ Func> getStreamingResponse,
+ bool singleUse = false) =>
+ AddSeed(requestPredicate, getResponse, getStreamingResponse, singleUse);
+
+ /// Seeds a streaming response factory for matching requests.
+ /// Predicate used to select whether this seed applies to a request.
+ ///
+ /// Asynchronously creates the updates for a matching streaming request. The supplied cancellation token is the
+ /// one passed to the matching chat-client call.
+ ///
+ ///
+ /// to remove this seed after its first matching request; otherwise it remains reusable.
+ ///
+ /// The same instance for chaining.
+ ///
+ /// If is called for a
+ /// matching request, these updates are converted to a single response by using the chat response conversion helpers.
+ ///
+ ///
+ /// or is .
+ ///
+ /// This instance has been disposed.
+ public virtual MockChatClient AddStreamingResponse(
+ Func requestPredicate,
+ Func> getResponse,
+ bool singleUse = false) =>
+ AddSeed(
+ requestPredicate,
+ (request, cancellationToken) => GetResponseFromUpdatesAsync(getResponse, request, cancellationToken),
+ getResponse,
+ singleUse);
+
+ /// Seeds an exception for matching requests.
+ /// Predicate used to select whether this seed applies to a request.
+ /// Creates the exception to throw for each matching request.
+ ///
+ /// to remove this seed after its first matching request; otherwise it remains reusable.
+ ///
+ /// The same instance for chaining.
+ ///
+ /// or is .
+ ///
+ /// This instance has been disposed.
+ public virtual MockChatClient AddException(
+ Func requestPredicate,
+ Func exceptionFactory,
+ bool singleUse = false)
+ {
+ _ = Throw.IfNull(exceptionFactory);
+
+ return AddSeed(
+ requestPredicate,
+ (_, _) => Task.FromException(Throw.IfNull(exceptionFactory())),
+ (_, cancellationToken) => ThrowExceptionAsync(exceptionFactory, cancellationToken),
+ singleUse);
+ }
+
+ /// Removes all seeded responses and exceptions.
+ ///
+ /// This method does not clear .
+ ///
+ /// The same instance for chaining.
+ /// This instance has been disposed.
+ public virtual MockChatClient ClearResponses()
+ {
+ ThrowIfDisposed();
+
+ lock (_sync)
+ {
+ _seededResponses.Clear();
+ }
+
+ return this;
+ }
+
+ ///
+ /// Returns a response for the most recently seeded match and records the request.
+ ///
+ /// The request messages.
+ /// Request options, if any.
+ /// The cancellation token.
+ /// A deterministic response for the most recently seeded match.
+ /// is .
+ /// No seeded response matched the request.
+ /// This instance has been disposed.
+ public virtual async Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ _ = Throw.IfNull(messages);
+ ThrowIfDisposed();
+
+ MockChatClientRequest request = RecordRequest(messages, options, isStreaming: false);
+ SeededResponse seeded = MatchSeededResponse(request);
+ return CloneResponse(Throw.IfNull(await seeded.ResponseFactory(request, cancellationToken).ConfigureAwait(false)));
+ }
+
+ ///
+ /// Returns streaming updates for the most recently seeded match and records the request.
+ ///
+ /// The request messages.
+ /// Request options, if any.
+ /// The cancellation token.
+ /// A deterministic update stream for the most recently seeded match.
+ /// is .
+ /// No seeded response matched the request.
+ /// This instance has been disposed.
+ public virtual IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ _ = Throw.IfNull(messages);
+ ThrowIfDisposed();
+
+ MockChatClientRequest request = RecordRequest(messages, options, isStreaming: true);
+ SeededResponse seeded = MatchSeededResponse(request);
+ return EnumerateUpdatesAsync(Throw.IfNull(seeded.StreamingFactory(request, cancellationToken)), cancellationToken);
+ }
+
+ ///
+ /// Gets a service object from this client or the optional provider.
+ ///
+ /// The type of service object to get.
+ /// An optional service key. Keyed lookup is not supported.
+ ///
+ /// This instance when is assignable from ;
+ /// otherwise a non-keyed service from when available; otherwise .
+ ///
+ /// is .
+ public virtual object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ _ = Throw.IfNull(serviceType);
+
+ if (serviceKey is null)
+ {
+ if (serviceType.IsInstanceOfType(this))
+ {
+ return this;
+ }
+
+ return Services?.GetService(serviceType);
+ }
+
+ return null;
+ }
+
+ /// Disposes this instance and clears all seeds and recorded requests.
+ public void Dispose()
+ {
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+
+ /// Adds a response seed.
+ /// Predicate used to select whether this seed applies to a request.
+ /// Factory that creates a response for a matching request.
+ /// Factory that creates streaming updates for a matching request.
+ ///
+ /// to remove this seed after its first matching request; otherwise it remains reusable.
+ ///
+ /// The same instance for chaining.
+ protected virtual MockChatClient AddSeed(
+ Func requestPredicate,
+ Func> getResponse,
+ Func> getStreamingResponse,
+ bool singleUse)
+ {
+ _ = Throw.IfNull(requestPredicate);
+ _ = Throw.IfNull(getResponse);
+ _ = Throw.IfNull(getStreamingResponse);
+
+ lock (_sync)
+ {
+ ThrowIfDisposed();
+
+ _seededResponses.Add(new SeededResponse
+ {
+ RequestPredicate = requestPredicate,
+ ResponseFactory = getResponse,
+ StreamingFactory = getStreamingResponse,
+ SingleUse = singleUse,
+ });
+ }
+
+ return this;
+ }
+
+ /// Finds the most recently added seed that matches a request.
+ /// The request to match.
+ /// The matching seed.
+ /// is .
+ /// No seeded response matched the request.
+ protected virtual SeededResponse MatchSeededResponse(MockChatClientRequest request)
+ {
+ request = Throw.IfNull(request);
+
+ lock (_sync)
+ {
+ for (int i = _seededResponses.Count - 1; i >= 0; i--)
+ {
+ SeededResponse seeded = _seededResponses[i];
+ if (!seeded.RequestPredicate(request))
+ {
+ continue;
+ }
+
+ if (seeded.SingleUse)
+ {
+ _seededResponses.RemoveAt(i);
+ }
+
+ return seeded;
+ }
+ }
+
+ throw new InvalidOperationException(
+ $"No seeded response matched. Last user text: '{request.LastUserText ?? ""}'.");
+ }
+
+ /// Represents a response seed.
+ protected sealed class SeededResponse
+ {
+ /// Gets the predicate used to select whether this seed applies to a request.
+ public Func RequestPredicate { get; init; } = default!;
+
+ /// Gets the factory that creates a response for a matching request.
+ public Func> ResponseFactory { get; init; } = default!;
+
+ /// Gets the factory that creates streaming updates for a matching request.
+ public Func> StreamingFactory { get; init; } = default!;
+
+ /// Gets a value indicating whether this seed is removed after its first matching request.
+ public bool SingleUse { get; init; }
+ }
+
+ /// Releases resources used by this mock client.
+ /// when called from .
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!disposing)
+ {
+ return;
+ }
+
+ lock (_sync)
+ {
+ _disposed = true;
+ _seededResponses.Clear();
+ _requests.Clear();
+ }
+ }
+
+ private MockChatClientRequest RecordRequest(IEnumerable messages, ChatOptions? options, bool isStreaming)
+ {
+ ChatMessage[] messageArray = messages.Select(m => Throw.IfNull(m).Clone()).ToArray();
+ var request = new MockChatClientRequest(messageArray, options, isStreaming);
+
+ lock (_sync)
+ {
+ _requests.Add(request);
+ }
+
+ return request;
+ }
+
+ private void ThrowIfDisposed()
+ {
+ if (_disposed)
+ {
+ throw new ObjectDisposedException(nameof(MockChatClient));
+ }
+ }
+}
diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClientRequest.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClientRequest.cs
new file mode 100644
index 00000000000..022d4c86455
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClientRequest.cs
@@ -0,0 +1,62 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+///
+/// Represents one request invocation captured by .
+///
+///
+/// Instances are created by when
+/// or
+///
+/// is called.
+///
+[Experimental(DiagnosticIds.Experiments.AITesting, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class MockChatClientRequest
+{
+ /// Initializes a new instance of the class.
+ /// The request messages in call order.
+ /// The request options, if any.
+ ///
+ /// when the request came from
+ /// .
+ ///
+ /// is .
+ public MockChatClientRequest(IReadOnlyList messages, ChatOptions? options, bool isStreaming)
+ {
+ Messages = Throw.IfNull(messages);
+ Options = options;
+ IsStreaming = isStreaming;
+ }
+
+ /// Gets the request messages.
+ ///
+ /// For requests recorded by , each message is cloned when captured so later changes
+ /// to original message instances do not affect stored request history.
+ ///
+ public IReadOnlyList Messages { get; }
+
+ /// Gets the request options.
+ public ChatOptions? Options { get; }
+
+ ///
+ /// Gets a value indicating whether this request originated from the streaming chat API.
+ ///
+ public bool IsStreaming { get; }
+
+ ///
+ /// Gets the text of the last user message in , if available.
+ ///
+ ///
+ /// Returns when there is no user message, or when the last user message has no text.
+ ///
+ public string? LastUserText =>
+ Messages.LastOrDefault(m => m.Role == ChatRole.User)?.Text;
+}
diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs
new file mode 100644
index 00000000000..ae8f0b856d3
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs
@@ -0,0 +1,103 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+///
+/// A configurable for tests and local mock scenarios.
+///
+/// The type of input accepted by the embedding generator.
+///
+/// Configure to produce the embeddings required by a test. The callback receives
+/// the original input enumerable and cancellation token. records every generation request.
+///
+[Experimental(DiagnosticIds.Experiments.AITesting, UrlFormat = DiagnosticIds.UrlFormat)]
+public class MockEmbeddingGenerator : IEmbeddingGenerator>
+{
+ private int _callCount;
+
+ /// Initializes a new instance of the class.
+ public MockEmbeddingGenerator()
+ {
+ GetServiceCallback = DefaultGetServiceCallback;
+ }
+
+ /// Gets or sets the callback that generates embeddings.
+ ///
+ /// The callback must be set before calling .
+ ///
+ public Func, EmbeddingGenerationOptions?, CancellationToken, Task>>>? GenerateAsyncCallback { get; set; }
+
+ /// Gets or sets the callback that resolves services.
+ ///
+ /// By default, this callback returns this generator for compatible, non-keyed requests and
+ /// for all other requests.
+ ///
+ public Func GetServiceCallback { get; set; }
+
+ /// Gets the number of calls to .
+ public int CallCount => Volatile.Read(ref _callCount);
+
+ ///
+ public Task>> GenerateAsync(
+ IEnumerable values,
+ EmbeddingGenerationOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ _ = Throw.IfNull(values);
+ RecordCall();
+
+ return GenerateCoreAsync(values, options, cancellationToken);
+ }
+
+ /// Generates embeddings after the request has been recorded.
+ /// The values to embed.
+ /// The generation options.
+ /// The cancellation token.
+ /// The generated embeddings.
+ protected virtual Task>> GenerateCoreAsync(
+ IEnumerable values,
+ EmbeddingGenerationOptions? options,
+ CancellationToken cancellationToken)
+ {
+ if (GenerateAsyncCallback is not { } callback)
+ {
+ throw new InvalidOperationException("No embedding generation callback has been configured.");
+ }
+
+ return Throw.IfNull(callback(values, options, cancellationToken));
+ }
+
+ ///
+ public virtual object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ _ = Throw.IfNull(serviceType);
+ return Throw.IfNull(GetServiceCallback)(serviceType, serviceKey);
+ }
+
+ ///
+ public void Dispose()
+ {
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+
+ /// Releases resources used by this mock generator.
+ /// when called from .
+ protected virtual void Dispose(bool disposing)
+ {
+ }
+
+ private void RecordCall() => Interlocked.Increment(ref _callCount);
+
+ private MockEmbeddingGenerator? DefaultGetServiceCallback(Type serviceType, object? serviceKey) =>
+ serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null;
+}
diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/README.md b/src/Libraries/Microsoft.Extensions.AI.Testing/README.md
new file mode 100644
index 00000000000..0a7999a32f3
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Testing/README.md
@@ -0,0 +1,312 @@
+# Microsoft.Extensions.AI.Testing
+
+Deterministic test doubles for applications built on `Microsoft.Extensions.AI`.
+
+> [!IMPORTANT]
+> This **preview** package is for **mocking and deterministic testing** of chat and embedding behavior. Its APIs are
+> experimental (`MEAI001`) and may change or be removed in future releases.
+
+## Install
+
+```console
+dotnet add package Microsoft.Extensions.AI.Testing
+```
+
+Or in a project file:
+
+```xml
+
+
+
+```
+
+## What you get
+
+- `MockChatClient` (`IChatClient`) for deterministic, request-aware chat responses.
+- `MockChatClientRequest` for request matching and request-history inspection.
+- `MockEmbeddingGenerator` (`IEmbeddingGenerator>`) for configurable, deterministic embeddings.
+
+## MockChatClient behavior
+
+| Behavior | Details |
+| --- | --- |
+| Starts empty | No responses are predefined. |
+| Match order | Last response added wins. Add the most specific matches last. |
+| Consumption | Seeds are reusable by default. |
+| One-time seed | Set `singleUse: true` to remove a seed after its first match. |
+| Unmatched request | Throws `InvalidOperationException` with the last user message text. |
+| History | Every request is captured in `client.Requests` (`Messages`, `Options`, `IsStreaming`). |
+| Streaming/non-streaming | Supports both `GetResponseAsync` and `GetStreamingResponseAsync`. |
+
+## Quick start
+
+`MockChatClient` accepts a required predicate plus a cancellable asynchronous response factory. The factory can inspect the captured request and receives the cancellation token from the matching chat call.
+
+```csharp
+using Microsoft.Extensions.AI;
+
+var client = new MockChatClient()
+ .AddResponse(
+ static _ => true,
+ static (_, _) => Task.FromResult(
+ new ChatResponse(new ChatMessage(ChatRole.Assistant, "Fallback response."))),
+ singleUse: true)
+ .AddResponse(
+ static request => request.LastUserText?.Contains("hello", StringComparison.OrdinalIgnoreCase) is true,
+ static (_, _) => Task.FromResult(
+ new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello from a deterministic mock."))));
+
+ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "hello")]);
+Console.WriteLine(response.Text);
+```
+
+### Response dictionary
+
+Use `AddResponses` when a response dictionary is more convenient than individual response factories. The default
+predicate is `string.Equals(request.LastUserText, key, StringComparison.OrdinalIgnoreCase)`. Entries are seeded in
+dictionary order, so the last matching entry wins.
+
+```csharp
+var client = new MockChatClient().AddResponses(new()
+{
+ ["hello"] = "Hello from a deterministic mock.",
+ ["goodbye"] = "Goodbye from a deterministic mock.",
+});
+```
+
+A dictionary deserialized from JSON works the same way:
+
+```csharp
+using System.Text.Json;
+
+var client = new MockChatClient().AddResponses(
+ JsonSerializer.Deserialize>(
+ """
+ {
+ "hello": "Hello from a deterministic mock.",
+ "goodbye": "Goodbye from a deterministic mock."
+ }
+ """)!);
+```
+
+Use an `IEnumerable>` only when repeated keys are needed. Entries are seeded in
+enumeration order, so the last matching entry wins:
+
+```csharp
+client.AddResponses(
+ new KeyValuePair[]
+ {
+ new("hello", "Hello again. Nice to see you."),
+ new("hello", "Hello. Nice to meet you."),
+ },
+ singleUse: true);
+```
+
+Use `AddResponse` to return fully populated `ChatResponse` instances, including non-text content such as tool calls:
+
+```csharp
+var client = new MockChatClient()
+ .AddResponse(
+ static request => request.LastUserText == "get-weather",
+ static (_, _) => Task.FromResult(
+ new ChatResponse(
+ new ChatMessage(
+ ChatRole.Assistant,
+ [new FunctionCallContent("weather-call", "GetWeather")]))));
+```
+
+Pass a predicate when the dictionary key needs a different matching rule:
+
+```csharp
+client.AddResponses(new()
+ {
+ ["hello"] = "Hello from a deterministic mock.",
+ ["goodbye"] = "Goodbye from a deterministic mock.",
+ },
+ static (request, key) => request.LastUserText?.StartsWith(key, StringComparison.OrdinalIgnoreCase) is true);
+```
+
+`singleUse` applies to every dictionary response. A custom predicate can map distinct dictionary keys to the same prompt:
+
+```csharp
+var client = new MockChatClient()
+ .AddResponses(
+ new()
+ {
+ ["hello:2"] = "Hello again. Nice to see you.",
+ ["hello:1"] = "Hello. Nice to meet you.",
+ },
+ static (request, key) =>
+ {
+ string promptKey = key.Split(new[] { ':' }, 2)[0];
+ return string.Equals(request.LastUserText, promptKey, StringComparison.OrdinalIgnoreCase);
+ },
+ singleUse: true);
+```
+
+Chat requests can include images as `DataContent`. A predicate can match image MIME types:
+
+```csharp
+var client = new MockChatClient()
+ .AddResponses(
+ new()
+ {
+ ["image/jpeg"] = "I see you shared a JPEG.",
+ ["image/png"] = "I see you shared a PNG.",
+ },
+ static (request, mediaType) => request.Messages
+ .SelectMany(static message => message.Contents)
+ .OfType()
+ .Any(content => string.Equals(content.MediaType, mediaType, StringComparison.OrdinalIgnoreCase)));
+```
+
+Pass `getResponse` to apply common asynchronous behavior to every selected response. String values are converted to
+`ChatResponse` instances before `getResponse` is invoked:
+
+```csharp
+var client = new MockChatClient()
+ .AddResponses(
+ new()
+ {
+ ["hello"] = "Hello from a deterministic mock.",
+ ["goodbye"] = "Goodbye from a deterministic mock.",
+ },
+ getResponse: async (response, cancellationToken) =>
+ {
+ await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
+ return response;
+ });
+```
+
+## Seeding patterns
+
+### Non-streaming response
+
+```csharp
+client.AddResponse(
+ static request => request.LastUserText == "explain",
+ static (_, _) => Task.FromResult(
+ new ChatResponse(new ChatMessage(ChatRole.Assistant, "Complete answer"))));
+```
+
+### Streaming response
+
+```csharp
+using System.Runtime.CompilerServices;
+
+client.AddStreamingResponse(
+ static request => request.LastUserText == "stream",
+ static (_, cancellationToken) => GetUpdatesAsync(cancellationToken));
+
+static async IAsyncEnumerable GetUpdatesAsync(
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+{
+ cancellationToken.ThrowIfCancellationRequested();
+ yield return new ChatResponseUpdate(ChatRole.Assistant, "Part 1 ");
+ yield return new ChatResponseUpdate(ChatRole.Assistant, "Part 2");
+}
+```
+
+### Distinct streaming and non-streaming responses
+
+```csharp
+client.AddResponse(
+ static request => request.LastUserText == "both",
+ static (_, _) => Task.FromResult(
+ new ChatResponse(new ChatMessage(ChatRole.Assistant, "Complete answer"))),
+ static (_, cancellationToken) => GetUpdatesAsync(cancellationToken));
+```
+
+## Exercise rich chat features
+
+`MockChatClient` does not synthesize model output; factories return the payloads you choose. Seed fully populated `ChatResponse` and `ChatResponseUpdate` payloads to exercise:
+
+- citations and other annotations
+- reasoning content
+- tool calls and tool results
+- usage fields
+- additional metadata
+
+For example, a response can include citations, reasoning, and usage:
+
+```csharp
+client.AddResponse(
+ static request => request.LastUserText == "sources",
+ static (_, _) => Task.FromResult(
+ new ChatResponse(
+ [
+ new ChatMessage(
+ ChatRole.Assistant,
+ [
+ new TextContent("TrailMaster tracks your progress.")
+ {
+ Annotations =
+ [
+ new CitationAnnotation
+ {
+ Title = "Example_GPS_Watch.md",
+ Snippet = "track your progress",
+ }
+ ]
+ },
+ new TextReasoningContent("The document describes tracking features.")
+ ])
+ ])
+ {
+ Usage = new UsageDetails { InputTokenCount = 3, OutputTokenCount = 7 }
+ }));
+```
+
+## Assert what your app sent
+
+```csharp
+MockChatClientRequest first = client.Requests[0];
+string? lastUserText = first.LastUserText;
+bool wasStreaming = first.IsStreaming;
+```
+
+This is useful for validating prompts, routing logic, and chat options.
+
+## Clear and reseed
+
+`ClearResponses()` removes all response and exception seeds while preserving captured request history:
+
+```csharp
+client.ClearResponses()
+ .AddResponse(
+ static _ => true,
+ static (_, _) => Task.FromResult(
+ new ChatResponse(new ChatMessage(ChatRole.Assistant, "New test scenario."))));
+```
+
+## Mock errors
+
+Use `AddException` to model a failing provider. The factory creates a new exception for each matching request:
+
+```csharp
+client.AddException(
+ static request => request.LastUserText == "UNAVAILABLE",
+ static () => new HttpRequestException("The provider is temporarily unavailable."));
+```
+
+## Mock embeddings
+
+Configure `MockEmbeddingGenerator` with the embeddings a test needs. Its callback receives the original input enumerable and cancellation token, and `CallCount` records generation requests.
+
+```csharp
+using var embeddings = new MockEmbeddingGenerator
+{
+ GenerateAsyncCallback = static (_, _, _) =>
+ Task.FromResult>>(
+ [new(new float[] { 0.1f, 0.2f, 0.3f })]),
+};
+
+GeneratedEmbeddings> result = await embeddings.GenerateAsync(["trail"]);
+Console.WriteLine(embeddings.CallCount);
+```
+
+Use the input type required by the test, such as `MockEmbeddingGenerator` or `MockEmbeddingGenerator`.
+
+## Feedback & Contributing
+
+We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions).
diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.csproj b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.csproj
index afbfd93c4b7..3022febd7d0 100644
--- a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.csproj
+++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.csproj
@@ -30,6 +30,7 @@
+
-
+
+
+
+
diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockChatClientExtensions.cs b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockChatClientExtensions.cs
new file mode 100644
index 00000000000..fd642cb6290
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockChatClientExtensions.cs
@@ -0,0 +1,72 @@
+using Microsoft.Extensions.AI;
+
+#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs
+
+internal static class MockChatClientExtensions
+{
+ /// Adds a canned response for matching requests.
+ /// The mock chat client to configure.
+ /// Predicate used to select whether the response applies to a request.
+ /// The assistant response text.
+ ///
+ /// The optional minimum simulated response delay in milliseconds. When supplied alone, it is the fixed delay.
+ ///
+ ///
+ /// The optional maximum simulated response delay in milliseconds. When supplied alone, a delay from zero up to this
+ /// value is selected.
+ ///
+ ///
+ /// to remove the response seed after its first matching request; otherwise it remains reusable.
+ ///
+ /// The configured .
+ internal static MockChatClient AddResponse(
+ this MockChatClient client,
+ Func requestPredicate,
+ string response,
+ int? minDelay = null,
+ int? maxDelay = null,
+ bool singleUse = false)
+ {
+ ArgumentNullException.ThrowIfNull(client);
+ ArgumentNullException.ThrowIfNull(requestPredicate);
+ ArgumentNullException.ThrowIfNull(response);
+ ValidateDelayRange(minDelay, maxDelay);
+
+ return client.AddResponse(
+ requestPredicate,
+ async (_, cancellationToken) =>
+ {
+ await Task.Delay(GetDelay(minDelay, maxDelay), cancellationToken);
+ return new ChatResponse(new ChatMessage(ChatRole.Assistant, response + "\n\n"));
+ },
+ singleUse);
+ }
+
+ private static int GetDelay(int? minDelay, int? maxDelay) =>
+ (minDelay, maxDelay) switch
+ {
+ (null, null) => 0,
+ ({ } min, null) => min,
+ (null, { } max) => Random.Shared.Next(0, max),
+ ({ } min, { } max) => Random.Shared.Next(min, max),
+ };
+
+ private static void ValidateDelayRange(int? minDelay, int? maxDelay)
+ {
+ if (minDelay is { } minimumDelay)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(minimumDelay, 0, nameof(minDelay));
+ }
+
+ if (maxDelay is { } maximumDelay)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelay, 0, nameof(maxDelay));
+
+ if (minDelay is { } configuredMinimumDelay)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelay, configuredMinimumDelay, nameof(maxDelay));
+ }
+ }
+ }
+}
+#pragma warning restore MEAI001
diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockServices.cs b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockServices.cs
new file mode 100644
index 00000000000..a44d52ecabf
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockServices.cs
@@ -0,0 +1,147 @@
+using Microsoft.Extensions.AI;
+
+#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs
+
+internal static class MockServices
+{
+ internal static MockChatClient CreateChatClient() =>
+ new MockChatClient()
+ .AddResponse(
+ static request => IsWriterStage(request),
+ """
+ ## Welcome to the Mock Writing Room
+
+ Meet The Writer, The Editor, and The Formatter. They are ready for their curtain call.
+
+ Try: **Write a .NET story.**, **Edit this story.**, or **Format for publishing.**
+ """,
+ maxDelay: 2000)
+ .AddResponse(
+ static request => HasMessageContent(request, "Welcome to the Mock Writing Room"),
+ """
+ The room is ready when you are. Bring a draft, and it will come back polished.
+ """,
+ maxDelay: 2000)
+ .AddResponse(
+ static request => IsWriterStage(request) && IsWritePrompt(request),
+ """
+ Maya opened her first .NET console project during an afternoon study session. The program printed "Hello, World!", but she wanted it to ask for a number and tell her whether it was even.
+
+ At first, compiler errors made every small change feel impossible. Instead of guessing, she read each message, used `int.TryParse`, and ran the app after every improvement.
+
+ By the end of the session, Maya had a working program and a short list of ideas for her next project. She realized that learning .NET was not about knowing everything at once; it was about trying one small step, seeing what happened, and learning from it.
+
+ """,
+ maxDelay: 2000)
+ .AddResponse(
+ static request => HasDraftContent(request, "Maya opened her first .NET console project during an afternoon study session."),
+ """
+ **Title**: Maya's First Green Check
+
+ Maya opened her first .NET console project during an afternoon study session. It printed "Hello, World!", but she wanted it to ask for a number and report whether it was even.
+
+ Compiler errors made every small change feel impossible at first. Rather than guessing, Maya read each message, used `int.TryParse` to handle input safely, and ran the app after every improvement.
+
+ By the end of the session, she had a working program and a short list of ideas for her next project. Maya learned that .NET did not require her to know everything at once: progress came from taking one small step, observing the result, and learning from it.
+ """,
+ maxDelay: 2000)
+ .AddResponse(
+ static request => IsWriterStage(request) && IsEditPrompt(request),
+ """
+ maya opened her first .net console project and wanted it to check whether a number was even. compiler errors made her frustrated at first.
+
+ she read the error messages, used int.tryparse, and kept running the program after each change. by the end, it worked and she was excited to build another project.
+
+ """,
+ maxDelay: 2000)
+ .AddResponse(
+ static request => HasDraftContent(request, "maya opened her first .net console project and wanted it to check whether a number was even."),
+ """
+ Maya opened her first .NET console project hoping to write a program that could identify even numbers. Compiler errors frustrated her at first, but she chose to slow down and read each message carefully.
+
+ She used `int.TryParse` to make the input safer and ran the program after every change. By the end of the session, Maya had a working app and a new confidence that learning .NET happens one experiment at a time.
+ """,
+ maxDelay: 2000)
+ .AddResponse(
+ static request => IsWriterStage(request) && IsFormatPrompt(request),
+ """
+ Maya learned to build her first .NET console app by reading compiler errors, trying `int.TryParse`, and testing each small change.
+
+ """,
+ maxDelay: 2000)
+ .AddResponse(
+ static request => HasDraftContent(request, "Maya learned to build her first .NET console app by reading compiler errors, trying `int.TryParse`, and testing each small change."),
+ """
+ **Title**: Maya's First .NET Project
+
+ Maya began her first .NET console app with a simple goal: identify whether a number was even. When compiler errors appeared, she slowed down, read the messages, and made one change at a time.
+
+ She tried `int.TryParse`, tested each improvement, and watched the program gradually come together. The project taught Maya that every small experiment is part of becoming a confident .NET developer.
+ """,
+ maxDelay: 2000);
+
+ private static bool IsWritePrompt(MockChatClientRequest request) =>
+ HasInteractivePrompt(request, "write");
+
+ private static bool IsEditPrompt(MockChatClientRequest request) =>
+ HasInteractivePrompt(request, "edit");
+
+ private static bool IsFormatPrompt(MockChatClientRequest request) =>
+ HasInteractivePrompt(request, "format");
+
+ private static bool IsWriterStage(MockChatClientRequest request) =>
+ !HasAssistantMessage(request);
+
+ private static bool HasAssistantMessage(MockChatClientRequest request)
+ {
+ foreach (ChatMessage message in request.Messages)
+ {
+ if (message.Role == ChatRole.Assistant &&
+ !string.IsNullOrWhiteSpace(message.Text))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool HasDraftContent(MockChatClientRequest request, string text) =>
+ request.LastUserText?.Contains(text, StringComparison.OrdinalIgnoreCase) is true ||
+ HasAssistantContent(request, text);
+
+ private static bool HasInteractivePrompt(MockChatClientRequest request, string text)
+ {
+ string? lastUserText = request.LastUserText;
+ return lastUserText?.Contains(text, StringComparison.OrdinalIgnoreCase) is true &&
+ lastUserText.Contains("Welcome to the Mock Writing Room", StringComparison.OrdinalIgnoreCase) is false;
+ }
+
+ private static bool HasMessageContent(MockChatClientRequest request, string text)
+ {
+ foreach (ChatMessage message in request.Messages)
+ {
+ if (message.Text?.Contains(text, StringComparison.OrdinalIgnoreCase) is true)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool HasAssistantContent(MockChatClientRequest request, string text)
+ {
+ foreach (ChatMessage message in request.Messages)
+ {
+ if (message.Role == ChatRole.Assistant &&
+ message.Text?.Contains(text, StringComparison.OrdinalIgnoreCase) is true)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
+#pragma warning restore MEAI001
diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/Program.cs b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/Program.cs
index 3702afef4cd..a3ad2a26d62 100644
--- a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/Program.cs
+++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/Program.cs
@@ -1,4 +1,4 @@
-#if (IsGHModels || IsOpenAI || (IsAzureOpenAI && !IsManagedIdentity))
+#if (IsOpenAI || (IsAzureOpenAI && !IsManagedIdentity))
using System.ClientModel;
#elif (IsAzureOpenAI && IsManagedIdentity)
using System.ClientModel.Primitives;
@@ -15,26 +15,16 @@
#if (IsOllama)
using OllamaSharp;
#endif
-#if (IsGHModels || IsAzureOpenAI)
+#if (IsAzureOpenAI)
using OpenAI;
#endif
-#if (IsGHModels || IsOpenAI || IsAzureOpenAI)
+#if (IsOpenAI || IsAzureOpenAI)
using OpenAI.Chat;
#endif
var builder = WebApplication.CreateBuilder(args);
-#if (IsGHModels)
-// You will need to set the token to your own value
-// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line:
-// cd this-project-directory
-// dotnet user-secrets set "GITHUB_TOKEN" "your-github-models-token-here"
-var chatClient = new ChatClient(
- "gpt-4o-mini",
- new ApiKeyCredential(builder.Configuration["GITHUB_TOKEN"] ?? throw new InvalidOperationException("Missing configuration: GITHUB_TOKEN")),
- new OpenAIClientOptions { Endpoint = new Uri("https://models.inference.ai.azure.com") })
- .AsIChatClient();
-#elif (IsOpenAI)
+#if (IsOpenAI)
// You will need to set the API key to your own value
// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line:
// cd this-project-directory
@@ -43,6 +33,8 @@
"gpt-4o-mini",
new ApiKeyCredential(builder.Configuration["OPENAI_KEY"] ?? throw new InvalidOperationException("Missing configuration: OPENAI_KEY")))
.AsIChatClient();
+#elif (IsMock)
+var chatClient = MockServices.CreateChatClient();
#elif (IsAzureOpenAI)
// You will need to set the endpoint to your own value
// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line:
@@ -97,6 +89,7 @@
// Register services for OpenAI responses and conversations (also required for DevUI)
builder.Services.AddOpenAIResponses();
builder.Services.AddOpenAIConversations();
+builder.Services.AddDevUI();
var app = builder.Build();
app.UseHttpsRedirection();
diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/README.md b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/README.md
index eb0a525f12a..29c3698b2c3 100644
--- a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/README.md
+++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/README.md
@@ -4,8 +4,8 @@ This is an AI Agent Web API application created from the `aiagent-webapi` templa
## Prerequisites
-
-- A GitHub Models API token (free to get started)
+
+- No external AI provider prerequisites
- An OpenAI API key
@@ -18,38 +18,18 @@ This is an AI Agent Web API application created from the `aiagent-webapi` templa
### 1. Configure Your AI Service
-
-#### GitHub Models Configuration
+
+#### Mock Configuration
-This application uses GitHub Models (model: gpt-4o-mini) for AI functionality. You'll need to configure your GitHub Models API token:
+This application uses a deterministic mock chat provider seeded for the writer/editor workflow.
-**Option A: Using User Secrets (Recommended for Development)**
+No API keys or model runtimes are required.
-```bash
-dotnet user-secrets set "GITHUB_TOKEN" "your-github-models-token-here"
-```
-
-**Option B: Using Environment Variables**
+Try prompts such as:
-Set the `GITHUB_TOKEN` environment variable:
-
-- **Windows (PowerShell)**:
- ```powershell
- $env:GITHUB_TOKEN = "your-github-models-token-here"
- ```
-
-- **Linux/macOS**:
- ```bash
- export GITHUB_TOKEN="your-github-models-token-here"
- ```
-
-#### Get a GitHub Models Token
-
-1. Visit [GitHub Models](https://github.com/marketplace/models)
-2. Sign in with your GitHub account
-3. Select a model (e.g., gpt-4o-mini)
-4. Click "Get API Key" or follow the authentication instructions
-5. Copy your personal access token
+1. `Write a short story about a student learning .NET.`
+2. `Edit this story for grammar and style.`
+3. `Format the final story for publishing.`
#### OpenAI Configuration
@@ -198,13 +178,13 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1
### Available Parameters
- **`--provider`**: Choose the AI service provider
- - `githubmodels` (default) - GitHub Models
+ - `mock` - Deterministic mock provider
- `azureopenai` - Azure OpenAI
- `openai` - OpenAI Platform
- `ollama` - Ollama (local development)
- **`--chat-model`**: Specify the chat model/deployment name
- - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini`
+ - Default for OpenAI/Azure OpenAI: `gpt-4o-mini`
- Default for Ollama: `llama3.2`
- **`--managed-identity`**: Use managed identity for Azure services (default: `true`)
@@ -223,9 +203,7 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1
- [AI apps for .NET developers](https://learn.microsoft.com/dotnet/ai)
- [Microsoft Agent Framework Documentation](https://aka.ms/dotnet/agent-framework/docs)
-
-- [GitHub Models](https://github.com/marketplace/models)
-
+
- [OpenAI Platform](https://platform.openai.com)
- [Azure OpenAI Service](https://azure.microsoft.com/products/ai-services/openai-service)
@@ -235,14 +213,10 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1
## Troubleshooting
-
-**Problem**: Application fails with "Missing configuration: GITHUB_TOKEN"
-
-**Solution**: Make sure you've configured your GitHub Models API token using one of the methods described above.
-
-**Problem**: API requests fail with authentication errors
+
+**Problem**: Responses seem repetitive.
-**Solution**: Verify your GitHub Models token is valid and hasn't expired. You may need to regenerate it from the GitHub Models website.
+**Solution**: The mock provider is intentionally deterministic. Use one of the three seeded workflow prompts listed above.
**Problem**: Application fails with "Missing configuration: OPENAI_KEY"
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj
index f83fda4db7e..a39a6b1492f 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj
@@ -26,6 +26,7 @@
+
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/.template.config/template.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/.template.config/template.json
index 4058affe004..5c7ccd3f487 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/.template.config/template.json
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/.template.config/template.json
@@ -98,6 +98,14 @@
"exclude": [
"AIChatWeb-CSharp.Web/Services/JsonVectorStore.cs"
]
+ },
+ {
+ "condition": "(!IsMock)",
+ "exclude": [
+ "AIChatWeb-CSharp.Web/Services/MockServices.cs",
+ "AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs",
+ "AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs"
+ ]
}
]
}],
@@ -128,7 +136,7 @@
"type": "parameter",
"displayName": "_AI service provider",
"datatype": "choice",
- "defaultValue": "githubmodels",
+ "isRequired": true,
"choices": [
{
"choice": "azureopenai",
@@ -136,9 +144,9 @@
"description": "Uses Azure OpenAI service"
},
{
- "choice": "githubmodels",
- "displayName": "GitHub Models",
- "description": "Uses GitHub Models"
+ "choice": "mock",
+ "displayName": "Mock (for local development and testing)",
+ "description": "Uses deterministic, pre-seeded responses"
},
{
"choice": "ollama",
@@ -215,14 +223,14 @@
"type": "computed",
"value": "(AiServiceProvider == \"openai\")"
},
- "IsGHModels": {
- "type": "computed",
- "value": "(AiServiceProvider == \"githubmodels\")"
- },
"IsOllama": {
"type": "computed",
"value": "(AiServiceProvider == \"ollama\")"
},
+ "IsMock": {
+ "type": "computed",
+ "value": "(AiServiceProvider == \"mock\")"
+ },
"IsAzureAIFoundry": {
"type": "computed",
"value": "(AiServiceProvider == \"azureaifoundry\")"
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.AppHost/AppHost.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.AppHost/AppHost.cs
index 3304884fa78..fa1d10780ac 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.AppHost/AppHost.cs
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.AppHost/AppHost.cs
@@ -1,18 +1,13 @@
var builder = DistributedApplication.CreateBuilder(args);
#if (IsOllama) // ASPIRE PARAMETERS
-#elif (IsOpenAI || IsGHModels)
+#elif (IsOpenAI)
// You will need to set the connection string to your own value
// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line:
// cd this-project-directory
-#if (IsOpenAI)
// dotnet user-secrets set ConnectionStrings:openai "Key=YOUR-API-KEY"
-#elif (IsGHModels)
-// dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://models.inference.ai.azure.com;Key=YOUR-API-KEY"
-#else // IsAzureOpenAI
-// dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY"
-#endif
var openai = builder.AddConnectionString("openai");
+#elif (IsMock)
#else // IsAzureOpenAI
// See https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration
@@ -62,8 +57,9 @@
.WithReference(embeddings)
.WaitFor(chat)
.WaitFor(embeddings);
-#elif (IsOpenAI || IsGHModels)
+#elif (IsOpenAI)
webApp.WithReference(openai);
+#elif (IsMock)
#else // IsAzureOpenAI
webApp
.WithReference(openai)
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/AIChatWeb-CSharp.Web.csproj-in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/AIChatWeb-CSharp.Web.csproj-in
index 293b315d3bd..84035f7bacd 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/AIChatWeb-CSharp.Web.csproj-in
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/AIChatWeb-CSharp.Web.csproj-in
@@ -13,13 +13,13 @@
#endif -->
-
+
@@ -28,6 +28,9 @@
+
+
+
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor
deleted file mode 100644
index 77557f20173..00000000000
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- How well is this template working for you? Please take a
- brief survey
- and tell us what you think.
-
- @* Remove this line to eliminate the template survey message *@
@code {
@@ -126,7 +126,10 @@
await InvokeAsync(StateHasChanged);
var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5);
return results.Select(result =>
- $"{result.Text}");
+ new XElement(
+ "result",
+ new XAttribute("filename", result.DocumentId),
+ result.Text).ToString(SaveOptions.DisableFormatting));
}
public void Dispose()
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/ChatMessageItem.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/ChatMessageItem.razor
index e45d92ab5f9..33367fa3974 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/ChatMessageItem.razor
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/ChatMessageItem.razor
@@ -1,6 +1,7 @@
@using System.Runtime.CompilerServices
@using System.Text.RegularExpressions
-@using System.Linq
+@using System.Xml
+@using System.Xml.Linq
@if (Message.Role == ChatRole.User)
{
@@ -69,7 +70,7 @@ else if (Message.Role == ChatRole.Assistant)
@code {
private static readonly ConditionalWeakTable SubscribersLookup = new();
- private static readonly Regex CitationRegex = new(@"(?.*?)", RegexOptions.NonBacktracking);
+ private static readonly Regex CitationRegex = new(@"]*>.*?", RegexOptions.NonBacktracking | RegexOptions.Singleline);
private List<(string File, string Quote)>? citations;
@@ -99,9 +100,24 @@ else if (Message.Role == ChatRole.Assistant)
private void ParseCitations(string text)
{
- var matches = CitationRegex.Matches(text);
- citations = matches.Any()
- ? matches.Select(m => (m.Groups["file"].Value, m.Groups["quote"].Value)).ToList()
- : null;
+ var parsedCitations = new List<(string File, string Quote)>();
+ foreach (Match match in CitationRegex.Matches(text))
+ {
+ try
+ {
+ XElement citation = XElement.Parse(match.Value);
+ if (citation.Name.LocalName == "citation" &&
+ citation.Attribute("filename")?.Value is { Length: > 0 } file)
+ {
+ parsedCitations.Add((file, citation.Value));
+ }
+ }
+ catch (XmlException)
+ {
+ // Ignore malformed citation markup returned by the model.
+ }
+ }
+
+ citations = parsedCitations.Count > 0 ? parsedCitations : null;
}
}
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.Aspire.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.Aspire.cs
index 31442718f1f..e19c44ffe45 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.Aspire.cs
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.Aspire.cs
@@ -1,5 +1,5 @@
using Microsoft.Extensions.AI;
-#if (IsOpenAI || IsGHModels)
+#if (IsOpenAI)
using OpenAI;
#endif
using AIChatWeb_CSharp.Web.Components;
@@ -19,7 +19,13 @@
builder.AddOllamaApiClient("embeddings")
.AddEmbeddingGenerator();
#elif (IsAzureAIFoundry)
-#else // (IsOpenAI || IsAzureOpenAI || IsGHModels)
+#elif (IsMock)
+builder.Services.AddChatClient(MockServices.CreateChatClient())
+ .UseFunctionInvocation()
+ .UseOpenTelemetry(configure: c =>
+ c.EnableSensitiveData = builder.Environment.IsDevelopment());
+builder.Services.AddEmbeddingGenerator(MockServices.CreateEmbeddingGenerator());
+#else // (IsOpenAI || IsAzureOpenAI)
#if (IsOpenAI)
var openai = builder.AddOpenAIClient("openai");
#else
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.cs
index 89a231105e9..8fb4d9f8887 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.cs
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.cs
@@ -1,4 +1,4 @@
-#if (IsGHModels || IsOpenAI || (IsAzureOpenAI && !IsManagedIdentity))
+#if (IsOpenAI || (IsAzureOpenAI && !IsManagedIdentity))
using System.ClientModel;
#elif (IsAzureOpenAI && IsManagedIdentity)
using System.ClientModel.Primitives;
@@ -11,7 +11,7 @@
using Microsoft.Extensions.AI;
#if (IsOllama)
using OllamaSharp;
-#elif (IsGHModels || IsOpenAI || IsAzureOpenAI)
+#elif (IsOpenAI || IsAzureOpenAI)
using OpenAI;
#endif
using AIChatWeb_CSharp.Web.Components;
@@ -21,21 +21,7 @@
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
-#if (IsGHModels)
-// You will need to set the endpoint and key to your own values
-// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line:
-// cd this-project-directory
-// dotnet user-secrets set GitHubModels:Token YOUR-GITHUB-TOKEN
-var credential = new ApiKeyCredential(builder.Configuration["GitHubModels:Token"] ?? throw new InvalidOperationException("Missing configuration: GitHubModels:Token. See the README for details."));
-var openAIOptions = new OpenAIClientOptions()
-{
- Endpoint = new Uri("https://models.inference.ai.azure.com")
-};
-
-var ghModelsClient = new OpenAIClient(credential, openAIOptions);
-var chatClient = ghModelsClient.GetChatClient("gpt-4o-mini").AsIChatClient();
-var embeddingGenerator = ghModelsClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator();
-#elif (IsOllama)
+#if (IsOllama)
IChatClient chatClient = new OllamaApiClient(new Uri("http://localhost:11434"),
"llama3.2");
IEmbeddingGenerator> embeddingGenerator = new OllamaApiClient(new Uri("http://localhost:11434"),
@@ -56,6 +42,10 @@
var embeddingGenerator = openAIClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator();
#elif (IsAzureAIFoundry)
+#elif (IsMock)
+var chatClient = MockServices.CreateChatClient();
+IEmbeddingGenerator> embeddingGenerator = MockServices.CreateEmbeddingGenerator();
+
#elif (IsAzureOpenAI)
// You will need to set the endpoint and key to your own values
// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line:
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/README.md
index 14677ca5372..bbaaacd54a1 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/README.md
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/README.md
@@ -2,8 +2,10 @@
This project is an AI chat application that demonstrates how to chat with custom data using an AI language model. Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet-chat-templatePreview2-survey).
+#### ---#if (!IsMock)
>[!NOTE]
> Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices.
+#### ---#endif
#### ---#if (IsAzure)
### Prerequisites
@@ -21,31 +23,19 @@ This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See
#### ---#endif
# Configure the AI Model Provider
-#### ---#if (IsGHModels)
-To use models hosted by GitHub Models, you will need to create a GitHub personal access token with `models:read` permissions, but no other scopes or permissions. See [Prototyping with AI models](https://docs.github.com/github-models/prototyping-with-ai-models) and [Managing your personal access tokens](https://docs.github.com/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) in the GitHub Docs for more information.
-
-#### ---#if (hostIdentifier == "vs")
-Configure your token for this project using .NET User Secrets:
+#### ---#if (IsMock)
+## Using Mock provider
-1. In Visual Studio, right-click on your project in the Solution Explorer and select "Manage User Secrets".
-2. This opens a `secrets.json` file where you can store your API keys without them being tracked in source control. Add the following key and value:
-
- ```json
- {
- "GitHubModels:Token": "YOUR-TOKEN"
- }
- ```
-#### ---#else
-From the command line, configure your token for this project using .NET User Secrets by running the following commands:
+The mock provider runs fully local with deterministic canned responses, so it requires no API keys, cloud credentials, or model downloads.
-```sh
-cd <>
-dotnet user-secrets set GitHubModels:Token YOUR-TOKEN
-```
-#### ---#endif
+It is pre-seeded with roughly 20 TrailMaster GPS Watch responses (plus follow-up suggestions) that map to the sample documents under `wwwroot/Data`.
-Learn more about [prototyping with AI models using GitHub Models](https://docs.github.com/github-models/prototyping-with-ai-models).
+Good starter prompts:
+- What does TrailMaster track?
+- How rugged is TrailMaster?
+- How does location sharing work?
+- Why should I buy this watch?
#### ---#endif
#### ---#if (IsOpenAI)
## Using OpenAI
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/IngestedChunk.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/IngestedChunk.cs
index 60e6b5684e4..75994063439 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/IngestedChunk.cs
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/IngestedChunk.cs
@@ -19,7 +19,11 @@ public class IngestedChunk
[VectorStoreKey(StorageName = "key")]
[JsonPropertyName("key")]
+#if (IsLocalVectorStore)
+ public required string Key { get; set; }
+#else
public required Guid Key { get; set; }
+#endif
[VectorStoreData(StorageName = "documentid")]
[JsonPropertyName("documentid")]
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs
new file mode 100644
index 00000000000..b2df852e252
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+
+namespace AIChatWeb_CSharp.Web.Services;
+
+#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs
+
+internal sealed class LexicalMockEmbeddingGenerator : MockEmbeddingGenerator
+{
+ private readonly int _dimensions;
+
+ internal LexicalMockEmbeddingGenerator(int dimensions)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(dimensions, 1);
+ _dimensions = dimensions;
+ }
+
+ protected override Task>> GenerateCoreAsync(
+ IEnumerable values,
+ EmbeddingGenerationOptions? options,
+ CancellationToken cancellationToken)
+ {
+ if (GenerateAsyncCallback is not null)
+ {
+ return base.GenerateCoreAsync(values, options, cancellationToken);
+ }
+
+ GeneratedEmbeddings> embeddings = [];
+ foreach (string value in values)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ embeddings.Add(new Embedding(CreateVector(value, _dimensions)));
+ }
+
+ return Task.FromResult(embeddings);
+ }
+
+ private static float[] CreateVector(string? value, int dimensions)
+ {
+ var vector = new float[dimensions];
+ if (string.IsNullOrEmpty(value))
+ {
+ return vector;
+ }
+
+ // Hash tokens and three-character shingles so shared words and word fragments have matching dimensions.
+ int tokenStart = -1;
+ for (int i = 0; i <= value.Length; i++)
+ {
+ if (i < value.Length && char.IsLetterOrDigit(value[i]))
+ {
+ tokenStart = tokenStart < 0 ? i : tokenStart;
+ }
+ else if (tokenStart >= 0)
+ {
+ AddTokenFeatures(value, tokenStart, i - tokenStart, vector);
+ tokenStart = -1;
+ }
+ }
+
+ // Normalize to unit length so cosine similarity favors lexical overlap rather than document length.
+ float squaredMagnitude = 0;
+ foreach (float component in vector)
+ {
+ squaredMagnitude += component * component;
+ }
+
+ if (squaredMagnitude > 0)
+ {
+ float scale = 1 / (float)Math.Sqrt(squaredMagnitude);
+ for (int i = 0; i < vector.Length; i++)
+ {
+ vector[i] *= scale;
+ }
+ }
+
+ return vector;
+ }
+
+ private static void AddTokenFeatures(string value, int tokenStart, int tokenLength, float[] vector)
+ {
+ AddFeature(value, tokenStart, tokenLength, vector);
+
+ for (int i = tokenStart; i <= tokenStart + tokenLength - 3; i++)
+ {
+ AddFeature(value, i, 3, vector);
+ }
+ }
+
+ private static void AddFeature(string value, int start, int length, float[] vector)
+ {
+ uint hash = 2_166_136_261;
+ unchecked
+ {
+ for (int i = start; i < start + length; i++)
+ {
+ hash = (hash ^ char.ToLowerInvariant(value[i])) * 16_777_619;
+ }
+ }
+
+ vector[(int)(hash % (uint)vector.Length)] += 1;
+ }
+}
+#pragma warning restore MEAI001
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs
new file mode 100644
index 00000000000..8067a6b1d62
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs
@@ -0,0 +1,297 @@
+using System.Linq;
+using System.Text.Json;
+using System.Xml;
+using System.Xml.Linq;
+using Microsoft.Extensions.AI;
+
+namespace AIChatWeb_CSharp.Web.Services;
+
+#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs
+
+internal static class MockChatClientExtensions
+{
+ private const string LoadDocumentsCallId = "mock-load-documents";
+ private const string SearchCallId = "mock-search";
+ private const string MockConversationId = "mock-conversation";
+
+ /// Adds a canned response for matching requests.
+ /// The mock chat client to configure.
+ /// Predicate used to select whether the response applies to a request.
+ /// The assistant response text.
+ ///
+ /// The optional minimum simulated response delay in milliseconds. When supplied alone, it is the fixed delay.
+ ///
+ ///
+ /// The optional maximum simulated response delay in milliseconds. When supplied alone, a delay from zero up to this
+ /// value is selected.
+ ///
+ ///
+ /// Optional aichatweb follow-up suggestions returned through its structured-output request. Supply an empty
+ /// collection when the response is a conversation dead end.
+ ///
+ ///
+ /// to invoke the document-search tools and append a citation from the search result.
+ ///
+ ///
+ /// to remove the response seed after its first matching request; otherwise it remains reusable.
+ ///
+ /// The configured .
+ internal static MockChatClient AddResponse(
+ this MockChatClient client,
+ Func requestPredicate,
+ string response,
+ int? minDelay = null,
+ int? maxDelay = null,
+ IEnumerable? suggestions = null,
+ bool includeCitation = false,
+ bool singleUse = false)
+ {
+ ArgumentNullException.ThrowIfNull(client);
+ ArgumentNullException.ThrowIfNull(requestPredicate);
+ ArgumentNullException.ThrowIfNull(response);
+ ValidateDelayRange(minDelay, maxDelay);
+
+ if (suggestions is null)
+ {
+ return AddResponse(client, requestPredicate, response, minDelay, maxDelay, includeCitation, singleUse);
+ }
+
+ string structuredResponse = JsonSerializer.Serialize(new { data = suggestions.ToArray() });
+
+ MockChatClient configuredClient = AddResponse(
+ client,
+ request => request.Options?.ResponseFormat is not ChatResponseFormatJson && requestPredicate(request),
+ response,
+ minDelay,
+ maxDelay,
+ includeCitation,
+ singleUse);
+
+ return AddResponse(
+ configuredClient,
+ request => request.Options?.ResponseFormat is ChatResponseFormatJson && requestPredicate(request),
+ structuredResponse,
+ minDelay,
+ maxDelay,
+ includeCitation,
+ singleUse);
+ }
+
+ internal static string Citation(string filename, string quote)
+ {
+ ArgumentNullException.ThrowIfNull(filename);
+ ArgumentNullException.ThrowIfNull(quote);
+
+ return new XElement(
+ "citation",
+ new XAttribute("filename", filename),
+ quote).ToString(SaveOptions.DisableFormatting);
+ }
+
+ private static MockChatClient AddResponse(
+ MockChatClient client,
+ Func requestPredicate,
+ string response,
+ int? minDelay,
+ int? maxDelay,
+ bool includeCitation,
+ bool singleUse) =>
+ client.AddResponse(
+ requestPredicate,
+ async (request, cancellationToken) =>
+ {
+ await Task.Delay(GetDelay(minDelay, maxDelay), cancellationToken);
+ return CreateResponse(request, response, includeCitation);
+ },
+ singleUse);
+
+ private static ChatResponse CreateResponse(MockChatClientRequest request, string response, bool includeCitation)
+ {
+ if (request.Options?.ResponseFormat is ChatResponseFormatJson)
+ {
+ return new ChatResponse(new ChatMessage(ChatRole.Assistant, response));
+ }
+
+ if (TryGetFunctionResult(request, SearchCallId, out FunctionResultContent? searchResult) && searchResult is not null)
+ {
+ return CreateConversationResponse($"{response}{GetCitation(searchResult, request.LastUserText)}");
+ }
+
+ if (TryGetFunctionResult(request, LoadDocumentsCallId, out _))
+ {
+ return CreateSearchFunctionCallResponse(request);
+ }
+
+ if (includeCitation)
+ {
+ return request.Options?.ConversationId is null
+ ? CreateFunctionCallResponse(LoadDocumentsCallId, "LoadDocuments")
+ : CreateSearchFunctionCallResponse(request);
+ }
+
+ return CreateConversationResponse(response);
+ }
+
+ private static ChatResponse CreateConversationResponse(string response) =>
+ new(new ChatMessage(ChatRole.Assistant, response))
+ {
+ ConversationId = MockConversationId,
+ };
+
+ private static ChatResponse CreateFunctionCallResponse(
+ string callId,
+ string name,
+ IDictionary? arguments = null) =>
+ new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(callId, name, arguments)]));
+
+ private static ChatResponse CreateSearchFunctionCallResponse(MockChatClientRequest request) =>
+ CreateFunctionCallResponse(
+ SearchCallId,
+ "Search",
+ new Dictionary
+ {
+ ["searchPhrase"] = request.LastUserText ?? string.Empty,
+ });
+
+ private static string GetCitation(FunctionResultContent searchResult, string? searchPhrase)
+ {
+ IEnumerable results = searchResult.Result switch
+ {
+ IEnumerable result => result,
+ JsonElement { ValueKind: JsonValueKind.String } result when result.GetString() is { } resultText => [resultText],
+ JsonElement { ValueKind: JsonValueKind.Array } result => result
+ .EnumerateArray()
+ .Where(static element => element.ValueKind is JsonValueKind.String)
+ .Select(static element => element.GetString()!),
+ _ => [],
+ };
+
+ foreach (string result in results)
+ {
+ try
+ {
+ XElement element = XElement.Parse(result);
+ if (element.Name.LocalName != "result" ||
+ element.Attribute("filename")?.Value is not { Length: > 0 } filename)
+ {
+ continue;
+ }
+
+ string quote = GetQuote(element.Value, searchPhrase);
+ if (quote.Length > 0)
+ {
+ return Citation(filename, quote);
+ }
+ }
+ catch (XmlException)
+ {
+ // Ignore malformed search results.
+ }
+ }
+
+ return string.Empty;
+ }
+
+ private static string GetQuote(string text, string? searchPhrase)
+ {
+ string[] words = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
+ if (words.Length == 0)
+ {
+ return string.Empty;
+ }
+
+ int start = 0;
+ if (!string.IsNullOrWhiteSpace(searchPhrase))
+ {
+ int fewestOccurrences = int.MaxValue;
+ foreach (string queryWord in searchPhrase.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))
+ {
+ string term = NormalizeWord(queryWord);
+ if (term.Length < 4)
+ {
+ continue;
+ }
+
+ int occurrences = 0;
+ int firstMatch = -1;
+ for (int i = 0; i < words.Length; i++)
+ {
+ if (IsTermMatch(NormalizeWord(words[i]), term))
+ {
+ occurrences++;
+ firstMatch = firstMatch < 0 ? i : firstMatch;
+ }
+ }
+
+ if (occurrences > 0 && occurrences < fewestOccurrences)
+ {
+ fewestOccurrences = occurrences;
+ start = firstMatch;
+ }
+ }
+ }
+
+ return string.Join(" ", words, start, Math.Min(5, words.Length - start));
+ }
+
+ private static bool IsTermMatch(string word, string term) =>
+ word.Length > 0 && (word.Contains(term, StringComparison.Ordinal) || term.Contains(word, StringComparison.Ordinal));
+
+ private static string NormalizeWord(string value) =>
+ string.Concat(value.Where(static character => char.IsLetterOrDigit(character)).Select(static character => char.ToLowerInvariant(character)));
+
+ private static bool TryGetFunctionResult(
+ MockChatClientRequest request,
+ string callId,
+ out FunctionResultContent? functionResult)
+ {
+ for (int i = request.Messages.Count - 1; i >= 0; i--)
+ {
+ ChatMessage message = request.Messages[i];
+ if (message.Role == ChatRole.User)
+ {
+ break;
+ }
+
+ foreach (AIContent content in message.Contents)
+ {
+ if (content is FunctionResultContent result && result.CallId == callId)
+ {
+ functionResult = result;
+ return true;
+ }
+ }
+ }
+
+ functionResult = null;
+ return false;
+ }
+
+ private static int GetDelay(int? minDelay, int? maxDelay) =>
+ (minDelay, maxDelay) switch
+ {
+ (null, null) => 0,
+ ({ } min, null) => min,
+ (null, { } max) => Random.Shared.Next(0, max),
+ ({ } min, { } max) => Random.Shared.Next(min, max),
+ };
+
+ private static void ValidateDelayRange(int? minDelay, int? maxDelay)
+ {
+ if (minDelay is { } minimumDelayMilliseconds)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(minimumDelayMilliseconds, 0, nameof(minDelay));
+ }
+
+ if (maxDelay is { } maximumDelayMilliseconds)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelayMilliseconds, 0, nameof(maxDelay));
+
+ if (minDelay is { } minimumDelayMillisecondsForMaximum)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelayMilliseconds, minimumDelayMillisecondsForMaximum, nameof(maxDelay));
+ }
+ }
+ }
+}
+#pragma warning restore MEAI001
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockServices.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockServices.cs
new file mode 100644
index 00000000000..55a5bd87d7a
--- /dev/null
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockServices.cs
@@ -0,0 +1,161 @@
+using Microsoft.Extensions.AI;
+
+namespace AIChatWeb_CSharp.Web.Services;
+
+#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs
+
+internal static class MockServices
+{
+ internal static MockChatClient CreateChatClient() =>
+ new MockChatClient()
+ .AddResponse(
+ static _ => true,
+ "Hi! I can help you plan an outdoor trip or find the right gear for it. What would you like to know?",
+ minDelay: 500,
+ maxDelay: 500,
+ suggestions: ["What kind of adventures?", "What kinds of products?"])
+ .AddResponse(
+ static request => LastQuestionContains(request, "what kind of adventures", "outdoor activities"),
+ "Mostly off-grid trips: hiking, backpacking, trail running, and mountaineering are the classics, and plenty of people use the same gear for camping and long day hikes. Anywhere you lose cell service, the right gear makes the trip safer and easier.",
+ minDelay: 750,
+ maxDelay: 1500,
+ suggestions: ["Tell me about hiking adventures.", "Tell me about trail safety."])
+ .AddResponse(
+ static request => LastQuestionContains(request, "what kinds of products"),
+ "Two main categories: adventure GPS watches for navigation and tracking, and emergency survival kits for backcountry safety. The GPS watch is the flagship, though a lot of people carry both. Want to dig into either one?",
+ minDelay: 750,
+ maxDelay: 1500,
+ suggestions: ["Tell me about GPS watches.", "Tell me about survival kits."])
+ .AddResponse(
+ static request => LastQuestionContains(request, "hiking adventures", "hiking"),
+ "Hiking is right in the TrailMaster GPS Watch's wheelhouse. It's made for hikers, backpackers, and trail runners heading into remote terrain, with offline topographic maps and real-time location sharing to keep you on route and reachable.",
+ minDelay: 1000,
+ maxDelay: 3000,
+ suggestions: ["What does TrailMaster track?", "How rugged is TrailMaster?", "How does location sharing work?"],
+ includeCitation: true)
+ .AddResponse(
+ static request => LastQuestionContains(request, "gps watches", "gps watch"),
+ "The TrailMaster GPS Watch pairs precise positioning with a rugged build. It runs a multi-constellation receiver for GPS, GLONASS, and Galileo, plus an altimeter, barometer, and compass, so navigation holds up even where satellite coverage is thin.",
+ minDelay: 1000,
+ maxDelay: 3000,
+ suggestions: ["What does TrailMaster track?", "What navigation tools are built in?", "How rugged is TrailMaster?"],
+ includeCitation: true)
+ .AddResponse(
+ static request => LastQuestionContains(request, "trail safety", "safety"),
+ "Trail safety really comes down to preparation. Before heading out, make sure the watch is charged and calibrated and that you know its controls, then use real-time location sharing so someone always knows where you are. For longer trips, an emergency survival kit is a smart backup.",
+ minDelay: 1000,
+ maxDelay: 3000,
+ suggestions: ["How does location sharing work?", "How do I fix GPS signal loss?", "Tell me about survival kits."],
+ includeCitation: true)
+ .AddResponse(
+ static request => LastQuestionContains(request, "survival kits", "survival kit", "emergency kit"),
+ "The Life Guard X Emergency Survival Kit is built for when a trip goes sideways. It packs first aid supplies, high-calorie emergency food, a water purification system, an emergency shelter, and signaling tools so you can stay safe and reach help.",
+ minDelay: 1000,
+ maxDelay: 3000,
+ suggestions: ["What's in the first aid supplies?", "How does water purification work?", "How do I signal for help?"],
+ includeCitation: true)
+ .AddResponse(
+ static request => LastQuestionContains(request, "what does trailmaster track", "what does it track", "performance metrics", "heart rate", "elevation gain"),
+ "It tracks the numbers that matter for training and pacing: distance traveled, speed, elevation gain, and heart rate. You can watch them live in tracking mode or review them after the activity.",
+ minDelay: 1500,
+ maxDelay: 7500,
+ suggestions: [],
+ includeCitation: true)
+ .AddResponse(
+ static request => LastQuestionContains(request, "how rugged", "rugged", "durable", "shock-resistant", "harsh conditions"),
+ "It's built for abuse. The casing is durable and shock-resistant, and the reinforced strap and rugged design shrug off extreme temperatures, water, and impact out in the field.",
+ minDelay: 1500,
+ maxDelay: 7500,
+ suggestions: [],
+ includeCitation: true)
+ .AddResponse(
+ static request => LastQuestionContains(request, "location sharing", "share location", "share my location"),
+ "Press the share button and pick the contacts you want to reach, and the watch sends your position to them in real time. It needs a stable GPS signal and your phone connected through the TrailMaster app.",
+ minDelay: 1500,
+ maxDelay: 7500,
+ suggestions: [],
+ includeCitation: true)
+ .AddResponse(
+ static request => LastQuestionContains(request, "navigation tools", "route planning", "waypoint", "trail tracking"),
+ "Plenty to work with: preloaded topographic maps, trail tracking, waypoint management, and route planning, backed by an onboard compass, altimeter, and barometer for orientation and weather.",
+ minDelay: 1500,
+ maxDelay: 7500,
+ suggestions: [],
+ includeCitation: true)
+ .AddResponse(
+ static request => LastQuestionContains(request, "gps signal loss", "no gps signal", "lost gps", "gps troubleshooting"),
+ "If it shows \"No GPS Signal,\" start with your surroundings: dense foliage, buildings, or terrain can block satellites. Check for RF interference, run a signal diagnostics test, and if it keeps happening, reach out to ExpeditionTech support.",
+ minDelay: 1500,
+ maxDelay: 7500,
+ suggestions: [],
+ includeCitation: true)
+ .AddResponse(
+ static request => LastQuestionContains(request, "bluetooth", "cannot connect", "connection problem", "connectivity issues"),
+ "For pairing trouble, keep the watch within Bluetooth range, clear out nearby sources of interference, and update to the latest firmware. If it still won't connect, a Bluetooth signal analyzer can help track down the cause.",
+ minDelay: 1500,
+ maxDelay: 7500,
+ suggestions: [],
+ includeCitation: true)
+ .AddResponse(
+ static request => LastQuestionContains(request, "first aid"),
+ "The first aid supplies cover the basics for minor injuries: adhesive bandages, sterile gauze and tape, antiseptic wipes and antibiotic ointment, over-the-counter medications, and tools like tweezers, scissors, and a thermometer. A first aid guide is included too.",
+ minDelay: 1500,
+ maxDelay: 7500,
+ suggestions: [],
+ includeCitation: true)
+ .AddResponse(
+ static request => LastQuestionContains(request, "water purification", "water filter", "purify water", "clean water"),
+ "It comes with a portable water filter that removes 99.9999% of waterborne bacteria and 99.9% of protozoan parasites, so you can drink from rivers and lakes. Purification tablets are included too for an extra layer of protection.",
+ minDelay: 1500,
+ maxDelay: 7500,
+ suggestions: [],
+ includeCitation: true)
+ .AddResponse(
+ static request => LastQuestionContains(request, "signal for help", "signal mirror", "two-way radio", "signaling"),
+ "For signaling, the kit includes a whistle for short range, a signal mirror that reflects sunlight to catch a rescuer's eye, and a weather-resistant two-way radio with up to 20 miles of range to reach your group or emergency services.",
+ minDelay: 1500,
+ maxDelay: 7500,
+ suggestions: [],
+ includeCitation: true);
+
+ internal static IEmbeddingGenerator> CreateEmbeddingGenerator() =>
+ new LexicalMockEmbeddingGenerator(IngestedChunk.VectorDimensions);
+
+ private static bool LastQuestionContains(MockChatClientRequest request, params string[] terms)
+ {
+ string? question = GetLastQuestion(request);
+ if (string.IsNullOrWhiteSpace(question))
+ {
+ return false;
+ }
+
+ foreach (string term in terms)
+ {
+ if (question.Contains(term, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static string? GetLastQuestion(MockChatClientRequest request)
+ {
+ int startIndex = request.Options?.ResponseFormat is ChatResponseFormatJson
+ ? request.Messages.Count - 2
+ : request.Messages.Count - 1;
+
+ for (int i = startIndex; i >= 0; i--)
+ {
+ ChatMessage message = request.Messages[i];
+ if (message.Role == ChatRole.User && !string.IsNullOrWhiteSpace(message.Text))
+ {
+ return message.Text;
+ }
+ }
+
+ return null;
+ }
+}
+#pragma warning restore MEAI001
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/README.Aspire.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/README.Aspire.md
index 0fff810489c..b4c9e3deb49 100644
--- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/README.Aspire.md
+++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/README.Aspire.md
@@ -2,8 +2,10 @@
This project is an AI chat application that demonstrates how to chat with custom data using an AI language model. Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet-chat-templatePreview2-survey).
+#### ---#if (!IsMock)
>[!NOTE]
> Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices.
+#### ---#endif
#### ---#if (IsAzure)
### Prerequisites
@@ -20,31 +22,19 @@ This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See
# Configure the AI Model Provider
-#### ---#if (IsGHModels)
-## Using GitHub Models
-To use models hosted by GitHub Models, you will need to create a GitHub personal access token. The token should not have any scopes or permissions. See [Managing your personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens).
+#### ---#if (IsMock)
+## Using Mock provider
-#### ---#if (hostIdentifier == "vs")
-Configure your token for this project using .NET User Secrets:
+The mock provider runs fully local with deterministic canned responses, so it requires no API keys, cloud credentials, or model downloads.
-1. In Visual Studio, right-click on the AIChatWeb-CSharp.AppHost project in the Solution Explorer and select "Manage User Secrets".
-2. This opens a `secrets.json` file where you can store your API keys without them being tracked in source control. Add the following key and value:
+It is pre-seeded with roughly 20 TrailMaster GPS Watch responses (plus follow-up suggestions) that map to the sample documents under `wwwroot/Data`.
- ```json
- {
- "ConnectionStrings:openai": "Endpoint=https://models.inference.ai.azure.com;Key=YOUR-API-KEY"
- }
- ```
-#### ---#else
-From the command line, configure your token for this project using .NET User Secrets by running the following commands:
-
-```sh
-cd AIChatWeb-CSharp.AppHost
-dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://models.inference.ai.azure.com;Key=YOUR-API-KEY"
-```
-#### ---#endif
+Good starter prompts:
-Learn more about [prototyping with AI models using GitHub Models](https://docs.github.com/github-models/prototyping-with-ai-models).
+- What does TrailMaster track?
+- How rugged is TrailMaster?
+- How does location sharing work?
+- Why should I buy this watch?
#### ---#endif
#### ---#if (IsOpenAI)
## Using OpenAI
diff --git a/src/ProjectTemplates/README.md b/src/ProjectTemplates/README.md
index 69c052282ad..6e5da5dabcf 100644
--- a/src/ProjectTemplates/README.md
+++ b/src/ProjectTemplates/README.md
@@ -140,13 +140,13 @@ Finally, create a project from the template and run it:
```pwsh
dotnet new aiagent-webapi `
- [--provider ] `
+ --provider `
[--managed-identity]
# or
dotnet new aichatweb `
- [--provider ] `
+ --provider `
[--vector-store ] `
[--aspire] `
[--managed-identity]
diff --git a/src/Shared/DiagnosticIds/DiagnosticIds.cs b/src/Shared/DiagnosticIds/DiagnosticIds.cs
index abddbf61c5a..17ad1211730 100644
--- a/src/Shared/DiagnosticIds/DiagnosticIds.cs
+++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs
@@ -60,6 +60,7 @@ internal static class Experiments
internal const string AIToolSearch = AIExperiments;
internal const string AIRealTime = AIExperiments;
internal const string AIFiles = AIExperiments;
+ internal const string AITesting = AIExperiments;
internal const string AIOpenAIRequestPolicies = AIExperiments;
// These diagnostic IDs are defined by the OpenAI package for its experimental APIs.
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs
index ededd588383..899d80f7420 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs
@@ -26,7 +26,7 @@ public async Task GenerateEmbeddingsDefaultsToInnerServiceAsync()
var expectedCancellationToken = cts.Token;
var expectedResult = new TaskCompletionSource>>();
var expectedEmbedding = new GeneratedEmbeddings>([new(new float[] { 1.0f, 2.0f, 3.0f })]);
- using var inner = new TestEmbeddingGenerator
+ using var inner = new MockEmbeddingGenerator
{
GenerateAsyncCallback = (input, options, cancellationToken) =>
{
@@ -51,7 +51,7 @@ public async Task GenerateEmbeddingsDefaultsToInnerServiceAsync()
[Fact]
public void GetServiceThrowsForNullType()
{
- using var inner = new TestEmbeddingGenerator();
+ using var inner = new MockEmbeddingGenerator();
using var delegating = new NoOpDelegatingEmbeddingGenerator(inner);
Assert.Throws("serviceType", () => delegating.GetService(null!));
}
@@ -60,7 +60,7 @@ public void GetServiceThrowsForNullType()
public void GetServiceReturnsSelfIfCompatibleWithRequestAndKeyIsNull()
{
// Arrange
- using var inner = new TestEmbeddingGenerator();
+ using var inner = new MockEmbeddingGenerator();
using var delegating = new NoOpDelegatingEmbeddingGenerator(inner);
// Act
@@ -76,8 +76,8 @@ public void GetServiceDelegatesToInnerIfKeyIsNotNull()
// Arrange
var expectedParam = new object();
var expectedKey = new object();
- using var expectedResult = new TestEmbeddingGenerator();
- using var inner = new TestEmbeddingGenerator
+ using var expectedResult = new MockEmbeddingGenerator();
+ using var inner = new MockEmbeddingGenerator
{
GetServiceCallback = (_, _) => expectedResult
};
@@ -97,7 +97,7 @@ public void GetServiceDelegatesToInnerIfNotCompatibleWithRequest()
var expectedParam = new object();
var expectedResult = TimeZoneInfo.Local;
var expectedKey = new object();
- using var inner = new TestEmbeddingGenerator
+ using var inner = new MockEmbeddingGenerator
{
GetServiceCallback = (type, key) => type == expectedResult.GetType() && key == expectedKey
? expectedResult
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs
index 60e15848e0f..e7c77371a07 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs
@@ -21,14 +21,14 @@ public void GetRequiredService_InvalidArgs_Throws()
{
Assert.Throws("generator", () => EmbeddingGeneratorExtensions.GetRequiredService