// 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)); } } }