Skip to content

feat: Microsoft.Extensions.AI IChatClient adapter (DotLLM.Extensions.AI) (#327) - #328

Open
jamesburton wants to merge 2 commits into
kkokosa:mainfrom
jamesburton:issue/327-extensions-ai-chatclient
Open

feat: Microsoft.Extensions.AI IChatClient adapter (DotLLM.Extensions.AI) (#327)#328
jamesburton wants to merge 2 commits into
kkokosa:mainfrom
jamesburton:issue/327-extensions-ai-chatclient

Conversation

@jamesburton

Copy link
Copy Markdown

Summary

Adds a Microsoft.Extensions.AI.IChatClient implementation for dotLLM in a new DotLLM.Extensions.AI package, making dotLLM a native, in-process backend for the Microsoft Agent Framework (GA April 2026) and the broader Microsoft.Extensions.AI (MEAI) ecosystem.

IChatClient is the universal plug for the Microsoft AI stack — MAF agents (ChatClientAgent/AIAgent), the function-invocation middleware, response caching, OpenTelemetry, and DI all consume it. Wrap a loaded model and pass the client to chatClient.CreateAIAgent(...) with zero glue.

Purely additive and independent of engine internals — a thin translation shim over TextGenerator. Branches cleanly off main.

Closes #327.

What's added

  • DotLLMChatClient : IChatClientGetResponseAsync, GetStreamingResponseAsync, GetService, Dispose, wrapping TextGenerator + IChatTemplate (+ optional IToolCallParser). Calls are serialized through an internal gate (the engine is single-request, like the server); streaming holds the gate for the enumeration.
  • ChatClientMapping — pure, unit-testable translation:
    • Request: ChatMessage/TextContent/FunctionCallContent/FunctionResultContent → engine ChatMessage[]/ToolCall/tool-role messages; ChatOptions (Temperature/TopP/TopK/MaxOutputTokens/StopSequences/Seed/ResponseFormat/Tools, plus min_p/repetition_penalty via AdditionalProperties) → InferenceOptions + ToolDefinition[].
    • Response: text → TextContent; detected ToolCalls → FunctionCallContent (text dropped, finish reason tool-calls); FinishReasonChatFinishReason; token counts → UsageDetails; streaming GenerationTokenChatResponseUpdate.

Quality

  • 22 unit tests covering message flattening, options/tool/response-format mapping, tool-use emission, finish-reason and usage mapping. Full solution build green (0 warnings).
  • AOT-clean: Utf8JsonWriter/JsonDocument only, no reflection-based JsonSerializer (the project's trim analyzer is satisfied).
  • Targets .NET 10; depends only on DotLLM.Engine + Microsoft.Extensions.AI.Abstractions 10.7.0 (added to central package management). Registered in dotLLM.slnx; packs cleanly.
  • Docs: new docs/EXTENSIONS_AI.md (usage, integration paths, full type mapping) + ROADMAP/README/CLAUDE sync.

Notes / limitations

  • Streaming tool calls are detected post-generation (matching the OpenAI/Anthropic server endpoints), so FunctionCallContent is emitted in the final streaming update rather than incrementally.
  • ChatOptions.FrequencyPenalty/PresencePenalty have no direct engine equivalent and are not applied; RepetitionPenalty/MinP are reachable via AdditionalProperties.
  • For automatic tool execution, wrap with MEAI's FunctionInvokingChatClient — no custom loop needed.

🤖 Generated with Claude Code

…AI) (#327)

Add a new DotLLM.Extensions.AI package implementing
Microsoft.Extensions.AI.IChatClient over the dotLLM engine, making dotLLM a
native, in-process backend for the Microsoft Agent Framework (GA Apr 2026) and
the broader Microsoft.Extensions.AI ecosystem — wrap a model and pass the client
to chatClient.CreateAIAgent(...).

- DotLLMChatClient : IChatClient (GetResponseAsync, GetStreamingResponseAsync,
  GetService, Dispose) wrapping TextGenerator + IChatTemplate (+ optional
  IToolCallParser). Calls serialized through an internal gate (single-request).
- ChatClientMapping (pure, unit-testable translation):
  * messages: ChatMessage/TextContent/FunctionCallContent/FunctionResultContent
    -> engine ChatMessage[] / ToolCall / tool-role messages.
  * ChatOptions (Temperature/TopP/TopK/MaxOutputTokens/StopSequences/Seed/
    ResponseFormat/Tools, + min_p/repetition_penalty via AdditionalProperties)
    -> InferenceOptions + ToolDefinition[].
  * output: text -> TextContent; detected ToolCalls -> FunctionCallContent;
    FinishReason -> ChatFinishReason; token counts -> UsageDetails; streaming
    GenerationToken -> ChatResponseUpdate.
- AOT-clean (Utf8JsonWriter/JsonDocument, no reflection JsonSerializer); targets
  .NET 10; depends only on Microsoft.Extensions.AI.Abstractions 10.7.0.

Tests: 22 unit tests covering message/options/tool/response/finish-reason/usage
mapping. Docs: docs/EXTENSIONS_AI.md + ROADMAP/README/CLAUDE sync; added to
dotLLM.slnx and central package management.

Closes #327

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 15, 2026 13:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds a Microsoft.Extensions.AI integration layer so dotLLM can be used as an in-process IChatClient (including tool calling, response formats, usage, and streaming), plus documentation and unit tests for the mapping layer.

Changes:

  • Introduces new DotLLM.Extensions.AI project with DotLLMChatClient (IChatClient) and a pure mapping layer (ChatClientMapping).
  • Adds mapping-focused unit tests and wires the new project into the solution/test projects.
  • Documents the integration in docs/EXTENSIONS_AI.md and updates README/roadmap references.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/DotLLM.Extensions.AI/DotLLMChatClient.cs New IChatClient adapter over TextGenerator with non-streaming + streaming APIs
src/DotLLM.Extensions.AI/ChatClientMapping.cs New translation layer between MEAI types and dotLLM engine types
src/DotLLM.Extensions.AI/DotLLM.Extensions.AI.csproj New project referencing dotLLM engine + MEAI abstractions
tests/DotLLM.Tests.Unit/Extensions/DotLLMChatClientMappingTests.cs Unit tests for mapping behavior (roles, tools, formats, usage)
tests/DotLLM.Tests.Unit/DotLLM.Tests.Unit.csproj Adds reference to the new integration project
docs/EXTENSIONS_AI.md New documentation for MEAI/MAF integration and mapping reference
README.md / docs/ROADMAP.md / CLAUDE.md / dotLLM.slnx / Directory.Packages.props Solution + docs wiring, package version addition

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +80 to +84
public static string ToRole(ChatRole role) =>
role == ChatRole.System ? "system"
: role == ChatRole.Assistant ? "assistant"
: role == ChatRole.Tool ? "tool"
: "user";
Comment on lines +63 to +69
InferenceResponse result;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
result = await Task.Run(
() => _generator.Generate(prompt, inferenceOptions), cancellationToken).ConfigureAwait(false);
}
Comment on lines +116 to +134
await foreach (var token in _generator
.GenerateStreamingTokensAsync(prompt, inferenceOptions, cancellationToken).ConfigureAwait(false))
{
if (token.Text.Length > 0)
{
accumulated.Append(token.Text);
yield return new ChatResponseUpdate
{
Role = ChatRole.Assistant,
Contents = [new TextContent(token.Text)],
ResponseId = responseId,
MessageId = responseId,
ModelId = modelId,
};
}

if (token.FinishReason.HasValue)
finishReason = token.FinishReason.Value;
}
Comment on lines +141 to +148
// Post-generation tool-call detection (mirrors the server streaming endpoints).
EngineToolCall[]? toolCalls = null;
if (_toolCallParser is not null && tools is { Length: > 0 })
{
toolCalls = _toolCallParser.TryParse(accumulated.ToString());
if (toolCalls is { Length: > 0 })
finishReason = FinishReason.ToolCalls;
}
Comment on lines +252 to +267
private static float? ReadFloat(AdditionalPropertiesDictionary? props, string key)
{
if (props is null || !props.TryGetValue(key, out var value) || value is null)
return null;

return value switch
{
float f => f,
double d => (float)d,
int i => i,
long l => l,
JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetSingle(),
string s when float.TryParse(s, out var r) => r,
_ => null,
};
}
Comment on lines +55 to +58
public async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
Comment on lines +99 to +102
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
…327)

- ToRole: preserve non-standard roles (e.g. "developer") instead of coercing
  every unrecognized ChatRole to "user"; the chat template now decides how to
  render them. Empty/whitespace roles still fall back to "user".
- ReadFloat: parse string-valued AdditionalProperties knobs (min_p,
  repetition_penalty) with InvariantCulture so "0.05" is not rejected on a
  machine whose current culture uses ',' as the decimal separator.
- Streaming + tools: when a tool-call parser is configured AND the request
  carries tools, buffer the text instead of emitting TextContent deltas, and
  deliver the message in the final update via the same mapping the
  non-streaming path uses. Previously the model's raw tool-call syntax was
  streamed to the caller, and the coalesced stream (ToChatResponse) disagreed
  with GetResponseAsync, which drops text when tool calls are present.
  Requests without tools (or without a parser) stream token-by-token as before.
- Documented in DotLLMChatClient and docs/EXTENSIONS_AI.md, including why
  GetResponseAsync offloads the synchronous Generate to the thread pool (the
  token gates scheduling only; use streaming for mid-generation cancellation).

Tests: new DotLLMChatClientTests drives the client with a scripted model
(deterministic one-hot logits) covering non-streaming mapping, max-tokens
finish reason, template/tool plumbing, tool-call detection, the streaming
update sequence, the buffered tool path, streaming/non-streaming agreement,
and GetService. Plus mapping tests for the unknown-role and comma-culture
fixes. 34 Extensions.AI unit tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jamesburton

Copy link
Copy Markdown
Author

Thanks for the review — went through all 7 comments. Pushed 594ffc91. Summary of what changed and what I pushed back on:

Fixed

1. ToRole coerced unknown roles to user (ChatClientMapping.cs:84) — agreed. Non-standard roles (developer, etc.) are now passed through verbatim so the chat template, not the adapter, decides how to render them; empty/whitespace roles still fall back to user. Test added.

2. Culture-sensitive float.TryParse (ChatClientMapping.cs:267) — agreed. min_p / repetition_penalty supplied as strings via AdditionalProperties now parse with NumberStyles.Float + CultureInfo.InvariantCulture. Added a test that sets CurrentCulture to pl-PL (comma decimal separator) and asserts "0.05" still maps to 0.05f — it failed before the fix.

3. Streaming leaked raw tool-call text (DotLLMChatClient.cs:134/:148) — agreed, and this was the substantive one. When a tool-call parser is configured and the request carries tools, the text is now buffered rather than emitted as TextContent deltas, and the final update carries the whole message built by the same ToResponseContents mapping the non-streaming path uses. So updates.ToChatResponse() and GetResponseAsync now agree, and the model's tool-call syntax never reaches the caller as streamed text. Requests without tools (or without a parser) stream token-by-token exactly as before, so the common no-tools path is unaffected.

The trade-off is explicit: a tool-enabled streaming request no longer streams incrementally. It can't, correctly — tool calls are only recognisable once generation completes (IToolCallParser.TryParse operates on the full text), so any incremental emission is a guess that may have to be retracted. Documented in docs/EXTENSIONS_AI.md under Notes/limitations. Happy to add an opt-out ctor flag if you'd rather keep the old behaviour available.

4. No tests for DotLLMChatClient itself (DotLLMChatClient.cs:58/:102) — agreed. TextGenerator is sealed, so it can't be stubbed directly, but it is constructible over IModel + ITokenizer. New tests/DotLLM.Tests.Unit/Extensions/DotLLMChatClientTests.cs builds a real TextGenerator over a scripted model that returns one-hot logits for a predetermined token sequence (fully deterministic) plus a scripted tokenizer and a recording chat template. Covers: non-streaming text/finish-reason/usage/model-id mapping, max-tokens → Length, template + tool-definition plumbing, tool-call detection (FunctionCallContent, text dropped), tool-call text without tools requested staying text, the streaming update sequence and final FinishReason, the buffered tool-call path, buffered plain text with tools present, streaming vs non-streaming agreement, and GetService. 34 Extensions.AI unit tests pass (--filter FullyQualifiedName~DotLLMChatClient).

Not applied

Task.Run in GetResponseAsync (DotLLMChatClient.cs:69) — respectfully disagree with removing it. TextGenerator.Generate is synchronous and runs for the entire completion (seconds). Calling it inline after await _gate.WaitAsync(...) would run it on whatever thread resumed the continuation — an ASP.NET request thread or a UI thread — blocking it for the whole generation. That's exactly what an async API must not do; the thread-pool hop is cheap relative to a multi-second generation and is the point, not overhead.

Your observation about cancellation is correct though, so I've documented it rather than papered over it: a comment on the call site and a note in docs/EXTENSIONS_AI.md now state that the token only gates scheduling and cannot abort a running generation, and point callers to GetStreamingResponseAsync, which cancels cooperatively between decode steps.

Verification

Built DotLLM.Tests.Unit (which builds DotLLM.Extensions.AI) — 0 warnings, 0 errors. Ran only the Extensions.AI filter, not the full unit suite, so I have not re-verified unrelated suites on this branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Microsoft.Extensions.AI IChatClient adapter (DotLLM.Extensions.AI) for Agent Framework compatibility

2 participants