using AIStudio.Tools.ToolCallingSystem; using AIStudio.Tools.ToolCallingSystem.Harness; namespace AIStudio.Provider.OpenAI; /// /// Speaks the OpenAI Responses wire format for the tool calling loop. /// /// /// Function calls arrive as output items and results go back as function call output items, /// correlated by call ID. Unlike Chat Completions, the whole output of a round has to be sent /// back for the next one, reasoning items included, or the API refuses to continue. /// public sealed class ResponsesToolCallingAdapter(Model chatModel, bool isReasoningModel, IList baseInput, IDictionary apiParameters, IList providerTools, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools, Func> executeRequestAsync) : IToolCallingProviderAdapter { private const string ENCRYPTED_REASONING_INCLUDE = "reasoning.encrypted_content"; private readonly List internalItems = []; private ResponsesResponse? lastResponse; /// /// The tools offered to the model: the provider-native ones plus our local functions. /// /// /// A provider-native tool whose type collides with one of our function names is dropped /// because the model could not tell the two apart. /// private readonly IList effectiveProviderTools = BuildEffectiveProviderTools(providerTools, runnableTools); /// public async Task ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default) { var requestInput = new List(baseInput); if (finalResponseInstruction is not null && requestInput.FirstOrDefault() is TextMessage systemPrompt) { requestInput[0] = systemPrompt with { Content = $"{systemPrompt.Content}{Environment.NewLine}{Environment.NewLine}{finalResponseInstruction}", }; } requestInput.AddRange(this.internalItems); var response = await executeRequestAsync(new ResponsesAPIRequest { Model = chatModel.Id, Input = requestInput, Stream = false, Store = false, Tools = includeTools ? this.effectiveProviderTools : [], AdditionalApiParameters = isReasoningModel ? IncludeEncryptedReasoning(apiParameters) : apiParameters, }, token); if (response is null) return null; this.lastResponse = response; return new ToolCallingRound( response.GetTextOutput(), response.GetThinkingOutput(), response.GetFunctionCalls() .Select(call => new ToolCallingRequestedCall( call.CallId ?? string.Empty, call.Name ?? string.Empty, call.Arguments ?? string.Empty, !string.IsNullOrWhiteSpace(call.Name) && ToolExecutor.IsValidArgumentsJson(call.Arguments))) .ToList(), response.GetSources()); } /// public void RecordAssistantTurn() { if (this.lastResponse is null) return; // Every output item, not just the function calls: the API rejects a continuation whose // reasoning items are missing. foreach (var outputItem in this.lastResponse.Output) this.internalItems.Add(outputItem); } /// /// /// The Responses API has no error flag on a function call output, so a failure travels in the /// output like any other result. /// public void RecordToolResult(string callId, string content, bool isError = false) => this.internalItems.Add(new ResponsesFunctionCallOutputItem { CallId = callId, Output = content, }); /// /// Request encrypted reasoning content without replacing any additional output data selected by the user. /// /// /// Tool rounds use stateless Responses requests. OpenAI requires encrypted reasoning items in that mode /// so that the complete output can be passed back with the tool result on the next round.

/// Only a reasoning model gets this, because the include is meaningless for every other one. ///
private static IDictionary IncludeEncryptedReasoning(IDictionary apiParameters) { var result = new Dictionary(apiParameters); var includeKey = result.Keys.FirstOrDefault(key => key.Equals("include", StringComparison.OrdinalIgnoreCase)); if (includeKey is null) { result["include"] = new List { ENCRYPTED_REASONING_INCLUDE }; return result; } // // Whatever the user put there stays. A value we cannot read as a list is kept as the // single entry it appears to be: the API rejecting the user's own value is the honest // outcome, while dropping it here would hide the mistake behind a request that works. // var includedOutput = result[includeKey] switch { IEnumerable values => values.ToList(), var value => [value], }; if (!includedOutput.Any(value => string.Equals(value as string, ENCRYPTED_REASONING_INCLUDE, StringComparison.Ordinal))) includedOutput.Add(ENCRYPTED_REASONING_INCLUDE); result[includeKey] = includedOutput; return result; } private static IList BuildEffectiveProviderTools(IList providerTools, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools) { var localFunctionNames = runnableTools .Select(x => x.Definition.Function.Name) .ToHashSet(StringComparer.Ordinal); return providerTools .Where(x => x is not ProviderTool providerTool || !localFunctionNames.Contains(providerTool.Type)) .Concat(runnableTools.Select(x => (object)ProviderToolAdapters.ToResponsesTool(x.Definition))) .ToList(); } }