Random wanderings through Microsoft Azure esp. PaaS plumbing, the IoT bits, AI on Micro controllers, AI on Edge Devices, .NET nanoFramework, .NET Core on *nix and ML.NET+ONNX
FoehnAIBuilder uses a plugin-based architecture where every tool implements a standard .NET class that conforms to the ITool C# interface. As part of the startup process, the application scans the plugin directory, loads all the available assemblies, and includes all that implement this contract.
Tools also have a risk level (considering increasing the number of options and training an ML.NET model to detect potentially malicious arguments), so the host application can apply safety controls such as requiring user confirmation before operations that may have significant side effects.
FoehnAIBuilder enforces a maximum tool iteration count. This prevents runaway execution loops, stops the context growing to the point where it impacts on the LLM’s performance (The dumb zone), and “burning” lots of Tokens
"Mistral": {
"BaseUrl": "https://api.mistral.ai",
"APIKey": "This is not the APIKey you are looking for",
"DefaultModel": "devstral-latest",
"TimeoutSeconds": 120,
"MaxRetries": 3,
"EnableStreaming": false
},
"FoehnAIBuilder": {
"SystemMessageFile": "foehn.md",
"PluginsPath": ".plugins",
"WorkingDirectory": "",
"MaxToolIterations": 30
},
}
Each tool exposes metadata that allows the LLM to understand how to invoke it. This includes a unique function name, a human-readable description, and a JSON Schema describing the parameters the tool expects. I’m considering implementing the parameters for a plug-in using Data Transfer Objects (DTO) rather than the current approach using strings.
public string Name => "scan";
public string Description =>
"Recursively lists files and directories under a given path, or the current working " +
"folder if no path is supplied. Use this first to discover what exists before reading, " +
"writing, deleting, or executing anything.";
public string Command => """
{
"type": "object",
"properties": {
"path": { "type": "string", "description": "Directory to scan. Defaults to the application's current working folder if omitted." },
"pattern": { "type": "string", "description": "Search pattern, e.g. '*.cs'. Defaults to '*' (all files)." },
"recursive": { "type": "boolean", "description": "Whether to recurse into subdirectories. Defaults to true." }
},
"required": []
}
The plugins have code to detect an LLM directory escape with a path in a parameter like “directory to scan”. When the LLM chooses to invoke a tool, FoehnAIBuilder calls the tool’s ExecuteAsync method and passes the arguments as a JSON document that conforms to the schema exposed by the tool.
FoehnAIBuilder processes the request and returns a ToolExecutionResult, which provides a standardised way for both the application and the LLM to understand the outcome. The result contains a boolean success indicator and a descriptive message that may include returned data, status information, or error details.
public sealed class ToolExecutionResult
{
public required bool Success { get; init; }
public required string Result { get; init; }
public static ToolExecutionResult Ok(string result) => new() { Success = true, Result = result };
public static ToolExecutionResult Fail(string result) => new() { Success = false, Result = result };
}
ToolExecutionResult approach follows the result pattern rather than an exception-driven programming model. Every tool invocation returns a result containing both a success indicator and a human-readable message describing the outcome. This provides a consistent contract between the tool, the host, and the language model. This allows the LLM to reason about both successful operations and expected failure conditions such as validation errors, missing resources, or access restrictions.
The plug-in implementations handle and translate all anticipated error conditions into a ToolExecutionResult.Fail response rather than allowing exceptions to propagate to the FoehnAIBuilder host (this would be bad). Returning structured failure information enables the language model to understand what went wrong and potentially adjust its behaviour and retry with different inputs.
try
{
var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
...
return Task.FromResult(ToolExecutionResult.Ok(sb.ToString()));
}
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
{
_logger.LogError(ex, "Error scanning {Path}", path);
return Task.FromResult(ToolExecutionResult.Fail($"Error scanning \"{path}\": {ex.Message}"));
}
Exceptions are reserved for genuinely unexpected conditions such as programming errors, infrastructure failures, or unrecoverable runtime errors. As a general rule, no exception in a tool plug-in should be returned to FoehnAIBuilder for business logic or user-correctable error, these should always be represented as a failed ToolExecutionResult containing a clear and actionable explanation of the problem.
The next couple of posts will explore progressively more capable (read dangerous) operations. First, file and directory tools, where path traversal, deletion, and privilege boundaries (file and directory permissions) introduce real risk.
Generate C# DTOs and a ChatCompletionClient for https://docs.mistral.ai/api/endpoint/chat. The ChatCompletionClient should
use HttpClientFactory with resilience and should have async methods where suitable. The DTOs should be split up into files
for shared, buffered, and streaming.
Generate a console application which uses ChatCompletionClient
Then I took the initial implementation and prompted GitHub Copilot, Anthropic Claude Code, and Mistral Vibe Code to review them. The aim was to see how each tool thinks, what they prioritise, and then how their feedback differs when given identical code.
TL;DR – the code was fine.
Github Copilot
Overall the library is well-structured, cleanly documented, and follows familiar patterns (Options, IHttpClientFactory, Polly).
Anthropic Claude
The client is a small, focused wrapper around POST /v1/chat/completions, supporting both a buffered call (CompleteAsync) and an SSE streaming call (StreamAsync). Overall design is clean: immutable JsonSerializerOptions, an immutable record request type mutated via with, DI registration through IHttpClientFactory with a resilience pipeline, and polymorphic DTOs for messages/content/tools that mirror the Mistral (OpenAI-compatible) wire format. No correctness-blocking bugs were found; findings below are mostly design nits and a few things worth double-checking against real API behavior.
Mistral Vibe Code
Overall Rating: ⭐⭐⭐⭐☆ (4.2/5) – Production Ready with Minor Improvements
Overall the library is well-structured, cleanly documented, and follows familiar patterns (Options, IHttpClientFactory, Polly). Below are the concrete issues and improvements I’d action, roughly ordered by impact.
🔴 Correctness / bugs
Streaming isn’t actually streaming. In StreamCompletionAsync you call _httpClient.PostAsync(...), which buffers the whole response before returning. You must pass HttpCompletionOption.ResponseHeadersRead, otherwise every “streamed” token arrives at once (and the retry policy will hold the full body in memory).
using var req = new HttpRequestMessage(HttpMethod.Post, requestUri) { Content = requestContent };
response = await _retryPolicy.ExecuteAsync(ct =>
_httpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct), cancellationToken);
Retry policy + single StringContent is broken.StringContent is disposed once sent, so any retry after the first attempt POSTs an empty/disposed body. The HttpRequestMessage/content must be re-created inside the ExecuteAsync delegate (or use HttpClientHandler-level Polly via AddPolicyHandler, which handles this).
IDisposable + IHttpClientFactory conflict. The class implements IDisposable and holds _disposed, implying it disposes _httpClient. HttpClients from IHttpClientFactorymust not be disposed by the consumer — the factory owns the handler lifetime. Either:
drop IDisposable, or
dispose only in the HttpClient-injected overload and track ownership with a flag.
Mutating a factory-provided HttpClient in the constructor. Setting Timeout, DefaultRequestHeaders.Authorization, and User-Agent on httpClientFactory.CreateClient() re-configures a shared/named client each time and, worse, appending User-Agent via Add(...) will throw InvalidOperationException on second construction against the same named client. Configure this once through services.AddHttpClient(c => …) and remove it from the ctor.
ReadLineAsync treats SSE as line-oriented text. Real SSE:
allows data: (no space) — your Substring(6) is off by one in that case,
allows multi-line data: chunks concatenated per event,
has comment lines starting with : (heartbeat/keep-alive) that must be ignored,
has event:, id:, retry: fields.
Consider System.Net.ServerSentEvents.SseParser (built-in) instead of hand-rolling.
Polymorphic streaming events. You deserialize into a single CompletionEvent type, yet StreamingDto.cs defines CompletionEventBase with TextCompletionEvent, ToolCallEvent, … discriminated by type. Without [JsonPolymorphic]/[JsonDerivedType] on the base, the derived data is lost. Either:
Add polymorphism attributes on CompletionEventBase and deserialize as the base, or
Keep a flat CompletionEvent DTO and drop the base/derived hierarchy — currently you have both, which is confusing.
Fallback path assumes Text exists on CompletionEvent. new CompletionEvent { Type = "unknown", Text = line } implies a flat DTO; reconcile with issue #6.
No Retry-After handling on 429. Polly’s WaitAndRetryAsync uses only 2^n. Mistral (like OpenAI) returns Retry-After — honour it.
response.Content.ReadAsStringAsync() / ReadAsStreamAsync() don’t take the cancellationToken. These overloads exist on .NET 8 — pass the token so hanging responses can be cancelled.
HttpClient.Timeout fights with cancellation. With Polly retries + streaming, a global 120 s timeout aborts long streams. Use HttpClient.Timeout = Timeout.InfiniteTimeSpan and enforce per-request timeout via a linked CancellationTokenSource.
🟠 API / design
Massive DTO duplication.ChatCompletionRequest (Buffered) and ChatCompletionStreamRequest (Streaming) are byte-for-byte identical apart from the default of Stream. Introduce a shared base (or single DTO) in DTOs.Shared and delete ~130 lines. The Stream boolean is the only real difference and can be set by the client method.
Stop and ToolChoice typed as object?. These serialize fine but deserialize poorly and lose IntelliSense. Use OneOf<string, List>, a small wrapper type, or JsonElement.
Metadata is Dictionary. Mistral’s schema is Dictionary.
MessageBase.Content is string? only. Mistral supports multimodal content (array of {type, text|image_url} parts) on user messages. Model as object? or OneOf<string, List> to be forward-compatible.
AssistantMessage missing prefix/reasoning/tool_call_id variants.ChatCompletionChoice.Prefix exists but not on the message itself, which is where the API places it for message replay.
GuardrailConfig is an empty placeholder. Either implement it or omit the property so serializers don’t emit "guardrails": [].
ResponseFormat.Schema should be JsonElement/typed to avoid double serialization surprises via object.
MistralAiException — add the three standard Exception ctors ((), (string), (string, Exception)), and consider a strongly typed MistralApiError model (type, message, param, code) parsed from the body instead of the raw string.
Validation.MistralAiOptions.ApiKey defaults to ""; the constructor happily builds a client with no auth. Add IValidateOptions or throw in the ctor.
GetJsonOptions() (not shown) should be cached in a static readonly field — creating JsonSerializerOptions per call is expensive and defeats the internal metadata cache.
CreateRequest / CreateStreamRequest just build DTOs. They add little over new ChatCompletionRequest { … } and inflate the surface area. Consider removing, or make them extension methods.
Second ctor mutates a caller-owned HttpClient. Prefer treating an injected HttpClient as read-only and route auth via HttpRequestMessage.Headers.Authorization on each request.
🟡 Style / minor
line.StartsWith("data: ", StringComparison.Ordinal) — add ordinal comparison; ditto for the [DONE] sentinel.
Use _httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("MistralAI.Client", "1.0")) instead of Add("User-Agent", …).
Consider sealed on ChatCompletionClient; extension is unlikely and it simplifies IDisposable.
MistralAiOptions.EnableStreaming is declared but never used.
XML doc for CreateStreamRequest says stream param exists — it doesn’t.
Naming: CompletionEvent vs CompletionEventBase vs TextCompletionEvent — pick one convention (drop Base, since it’s abstract).
The client is a small, focused wrapper around POST /v1/chat/completions, supporting both a buffered call (CompleteAsync) and an SSE streaming call (StreamAsync). Overall design is clean: immutable JsonSerializerOptions, an immutable record request type mutated via with, DI registration through IHttpClientFactory with a resilience pipeline, and polymorphic DTOs for messages/content/tools that mirror the Mistral (OpenAI-compatible) wire format. No correctness-blocking bugs were found; findings below are mostly design nits and a few things worth double-checking against real API behavior.
ChatCompletionClient.cs
Strengths
JsonOptions is a single static, cached JsonSerializerOptions instance (line 18) — correct, since re-creating JsonSerializerOptions per call is a well-known perf trap with System.Text.Json.
CompleteAsync/StreamAsync both null-check request at the boundary (ArgumentNullException.ThrowIfNull), consistent with API-boundary validation.
request with { Stream = false/true } cleanly forces the correct value regardless of what the caller set, without mutating the caller’s object.
StreamAsync uses HttpCompletionOption.ResponseHeadersRead so the response isn’t buffered before the SSE loop starts — necessary for real streaming.
Malformed SSE chunks are swallowed with a comment explaining why (// Skip malformed chunks rather than aborting the whole stream.) — an intentional, documented tradeoff rather than silent-failure-by-accident.
EnsureSuccessAsync reads the error body only on failure, and wraps it into HttpRequestException with the status code attached (statusCode: response.StatusCode) — good for callers that pattern-match on StatusCode.
Findings / things to double check
ChatCompletionRequest.Stream is public but always overwritten.
The DTO exposes a settable Stream property, documented as "Overridden internally by the client depending on the method called." (ChatCompletionRequest.cs:26). Any value the caller sets is silently discarded by both CompleteAsync and StreamAsync. This isn’t a bug, but it’s a slightly surprising public API — a caller could reasonably expect setting Stream = true and calling CompleteAsync to do something, when it’s actually a no-op. Consider either making the setter internal/removing it from the public request shape, or asserting/ignoring rather than silently stomping it.
Silent chunk-skip on JsonException has no observability hook.StreamAsync (lines 104–113) catches JsonException and continues with no logging or counter. For a PoC this is fine, but if the wire format ever drifts (e.g., a new event type Mistral adds), failures will be invisible — chunks just quietly vanish. Consider at least a ILogger debug-level log if one is easy to thread through, since this is the one place errors are deliberately suppressed rather than propagated.
SSE parsing only recognizes data: lines.
The reader loop only reacts to lines starting with data: and otherwise continues (lines 89–92), which correctly skips blank lines and SSE comment/event: lines. This matches Mistral’s OpenAI-compatible SSE format (each event is a single data: {...} line), so it should be fine in practice — just flagging that it assumes single-line JSON payloads per event; a server that ever wrapped a JSON payload across multiple data: lines (some SSE producers do this) would need concatenation logic that isn’t present here.
No cancellation-specific handling.CancellationToken is threaded through correctly (ConfigureAwait(false) + [EnumeratorCancellation]), but there’s no explicit catch/rethrow around OperationCanceledException — that’s actually correct behavior (let it propagate), just noting it was checked.
Request DTOs (ChatCompletionRequest.cs)
Strengths
ChatCompletionRequest is a sealed record with required members for Model/Messages, giving compile-time enforcement of the two truly mandatory fields.
Polymorphic ChatMessage/ContentPart hierarchies use [JsonPolymorphic] + [JsonDerivedType] correctly keyed off role and type respectively, matching the API’s discriminator fields.
MessageContent cleanly models the “string OR array of parts” duality the API allows, with implicit conversions (string, ContentPart[], List) making call sites ergonomic, and a custom JsonConverter handling both read and write shapes.
GuardrailConfig.AdditionalProperties via [JsonExtensionData] is a sensible forward-compatibility escape hatch for a field Mistral is likely to extend.
Findings
Naming collision: ToolChoice property vs. ToolChoice static helper class.ChatCompletionRequest.ToolChoice (an object? property, line 41) and the top-level public static class ToolChoice (line 239) share the identifier ToolChoice in the same namespace (Mistral.Client.Shared). This compiles and works — new ChatCompletionRequest { ToolChoice = ToolChoice.Auto } resolves the right-hand side to the static class since object-initializer right-hand expressions aren’t evaluated in the initializer’s member scope — but it’s a readability trap: IntelliSense/go-to-definition on ToolChoice inside that expression can be momentarily ambiguous to a human reader, and a future refactor that moves code around could make this genuinely ambiguous or require global::/qualification. Consider renaming the helper class (e.g. ToolChoiceValues or ToolChoices) to remove the shadow.
ToolChoice property typed as object?.
Reasonable given the API accepts either a string enum or an object literal, and the ToolChoice helper class exists specifically to build valid values — but it does mean nothing stops a caller from passing an arbitrary/invalid object that will only fail at the HTTP layer. Given this is a PoC and the alternative (a discriminated-union style type) is meaningfully more code, this looks like an acceptable, deliberate tradeoff rather than an oversight.
MessageContentJsonConverter.Write prefers Text over Parts if both are set.
Not reachable through the public implicit-conversion surface (only one of Text/Parts is ever set), but since MessageContent has public init accessors, nothing stops object-initializer code from setting both fields directly, at which point Parts would be silently dropped on serialize. Low risk given current usage patterns, just noting the type doesn’t defensively guard against that state.
Minor: IReadOnlyList has no implicit conversion, only ContentPart[] and List do (lines 122–124). Not a bug — just means a caller holding an IReadOnlyList from elsewhere has to materialize it to one of the two supported types first.
Response DTOs (ChatCompletionResponse.cs)
Straightforward, mutable POCOs (get; set;) matching typical System.Text.Json deserialization targets — appropriately different from the request side (which is a record built by the caller) since these are populated by the deserializer, not constructed by consumers.
ChatCompletionChoice.Message is typed as AssistantMessage? (line 21), correctly narrowing the polymorphic ChatMessage base to the one concrete type a response choice can actually contain.
Choices and other list-typed members default to [] rather than null (e.g., ChatCompletionResponse.cs:12), which avoids null-checks for consumers even before deserialization overwrites them — good defensive default.
No SystemFingerprint/LogProbs/similar fields are modeled. If Mistral’s response includes them and callers might want them, they’re currently unreachable (no [JsonExtensionData] catch-all on the response types, unlike GuardrailConfig on the request side). Worth confirming this is an intentional scope decision for the PoC.
Streaming DTOs (Streaming/StreamingDTOs.cs)
ChatCompletionChunk/ChatCompletionChoiceChunk/DeltaMessage correctly mirror the non-streaming shapes where structurally identical (UsageInfo, MessageContent, ToolCall are reused rather than duplicated), which is good — no drift risk between streaming/non-streaming content models.
The XML doc on DeltaMessage (lines 28–32) correctly documents the aggregation contract callers must implement (accumulate Content, merge ToolCalls by Index) — useful since the client itself provides no aggregation helper, so this is the only place that contract is written down. Worth keeping in sync with any future consumer-side aggregation utility.
Minor cosmetic: namespace Mistral.Client.Shared.Streaming; (line 3) has a double space after the namespace keyword. Harmless, but worth a quick formatter pass.
Supporting types (context only)
MistralClientOptions / MistralServiceCollectionExtensions: registration wires IChatCompletionClient as a singleton backed by a named HttpClient with Timeout = Timeout.InfiniteTimeSpan and delegates time-boxing to the standard resilience handler (AttemptTimeout 90s, TotalRequestTimeout 5m) — a sensible choice given long-running generations and streaming responses, and the comment explaining why the infinite client timeout is safe here is a good practice (explains a non-obvious decision rather than just stating what the code does).
Overall assessment
No functional bugs found. The code is well-structured for a PoC: immutable request model, cached serializer options, correct streaming/cancellation plumbing, and DTOs that faithfully mirror the API’s polymorphic wire format. The main things worth a follow-up decision are the ToolChoice naming collision (cosmetic but worth a rename) and whether the public, always-overridden Stream property on ChatCompletionRequest is intentional API surface or should be tightened.
This creates confusion about which namespace to use
💡 Recommendation: Standardize on Mistral.Client or MistralAI.Client throughout
2. Nullability Annotations
⚠️ Issue: Incomplete nullability annotations
Many DTO properties lack ? for nullable reference types
Example: ChatCompletionRequest.Model is required string but could benefit from explicit nullability
💡 Recommendation: Add consistent nullability annotations, especially for optional fields
3. Record vs Class Usage
⚠️ Issue: Mixed use of record and class for DTOs
ChatCompletionRequest is a record (good for immutability)
Message types (SystemMessage, UserMessage) are class with setters
💡 Recommendation: Consider making all DTOs immutable record types for consistency
4. JSON Serialization Edge Cases
⚠️ Issue: MessageContentJsonConverter doesn’t handle all possible JSON scenarios
No validation that exactly one of Text or Parts is set
Could throw on unexpected JSON structures
💡 Recommendation: Add validation in converter to ensure data integrity
5. Error Handling in Streaming
⚠️ Issue: Silent skipping of malformed chunks
Line 111-112: continue on JsonException
No logging or telemetry for skipped chunks
💡 Recommendation: Add optional logging/delegate for error handling
6. Configuration Validation
⚠️ Issue: Minimal validation in MistralClientOptions
No validation that ApiKey is not empty when provided
No validation that BaseAddress ends with trailing slash (as documented)
💡 Recommendation: Add validation in property setters or use validation library
7. HTTP Client Configuration
⚠️ Issue: Hardcoded timeout values in resilience pipeline
90s attempt timeout, 180s sampling duration, 5min total timeout
These may not be appropriate for all use cases
💡 Recommendation: Make timeouts configurable via MistralClientOptions
8. Missing Features
⚠️ Missing: No support for custom headers
⚠️ Missing: No retry-after header handling
⚠️ Missing: No built-in rate limit detection
🐛 Potential Bugs
1. JSON Serialization Issue
File: ChatCompletionRequest.cs, Line 137
Parts = JsonSerializer.Deserialize<List>(ref reader, options),
Issue: If deserialization fails, this will throw JsonException which isn’t caught
Impact: Could break entire request parsing
Fix: Wrap in try-catch similar to streaming logic
2. Content Part Initialization
File: ChatCompletionRequest.cs, Line 137
Parts = JsonSerializer.Deserialize<List>(ref reader, options),
Issue: Creates mutable List but property expects IReadOnlyListImpact: Type mismatch – should use ToList().AsReadOnly() or change property type
3. Usage Info in Streaming
File: StreamingDTOs.csIssue: UsageInfo appears on terminal chunk, but no validation that it’s only set once
Impact: Could lead to incorrect token counting if multiple usage objects received
Fix: Document this behavior or add validation
📈 Performance Considerations
Aspect
Status
Notes
Async/Await
✅ Excellent
Proper use throughout
Memory Usage
✅ Good
Streaming implementation is memory-efficient
JSON Serialization
✅ Good
Uses System.Text.Json
HTTP Client
✅ Good
Uses IHttpClientFactory for connection pooling
Object Allocation
⚠️ Minor
Could reduce allocations in hot paths
Recommendation: Consider using ArrayPool for buffer management in high-throughput scenarios
❌ Real API Calls: Test against Mistral API (with test API key)
❌ Mock Server: Test with local mock server for controlled scenarios
📝 Code Style & Best Practices
Following .NET Best Practices
Practice
Status
Async method naming (Async suffix)
✅
Cancellation token support
✅
IDisposable pattern for resources
✅
Argument validation
✅
Nullable reference types enabled
✅ (but needs more annotations)
Consistent naming conventions
✅
XML documentation
✅
Style Issues
⚠️ Brace Style: Inconsistent – some files use same-line braces, others use new-line
⚠️ Indentation: Mostly consistent (4 spaces), but verify throughout
⚠️ Line Length: Some lines exceed typical 100-120 character limits
🛡️ Security Considerations
Aspect
Status
Notes
API Key Handling
✅ Good
Supports both configured and custom auth headers
HTTPS
✅ Good
Defaults to https://api.mistral.ai/
Input Validation
✅ Good
Argument null checks
Error Messages
⚠️ Caution
Error messages include response body (could expose sensitive data)
Rate Limiting
✅ Good
Built-in via resilience pipeline
Recommendation: Consider sanitizing error messages in production to avoid exposing sensitive API response data
🚀 Usage Example
// Configuration
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMistralChatClient(options =>
{
options.ApiKey = "your-api-key";
options.BaseAddress = new Uri("https://api.mistral.ai/");
});
// Usage
var client = serviceProvider.GetRequiredService();
// Non-streaming
var request = new ChatCompletionRequest
{
Model = "mistral-large",
Messages = new List
{
new UserMessage { Content = "Hello, how are you?" }
}
};
var response = await client.CompleteAsync(request);
// Streaming
await foreach (var chunk in client.StreamAsync(request))
{
if (chunk.Choices.FirstOrDefault()?.Delta?.Content is { } content)
{
Console.Write(content);
}
}
📊 Summary & Rating
Category
Score
Notes
Architecture
⭐⭐⭐⭐⭐
Excellent separation of concerns, DI-friendly
Code Quality
⭐⭐⭐⭐☆
Very good, minor inconsistencies
Error Handling
⭐⭐⭐⭐☆
Robust, could improve logging
Performance
⭐⭐⭐⭐☆
Good, streaming is efficient
Documentation
⭐⭐⭐⭐☆
Good XML docs, could use more examples
Testing
⭐⭐☆☆☆
Needs comprehensive test coverage
Security
⭐⭐⭐⭐☆
Good foundation, watch error messages
Overall Rating: ⭐⭐⭐⭐☆ (4.2/5) – Production Ready with Minor Improvements
✅ Action Items
High Priority
[P1] Fix namespace inconsistency between Mistral.Client and Mistral.Client.Shared
[P1] Fix MessageContentJsonConverter to handle all edge cases properly
[P1] Add validation for MistralClientOptions.ApiKey and BaseAddress
Medium Priority
[P2] Add comprehensive unit tests
[P2] Standardize all DTOs as immutable records
[P2] Make resilience timeouts configurable
[P2] Add nullability annotations to all properties
Low Priority
[P3] Add logging for streaming errors
[P3] Consider adding telemetry support
[P3] Add integration tests with mock server
Review completed using Mistral Vibe | Generated: 2026-07-20
Like many .NET developers, I started with Copilot as a natural extension of my workflow, expecting it to streamline repetitive tasks and accelerate development. When I started using Building Edge AI with Github Copilot- Security Camera HTTP(Jan 2025) the experience wasn’t great. Especially when I was using it for the “niche” areas I work-in it was pretty hopeless (sometimes even referred me to my own blog posts).
After a while I started trialing the other tools in my workflow and though they were better, sometimes F2-Replace or intellisense were faster and used a lot less tokens. I would also get the tools to review the code of the others, and I especially liked the Claude “Irony stack” when using it review Co-Pilot generated code.
While the other tools certainly helped (especially after adding custom skills files), I often found myself spending as much time going “down rabbit holes”(not the tool’s problem, though I hopefully learnt some useful stuff) and correcting or restructuring or debugging generated code that I could have written faster from scratch.
That’s what made my “out of box” experience with Mistral stand out. With a relatively simple prompt, it produced code that was not only concise but surprisingly accurate with just a single compile time error and no warnings on the first pass.
NOTE: This was using the webby interface, but I now have a paid for subscription.
The instructions which included .NET 8 (bit retro) and “dotnet add package”(pretty good) meant the code compiled on second attempt. The issue was a syntax error initialising OpenTelemetry which was quickly fixed, somewhat ironically with GitHub Copilot.
.ConfigureResource(resourceBuilder) rather than .ConfigureResource(rb => rb = resourceBuilder)
//dotnet add package OpenTelemetry
//dotnet add package OpenTelemetry.Extensions.Hosting
//dotnet add package OpenTelemetry.Instrumentation.AspNetCore
//dotnet add package OpenTelemetry.Instrumentation.Http
//dotnet add package OpenTelemetry.Exporter.Console
//dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
//
//using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using System.Diagnostics;
var builder = WebApplication.CreateBuilder(args);
// Configure OpenTelemetry with a resource (service name)
var resourceBuilder = ResourceBuilder.CreateDefault()
.AddService(serviceName: builder.Environment.ApplicationName);
// Add OpenTelemetry Tracing
builder.Services.AddOpenTelemetry()
//.ConfigureResource(resourceBuilder) /**** This was the only compile time issue
.ConfigureResource(rb => rb = resourceBuilder)
.WithTracing(tracerProviderBuilder =>
{
tracerProviderBuilder
.AddSource("MinimalApiSample")
.AddAspNetCoreInstrumentation(options =>
{
options.RecordException = true;
})
.AddHttpClientInstrumentation()
.AddConsoleExporter(); // For demo: export to console
//.AddOtlpExporter(); // Uncomment to export to OpenTelemetry Collector
})
.WithMetrics(metricsProviderBuilder =>
{
metricsProviderBuilder
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddConsoleExporter(); // For demo: export to console
//.AddOtlpExporter(); // Uncomment to export to OpenTelemetry Collector
});
var app = builder.Build();
// Example of a custom activity for tracing
var activitySource = new ActivitySource("MinimalApiSample");
app.MapGet("/", () =>
{
using var activity = activitySource.StartActivity("RootEndpoint");
activity?.SetTag("custom.tag", "Hello, OpenTelemetry!");
return Results.Ok("Hello, OpenTelemetry!");
});
app.MapGet("/metrics", () =>
{
// This endpoint is just for demo; metrics are exported automatically
return Results.Ok("Metrics are being collected in the background.");
});
app.Run();
//dotnet add package OpenTelemetry
//dotnet add package OpenTelemetry.Extensions.Hosting
//dotnet add package OpenTelemetry.Instrumentation.AspNetCore
//dotnet add package OpenTelemetry.Instrumentation.Http
//dotnet add package Azure.Monitor.OpenTelemetry.Exporter
//
using Azure.Monitor.OpenTelemetry.Exporter;
//using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using System.Diagnostics;
var builder = WebApplication.CreateBuilder(args);
// Configure OpenTelemetry with a resource (service name)
var resourceBuilder = ResourceBuilder.CreateDefault()
.AddService(serviceName: builder.Environment.ApplicationName)
.AddTelemetrySdk();
// Add OpenTelemetry Tracing and Metrics for Azure Application Insights
builder.Services.AddOpenTelemetry()
//.ConfigureResource(resourceBuilder)
.ConfigureResource(rb => rb = resourceBuilder) //*****
.WithTracing(tracerProviderBuilder =>
{
tracerProviderBuilder
.AddSource("MinimalApiSample")
.AddAspNetCoreInstrumentation(options =>
{
options.RecordException = true;
})
.AddHttpClientInstrumentation()
.AddAzureMonitorTraceExporter(options =>
{
options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
});
})
.WithMetrics(metricsProviderBuilder =>
{
metricsProviderBuilder
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddAzureMonitorMetricExporter(options =>
{
options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
});
});
var app = builder.Build();
// Example of a custom activity for tracing
var activitySource = new ActivitySource("MinimalApiSample");
app.MapGet("/", () =>
{
using var activity = activitySource.StartActivity("RootEndpoint");
activity?.SetTag("custom.tag", "Hello, Azure Application Insights!");
return Results.Ok("Hello, Azure Application Insights!");
});
app.MapGet("/metrics", () =>
{
return Results.Ok("Metrics and traces are being sent to Azure Application Insights.");
});
app.Run();
Using Application Insights metrics the Kestral.active_connections graphs to shows some of the additional telemetry emitted by the application.
//dotnet add package OpenTelemetry
//dotnet add package OpenTelemetry.Extensions.Hosting
//dotnet add package OpenTelemetry.Instrumentation.AspNetCore
//dotnet add package OpenTelemetry.Instrumentation.Http
//dotnet add package Azure.Monitor.OpenTelemetry.Exporter
//
using Azure.Monitor.OpenTelemetry.Exporter;
//using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using System.Diagnostics;
using System.Diagnostics.Metrics;
var builder = WebApplication.CreateBuilder(args);
// Configure OpenTelemetry with a resource (service name)
var resourceBuilder = ResourceBuilder.CreateDefault()
.AddService(serviceName: builder.Environment.ApplicationName)
.AddTelemetrySdk();
// Create a meter for custom metrics
var meter = new Meter("MinimalApiSample.Metrics");
var metricsCounter = meter.CreateCounter<int>("MetricsEndpointAccessCount");
// Add OpenTelemetry Tracing and Metrics for Azure Application Insights
builder.Services.AddOpenTelemetry()
//.ConfigureResource(resourceBuilder) /**** This is the only compile time issue
.ConfigureResource(rb=>rb = resourceBuilder)
.WithTracing(tracerProviderBuilder =>
{
tracerProviderBuilder
.AddSource("MinimalApiSample")
.AddAspNetCoreInstrumentation(options =>
{
options.RecordException = true;
})
.AddHttpClientInstrumentation()
.AddAzureMonitorTraceExporter(options =>
{
options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
});
})
.WithMetrics(metricsProviderBuilder =>
{
metricsProviderBuilder
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter("MinimalApiSample.Metrics") // Add your custom meter
.AddAzureMonitorMetricExporter(options =>
{
options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
});
});
var app = builder.Build();
// Example of a custom activity for tracing
var activitySource = new ActivitySource("MinimalApiSample");
app.MapGet("/", () =>
{
using var activity = activitySource.StartActivity("RootEndpoint");
activity?.SetTag("custom.tag", "Hello, Azure Application Insights!");
return Results.Ok("Hello, Azure Application Insights!");
});
app.MapGet("/metrics", () =>
{
// Increment custom metric on each access
metricsCounter.Add(1);
return Results.Ok("Metrics and traces are being sent to Azure Application Insights.");
});
app.Run();
I by pleasantly surprised by suggestion of a counter for each endpoint which was my original intent.
Couldn’t think of a better name “scirtem” is “metrics” backwards. The way Meter and CreateCount are global would not be a good idea in a more complex system but this is fine for a hacky PoC.
//dotnet add package OpenTelemetry
//dotnet add package OpenTelemetry.Extensions.Hosting
//dotnet add package OpenTelemetry.Instrumentation.AspNetCore
//dotnet add package OpenTelemetry.Instrumentation.Http
//dotnet add package Azure.Monitor.OpenTelemetry.Exporter
//
using Azure.Monitor.OpenTelemetry.Exporter;
//using OpenTelemetry; //***** Unnecessary with OpenTelemetry.Extensions.Hosting
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using System.Diagnostics;
using System.Diagnostics.Metrics;
var builder = WebApplication.CreateBuilder(args);
// Configure OpenTelemetry with a resource (service name)
var resourceBuilder = ResourceBuilder.CreateDefault()
.AddService(serviceName: builder.Environment.ApplicationName)
.AddTelemetrySdk();
// Create a meter for custom metrics
var meter = new Meter("MinimalApiSample.Metrics");
var metricsCounter = meter.CreateCounter<int>("MetricsEndpointAccessCount");
var scirtemCounter = meter.CreateCounter<int>("ScirtemEndpointAccessCount");
// Add OpenTelemetry Tracing and Metrics for Azure Application Insights
builder.Services.AddOpenTelemetry()
//.ConfigureResource(resourceBuilder) /**** This is the only compile time issue
.ConfigureResource(rb=>rb = resourceBuilder)
.WithTracing(tracerProviderBuilder =>
{
tracerProviderBuilder
.AddSource("MinimalApiSample")
.AddAspNetCoreInstrumentation(options =>
{
options.RecordException = true;
})
.AddHttpClientInstrumentation()
.AddAzureMonitorTraceExporter(options =>
{
options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
});
})
.WithMetrics(metricsProviderBuilder =>
{
metricsProviderBuilder
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter("MinimalApiSample.Metrics") // Add your custom meter
.AddAzureMonitorMetricExporter(options =>
{
options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
});
});
var app = builder.Build();
// Example of a custom activity for tracing
var activitySource = new ActivitySource("MinimalApiSample");
app.MapGet("/", () =>
{
using var activity = activitySource.StartActivity("RootEndpoint");
activity?.SetTag("custom.tag", "Hello, Azure Application Insights!");
return Results.Ok("Hello, Azure Application Insights!");
});
app.MapGet("/metrics", () =>
{
// Increment custom metric on each access
metricsCounter.Add(1);
return Results.Ok("Metrics and traces are being sent to Azure Application Insights.");
});
app.MapGet("/scirtem", () =>
{
scirtemCounter.Add(1);
return Results.Ok("Scirtem endpoint accessed.");
});
app.Run();
Using Application Insights metrics the MetricsEndPointAccesCount, and ScirtemEndPointAccesCount, plots to show the OLTP telemetry emitted by the application.
Mistral generated the code for the endpoint latency histogram without any prompting.
//dotnet add package OpenTelemetry
//dotnet add package OpenTelemetry.Extensions.Hosting
//dotnet add package OpenTelemetry.Instrumentation.AspNetCore
//dotnet add package OpenTelemetry.Instrumentation.Http
//dotnet add package Azure.Monitor.OpenTelemetry.Exporter
//
using Azure.Monitor.OpenTelemetry.Exporter;
//using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using System.Diagnostics;
using System.Diagnostics.Metrics;
var builder = WebApplication.CreateBuilder(args);
// Configure OpenTelemetry with a resource (service name)
var resourceBuilder = ResourceBuilder.CreateDefault()
.AddService(serviceName: builder.Environment.ApplicationName)
.AddTelemetrySdk();
// Create a meter for custom metrics
var meter = new Meter("MinimalApiSample.Metrics");
var metricsCounter = meter.CreateCounter<int>("MetricsEndpointAccessCount");
var scirtemCounter = meter.CreateCounter<int>("ScirtemEndpointAccessCount");
var histogram = meter.CreateHistogram<double>("HistogramEndpointLatencyMs");
// Add OpenTelemetry Tracing and Metrics for Azure Application Insights
builder.Services.AddOpenTelemetry()
//.ConfigureResource(resourceBuilder) /**** This is the only compile time issue
.ConfigureResource(rb=>rb = resourceBuilder)
.WithTracing(tracerProviderBuilder =>
{
tracerProviderBuilder
.AddSource("MinimalApiSample")
.AddAspNetCoreInstrumentation(options =>
{
options.RecordException = true;
})
.AddHttpClientInstrumentation()
.AddAzureMonitorTraceExporter(options =>
{
options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
});
})
.WithMetrics(metricsProviderBuilder =>
{
metricsProviderBuilder
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter("MinimalApiSample.Metrics") // Register your custom meter
.AddAzureMonitorMetricExporter(options =>
{
options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
});
});
var app = builder.Build();
// Example of a custom activity for tracing
var activitySource = new ActivitySource("MinimalApiSample");
app.MapGet("/", () =>
{
using var activity = activitySource.StartActivity("RootEndpoint");
activity?.SetTag("custom.tag", "Hello, Azure Application Insights!");
return Results.Ok("Hello, Azure Application Insights!");
});
app.MapGet("/metrics", () =>
{
metricsCounter.Add(1);
return Results.Ok("Metrics endpoint accessed.");
});
app.MapGet("/scirtem", () =>
{
scirtemCounter.Add(1);
return Results.Ok("Scirtem endpoint accessed.");
});
app.MapGet("/histogram", async () =>
{
// Simulate some work
var startTime = Stopwatch.GetTimestamp();
await Task.Delay(Random.Shared.Next(50, 200)); // Random delay between 50-200ms
var endTime = Stopwatch.GetTimestamp();
// Calculate latency in milliseconds
var latencyMs = (endTime - startTime) * 1000.0 / Stopwatch.Frequency;
histogram.Record(latencyMs);
return Results.Ok($"Histogram endpoint accessed. Latency: {latencyMs:F2}ms");
});
app.Run();
Using Application Insights metrics the OpenTelemetry.HistogramEndpointLatencyMs plot to show the OLTP telemetry emitted by the application.
Even with my relatively trivial OTLP learning applications, Mistral consistently produced clean and usable code with my simple prompts (maybe, I have got better and prompting). The generated code was straightforward, required only minor fixes, and avoided much of the over-complexity I’d seen in earlier experiments with other tools (looking at you mid/late 2025 Copilot). For my simple OTLP observability learning scenarios, that translated into faster iteration and less time spent refactoring and debugging generated code.
The second prototype of “transforming” telemetry data used C# code complied and binary cached on demand. The HiveMQClient based application subscribes to topics (devices publishing environmental measurements) and then republishes them to multiple topics.
public class messageTransformer : IMessageTransformer
{
public MQTT5PublishMessage[] Transform(MQTT5PublishMessage message)
{
if (message.Payload is null)
{
return [];
}
var payload = Encoding.UTF8.GetString(message.Payload);
// Simple transformations: convert to both upper and lower case
var toLower = new MQTT5PublishMessage
{
Topic = message.Topic,
Payload = Encoding.UTF8.GetBytes(payload.ToLower()),
QoS = QualityOfService.AtLeastOnceDelivery
};
var toUpper = new MQTT5PublishMessage
{
Topic = message.Topic,
Payload = Encoding.UTF8.GetBytes(payload.ToUpper()),
QoS = QualityOfService.AtLeastOnceDelivery
};
return [toLower, toUpper];
}
}
The sample C# code (LowerUpper.cs) implements the IMessageTransformer interface and republishes both lower and upper case versions of the message.
Once the transformer had been loaded then compiled there was no noticeable difference between the application, loaded from constant string, and loaded from external file versions.
private static void OnMessageReceived(object? sender, HiveMQtt.Client.Events.OnMessageReceivedEventArgs e)
{
HiveMQClient client = (HiveMQClient)sender!;
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} HiveMQ.receive start");
Console.WriteLine($" Topic:{e.PublishMessage.Topic} QoS:{e.PublishMessage.QoS} Payload:{e.PublishMessage.PayloadAsString}");
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} HiveMQ.Publish start");
foreach (string topic in _applicationSettings.PublishTopics.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
e.PublishMessage.Topic = string.Format(topic, _applicationSettings.ClientId);
var transformer = _scriptEngine.GetTransformer();
if (transformer is null)
{
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} Transformer is null");
return;
}
var transformedMessages = transformer.Transform(e.PublishMessage);
if (transformedMessages is null)
{
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} Transformer returned null");
return;
}
if (transformedMessages.Length == 0)
{
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} Transformer returned no messages");
return;
}
foreach (MQTT5PublishMessage message in transformer.Transform(e.PublishMessage))
{
if (message is null)
{
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} Transformer message is null");
continue;
}
try
{
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} Topic:{e.PublishMessage.Topic} HiveMQ Publish start ");
var resultPublish = client.PublishAsync(message).GetAwaiter().GetResult();
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} Published:{resultPublish.QoS1ReasonCode} {resultPublish.QoS2ReasonCode}");
}
catch (Exception ex)
{
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} HiveMQ Publish exception {ex.Message}");
}
}
}
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} HiveMQ.Receive finish");
}
The MQTTX application subscribed to topics that devices (XiaoTandHandCO2A, XiaoTandHandCO2B etc.) and the simulated bridge (in my case DESKTOP-EN0QGL0) published.
The first prototype of “transforming” telemetry data used C# code complied with the application. The HiveMQClient based application subscribes to topics (devices publishing environmental measurements) and then republishes them to multiple topics.
The MQTTX application subscribed to topics that devices (XiaoTandHandCO2A, XiaoTandHandCO2B etc.) and the simulated bridge (DESKTOP-EN0QGL0) published.
The second prototype “transforms” the telemetry message payloads with C# code that is compiled (with CSScript) as the application starts. The application subscribes to the topics which devices publish (environmental measurements), transforms the payloads, and then republishes the transformed messages to “bridge” topics
private static void OnMessageReceived(object? sender, HiveMQtt.Client.Events.OnMessageReceivedEventArgs e)
{
HiveMQClient client = (HiveMQClient)sender!;
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} HiveMQ.receive start");
Console.WriteLine($" Topic:{e.PublishMessage.Topic} QoS:{e.PublishMessage.QoS} Payload:{e.PublishMessage.PayloadAsString}");
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} HiveMQ.Publish start");
foreach (string topic in _applicationSettings.PublishTopics.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
e.PublishMessage.Topic = string.Format(topic, _applicationSettings.ClientId);
foreach (MQTT5PublishMessage message in _evaluator.Transform(e.PublishMessage))
{
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} Topic:{e.PublishMessage.Topic} HiveMQ Publish start ");
var resultPublish = client.PublishAsync(message).GetAwaiter().GetResult();
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} Published:{resultPublish.QoS1ReasonCode} {resultPublish.QoS2ReasonCode}");
}
}
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss:fff} HiveMQ.Receive finish");
}
// This code is compiled as the application starts up, it implements the IMessageTransformer interface
const string sampleTransformCode = @"
using System.Text;
using HiveMQtt.MQTT5.Types;
public class messageTransformer : devMobile.IoT.MqttTransformer.CSScriptLoopback.IMessageTransformer
{
public MQTT5PublishMessage[] Transform(MQTT5PublishMessage message)
{
// Example: echo the payload to a new topic
var payload = Encoding.UTF8.GetString(message.Payload);
// Simple transformations: convert to uppercase or lowercase
var toLower = new MQTT5PublishMessage
{
Topic = message.Topic,
Payload = Encoding.UTF8.GetBytes(payload.ToLower()),
QoS = QualityOfService.AtLeastOnceDelivery
};
var toUpper = new MQTT5PublishMessage
{
Topic = message.Topic,
Payload = Encoding.UTF8.GetBytes(payload.ToUpper()),
QoS = QualityOfService.AtLeastOnceDelivery
};
return new[] { toLower, toUpper };
}
}";
The sample C# code implements the IMessageTransformer interface and republishes lower and upper case versions of the message.
public interface IMessageTransformer
{
public MQTT5PublishMessage[] Transform(MQTT5PublishMessage mqttPublishMessage);
}
...
_evaluator = CSScript.Evaluator.LoadCode<IMessageTransformer>(sampleTransformCode);
The SwarmSpace, and Myriota gateways both use an interface based approach to process uplink and downlink messages. Future versions will include support for isolating processing so that a rogue script can’t crash the application or reference unapproved assemblies
After hours of fail trying to get nanoMQ TCP bridge running on my Windows11 development system it was time to walk away. I ran nanoMQ with different log levels but “nng_dialer_create failed 9” was the initial error message displayed.
The setup looked good…
bridges.mqtt.MyBridgeDeviceID {
## Azure Event Grid MQTT broker endpoint
server = "tls+mqtt-tcp://xxxx.newzealandnorth-1.ts.eventgrid.azure.net:8883"
proto_ver = 5
clientid = "MyBridgeDeviceID"
username = "MyBridgeDeviceID"
clean_start = true
keepalive = "60s"
## TLS client certificate authentication
ssl = {
# key_password = ""
keyfile = "certificates/MyBridgeDeviceID.key"
certfile = "certificates/MyBridgeDeviceID.crt"
cacertfile = "certificates/xxxx.crt"
}
## ------------------------------------------------------------
## Topic forwarding (NanoMQ → Azure Event Grid)
## ------------------------------------------------------------
## These are the topics your device publishes locally.
## They will be forwarded upstream to Event Grid.
##
forwards = [xxxx]
## ------------------------------------------------------------
## Topic subscription (Azure Event Grid → NanoMQ)
## ------------------------------------------------------------
## This is the topic your device subscribes to from Event Grid.
subscription = [xxxx]
}
Most of my applications have focused on telemetry but I had been thinking about local control for solutions that have to run disconnected. In “real-world” deployments connectivity to Azure EventGrid MQTT Broker isn’t 100% reliable (also delay and jitter issues) which are an issue for control at the edge.
When I started with the Security Camera HTTP code and added code to process the images with Ultralytics Object Detection model I found the order of the prompts could make a difference. My first attempt at adding YoloSharp to the SecurityCameraHttpClient application with Github Copilot didn’t go well and needed some “human intervention”. When I thought more about the order of the prompts the adding the same functionality went a lot better.
// Use a stream rather than loading image from a file // Use YoloSharp to run an onnx Object Detection model on the image // Make the YoloPredictor a class variable // Save image if object with specified image class name detected // Modify so objectDetected supports multiple image class names // Modify code to make use of GPU configurable // Make display of detections configurable in app settings // Make saving of image configurable in app settings
internal class Program
{
private static HttpClient _client;
private static bool _isRetrievingImage = false;
private static ApplicationSettings _applicationSettings;
private static YoloPredictor _yoloPredictor;
static void Main(string[] args)
{
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss} SecurityCameraClient starting");
#if RELEASE
Console.WriteLine("RELEASE");
#else
Console.WriteLine("DEBUG");
#endif
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", false, true)
.AddUserSecrets<Program>()
.Build();
_applicationSettings = configuration.GetSection("ApplicationSettings").Get<ApplicationSettings>();
// Initialize YoloPredictor with GPU configuration
_yoloPredictor = new YoloPredictor(_applicationSettings.OnnxModelPath, new YoloPredictorOptions()
{
UseCuda = _applicationSettings.UseCuda, // Configurable GPU usage
});
using (HttpClientHandler handler = new HttpClientHandler { Credentials = new NetworkCredential(_applicationSettings.Username, _applicationSettings.Password) })
using (_client = new HttpClient(handler))
using (var timer = new Timer(async _ => await RetrieveImageAsync(), null, _applicationSettings.TimerDue, _applicationSettings.TimerPeriod))
{
Console.WriteLine("Press any key to exit...");
Console.ReadKey();
}
}
private static async Task RetrieveImageAsync()
{
if (_isRetrievingImage) return;
_isRetrievingImage = true;
try
{
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss.fff} SecurityCameraClient download starting");
HttpResponseMessage response = await _client.GetAsync(_applicationSettings.CameraUrl);
response.EnsureSuccessStatusCode();
using (Stream imageStream = await response.Content.ReadAsStreamAsync())
{
var detections = _yoloPredictor.Detect(imageStream);
bool objectDetected = false;
foreach (var detection in detections)
{
if (_applicationSettings.LogDetections) // Check if logging detections is enabled
{
Console.WriteLine($"Detected {detection.Name.Name} with confidence {detection.Confidence}");
}
if (_applicationSettings.ClassNames.Contains(detection.Name.Name))
{
objectDetected = true;
}
}
if (objectDetected && _applicationSettings.SaveImage) // Check if saving images is enabled
{
string savePath = string.Format(_applicationSettings.SavePath, DateTime.UtcNow);
using (FileStream fileStream = new FileStream(savePath, FileMode.Create, FileAccess.Write, FileShare.None))
{
imageStream.Position = 0;
await imageStream.CopyToAsync(fileStream);
}
}
}
Console.WriteLine($"{DateTime.UtcNow:yy-MM-dd HH:mm:ss.fff} SecurityCameraClient download done");
}
catch (Exception ex)
{
Console.WriteLine($"An error occurred: {ex.Message}");
}
finally
{
_isRetrievingImage = false;
}
}
}
public class ApplicationSettings
{
public string CameraUrl { get; set; } = "";
public string SavePath { get; set; } = "";
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public TimeSpan TimerDue { get; set; } = TimeSpan.Zero;
public TimeSpan TimerPeriod { get; set; } = TimeSpan.Zero;
public string OnnxModelPath { get; set; } = "";
public bool UseCuda { get; set; } = false; // Configurable GPU usage
public List<string> ClassNames { get; set; } //= new List<string>();
public bool LogDetections { get; set; } = false; // Configurable logging of detections
public bool SaveImage { get; set; } = false; // Configurable saving of images
}
}
One odd thing that I noticed when scrolling “back and forth” through the images around when there was exception was that the date and time on the top left of the image was broken.
I wonder if the image was “broken” in some subtle way and FFMpegCore is handling this differently to the other libraries I’m trialing.
There was another sample application RabbitOM.Streaming.Tests.Mjpeg which displayed JPEG images. After looking at the code I figured out I need to use the RtpFrameBuilder class to assemble the RTSP packets into frames.
private static readonly RtpFrameBuilder _frameBuilder = new JpegFrameBuilder();
...
_frameBuilder.FrameReceived += OnFrameReceived;
...
client.PacketReceived += (sender, e) =>
{
var interleavedPacket = e.Packet as RtspInterleavedPacket;
if (interleavedPacket != null && interleavedPacket.Channel > 0)
{
// In most of case, avoid this packet
Console.ForegroundColor = ConsoleColor.DarkCyan;
Console.WriteLine("Skipping some data : size {0}", e.Packet.Data.Length);
return;
}
_frameBuilder.Write(interleavedPacket.Data);
};