// Copyright (c) Microsoft. All rights reserved. using System; using System.ClientModel; using System.ClientModel.Primitives; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; using OpenAI.Files; using OpenAI.Responses; using OpenAI.VectorStores; #pragma warning disable OPENAI001 namespace Microsoft.Agents.AI.Foundry; /// /// Foundry chat-client decorator that unifies the three Foundry chat-client construction /// modes (Responses Agent, Prompt Agent, Agent Endpoint) behind a single type and centralizes /// Foundry-specific concerns: microsoft.foundry telemetry tagging, /// agent-framework-dotnet/{version} User-Agent stamping, x-ms-served-model /// response-header capture, and (for Prompt Agents) per-request payload mutation that injects /// the agent reference and strips per-request overrides that the server owns. /// /// /// /// Replaces the previous AzureAIProjectChatClient and AzureAIProjectResponsesChatClient /// decorators. All Foundry entry points (the public FoundryAgent constructors and the /// AIProjectClientExtensions.AsAIAgent overloads) now construct a /// internally, so telemetry and the agent-framework User-Agent /// segment are uniform across paths. /// /// /// The three construction modes are: /// /// /// Responses Agent (Mode 1): direct Responses API call against a project-level model id; no server-side agent definition exists. Constructed from (AIProjectClient, modelId). /// Prompt Agent (Mode 2): server-side agent definition (a , typically a ) invoked by against the project Responses URL. Constructed from , , or . /// Agent Endpoint (Mode 3): invocation via the per-agent endpoint URL …/projects/{p}/agents/{name}/endpoint/protocols/openai. The agent behind the endpoint can be either a hosted (container-backed) agent or a Prompt Agent. Constructed from (Uri agentEndpoint, credential). /// /// /// Note: "Hosted Agent" refers to a container-based runtime agent (see /// Microsoft.Agents.AI.Foundry.Hosting) and is the kind of agent that may sit /// behind an Agent Endpoint. It is not synonymous with the Agent Endpoint mode itself. /// /// [Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] public sealed class FoundryChatClient : DelegatingChatClient { private readonly ChatClientMetadata _metadata; private readonly AIProjectClient? _aiProjectClient; private readonly AgentReference? _agentReference; private readonly ProjectsAgentVersion? _agentVersion; private readonly ProjectsAgentRecord? _agentRecord; private readonly ChatOptions? _baseChatOptions; /// /// Initializes a new instance for the Responses Agent mode (Mode 1): direct Responses API /// call against a project-level model id; no server-side agent definition exists. /// /// The project client. /// The model deployment id. internal FoundryChatClient(AIProjectClient aiProjectClient, string modelId) : base(Throw.IfNull(aiProjectClient) .GetProjectOpenAIClient() .GetProjectResponsesClientForModel(Throw.IfNullOrWhitespace(modelId)) .AsIChatClient()) { this._aiProjectClient = aiProjectClient; this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: modelId); TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient); TryRegisterServedModelPolicy(this.InnerClient); } /// /// Initializes a new instance for the Prompt Agent mode (Mode 2): server-side agent /// definition invoked by . /// internal FoundryChatClient(AIProjectClient aiProjectClient, AgentReference agentReference, string? defaultModelId, ChatOptions? baseChatOptions) : base(Throw.IfNull(aiProjectClient) .GetProjectOpenAIClient() .GetProjectResponsesClientForAgent(Throw.IfNull(agentReference)) .AsIChatClient()) { this._aiProjectClient = aiProjectClient; this._agentReference = agentReference; this._metadata = new ChatClientMetadata("microsoft.foundry", defaultModelId: defaultModelId); this._baseChatOptions = baseChatOptions; this.AgentName = agentReference.Name; TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient); TryRegisterServedModelPolicy(this.InnerClient); } /// /// Initializes a new instance for the Prompt Agent mode (Mode 2, record variant): /// server-side agent definition invoked by record, resolving to the latest version. /// internal FoundryChatClient(AIProjectClient aiProjectClient, ProjectsAgentRecord agentRecord, ChatOptions? baseChatOptions) : this(aiProjectClient, Throw.IfNull(agentRecord).GetLatestVersion(), baseChatOptions) { this._agentRecord = agentRecord; } /// /// Initializes a new instance for the Prompt Agent mode (Mode 2, version variant): /// server-side agent definition invoked by a specific version. /// internal FoundryChatClient(AIProjectClient aiProjectClient, ProjectsAgentVersion agentVersion, ChatOptions? baseChatOptions) : this( aiProjectClient, CreateAgentReference(Throw.IfNull(agentVersion)), (agentVersion.Definition as DeclarativeAgentDefinition)?.Model, baseChatOptions) { this._agentVersion = agentVersion; } /// /// Initializes a new instance for the Agent Endpoint mode (Mode 3): invocation via the /// per-agent endpoint URL. Parses the URL into its per-agent /// shape internally and forwards through the resulting /// responses client. /// /// /// The agent-specific endpoint URI. Must be of the shape /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai. /// /// The authentication credential. /// Optional per-agent client options. Endpoint and AgentName are owned by this ctor and overridden with values derived from . internal FoundryChatClient(Uri agentEndpoint, AuthenticationTokenProvider credential, ProjectOpenAIClientOptions? clientOptions) : this(BuildAgentEndpointInner(agentEndpoint, credential, clientOptions)) { } /// /// Initializes a new instance for the Agent Endpoint mode (Mode 3) by reusing an existing /// 's pipeline. Equivalent to the /// /// constructor but skips building a fresh per-agent pipeline: the project-level /// on is used directly. /// /// The project client already configured at the project root containing . /// The per-agent endpoint URI. Same shape constraints as the other agent-endpoint ctor. /// Optional per-agent client options applied to the per-agent GetProjectResponsesClientForAgentEndpoint call. internal FoundryChatClient(AIProjectClient aiProjectClient, Uri agentEndpoint, ProjectOpenAIClientOptions? clientOptions) : this(BuildAgentEndpointInnerFromProjectClient(aiProjectClient, agentEndpoint, clientOptions)) { } private FoundryChatClient(AgentEndpointInner inner) : base(inner.ChatClient) { this._aiProjectClient = inner.AIProjectClient; this.AgentName = inner.AgentName; this._metadata = new ChatClientMetadata("microsoft.foundry"); TryRegisterAgentFrameworkUserAgentPolicy(this.InnerClient); TryRegisterServedModelPolicy(this.InnerClient); } /// /// Gets the agent name associated with this chat client. /// /// /// Set in two cases: /// /// /// /// Prompt Agent mode (Mode 2): the value of supplied at /// construction. /// /// /// /// /// Agent Endpoint mode (Mode 3): the agent name segment parsed from the supplied agent /// endpoint URI. /// /// /// /// /// Returns for the Responses Agent mode (Mode 1) where no agent name /// exists. /// /// internal string? AgentName { get; } /// public override object? GetService(Type serviceType, object? serviceKey = null) { return (serviceKey is null && serviceType == typeof(ChatClientMetadata)) ? this._metadata : (serviceKey is null && serviceType == typeof(AIProjectClient)) ? this._aiProjectClient : (serviceKey is null && serviceType == typeof(AgentReference)) ? this._agentReference : (serviceKey is null && serviceType == typeof(ProjectsAgentVersion)) ? this._agentVersion : (serviceKey is null && serviceType == typeof(ProjectsAgentRecord)) ? this._agentRecord : base.GetService(serviceType, serviceKey); } /// public override async Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { var effectiveOptions = this._agentReference is not null ? this.GetAgentEnabledChatOptions(options) : options; var box = new StrongBox(null); var previous = ServedModelScope.Current; ServedModelScope.Current = box; try { var response = await base.GetResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false); if (box.Value is { } servedModel) { response.ModelId = servedModel; } return response; } finally { ServedModelScope.Current = previous; } } /// public override async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { var effectiveOptions = this._agentReference is not null ? this.GetAgentEnabledChatOptions(options) : options; var box = new StrongBox(null); var previous = ServedModelScope.Current; ServedModelScope.Current = box; try { await foreach (var chunk in base.GetStreamingResponseAsync(messages, effectiveOptions, cancellationToken).ConfigureAwait(false)) { if (box.Value is { } servedModel) { chunk.ModelId = servedModel; } yield return chunk; } } finally { ServedModelScope.Current = previous; } } #region File and vector-store helpers (mirrors Python's foundry_chat_client surface) /// /// Uploads a single file to the project for the supplied purpose. The upload is performed /// against the project-level reachable via /// , so this method works uniformly across all three /// FoundryChatClient construction modes. /// /// Absolute or relative path to the file to upload. The file must exist. /// The file upload purpose (e.g. ). /// A token that can cancel the upload. /// The created as returned by the service. /// is . /// The file at does not exist. public async Task UploadFileAsync(string filePath, FileUploadPurpose purpose, CancellationToken cancellationToken = default) { Throw.IfNull(filePath); if (!File.Exists(filePath)) { throw new FileNotFoundException($"File not found: '{filePath}'.", filePath); } var fileClient = this.GetOpenAIFileClient(); // Use the Stream overload to honor cancellation; the (string, purpose) overload has no // CancellationToken parameter in the OpenAI SDK. using var stream = File.OpenRead(filePath); var result = await fileClient.UploadFileAsync(stream, Path.GetFileName(filePath), purpose, cancellationToken).ConfigureAwait(false); return result.Value; } /// Deletes a file previously uploaded to the project. /// The file id returned by . /// A token that can cancel the delete. /// The deletion result. /// is or whitespace. public async Task DeleteFileAsync(string fileId, CancellationToken cancellationToken = default) { Throw.IfNullOrWhitespace(fileId); var fileClient = this.GetOpenAIFileClient(); var result = await fileClient.DeleteFileAsync(fileId, cancellationToken).ConfigureAwait(false); return result.Value; } /// /// Uploads the supplied files, creates a vector store containing them, waits until the /// store finishes ingesting its files (status leaves ), /// and returns the . Mirrors Python's /// foundry_chat_client.create_vector_store(name, files, expires_after_days). /// /// The vector store name. /// Paths to files to upload and attach to the store. /// Optional last-active-at expiration window. When supplied, the vector store expires this many days after its last use. /// Optional upper bound on the wait for the vector store to leave . Defaults to 5 minutes when not supplied; pass to disable. Independent of : cancellation always wins. /// A token that can cancel the orchestration. /// The created and fully-ready . The returned instance reflects the state observed after polling completes; it may be in (typical), , or any other terminal status returned by the service. Only is polled. /// /// /// File-upload semantics are best-effort: when one of the per-file uploads throws, this method /// makes a best-effort attempt to delete the files it has already uploaded so they do not /// accumulate as orphaned resources on the project, then rethrows the original exception. The /// cleanup itself does not throw — its failures are silently ignored because the caller is /// already receiving a more meaningful exception from the original upload failure. /// /// /// Cancellation aborts the polling loop with an ; any /// already-uploaded files and the partially-created vector store remain on the project and are /// the caller's responsibility to clean up. The same applies when the polling timeout elapses /// (a is thrown instead). /// /// /// is or whitespace, or is . /// The vector store did not leave within . public async Task CreateVectorStoreAsync(string name, IEnumerable filePaths, TimeSpan? expiresAfter = null, TimeSpan? pollingTimeout = null, CancellationToken cancellationToken = default) { Throw.IfNullOrWhitespace(name); Throw.IfNull(filePaths); var fileIds = new List(); try { foreach (var path in filePaths) { cancellationToken.ThrowIfCancellationRequested(); var uploaded = await this.UploadFileAsync(path, FileUploadPurpose.Assistants, cancellationToken).ConfigureAwait(false); fileIds.Add(uploaded.Id); } } catch { // Q-B: best-effort cleanup of files already uploaded before the mid-loop failure so // they do not accumulate as orphaned resources on the project. Swallow cleanup // exceptions — the caller is already going to see the original upload exception, and // there is nothing useful we can do with a secondary delete failure. await this.BestEffortDeleteFilesAsync(fileIds).ConfigureAwait(false); throw; } var options = new VectorStoreCreationOptions { Name = name, }; foreach (var id in fileIds) { options.FileIds.Add(id); } if (expiresAfter is { } window) { options.ExpirationPolicy = new VectorStoreExpirationPolicy(VectorStoreExpirationAnchor.LastActiveAt, (int)Math.Ceiling(window.TotalDays)); } var vectorStoreClient = this.GetVectorStoreClient(); var createResult = await vectorStoreClient.CreateVectorStoreAsync(options, cancellationToken).ConfigureAwait(false); var created = createResult.Value; // Q-A: poll until the vector store leaves the in-progress state. Without this the helper // hands the caller a vector store whose file ingestion may still be running, defeating // the purpose of the one-call wrapper. return await WaitForVectorStoreReadyAsync(vectorStoreClient, created, pollingTimeout ?? s_defaultPollingTimeout, cancellationToken).ConfigureAwait(false); } private async Task BestEffortDeleteFilesAsync(IEnumerable fileIds) { foreach (var id in fileIds) { try { // Pass CancellationToken.None: cleanup runs in the catch path; the caller's // token may already be cancelled and we still want to do our best to free // orphaned resources before propagating the original exception. await this.DeleteFileAsync(id, CancellationToken.None).ConfigureAwait(false); } catch { // Silently ignore cleanup failures; see XML doc on CreateVectorStoreAsync. } } } /// Upper bound on when the caller does not supply one. Chosen to comfortably cover normal Foundry vector-store ingestion (seconds to a minute for modest file sets) while still surfacing a clear failure if the server is stuck. private static readonly TimeSpan s_defaultPollingTimeout = TimeSpan.FromMinutes(5); private static async Task WaitForVectorStoreReadyAsync(VectorStoreClient client, VectorStore initial, TimeSpan timeout, CancellationToken cancellationToken) { if (initial.Status != VectorStoreStatus.InProgress) { return initial; } var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var delay = TimeSpan.FromMilliseconds(250); var maxDelay = TimeSpan.FromSeconds(2); var current = initial; while (current.Status == VectorStoreStatus.InProgress) { if (timeout != Timeout.InfiniteTimeSpan && stopwatch.Elapsed >= timeout) { throw new TimeoutException( $"Vector store '{current.Id}' did not leave the in-progress state within {timeout.TotalSeconds:0.##} seconds."); } await Task.Delay(delay, cancellationToken).ConfigureAwait(false); var refreshed = await client.GetVectorStoreAsync(current.Id, cancellationToken).ConfigureAwait(false); current = refreshed.Value; if (delay < maxDelay) { var next = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2); delay = next < maxDelay ? next : maxDelay; } } return current; } /// Deletes a vector store. The associated files (if any) are not deleted by this method; call separately to clean them up. /// The vector store id. /// A token that can cancel the delete. /// The deletion result. /// is or whitespace. public async Task DeleteVectorStoreAsync(string vectorStoreId, CancellationToken cancellationToken = default) { Throw.IfNullOrWhitespace(vectorStoreId); var vectorStoreClient = this.GetVectorStoreClient(); var result = await vectorStoreClient.DeleteVectorStoreAsync(vectorStoreId, cancellationToken).ConfigureAwait(false); return result.Value; } private OpenAIFileClient GetOpenAIFileClient() { var projectClient = this._aiProjectClient ?? throw new InvalidOperationException("This FoundryChatClient does not have an AIProjectClient available. File and vector-store helpers require an AIProjectClient."); return projectClient.GetProjectOpenAIClient().GetOpenAIFileClient(); } private VectorStoreClient GetVectorStoreClient() { var projectClient = this._aiProjectClient ?? throw new InvalidOperationException("This FoundryChatClient does not have an AIProjectClient available. File and vector-store helpers require an AIProjectClient."); return projectClient.GetProjectOpenAIClient().GetVectorStoreClient(); } #endregion /// /// Parses an agent endpoint URI of shape /// https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai /// and returns the agent name and the derived project-root URI. /// /// /// Tolerates trailing slash, casing variants on /agents/ and the suffix segment, and /// strips query string and fragment. Throws for inputs that /// do not match the expected shape. /// /// /// The endpoint is missing the /agents/ segment, has an empty agent name, or has a /// suffix other than /endpoint/protocols/openai. /// internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint) { Throw.IfNull(agentEndpoint); const string AgentsSegment = "/agents/"; const string ExpectedSuffix = "/endpoint/protocols/openai"; var path = agentEndpoint.AbsolutePath.TrimEnd('/'); var idx = path.IndexOf(AgentsSegment, StringComparison.OrdinalIgnoreCase); if (idx < 0) { throw new ArgumentException( $"Expected an agent endpoint of shape 'https:///.../projects//agents//endpoint/protocols/openai' but got '{agentEndpoint}'. " + "If you want to construct a FoundryAgent against a project endpoint, use the (Uri projectEndpoint, AuthenticationTokenProvider credential, string model, string instructions, ...) constructor instead.", nameof(agentEndpoint)); } var afterAgents = path.Substring(idx + AgentsSegment.Length); var nextSlash = afterAgents.IndexOf('/'); if (nextSlash <= 0) { throw new ArgumentException( $"Agent endpoint '{agentEndpoint}' is missing the '{ExpectedSuffix}' suffix.", nameof(agentEndpoint)); } var agentName = afterAgents.Substring(0, nextSlash); var suffix = afterAgents.Substring(nextSlash); if (!string.Equals(suffix, ExpectedSuffix, StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException( $"Agent endpoint '{agentEndpoint}' has an unexpected suffix '{suffix}'. Expected '{ExpectedSuffix}'.", nameof(agentEndpoint)); } var rootPath = path.Substring(0, idx); var projectRoot = new UriBuilder(agentEndpoint) { Path = rootPath, Query = string.Empty, Fragment = string.Empty, }.Uri; return (agentName, projectRoot); } private ChatOptions GetAgentEnabledChatOptions(ChatOptions? options) { // Start with a clone of the base chat options defined for the agent, if any. ChatOptions agentEnabledChatOptions = this._baseChatOptions?.Clone() ?? new(); // Ignore per-request all options that can't be overridden. agentEnabledChatOptions.Instructions = null; agentEnabledChatOptions.Tools = null; agentEnabledChatOptions.Temperature = null; agentEnabledChatOptions.TopP = null; agentEnabledChatOptions.PresencePenalty = null; agentEnabledChatOptions.ResponseFormat = null; // Use the conversation from the request, or the one defined at the client level. agentEnabledChatOptions.ConversationId = options?.ConversationId ?? this._baseChatOptions?.ConversationId; // Preserve the original RawRepresentationFactory. var originalFactory = options?.RawRepresentationFactory; agentEnabledChatOptions.RawRepresentationFactory = (client) => { if (originalFactory?.Invoke(this) is not CreateResponseOptions responseCreationOptions) { responseCreationOptions = new CreateResponseOptions(); } responseCreationOptions.Agent = this._agentReference; #pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. responseCreationOptions.Patch.Remove("$.model"u8); #pragma warning restore SCME0001 return responseCreationOptions; }; return agentEnabledChatOptions; } private static AgentReference CreateAgentReference(ProjectsAgentVersion agentVersion) { // If the version is null, empty, or whitespace, use "latest" as the default. This handles // cases where hosted agents (like MCP agents) may not have a version assigned. var version = string.IsNullOrWhiteSpace(agentVersion.Version) ? "latest" : agentVersion.Version; return new AgentReference(agentVersion.Name, version); } private static AgentEndpointInner BuildAgentEndpointInner( Uri agentEndpoint, AuthenticationTokenProvider credential, ProjectOpenAIClientOptions? clientOptions) { Throw.IfNull(agentEndpoint); Throw.IfNull(credential); var (agentName, projectRoot) = ParseAgentEndpoint(agentEndpoint); var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions(); perAgentOptions.Endpoint = agentEndpoint; perAgentOptions.AgentName = agentName; var authPolicy = new BearerTokenPolicy(credential, AzureAiResourceScope); var perAgentClient = new ProjectOpenAIClient(authPolicy, perAgentOptions); var chatClient = perAgentClient.GetProjectResponsesClient().AsIChatClient(); // Materialize a project-level AIProjectClient from the parsed project root so // GetService() returns non-null for all FoundryChatClient // construction modes. Project-level helpers (file upload, vector store create/delete) // depend on this. RBAC for those calls is at the project level; if the supplied // credential lacks project-scope permissions, the SDK surfaces a clean 401/403 at // call time. The four observable primitive ClientPipelineOptions properties are // propagated from the caller's per-agent options bag so test-injected transports and // explicit RetryPolicy / NetworkTimeout / UserAgentApplicationId reach the // project-level pipeline. Pipeline policies added via AddPolicy on the caller bag are // NOT propagated because ClientPipelineOptions does not publicly enumerate policies. var aiProjectClientOptions = new AIProjectClientOptions(); if (clientOptions is not null) { if (clientOptions.RetryPolicy is not null) { aiProjectClientOptions.RetryPolicy = clientOptions.RetryPolicy; } if (clientOptions.NetworkTimeout is not null) { aiProjectClientOptions.NetworkTimeout = clientOptions.NetworkTimeout; } if (clientOptions.Transport is not null) { aiProjectClientOptions.Transport = clientOptions.Transport; } if (!string.IsNullOrEmpty(clientOptions.UserAgentApplicationId)) { aiProjectClientOptions.UserAgentApplicationId = clientOptions.UserAgentApplicationId; } } var aiProjectClient = new AIProjectClient(projectRoot, credential, aiProjectClientOptions); return new AgentEndpointInner(chatClient, aiProjectClient, agentName); } private static AgentEndpointInner BuildAgentEndpointInnerFromProjectClient( AIProjectClient aiProjectClient, Uri agentEndpoint, ProjectOpenAIClientOptions? clientOptions) { Throw.IfNull(aiProjectClient); Throw.IfNull(agentEndpoint); var (agentName, _) = ParseAgentEndpoint(agentEndpoint); var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions(); perAgentOptions.Endpoint = agentEndpoint; perAgentOptions.AgentName = agentName; var chatClient = aiProjectClient.GetProjectOpenAIClient() .GetProjectResponsesClientForAgentEndpoint(agentName, options: perAgentOptions) .AsIChatClient(); // Reuse the caller's AIProjectClient verbatim — no new pipeline is materialized. return new AgentEndpointInner(chatClient, aiProjectClient, agentName); } /// Best-effort registration of via the MEAI hook with at-most-once dedup per pipeline. private static void TryRegisterAgentFrameworkUserAgentPolicy(IChatClient? innerClient) { if (innerClient?.GetService() is { } policies) { // OpenAIRequestPoliciesReflection.AddPolicyIfMissing performs a check-then-add against // the private _entries collection on the OpenAIRequestPolicies instance, so the // policy is registered at most once even when many FoundryChatClient instances share // the same underlying chat client. OpenAIRequestPoliciesReflection.AddPolicyIfMissing( policies, AgentFrameworkUserAgentPolicy.Instance, PipelinePosition.PerCall); } } /// /// Best-effort registration of via the MEAI /// hook. The policy captures the /// x-ms-served-model response header from Azure OpenAI and writes it into /// so the and /// overrides can overwrite /// with the actual model snapshot. /// private static void TryRegisterServedModelPolicy(IChatClient? innerClient) { if (innerClient?.GetService() is { } policies) { OpenAIRequestPoliciesReflection.AddPolicyIfMissing( policies, ServedModelPolicy.Instance, PipelinePosition.PerCall); } } /// Default OAuth scope for the Azure AI resource. Matches the scope used by Azure.AI.Extensions.OpenAI's internal authentication helper so the bearer token is accepted by the Foundry control plane. private const string AzureAiResourceScope = "https://ai.azure.com/.default"; private readonly struct AgentEndpointInner { public AgentEndpointInner(IChatClient chatClient, AIProjectClient aiProjectClient, string agentName) { this.ChatClient = chatClient; this.AIProjectClient = aiProjectClient; this.AgentName = agentName; } public IChatClient ChatClient { get; } public AIProjectClient AIProjectClient { get; } public string AgentName { get; } } }