// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Foundry;
///
/// Provides extension methods for attaching per-call x-client-* headers to an agent run
/// and for opting an existing into the client-headers pipeline.
///
///
///
/// The Foundry platform forwards headers prefixed with x-client- transparently from the
/// Agent Endpoint into the agent container (see the multi-tenant overlay design). Callers use
/// or
/// to
/// stamp headers per RunAsync call (for example to attest the SaaS end-user identity
/// in x-client-end-user-id).
///
///
/// Headers are only delivered to the wire when:
///
/// - the agent has been wrapped with (or built via a Foundry factory that pre-wires it), and
/// - the underlying exposes the experimental MEAI 10.5.1 service (true for OpenAI-backed clients).
///
/// When either condition is not met the call is a silent no-op.
///
///
[Experimental(DiagnosticIds.Experiments.AIOpenAIRequestPolicies)]
public static class ClientHeadersExtensions
{
/// The well-known key used to carry the dictionary across packages.
internal const string ClientHeadersKey = "Microsoft.Agents.AI.Foundry.ClientHeaders";
/// The required prefix on every client header name (case-insensitive).
private const string ClientHeaderPrefix = "x-client-";
///
/// Adds a single x-client-* header to the per-call carrier on .
///
/// The instance to mutate.
/// The header name. Must start with x-client- (case-insensitive).
/// The header value. Must be non-empty.
/// for fluent chaining.
/// , , or is .
/// does not start with x-client-, or is empty/whitespace, or is empty.
/// The carrier slot on is occupied by a value of a foreign type.
public static ChatOptions WithClientHeader(this ChatOptions options, string name, string value)
{
_ = Throw.IfNull(options);
ValidateHeader(name, value);
var dict = GetOrCreateHeadersDictionary(options);
dict[name] = value;
return options;
}
///
/// Adds multiple x-client-* headers to the per-call carrier on .
///
/// Validation is all-or-nothing: if any entry is invalid no entries are written.
/// The instance to mutate.
/// The headers to add. Each name must start with x-client-.
/// for fluent chaining.
/// or is , or any element of has a name or value.
/// Any header name does not start with x-client-, or any name is empty/whitespace, or any value is empty.
/// The carrier slot on is occupied by a value of a foreign type.
public static ChatOptions WithClientHeaders(this ChatOptions options, IEnumerable> headers)
{
_ = Throw.IfNull(options);
_ = Throw.IfNull(headers);
// Validate first; mutate only when every entry passes.
var staged = new List>();
foreach (var kvp in headers)
{
ValidateHeader(kvp.Key, kvp.Value);
staged.Add(kvp);
}
if (staged.Count == 0)
{
return options;
}
var dict = GetOrCreateHeadersDictionary(options);
foreach (var kvp in staged)
{
dict[kvp.Key] = kvp.Value;
}
return options;
}
///
/// Wraps the agent built by so that headers stamped by
/// on the per-call
/// are forwarded onto the outbound HTTP request.
///
///
///
/// Idempotent: if the inner agent is already wrapped with a
/// anywhere in its delegating chain, the agent is returned unchanged. This makes
/// myFoundryAgent.AsBuilder().UseClientHeaders().Build() safe even though Foundry
/// agents are pre-wired automatically.
///
///
/// Also registers against the underlying chat client's
/// service if available. When the underlying chat client
/// is not OpenAI-backed (the service lookup returns ), the registration
/// step is silently skipped; the agent decorator still runs but no headers are stamped on
/// the wire. See the type-level remarks for the conditions under which delivery happens.
///
///
/// The to extend.
/// The same builder, to allow fluent chaining.
/// is .
public static AIAgentBuilder UseClientHeaders(this AIAgentBuilder builder) =>
Throw.IfNull(builder).Use((AIAgent innerAgent, IServiceProvider services) =>
{
// Agent-side dedup: if any decorator in the chain is already a ClientHeadersAgent, no-op.
if (innerAgent.GetService() is not null)
{
return innerAgent;
}
// Best-effort policy registration on the underlying OpenAI-backed chat client.
// Silent no-op when the service is unavailable (non-OpenAI providers).
if (innerAgent.GetService() is { } policies)
{
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
policies,
ClientHeadersPolicy.Instance,
System.ClientModel.Primitives.PipelinePosition.PerCall);
}
return new ClientHeadersAgent(innerAgent);
});
/// Reads the headers dictionary stamped by callers, or if none.
[SuppressMessage("Design", "CA1002:Do not expose generic lists", Justification = "Internal helper.")]
internal static IReadOnlyDictionary? GetClientHeaders(this ChatOptions options)
{
if (options.AdditionalProperties is null)
{
return null;
}
if (!options.AdditionalProperties.TryGetValue(ClientHeadersKey, out var raw))
{
return null;
}
return raw as Dictionary;
}
private static Dictionary GetOrCreateHeadersDictionary(ChatOptions options)
{
options.AdditionalProperties ??= new AdditionalPropertiesDictionary();
if (options.AdditionalProperties.TryGetValue(ClientHeadersKey, out var existing))
{
if (existing is Dictionary dict)
{
return dict;
}
throw new InvalidOperationException(
$"ChatOptions.AdditionalProperties[\"{ClientHeadersKey}\"] is occupied by a value of type '{existing?.GetType().FullName ?? "null"}', expected Dictionary.");
}
var fresh = new Dictionary(StringComparer.OrdinalIgnoreCase);
options.AdditionalProperties[ClientHeadersKey] = fresh;
return fresh;
}
private static void ValidateHeader(string name, string value)
{
_ = Throw.IfNull(name);
_ = Throw.IfNull(value);
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Header name must not be empty or whitespace.", nameof(name));
}
if (value.Length == 0)
{
throw new ArgumentException("Header value must not be empty.", nameof(value));
}
if (!name.StartsWith(ClientHeaderPrefix, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
$"Header name '{name}' must start with '{ClientHeaderPrefix}' (case-insensitive). Only x-client-* headers are forwarded by the Foundry platform.",
nameof(name));
}
}
}