diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 38ed7f9884..7277c0c761 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -23,10 +23,10 @@ - + - + diff --git a/dotnet/samples/GettingStarted/AgentSample.cs b/dotnet/samples/GettingStarted/AgentSample.cs index 620c395d51..686f6bb383 100644 --- a/dotnet/samples/GettingStarted/AgentSample.cs +++ b/dotnet/samples/GettingStarted/AgentSample.cs @@ -7,8 +7,8 @@ using Azure.Identity; using Microsoft.Extensions.AI; using Microsoft.Extensions.AI.Agents; using Microsoft.Shared.Samples; -using OpenAI; using OpenAI.Assistants; +using OpenAI.Chat; using OpenAI.Responses; #pragma warning disable OPENAI001 @@ -53,10 +53,6 @@ public class AgentSample(ITestOutputHelper output) : BaseSample(output) _ => null }; - protected OpenAIClient OpenAIClient => new(TestConfiguration.OpenAI.ApiKey); - - protected PersistentAgentsClient AzureAIPersistentAgentsClient => new(TestConfiguration.AzureAI.Endpoint, new AzureCliCredential()); - /// /// For providers that store the agent and the thread on the server side, this will clean and delete /// any sample agent and thread that was created during this execution. @@ -83,8 +79,7 @@ public class AgentSample(ITestOutputHelper output) : BaseSample(output) private Task GetOpenAIChatClientAsync() => Task.FromResult( - OpenAIClient - .GetChatClient(TestConfiguration.OpenAI.ChatModelId) + new ChatClient(TestConfiguration.OpenAI.ChatModelId, TestConfiguration.OpenAI.ApiKey) .AsIChatClient()); private Task GetAzureOpenAIChatClientAsync() @@ -98,39 +93,52 @@ public class AgentSample(ITestOutputHelper output) : BaseSample(output) private Task GetOpenAIResponsesClientAsync() => Task.FromResult( - OpenAIClient - .GetOpenAIResponseClient(TestConfiguration.OpenAI.ChatModelId) + new OpenAIResponseClient(TestConfiguration.OpenAI.ChatModelId, TestConfiguration.OpenAI.ApiKey) .AsIChatClient()); private async Task GetAzureAIAgentPersistentClientAsync(ChatClientAgentOptions options, CancellationToken cancellationToken) { - // Create a server side agent to work with. - var persistentAgentResponse = await AzureAIPersistentAgentsClient.Administration.CreateAgentAsync( - model: TestConfiguration.AzureAI.DeploymentName, - name: options.Name, - instructions: options.Instructions, - cancellationToken: cancellationToken); + var persistentAgentsClient = new PersistentAgentsClient( + TestConfiguration.AzureAI.Endpoint, + new AzureCliCredential()); - var persistentAgent = persistentAgentResponse.Value; + // If the Id is not provided, create a new agent. + if (options.Id is null) + { + var persistentAgentResponse = await persistentAgentsClient.Administration.CreateAgentAsync( + model: TestConfiguration.AzureAI.DeploymentName, + name: options.Name, + instructions: options.Instructions, + cancellationToken: cancellationToken); - // Get the chat client to use for the agent. - return AzureAIPersistentAgentsClient.AsIChatClient(persistentAgent.Id); + var persistentAgent = persistentAgentResponse.Value; + + return persistentAgentsClient.AsIChatClient(persistentAgent.Id); + } + + return persistentAgentsClient.AsIChatClient(options.Id); } private async Task GetOpenAIAssistantChatClientAsync(ChatClientAgentOptions options, CancellationToken cancellationToken) { - var assistantClient = OpenAIClient.GetAssistantClient(); + var assistantClient = new AssistantClient(TestConfiguration.OpenAI.ApiKey); - Assistant assistant = await assistantClient.CreateAssistantAsync( - TestConfiguration.OpenAI.ChatModelId, - new() - { - Name = options.Name, - Instructions = options.Instructions - }, - cancellationToken); + // If the Id is not provided, create a new assistant. + if (options.Id is null) + { + Assistant assistant = await assistantClient.CreateAssistantAsync( + TestConfiguration.OpenAI.ChatModelId, + new() + { + Name = options.Name, + Instructions = options.Instructions + }, + cancellationToken); - return assistantClient.AsIChatClient(assistant.Id); + return new NewOpenAIAssistantChatClient(assistantClient, assistant.Id, null); + } + + return new NewOpenAIAssistantChatClient(assistantClient, options.Id, null); } #endregion diff --git a/dotnet/samples/GettingStarted/External/MEAI.Abstractions/NewHostedCodeInterpreterTool.cs b/dotnet/samples/GettingStarted/External/MEAI.Abstractions/NewHostedCodeInterpreterTool.cs new file mode 100644 index 0000000000..1c018593b8 --- /dev/null +++ b/dotnet/samples/GettingStarted/External/MEAI.Abstractions/NewHostedCodeInterpreterTool.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace OpenAI.Assistants; + +/// +/// Proposal for abstraction updates based on the common code interpreter tool properties. +/// Based on the decision, the abstraction can be updated in M.E.AI directly. +/// +public class NewHostedCodeInterpreterTool : HostedCodeInterpreterTool +{ + // Usage of an internal dictionary is temporary and only used here because the MEAI.Abstractions does not have this specialization yet and the + // ChatClients must rely on the AdditionalProperties to check and set correctly the Code Interpreter Resource avoiding a customized RawRepresentationFactory implementation. + private readonly Dictionary _additionalProperties = []; + + /// Gets or sets the list of file IDs that the code interpreter tool can access. + public IList FileIds + { + get + { + // Only create the property in the dictionary when it is actually used + if (!this._additionalProperties.TryGetValue("fileIds", out var value) || value is null) + { + value = new List(); + this._additionalProperties["fileIds"] = value; + } + + return (IList)value; + } + } + + /// + public override IReadOnlyDictionary AdditionalProperties => this._additionalProperties; +} diff --git a/dotnet/samples/GettingStarted/External/MEAI.OpenAI/NewOpenAIAssistantChatClient.cs b/dotnet/samples/GettingStarted/External/MEAI.OpenAI/NewOpenAIAssistantChatClient.cs new file mode 100644 index 0000000000..9a747ea620 --- /dev/null +++ b/dotnet/samples/GettingStarted/External/MEAI.OpenAI/NewOpenAIAssistantChatClient.cs @@ -0,0 +1,644 @@ +#pragma warning disable IDE0005 // Using directive is unnecessary. +#pragma warning disable IDE0073 // The file header does not match the required text +#pragma warning disable CS0436 // Type conflicts with imported type +#pragma warning disable CA1063 // Implement IDisposable Correctly + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; +using OpenAI; +using OpenAI.Assistants; +using OpenAI.Audio; +using OpenAI.Chat; +using OpenAI.Embeddings; + +#pragma warning disable CA1031 // Do not catch general exception types +#pragma warning disable SA1005 // Single line comments should begin with single space +#pragma warning disable SA1204 // Static elements should appear before instance elements +#pragma warning disable S125 // Sections of code should not be commented out +#pragma warning disable S907 // "goto" statement should not be used +#pragma warning disable S1067 // Expressions should not be too complex +#pragma warning disable S1751 // Loops with at most one iteration should be refactored +#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields +#pragma warning disable S4456 // Parameter validation in yielding methods should be wrapped +#pragma warning disable S4457 // Parameter validation in "async"/"await" methods should be wrapped + +namespace Microsoft.Extensions.AI; + +/// Represents an for an Azure.AI.Agents.Persistent . +public sealed class NewOpenAIAssistantChatClient : IChatClient +{ + /// The underlying . +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + private readonly AssistantClient _client; + + /// Metadata for the client. + private readonly ChatClientMetadata _metadata; + + /// The ID of the agent to use. + private readonly string _assistantId; + + /// The thread ID to use if none is supplied in . + private readonly string? _defaultThreadId; + + /// List of tools associated with the assistant. + private IReadOnlyList? _assistantTools; + + /// Initializes a new instance of the class for the specified . + public NewOpenAIAssistantChatClient(AssistantClient assistantClient, string assistantId, string? defaultThreadId) + { + _client = Throw.IfNull(assistantClient); + _assistantId = Throw.IfNullOrWhitespace(assistantId); + + _defaultThreadId = defaultThreadId; + + // https://github.com/openai/openai-dotnet/issues/215 + // The endpoint isn't currently exposed, so use reflection to get at it, temporarily. Once packages + // implement the abstractions directly rather than providing adapters on top of the public APIs, + // the package can provide such implementations separate from what's exposed in the public API. + Uri providerUrl = typeof(AssistantClient).GetField("_endpoint", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + ?.GetValue(assistantClient) as Uri ?? OpenAIClientExtensions2.DefaultOpenAIEndpoint; + + _metadata = new("openai", providerUrl); + } + + /// + public object? GetService(Type serviceType, object? serviceKey = null) => + serviceType is null ? throw new ArgumentNullException(nameof(serviceType)) : + serviceKey is not null ? null : + serviceType == typeof(ChatClientMetadata) ? _metadata : + serviceType == typeof(AssistantClient) ? _client : + serviceType.IsInstanceOfType(this) ? this : + null; + + /// + public Task GetResponseAsync( + IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + GetStreamingResponseAsync(messages, options, cancellationToken).ToChatResponseAsync(cancellationToken); + + /// + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + + // Extract necessary state from messages and options. + (RunCreationOptions runOptions, List? toolResults) = await CreateRunOptionsAsync(messages, options, cancellationToken).ConfigureAwait(false); + + // Get the thread ID. + string? threadId = options?.ConversationId ?? _defaultThreadId; + if (threadId is null && toolResults is not null) + { + Throw.ArgumentException(nameof(messages), "No thread ID was provided, but chat messages includes tool results."); + } + + // Get any active run ID for this thread. This is necessary in case a thread has been left with an + // active run, in which all attempts other than submitting tools will fail. We thus need to cancel + // any active run on the thread. + ThreadRun? threadRun = null; + if (threadId is not null) + { + await foreach (var run in _client.GetRunsAsync( + threadId, + new RunCollectionOptions { Order = RunCollectionOrder.Descending, PageSizeLimit = 1 }, + cancellationToken: cancellationToken).ConfigureAwait(false)) + { + if (run.Status != RunStatus.Completed && run.Status != RunStatus.Cancelled && run.Status != RunStatus.Failed && run.Status != RunStatus.Expired) + { + threadRun = run; + } + + break; + } + } + + // Submit the request. + IAsyncEnumerable updates; + if (threadRun is not null && + ConvertFunctionResultsToToolOutput(toolResults, out List? toolOutputs) is { } toolRunId && + toolRunId == threadRun.Id) + { + // There's an active run and we have tool results to submit, so submit the results and continue streaming. + // This is going to ignore any additional messages in the run options, as we are only submitting tool outputs, + // but there doesn't appear to be a way to submit additional messages, and having such additional messages is rare. + updates = _client.SubmitToolOutputsToRunStreamingAsync(threadRun.ThreadId, threadRun.Id, toolOutputs, cancellationToken); + } + else + { + if (threadId is null) + { + // No thread ID was provided, so create a new thread. + ThreadCreationOptions threadCreationOptions = new(); + foreach (var message in runOptions.AdditionalMessages) + { + threadCreationOptions.InitialMessages.Add(message); + } + + runOptions.AdditionalMessages.Clear(); + + var thread = await _client.CreateThreadAsync(threadCreationOptions, cancellationToken).ConfigureAwait(false); + threadId = thread.Value.Id; + } + else if (threadRun is not null) + { + // There was an active run; we need to cancel it before starting a new run. + _ = await _client.CancelRunAsync(threadId, threadRun.Id, cancellationToken).ConfigureAwait(false); + threadRun = null; + } + + // Now create a new run and stream the results. + updates = _client.CreateRunStreamingAsync( + threadId: threadId, + _assistantId, + runOptions, + cancellationToken); + } + + // Process each update. + string? responseId = null; + await foreach (var update in updates.ConfigureAwait(false)) + { + switch (update) + { + case ThreadUpdate tu: + threadId ??= tu.Value.Id; + goto default; + + case RunUpdate ru: + threadId ??= ru.Value.ThreadId; + responseId ??= ru.Value.Id; + + ChatResponseUpdate ruUpdate = new() + { + AuthorName = _assistantId, + ConversationId = threadId, + CreatedAt = ru.Value.CreatedAt, + MessageId = responseId, + ModelId = ru.Value.Model, + RawRepresentation = ru, + ResponseId = responseId, + Role = ChatRole.Assistant, + }; + + if (ru.Value.Usage is { } usage) + { + ruUpdate.Contents.Add(new UsageContent(new() + { + InputTokenCount = usage.InputTokenCount, + OutputTokenCount = usage.OutputTokenCount, + TotalTokenCount = usage.TotalTokenCount, + })); + } + + if (ru is RequiredActionUpdate rau && rau.ToolCallId is string toolCallId && rau.FunctionName is string functionName) + { + ruUpdate.Contents.Add( + new FunctionCallContent( + JsonSerializer.Serialize([ru.Value.Id, toolCallId], OpenAIJsonContext.Default.StringArray), + functionName, + JsonSerializer.Deserialize(rau.FunctionArguments, OpenAIJsonContext.Default.IDictionaryStringObject)!)); + } + + yield return ruUpdate; + break; + + case MessageContentUpdate mcu: + yield return new(mcu.Role == MessageRole.User ? ChatRole.User : ChatRole.Assistant, mcu.Text) + { + AuthorName = _assistantId, + ConversationId = threadId, + MessageId = responseId, + RawRepresentation = mcu, + ResponseId = responseId, + }; + break; + + default: + yield return new ChatResponseUpdate + { + AuthorName = _assistantId, + ConversationId = threadId, + MessageId = responseId, + RawRepresentation = update, + ResponseId = responseId, + Role = ChatRole.Assistant, + }; + break; + } + } + } + + /// + void IDisposable.Dispose() + { + // nop + } + + /// Converts an Extensions function to an OpenAI assistants function tool. + internal static FunctionToolDefinition ToOpenAIAssistantsFunctionToolDefinition(AIFunction aiFunction, ChatOptions? options = null) + { + bool? strict = + OpenAIClientExtensions2.HasStrict(aiFunction.AdditionalProperties) ?? + OpenAIClientExtensions2.HasStrict(options?.AdditionalProperties); + + return new FunctionToolDefinition(aiFunction.Name) + { + Description = aiFunction.Description, + Parameters = OpenAIClientExtensions2.ToOpenAIFunctionParameters(aiFunction, strict), + StrictParameterSchemaEnabled = strict, + }; + } + + /// + /// Creates the to use for the request and extracts any function result contents + /// that need to be submitted as tool results. + /// + private async ValueTask<(RunCreationOptions RunOptions, List? ToolResults)> CreateRunOptionsAsync( + IEnumerable messages, ChatOptions? options, CancellationToken cancellationToken) + { + // Create the options instance to populate, either a fresh or using one the caller provides. + RunCreationOptions runOptions = + options?.RawRepresentationFactory?.Invoke(this) as RunCreationOptions ?? + new(); + + // Populate the run options from the ChatOptions, if provided. + if (options is not null) + { + runOptions.MaxOutputTokenCount ??= options.MaxOutputTokens; + runOptions.ModelOverride ??= options.ModelId; + runOptions.NucleusSamplingFactor ??= options.TopP; + runOptions.Temperature ??= options.Temperature; + runOptions.AllowParallelToolCalls ??= options.AllowMultipleToolCalls; + + if (options.Tools is { Count: > 0 } tools) + { + // If the caller has provided any tool overrides, we'll assume they don't want to use the assistant's tools. + // But if they haven't, the only way we can provide our tools is via an override, whereas we'd really like to + // just add them. To handle that, we'll get all of the assistant's tools and add them to the override list + // along with our tools. + if (runOptions.ToolsOverride.Count == 0) + { + if (_assistantTools is null) + { + var assistant = await _client.GetAssistantAsync(_assistantId, cancellationToken).ConfigureAwait(false); + _assistantTools = assistant.Value.Tools; + } + + foreach (var tool in _assistantTools) + { + runOptions.ToolsOverride.Add(tool); + } + } + + // The caller can provide tools in the supplied ThreadAndRunOptions. Augment it with any supplied via ChatOptions.Tools. + foreach (AITool tool in tools) + { + switch (tool) + { + case AIFunction aiFunction: + runOptions.ToolsOverride.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction, options)); + break; + + case HostedCodeInterpreterTool: + var codeInterpreterToolDefinition = new CodeInterpreterToolDefinition(); + runOptions.ToolsOverride.Add(codeInterpreterToolDefinition); + + // Once available, HostedCodeInterpreterTool.FileIds property will be used instead of the AdditionalProperties. + if (tool.AdditionalProperties.TryGetValue("fileIds", out object? fileIdsObject) && fileIdsObject is IEnumerable fileIds) + { + var threadInitializationMessage = new ThreadInitializationMessage(OpenAI.Assistants.MessageRole.User, [OpenAI.Assistants.MessageContent.FromText("attachments")]); + + foreach (var fileId in fileIds) + { + threadInitializationMessage.Attachments.Add(new(fileId, [codeInterpreterToolDefinition])); + } + + runOptions.AdditionalMessages.Add(threadInitializationMessage); + } + + break; + } + } + } + + // Store the tool mode, if relevant. + if (runOptions.ToolConstraint is null) + { + switch (options.ToolMode) + { + case NoneChatToolMode: + runOptions.ToolConstraint = ToolConstraint.None; + break; + + case AutoChatToolMode: + runOptions.ToolConstraint = ToolConstraint.Auto; + break; + + case RequiredChatToolMode required when required.RequiredFunctionName is { } functionName: + runOptions.ToolConstraint = new ToolConstraint(ToolDefinition.CreateFunction(functionName)); + break; + + case RequiredChatToolMode required: + runOptions.ToolConstraint = ToolConstraint.Required; + break; + } + } + + // Store the response format, if relevant. + if (runOptions.ResponseFormat is null) + { + switch (options.ResponseFormat) + { + case ChatResponseFormatText: + runOptions.ResponseFormat = AssistantResponseFormat.CreateTextFormat(); + break; + + case ChatResponseFormatJson jsonFormat when OpenAIClientExtensions2.StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema: + runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonSchemaFormat( + jsonFormat.SchemaName, + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(jsonSchema, OpenAIJsonContext.Default.JsonElement)), + jsonFormat.SchemaDescription, + OpenAIClientExtensions2.HasStrict(options.AdditionalProperties)); + break; + + case ChatResponseFormatJson jsonFormat: + runOptions.ResponseFormat = AssistantResponseFormat.CreateJsonObjectFormat(); + break; + } + } + } + + // Configure system instructions. + StringBuilder? instructions = null; + void AppendSystemInstructions(string? toAppend) + { + if (!string.IsNullOrEmpty(toAppend)) + { + if (instructions is null) + { + instructions = new(toAppend); + } + else + { + _ = instructions.AppendLine().AppendLine(toAppend); + } + } + } + + AppendSystemInstructions(runOptions.AdditionalInstructions); + AppendSystemInstructions(options?.Instructions); + + // Process ChatMessages. + List? functionResults = null; + foreach (var chatMessage in messages) + { + List messageContents = []; + + // Assistants doesn't support system/developer messages directly. It does support transient per-request instructions, + // so we can use the system/developer messages to build up a set of instructions that will be passed to the assistant + // as part of this request. However, in doing so, on a subsequent request that information will be lost, as there's no + // way to store per-thread instructions in the OpenAI Assistants API. We don't want to convert these to user messages, + // however, as that would then expose the system/developer messages in a way that might make the model more likely + // to include that information in its responses. System messages should ideally be instead done as instructions to + // the assistant when the assistant is created. + if (chatMessage.Role == ChatRole.System || + chatMessage.Role == OpenAIClientExtensions2.ChatRoleDeveloper) + { + foreach (var textContent in chatMessage.Contents.OfType()) + { + AppendSystemInstructions(textContent.Text); + } + + continue; + } + + foreach (AIContent content in chatMessage.Contents) + { + switch (content) + { + case TextContent text: + messageContents.Add(MessageContent.FromText(text.Text)); + break; + + case UriContent image when image.HasTopLevelMediaType("image"): + messageContents.Add(MessageContent.FromImageUri(image.Uri)); + break; + + // Assistants doesn't support data URIs. + //case DataContent image when image.HasTopLevelMediaType("image"): + // messageContents.Add(MessageContent.FromImageUri(new Uri(image.Uri))); + // break; + + case FunctionResultContent result: + (functionResults ??= []).Add(result); + break; + + case AIContent when content.RawRepresentation is MessageContent rawRep: + messageContents.Add(rawRep); + break; + } + } + + if (messageContents.Count > 0) + { + runOptions.AdditionalMessages.Add(new ThreadInitializationMessage( + chatMessage.Role == ChatRole.Assistant ? MessageRole.Assistant : MessageRole.User, + messageContents)); + } + } + + runOptions.AdditionalInstructions = instructions?.ToString(); + + return (runOptions, functionResults); + } + + /// Convert instances to instances. + /// The tool results to process. + /// The generated list of tool outputs, if any could be created. + /// The run ID associated with the corresponding function call requests. + private static string? ConvertFunctionResultsToToolOutput(List? toolResults, out List? toolOutputs) + { + string? runId = null; + toolOutputs = null; + if (toolResults?.Count > 0) + { + foreach (var frc in toolResults) + { + // When creating the FunctionCallContext, we created it with a CallId == [runId, callId]. + // We need to extract the run ID and ensure that the ToolOutput we send back to Azure + // is only the call ID. + string[]? runAndCallIDs; + try + { + runAndCallIDs = JsonSerializer.Deserialize(frc.CallId, OpenAIJsonContext.Default.StringArray); + } + catch + { + continue; + } + + if (runAndCallIDs is null || + runAndCallIDs.Length != 2 || + string.IsNullOrWhiteSpace(runAndCallIDs[0]) || // run ID + string.IsNullOrWhiteSpace(runAndCallIDs[1]) || // call ID + (runId is not null && runId != runAndCallIDs[0])) + { + continue; + } + + runId = runAndCallIDs[0]; + (toolOutputs ??= []).Add(new(runAndCallIDs[1], frc.Result?.ToString() ?? string.Empty)); + } + } + + return runId; + } +} + +/// Provides extension methods for working with s. +internal static class OpenAIClientExtensions2 +{ + /// Key into AdditionalProperties used to store a strict option. + private const string StrictKey = "strictJsonSchema"; + + /// Gets the default OpenAI endpoint. + internal static Uri DefaultOpenAIEndpoint { get; } = new("https://api.openai.com/v1"); + + /// Gets a for "developer". + internal static ChatRole ChatRoleDeveloper { get; } = new ChatRole("developer"); + + /// + /// Gets the JSON schema transformer cache conforming to OpenAI strict / structured output restrictions per + /// https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#supported-schemas. + /// + internal static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } = new(new() + { + DisallowAdditionalProperties = true, + ConvertBooleanSchemas = true, + MoveDefaultKeywordToDescription = true, + RequireAllProperties = true, + TransformSchemaNode = (ctx, node) => + { + // Move content from common but unsupported properties to description. In particular, we focus on properties that + // the AIJsonUtilities schema generator might produce and/or that are explicitly mentioned in the OpenAI documentation. + + if (node is JsonObject schemaObj) + { + StringBuilder? additionalDescription = null; + + ReadOnlySpan unsupportedProperties = + [ + // Produced by AIJsonUtilities but not in allow list at https://platform.openai.com/docs/guides/structured-outputs#supported-properties: + "contentEncoding", "contentMediaType", "not", + + // Explicitly mentioned at https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#key-ordering as being unsupported with some models: + "minLength", "maxLength", "pattern", "format", + "minimum", "maximum", "multipleOf", + "patternProperties", + "minItems", "maxItems", + + // Explicitly mentioned at https://learn.microsoft.com/azure/ai-services/openai/how-to/structured-outputs?pivots=programming-language-csharp&tabs=python-secure%2Cdotnet-entra-id#unsupported-type-specific-keywords + // as being unsupported with Azure OpenAI: + "unevaluatedProperties", "propertyNames", "minProperties", "maxProperties", + "unevaluatedItems", "contains", "minContains", "maxContains", "uniqueItems", + ]; + + foreach (string propName in unsupportedProperties) + { + if (schemaObj[propName] is { } propNode) + { + _ = schemaObj.Remove(propName); + AppendLine(ref additionalDescription, propName, propNode); + } + } + + if (additionalDescription is not null) + { + schemaObj["description"] = schemaObj["description"] is { } descriptionNode && descriptionNode.GetValueKind() == JsonValueKind.String ? + $"{descriptionNode.GetValue()}{Environment.NewLine}{additionalDescription}" : + additionalDescription.ToString(); + } + + return node; + + static void AppendLine(ref StringBuilder? sb, string propName, JsonNode propNode) + { + sb ??= new(); + + if (sb.Length > 0) + { + _ = sb.AppendLine(); + } + + _ = sb.Append(propName).Append(": ").Append(propNode); + } + } + + return node; + }, + }); + + // TODO: Once we're ready to rely on C# 14 features, add an extension property ChatOptions.Strict. + + /// Gets whether the properties specify that strict schema handling is desired. + internal static bool? HasStrict(IReadOnlyDictionary? additionalProperties) => + additionalProperties?.TryGetValue(StrictKey, out object? strictObj) is true && + strictObj is bool strictValue ? + strictValue : null; + + /// Extracts from an the parameters and strictness setting for use with OpenAI's APIs. + internal static BinaryData ToOpenAIFunctionParameters(AIFunction aiFunction, bool? strict) + { + // Perform any desirable transformations on the function's JSON schema, if it'll be used in a strict setting. + JsonElement jsonSchema = strict is true ? + StrictSchemaTransformCache.GetOrCreateTransformedSchema(aiFunction) : + aiFunction.JsonSchema; + + // Roundtrip the schema through the ToolJson model type to remove extra properties + // and force missing ones into existence, then return the serialized UTF8 bytes as BinaryData. + var tool = JsonSerializer.Deserialize(jsonSchema, OpenAIJsonContext.Default.ToolJson)!; + var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool, OpenAIJsonContext.Default.ToolJson)); + + return functionParameters; + } + + /// Used to create the JSON payload for an OpenAI tool description. + internal sealed class ToolJson + { + [JsonPropertyName("type")] + public string Type { get; set; } = "object"; + + [JsonPropertyName("required")] + public HashSet Required { get; set; } = []; + + [JsonPropertyName("properties")] + public Dictionary Properties { get; set; } = []; + + [JsonPropertyName("additionalProperties")] + public bool AdditionalProperties { get; set; } + } +} + +/// Source-generated JSON type information for use by all OpenAI implementations. +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, + UseStringEnumConverter = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true)] +[JsonSerializable(typeof(OpenAIClientExtensions2.ToolJson))] +[JsonSerializable(typeof(IDictionary))] +[JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(JsonElement))] +internal sealed partial class OpenAIJsonContext : JsonSerializerContext; diff --git a/dotnet/samples/GettingStarted/External/README.md b/dotnet/samples/GettingStarted/External/README.md new file mode 100644 index 0000000000..7e1ab9d811 --- /dev/null +++ b/dotnet/samples/GettingStarted/External/README.md @@ -0,0 +1,3 @@ +# Proposed External references + +This directory contains proposed external references for the Agent Framework that are not yet available and may be considered and added in the future. \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/GettingStarted.csproj b/dotnet/samples/GettingStarted/GettingStarted.csproj index 5e096b6b48..3389787893 100644 --- a/dotnet/samples/GettingStarted/GettingStarted.csproj +++ b/dotnet/samples/GettingStarted/GettingStarted.csproj @@ -36,13 +36,15 @@ + + true + + + Always - - Always - diff --git a/dotnet/samples/GettingStarted/Providers/ChatClientAgent_With_OpenAIResponseChatCompletion.cs b/dotnet/samples/GettingStarted/Providers/ChatClientAgent_With_OpenAIResponseChatCompletion.cs index d454ab1a09..231f9a2d7a 100644 --- a/dotnet/samples/GettingStarted/Providers/ChatClientAgent_With_OpenAIResponseChatCompletion.cs +++ b/dotnet/samples/GettingStarted/Providers/ChatClientAgent_With_OpenAIResponseChatCompletion.cs @@ -24,6 +24,7 @@ public sealed class ChatClientAgent_With_OpenAIResponsesChatCompletion(ITestOutp public async Task RunWithChatCompletion(bool useConversationIdThread) { // Get the chat client to use for the agent. +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. using var chatClient = new OpenAIClient(TestConfiguration.OpenAI.ApiKey) .GetOpenAIResponseClient(TestConfiguration.OpenAI.ChatModelId) .AsIChatClient(); @@ -39,6 +40,7 @@ public sealed class ChatClientAgent_With_OpenAIResponsesChatCompletion(ITestOutp RawRepresentationFactory = (_) => new ResponseCreationOptions() { StoredOutputEnabled = useConversationIdThread } } }); +#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. // Start a new thread for the agent conversation based on the type. AgentThread thread = agent.GetNewThread(); diff --git a/dotnet/samples/GettingStarted/Tools/Files/groceries.txt b/dotnet/samples/GettingStarted/Resources/groceries.txt similarity index 100% rename from dotnet/samples/GettingStarted/Tools/Files/groceries.txt rename to dotnet/samples/GettingStarted/Resources/groceries.txt diff --git a/dotnet/samples/GettingStarted/Steps/Step02_ChatClientAgent_UsingTools.cs b/dotnet/samples/GettingStarted/Steps/Step02_ChatClientAgent_UsingFunctionTools.cs similarity index 94% rename from dotnet/samples/GettingStarted/Steps/Step02_ChatClientAgent_UsingTools.cs rename to dotnet/samples/GettingStarted/Steps/Step02_ChatClientAgent_UsingFunctionTools.cs index 603476503e..c54b0bf07a 100644 --- a/dotnet/samples/GettingStarted/Steps/Step02_ChatClientAgent_UsingTools.cs +++ b/dotnet/samples/GettingStarted/Steps/Step02_ChatClientAgent_UsingFunctionTools.cs @@ -6,7 +6,11 @@ using Microsoft.Extensions.AI.Agents; namespace Steps; -public sealed class Step02_ChatClientAgent_UsingTools(ITestOutputHelper output) : AgentSample(output) +/// +/// This sample demonstrates how to use a with function tools. +/// It includes examples of both streaming and non-streaming agent interactions. +/// +public sealed class Step02_ChatClientAgent_UsingFunctionTools(ITestOutputHelper output) : AgentSample(output) { [Theory] [InlineData(ChatClientProviders.AzureOpenAI)] diff --git a/dotnet/samples/GettingStarted/Steps/Step03_ChatClientAgent_UsingCodeInterpreterTools.cs b/dotnet/samples/GettingStarted/Steps/Step03_ChatClientAgent_UsingCodeInterpreterTools.cs new file mode 100644 index 0000000000..d87219d53b --- /dev/null +++ b/dotnet/samples/GettingStarted/Steps/Step03_ChatClientAgent_UsingCodeInterpreterTools.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text; +using Azure.AI.Agents.Persistent; +using Azure.Identity; +using Microsoft.Extensions.AI.Agents; +using Microsoft.Shared.Samples; +using OpenAI.Assistants; +using OpenAI.Files; + +namespace Steps; + +/// +/// Demonstrates how to use with code interpreter tools and file references. +/// Shows uploading files to different providers and using them with code interpreter capabilities to analyze data and generate responses. +/// +public sealed class Step03_ChatClientAgent_UsingCodeInterpreterTools(ITestOutputHelper output) : AgentSample(output) +{ + [Theory] + [InlineData(ChatClientProviders.OpenAIAssistant)] + [InlineData(ChatClientProviders.AzureAIAgentsPersistent)] + public async Task RunningWithFileReferenceAsync(ChatClientProviders provider) + { + var codeInterpreterTool = new NewHostedCodeInterpreterTool(); + codeInterpreterTool.FileIds.Add(await UploadFileAsync("Resources/groceries.txt", provider)); + + var agentOptions = new ChatClientAgentOptions + { + Name = "HelpfulAssistant", + Instructions = "You are a helpful assistant.", + ChatOptions = new() { Tools = [codeInterpreterTool] } + }; + + using var chatClient = await base.GetChatClientAsync(provider, agentOptions); + + ChatClientAgent agent = new(chatClient, agentOptions); + + var thread = agent.GetNewThread(); + + // Prompt which allows to verify that the data was processed from file correctly and current datetime is returned. + const string Prompt = "Calculate the total number of items, identify the most frequently purchased item and return the result with today's datetime."; + + var assistantOutput = new StringBuilder(); + var codeInterpreterOutput = new StringBuilder(); + + await foreach (var update in agent.RunStreamingAsync(Prompt, thread)) + { + if (!string.IsNullOrWhiteSpace(update.Text)) + { + assistantOutput.Append(update.Text); + } + + if (update.RawRepresentation is not null) + { + codeInterpreterOutput.Append(GetCodeInterpreterOutput(update.RawRepresentation, provider)); + } + } + + Console.WriteLine("Assistant Output:"); + Console.WriteLine(assistantOutput.ToString()); + + Console.WriteLine("Code interpreter Output:"); + Console.WriteLine(codeInterpreterOutput.ToString()); + } + + #region private + + /// + /// Uploads a file to the specified chat client provider and returns the file ID. + /// + /// Path to the file to be uploaded. + /// The chat client provider to use for uploading the file. + /// The ID of the uploaded file. + /// + private async Task UploadFileAsync(string filePath, ChatClientProviders provider) + { + switch (provider) + { + case ChatClientProviders.OpenAIAssistant: + var fileClient = new OpenAIFileClient(TestConfiguration.OpenAI.ApiKey); + OpenAIFile openAIFileInfo = await fileClient.UploadFileAsync(filePath, FileUploadPurpose.Assistants); + + return openAIFileInfo.Id; + case ChatClientProviders.AzureAIAgentsPersistent: + var persistentAgentsClient = new PersistentAgentsClient(TestConfiguration.AzureAI.Endpoint, new AzureCliCredential()); + PersistentAgentFileInfo persistentAgentFileInfo = await persistentAgentsClient.Files.UploadFileAsync(filePath, PersistentAgentFilePurpose.Agents); + + return persistentAgentFileInfo.Id; + + default: + throw new NotSupportedException($"Client provider {provider} is not supported."); + } + } + + /// + /// Depending on the provider, different strategies are used to extract the code interpreter output from the response raw representation. + /// + /// Raw representation of the response containing code interpreter output. + /// Provider of the chat client that is used to determine how to extract the output. + /// The code interpreter output as a string. + private static string? GetCodeInterpreterOutput(object rawRepresentation, ChatClientProviders provider) + { +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + switch (provider) + { + case ChatClientProviders.OpenAIAssistant + when rawRepresentation is OpenAI.Assistants.RunStepDetailsUpdate stepDetails: + return $"{stepDetails.CodeInterpreterInput}{string.Join( + string.Empty, + stepDetails.CodeInterpreterOutputs.SelectMany(l => l.Logs) + )}"; +#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + + case ChatClientProviders.AzureAIAgentsPersistent + when rawRepresentation is Azure.AI.Agents.Persistent.RunStepDetailsUpdate stepDetails: + return $"{stepDetails.CodeInterpreterInput}{string.Join( + string.Empty, + stepDetails.CodeInterpreterOutputs.OfType().SelectMany(l => l.Logs) + )}"; + } + + return null; + } + + #endregion +} diff --git a/dotnet/samples/GettingStarted/Tools/Abstractions/NewHostedCodeInterpreterTool.cs b/dotnet/samples/GettingStarted/Tools/Abstractions/NewHostedCodeInterpreterTool.cs deleted file mode 100644 index 8f7ec5dedc..0000000000 --- a/dotnet/samples/GettingStarted/Tools/Abstractions/NewHostedCodeInterpreterTool.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Extensions.AI; - -namespace GettingStarted.Tools.Abstractions; - -/// -/// Proposal for abstraction updates based on the common code interpreter tool properties. -/// Based on the decision, the abstraction can be updated in M.E.AI or specific SDK if some properties are not common. -/// -public class NewHostedCodeInterpreterTool : HostedCodeInterpreterTool -{ - public IList? FileIds { get; set; } -} diff --git a/dotnet/samples/GettingStarted/Tools/CodeInterpreterTools.cs b/dotnet/samples/GettingStarted/Tools/CodeInterpreterTools.cs deleted file mode 100644 index a3444a48cd..0000000000 --- a/dotnet/samples/GettingStarted/Tools/CodeInterpreterTools.cs +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text; -using Azure.AI.Agents.Persistent; -using GettingStarted.Tools.Abstractions; -using GettingStarted.Tools.Extensions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.AI.Agents; -using OpenAI; -using OpenAI.Files; - -#pragma warning disable OPENAI001 - -namespace GettingStarted.Tools; - -public sealed class CodeInterpreterTools(ITestOutputHelper output) : AgentSample(output) -{ - [Theory] - [InlineData(ChatClientProviders.OpenAIAssistant)] - [InlineData(ChatClientProviders.AzureAIAgentsPersistent)] - public async Task RunningWithFileReferenceAsync(ChatClientProviders provider) - { - var fileId = await UploadTestFileAsync(provider); - - var chatOptions = new ChatOptions() - { - Tools = [new NewHostedCodeInterpreterTool { FileIds = [fileId] }] - }; - - var agentOptions = new ChatClientAgentOptions - { - Name = "HelpfulAssistant", - Instructions = "You are a helpful assistant.", - // Transformation is required until the abstraction will be added to either SDK provider or M.E.AI and - // implementations will handle new properties/classes. - ChatOptions = TransformChatOptions(chatOptions, provider) - }; - - using var chatClient = await base.GetChatClientAsync(provider, agentOptions); - - ChatClientAgent agent = new(chatClient, agentOptions); - - var thread = agent.GetNewThread(); - - // Prompt which allows to verify that the data was processed from file correctly and current datetime is returned. - const string Prompt = "Calculate the total number of items, identify the most frequently puchased item and return the result with today's datetime."; - - var assistantOutput = new StringBuilder(); - var codeInterpreterOutput = new StringBuilder(); - - await foreach (var update in agent.RunStreamingAsync(Prompt, thread)) - { - if (!string.IsNullOrWhiteSpace(update.Text)) - { - assistantOutput.Append(update.Text); - } - else if (update.RawRepresentation is not null) - { - ProcessRawRepresentationOutput(update.RawRepresentation, codeInterpreterOutput, provider); - } - } - - Console.WriteLine("Assistant Output:"); - Console.WriteLine(assistantOutput.ToString()); - - Console.WriteLine("Code interpreter Output:"); - Console.WriteLine(codeInterpreterOutput.ToString()); - } - - #region private - - /// - /// This method creates a raw representation of tools from newly proposed abstractions, so underlying SDKs can work with it. - /// Once the tool abstraction is added to either SDK provider or M.E.AI, this method can be removed. - /// The logic under each provider case should go to related SDK. - /// - private static ChatOptions TransformChatOptions(ChatOptions chatOptions, ChatClientProviders provider) - { - return provider switch - { - ChatClientProviders.OpenAIAssistant => chatOptions.ToOpenAIAssistantChatOptions(), - ChatClientProviders.AzureAIAgentsPersistent => chatOptions.ToAzureAIPersistentAgentChatOptions(), - _ => chatOptions - }; - } - - private Task UploadTestFileAsync(ChatClientProviders provider) - { - var filePath = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "Tools", "Files", "groceries.txt")); - return UploadFileAsync(filePath, provider); - } - - private async Task UploadFileAsync(string filePath, ChatClientProviders provider) - { - switch (provider) - { - case ChatClientProviders.OpenAIAssistant: - var fileClient = GetOpenAIFileClient(); - OpenAIFile openAIFileInfo = await fileClient.UploadFileAsync(filePath, FileUploadPurpose.Assistants); - - return openAIFileInfo.Id; - case ChatClientProviders.AzureAIAgentsPersistent: - PersistentAgentFileInfo persistentAgentFileInfo = await AzureAIPersistentAgentsClient.Files.UploadFileAsync(filePath, PersistentAgentFilePurpose.Agents); - - return persistentAgentFileInfo.Id; - - default: - throw new NotSupportedException($"Client provider {provider} is not supported."); - } - } - - private static void ProcessRawRepresentationOutput(object rawRepresentation, StringBuilder builder, ChatClientProviders provider) - { - switch (provider) - { - case ChatClientProviders.OpenAIAssistant: - if (rawRepresentation is OpenAI.Assistants.RunStepDetailsUpdate openAIStepDetailsUpdate) - { - builder.Append(openAIStepDetailsUpdate.CodeInterpreterInput); - builder.Append(string.Join(string.Empty, openAIStepDetailsUpdate.CodeInterpreterOutputs.SelectMany(l => l.Logs))); - } - - break; - case ChatClientProviders.AzureAIAgentsPersistent: - if (rawRepresentation is Azure.AI.Agents.Persistent.RunStepDetailsUpdate persistentAgentStepDetailsUpdate) - { - builder.Append(persistentAgentStepDetailsUpdate.CodeInterpreterInput); - builder.Append(string.Join(string.Empty, persistentAgentStepDetailsUpdate - .CodeInterpreterOutputs - .OfType().SelectMany(l => l.Logs))); - } - - break; - } - } - - private OpenAIFileClient GetOpenAIFileClient() => OpenAIClient.GetOpenAIFileClient(); - - #endregion -} diff --git a/dotnet/samples/GettingStarted/Tools/Extensions/AzureAIPersistentAgentChatOptionsExtensions.cs b/dotnet/samples/GettingStarted/Tools/Extensions/AzureAIPersistentAgentChatOptionsExtensions.cs deleted file mode 100644 index 1398403935..0000000000 --- a/dotnet/samples/GettingStarted/Tools/Extensions/AzureAIPersistentAgentChatOptionsExtensions.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure.AI.Agents.Persistent; -using GettingStarted.Tools.Abstractions; -using Microsoft.Extensions.AI; - -namespace GettingStarted.Tools.Extensions; - -/// -/// conversion for Azure AI Persistent Agent. -/// When abstraction is in place, this logic should go to Azure AI Persistent Agents SDK. -/// -internal static class AzureAIPersistentAgentChatOptionsExtensions -{ - public static ChatOptions ToAzureAIPersistentAgentChatOptions(this ChatOptions chatOptions) - { - var fileIds = new List(); - - foreach (var tool in chatOptions.Tools!) - { - if (tool is NewHostedCodeInterpreterTool codeInterpreterTool && - codeInterpreterTool.FileIds is { Count: > 0 }) - { - fileIds.AddRange(codeInterpreterTool.FileIds); - } - } - - if (fileIds.Count > 0) - { - var toolResources = new Azure.AI.Agents.Persistent.ToolResources() - { - CodeInterpreter = new Azure.AI.Agents.Persistent.CodeInterpreterToolResource() - }; - - foreach (var fileId in fileIds) - { - toolResources.CodeInterpreter.FileIds.Add(fileId); - } - - var threadAndRunOptions = new ThreadAndRunOptions { ToolResources = toolResources }; - - chatOptions.RawRepresentationFactory = (_) => threadAndRunOptions; - } - - return chatOptions; - } -} diff --git a/dotnet/samples/GettingStarted/Tools/Extensions/OpenAIAssistantChatOptionsExtensions.cs b/dotnet/samples/GettingStarted/Tools/Extensions/OpenAIAssistantChatOptionsExtensions.cs deleted file mode 100644 index bdba77517d..0000000000 --- a/dotnet/samples/GettingStarted/Tools/Extensions/OpenAIAssistantChatOptionsExtensions.cs +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using GettingStarted.Tools.Abstractions; -using Microsoft.Extensions.AI; -using OpenAI.Assistants; - -#pragma warning disable OPENAI001 - -namespace GettingStarted.Tools.Extensions; - -/// -/// conversion for OpenAI Assistants. -/// When abstraction is in place, this logic should go to OpenAI Assistants SDK. -/// -internal static class OpenAIAssistantChatOptionsExtensions -{ - public static ChatOptions ToOpenAIAssistantChatOptions(this ChatOptions chatOptions) - { - // File references can be added on message attachment level only and not on code interpreter tool definition level. - // Message attachment content should be non-empty. - var threadInitializationMessage = new ThreadInitializationMessage(MessageRole.User, [MessageContent.FromText("attachments")]); - var toolDefinitions = new List(); - - foreach (var tool in chatOptions.Tools!) - { - if (tool is NewHostedCodeInterpreterTool codeInterpreterTool) - { - var codeInterpreterToolDefinition = new CodeInterpreterToolDefinition(); - toolDefinitions.Add(codeInterpreterToolDefinition); - - if (codeInterpreterTool.FileIds is { Count: > 0 }) - { - foreach (var fileId in codeInterpreterTool.FileIds) - { - threadInitializationMessage.Attachments.Add(new(fileId, [codeInterpreterToolDefinition])); - } - } - } - } - - var runCreationOptions = new RunCreationOptions(); - - runCreationOptions.AdditionalMessages.Add(threadInitializationMessage); - - chatOptions.RawRepresentationFactory = (_) => runCreationOptions; - - return chatOptions; - } -} diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.AzureAI/PersistentAgentsChatClient.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.AzureAI/PersistentAgentsChatClient.cs index 21c383071a..fad92f7696 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.AzureAI/PersistentAgentsChatClient.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.AzureAI/PersistentAgentsChatClient.cs @@ -251,6 +251,7 @@ public sealed partial class PersistentAgentsChatClient : IChatClient if (options.Tools is { Count: > 0 } tools) { List toolDefinitions = []; + ToolResources? toolResources = null; // If the caller has provided any tool overrides, we'll assume they don't want to use the agent's tools. // But if they haven't, the only way we can provide our tools is via an override, whereas we'd really like to @@ -287,6 +288,15 @@ public sealed partial class PersistentAgentsChatClient : IChatClient case HostedCodeInterpreterTool: toolDefinitions.Add(new CodeInterpreterToolDefinition()); + + // Once available, HostedCodeInterpreterTool.FileIds property will be used instead of the AdditionalProperties. + if (tool.AdditionalProperties.TryGetValue("fileIds", out object? fileIdsObject) && fileIdsObject is IEnumerable fileIds) + { + foreach (var fileId in fileIds) + { + (toolResources ??= new() { CodeInterpreter = new() }).CodeInterpreter.FileIds.Add(fileId); + } + } break; case HostedWebSearchTool webSearch when webSearch.AdditionalProperties?.TryGetValue("connectionId", out object? connectionId) is true: @@ -299,6 +309,11 @@ public sealed partial class PersistentAgentsChatClient : IChatClient { runOptions.OverrideTools = toolDefinitions; } + + if (toolResources is not null) + { + runOptions.ToolResources = toolResources; + } } // Store the tool mode, if relevant. diff --git a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs index 0bdfd9091a..bce26d9e21 100644 --- a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs +++ b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs @@ -12,6 +12,8 @@ using OpenAI; using OpenAI.Responses; using Shared.IntegrationTests; +#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + namespace OpenAIResponse.IntegrationTests; public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture