Seeing every request
What is sent lists what a call sends to your model. This guide shows how to see it
for yourself: log each request and each reply, in full, in your own logs. There are two ways, and both are a few
lines of your own code. Wrap the provider, or, when you run IntentSharp on a Microsoft.Extensions.AI IChatClient,
add middleware to the chat client.
The result holds the replies it checked
Before you add anything, the result records the replies it checked. AIResult<T>.Attempts has one AttemptRecord
per attempt that finished its checks, and its RawOutput is that attempt's reply text, before it was read into your
type; AIResult<T>.RawOutput is the final one. An attempt that ended with a provider error or a timeout has no
record, nor has an attempt whose judge call did not run or failed, though its reply arrived, and neither has a
cached reply that failed the checks and was set aside. The requests are not on the result, and neither is a judge's
reply. To see those, log them as below.
Wrap the provider
Every request a client sends goes through its provider, IAIProvider. A provider that wraps another can log each
request on the way out and each reply on the way back, and pass both on unchanged:
/// <summary>Logs every request IntentSharp sends and every reply that comes back, and passes both on unchanged.</summary>
public sealed class LoggingProvider(IAIProvider inner, ILogger logger) : IAIProvider, IDisposable
{
// The wrapped provider's name and model, so results, rate limits and per-request provider choices are unchanged.
public string Name => inner.Name;
public string DefaultModel => inner.DefaultModel;
public bool SupportsEmbeddings => inner.SupportsEmbeddings;
public async Task<AIResponse> CompleteAsync(AIRequest request, CancellationToken cancellationToken = default)
{
if (logger.IsEnabled(LogLevel.Debug))
logger.LogDebug("Request to {Provider} ({Model}), format {Format}, at most {MaxOutputTokens} tokens, temperature {Temperature}\nSystem: {System}\n{Messages}\nSchema: {Schema}",
Name, request.Model ?? DefaultModel, request.Format, request.MaxOutputTokens, request.Temperature, request.SystemPrompt,
string.Join("\n", request.Messages.Select(m => $"{m.Role}: {m.Content}")),
request.JsonSchema?.ToJsonString() ?? "none");
// A failure is not caught here, so the client's fallback and its issues work as before.
var response = await inner.CompleteAsync(request, cancellationToken);
logger.LogDebug("Reply from {Provider} ({Model}), {InputTokens} tokens in, {OutputTokens} out\n{Content}",
response.Provider, response.Model, response.Usage.InputTokens, response.Usage.OutputTokens, response.Content);
return response;
}
public Task<float[]> EmbedAsync(string text, CancellationToken cancellationToken = default)
{
logger.LogDebug("Embedding request to {Provider}\n{Text}", Name, text);
return inner.EmbedAsync(text, cancellationToken);
}
// The client disposes its providers when it is disposed; this passes that on to the wrapped one.
public void Dispose() => (inner as IDisposable)?.Dispose();
}
The wrapper keeps the wrapped provider's name and model, so results, rate limits and requests that name a provider
behave as before. It does not catch exceptions, so the fallback and the issues on the result work as before too. It
passes Dispose on, because the client disposes its providers.
Give the client a wrapped provider wherever you would give it a provider. To wrap the built-in providers, create
them yourself rather than with UseAnthropic or UseOpenAI, and wrap the fallback as well as the primary:
var logger = loggerFactory.CreateLogger<LoggingProvider>();
// Providers you create yourself take their own logger (for their retry warnings) and, optionally, an HttpClient.
var anthropic = new AnthropicProvider(Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")!,
logger: loggerFactory.CreateLogger<AnthropicProvider>());
var openAI = new OpenAIProvider(Environment.GetEnvironmentVariable("OPENAI_API_KEY")!,
logger: loggerFactory.CreateLogger<OpenAIProvider>());
using var client = new IntentClientBuilder()
.WithLoggerFactory(loggerFactory)
.UseProvider(new LoggingProvider(anthropic, logger))
.UseFallbackProvider(new LoggingProvider(openAI, logger))
.Build();
A provider you create yourself takes its own logger and, optionally, its own HttpClient: WithLoggerFactory and
WithHttpClientFactory reach only the providers that UseAnthropic and UseOpenAI create. AddIntentSharp
creates its providers itself and has no place to wrap them, so to wrap them under dependency injection, build the
client with the builder and register it, as in
A provider AddIntentSharp does not create.
What the log shows
Here the wrapper runs on a scripted model. The first answer breaks the request's rule, so a second attempt follows:
using var client = new IntentClientBuilder()
.UseProvider(new LoggingProvider(provider, logger))
.Build();
var options = new AIRequestOptions().Require<int>(u => u is >= 1 and <= 5, "The urgency must be a whole number from 1 to 5.");
var urgency = await email.AIAsync<int>(client, "Rate the urgency of this order email from 1 (routine) to 5 (critical).", options);
// Every reply is on the result as well, one per attempt. The requests are not: they are in your log.
var replies = urgency.Attempts.Select(a => $"Attempt {a.Number}: {a.RawOutput}").ToList();
Scripted model answers, in order: {"value": 9}, then {"value": 4}
LoggingProvider wrote 4 entries at Debug:
request 1 to Scripted (scripted-1), format JsonSchema: the intent, the input and the result schema
reply 1: {"value": 9}
request 2 to Scripted (scripted-1), format JsonSchema: the intent, the input, the result schema, the previous reply and the finding
reply 2: {"value": 4}
Success: true
Value: 4
Issues:
[Validator] attempt 1: The urgency must be a whole number from 1 to 5.
From the result:
Attempt 1: {"value": 9}
Attempt 2: {"value": 4}
The answers come from a scripted model, not a real one. The output above summarizes each logged request; in your log each one is in full: the system message, the user message, the model, the response format, the output token limit, the temperature and the schema. The second request carries the first reply and the rule's message. The demo checks what each logged request held, on every build of this site.
Or add middleware to the chat client
When IntentSharp runs on an IChatClient (UseChatClient, from the IntentSharp.Extensions.AI package), the chat
client sees every request. Middleware from the Microsoft.Extensions.AI package can log it:
// ChatClientBuilder and Use come from the Microsoft.Extensions.AI package.
IChatClient logged = chatClient.AsBuilder()
.Use(async (messages, options, inner, cancellationToken) =>
{
var sent = messages.ToList();
var schema = (options?.ResponseFormat as ChatResponseFormatJson)?.Schema;
logger.LogDebug("Chat request\n{Messages}\nSchema: {Schema}",
string.Join("\n", sent.Select(m => $"{m.Role}: {m.Text}")), schema?.GetRawText() ?? "none");
var response = await inner.GetResponseAsync(sent, options, cancellationToken);
logger.LogDebug("Chat reply\n{Text}", response.Text);
return response;
}, getStreamingResponseFunc: null)
.Build();
using var client = new IntentClientBuilder()
.UseChatClient(logged)
.Build();
Scripted chat client replies, in order: {"value": 9}, then {"value": 4}
The middleware wrote 4 entries at Debug:
chat request 1: 2 messages (system, user): the intent, the input and the result schema
chat reply 1: {"value": 9}
chat request 2: 2 messages (system, user): the intent, the input, the result schema, the previous reply and the finding
chat reply 2: {"value": 4}
Success: true
Value: 4
Issues:
[Validator] attempt 1: The urgency must be a whole number from 1 to 5.
Provider: ChatClient
IntentSharp sends each request as two chat messages: a system message and a user message. Microsoft's own
UseLogging middleware also works: it writes the message contents only when its logger enables the Trace level.
What each way sees
- Judge calls. The default judge asks the primary provider, so a wrapped primary, or the chat client behind it, logs judge requests and verdicts too.
- Cache hits. An answer served from the request cache makes no model call for the answer, so nothing is logged
for it; if the judge is on, the judge request and its verdict are still logged. The result shows a hit in
FromCache. - Retries inside a provider. The built-in providers send a request again after a 429, a 5xx or a transport error. That happens inside the wrapped provider, so a wrapper logs one request, and one reply when the call succeeds. Middleware on a chat client sees what its own pipeline sends.
- The request on the wire. A wrapper logs the request as IntentSharp hands it to the provider. The built-in
providers then write it in the vendor's own format, with the vendor's own fields and the API key in a header, and
the Anthropic provider leaves the temperature out unless
SendTemperatureis on. To capture each HTTP send, retries included, add aDelegatingHandlerto theHttpClientthe provider uses: throughWithHttpClientFactory, or on the named HTTP clients underAddIntentSharp(The HTTP clients). Such a handler also sees the API key header, so keep headers out of its log. - Embeddings. The wrapper above logs the text of each
EmbedAsyncrequest as well. A memory with an embedding provider sends its embedding requests to the provider instance you gave the memory, so give it the wrapped one to log them. - Agent tools. A tool's description and its answers go to your agent's chat client, not through IntentSharp's
provider; log them with middleware on the agent's own
IChatClient.
Keep the log as safe as the data
These logs hold your input, your instructions and the model's replies in full. Treat them like the data itself: log
at Debug or lower, as the examples do, and turn that level on only where you need it. DoNotRecord on a request
keeps it out of the client's memory, not out of your own logger.
Next
- What is sent: each part of a request, where it comes from, and what is never sent.
- Providers: the built-in providers, the fallback and writing a provider of your own.
- Microsoft.Extensions.AI: running IntentSharp on any
IChatClient.