From ca38d15f4c6c9bd63927089113bf7982dd2c94c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Mon, 9 Feb 2026 11:04:46 +0100 Subject: [PATCH] OpenAI native: default to nested function tools; broaden fallback --- .../Program.OpenAI.NativeToolSchema.cs | 43 +++++++- .../Native/OpenAINativeTransport.Requests.cs | 33 +++++- ...penAINativeTransport.ToolSchemaFallback.cs | 104 +++++++++++++++++- .../OpenAI/Native/OpenAINativeTransport.cs | 95 +++++----------- 4 files changed, 196 insertions(+), 79 deletions(-) diff --git a/IntelligenceX.Tests/Program.OpenAI.NativeToolSchema.cs b/IntelligenceX.Tests/Program.OpenAI.NativeToolSchema.cs index c1513166b..4c8edfbcf 100644 --- a/IntelligenceX.Tests/Program.OpenAI.NativeToolSchema.cs +++ b/IntelligenceX.Tests/Program.OpenAI.NativeToolSchema.cs @@ -14,6 +14,12 @@ private static void TestNativeToolSchemaFallbackDetectsIndex() { fallback = DetectFallbackKind("Unrecognized request argument: tools[12].input_schema"); AssertEqual("Parameters", fallback, "fallbackKind"); + + fallback = DetectFallbackKind("Unknown parameter: 'tools[0].function.parameters'."); + AssertEqual("InputSchema", fallback, "fallbackKind"); + + fallback = DetectFallbackKind("Unknown field tools[9].function.input_schema"); + AssertEqual("Parameters", fallback, "fallbackKind"); } private static void TestNativeToolSchemaFallbackDetectsDotIndex() { @@ -22,6 +28,12 @@ private static void TestNativeToolSchemaFallbackDetectsDotIndex() { fallback = DetectFallbackKind("Unknown parameter tools.0.input_schema"); AssertEqual("Parameters", fallback, "fallbackKind"); + + fallback = DetectFallbackKind("Unknown field tools.3.function.parameters"); + AssertEqual("InputSchema", fallback, "fallbackKind"); + + fallback = DetectFallbackKind("Unknown parameter tools.1.function.input_schema"); + AssertEqual("Parameters", fallback, "fallbackKind"); } private static void TestNativeToolChoiceSerializationMatchesWireFormat() { @@ -35,6 +47,7 @@ private static void TestNativeToolChoiceSerializationMatchesWireFormat() { AssertNotNull(method, "SerializeToolChoice method"); var customParameters = Enum.Parse(enumType!, "CustomParameters"); + var functionNestedParameters = Enum.Parse(enumType!, "FunctionNestedParameters"); var functionFlatParameters = Enum.Parse(enumType!, "FunctionFlatParameters"); var forced = ToolChoice.Custom("test-tool"); @@ -43,9 +56,15 @@ private static void TestNativeToolChoiceSerializationMatchesWireFormat() { var functionObj = functionChoice as JsonObject; AssertNotNull(functionObj, "function tool_choice as JsonObject"); AssertEqual("function", functionObj!.GetString("type") ?? string.Empty, "type"); - var function = functionObj.GetObject("function"); - AssertNotNull(function, "function object"); - AssertEqual("test-tool", function!.GetString("name") ?? string.Empty, "name"); + AssertEqual("test-tool", functionObj.GetString("name") ?? string.Empty, "name"); + + var nestedChoice = method!.Invoke(null, new object?[] { forced, functionNestedParameters }); + var nestedObj = nestedChoice as JsonObject; + AssertNotNull(nestedObj, "nested function tool_choice as JsonObject"); + AssertEqual("function", nestedObj!.GetString("type") ?? string.Empty, "type"); + var nestedFunction = nestedObj.GetObject("function"); + AssertNotNull(nestedFunction, "nested function object"); + AssertEqual("test-tool", nestedFunction!.GetString("name") ?? string.Empty, "name"); var customChoice = (JsonObject)method!.Invoke(null, new object?[] { forced, customParameters })!; AssertEqual("custom", customChoice.GetString("type") ?? string.Empty, "type"); @@ -192,6 +211,8 @@ private static void TestNativeToolSchemaSerializationSwitchesFieldName() { var customParameters = Enum.Parse(enumType!, "CustomParameters"); var customInputSchema = Enum.Parse(enumType!, "CustomInputSchema"); + var functionNestedParameters = Enum.Parse(enumType!, "FunctionNestedParameters"); + var functionNestedInputSchema = Enum.Parse(enumType!, "FunctionNestedInputSchema"); var functionFlatParameters = Enum.Parse(enumType!, "FunctionFlatParameters"); var functionFlatInputSchema = Enum.Parse(enumType!, "FunctionFlatInputSchema"); @@ -216,6 +237,22 @@ private static void TestNativeToolSchemaSerializationSwitchesFieldName() { AssertEqual(true, withFunctionFlatInputSchema.TryGetValue("name", out _), "name present"); AssertEqual(false, withFunctionFlatInputSchema.TryGetValue("parameters", out _), "parameters absent"); AssertEqual(true, withFunctionFlatInputSchema.TryGetValue("input_schema", out _), "input_schema present"); + + var withFunctionNestedParameters = (JsonObject)serialize!.Invoke(null, new object?[] { tool, functionNestedParameters })!; + AssertEqual("function", withFunctionNestedParameters.GetString("type") ?? string.Empty, "type"); + var nestedFunctionParams = withFunctionNestedParameters.GetObject("function"); + AssertNotNull(nestedFunctionParams, "function nested object"); + AssertEqual("test-tool", nestedFunctionParams!.GetString("name") ?? string.Empty, "name"); + AssertEqual(true, nestedFunctionParams.TryGetValue("parameters", out _), "parameters present"); + AssertEqual(false, nestedFunctionParams.TryGetValue("input_schema", out _), "input_schema absent"); + + var withFunctionNestedInputSchema = (JsonObject)serialize!.Invoke(null, new object?[] { tool, functionNestedInputSchema })!; + AssertEqual("function", withFunctionNestedInputSchema.GetString("type") ?? string.Empty, "type"); + var nestedFunctionSchema = withFunctionNestedInputSchema.GetObject("function"); + AssertNotNull(nestedFunctionSchema, "function nested object"); + AssertEqual("test-tool", nestedFunctionSchema!.GetString("name") ?? string.Empty, "name"); + AssertEqual(false, nestedFunctionSchema.TryGetValue("parameters", out _), "parameters absent"); + AssertEqual(true, nestedFunctionSchema.TryGetValue("input_schema", out _), "input_schema present"); } private static string DetectFallbackKind(string message) { diff --git a/IntelligenceX/Providers/OpenAI/Native/OpenAINativeTransport.Requests.cs b/IntelligenceX/Providers/OpenAI/Native/OpenAINativeTransport.Requests.cs index 792f009c4..b64a58b57 100644 --- a/IntelligenceX/Providers/OpenAI/Native/OpenAINativeTransport.Requests.cs +++ b/IntelligenceX/Providers/OpenAI/Native/OpenAINativeTransport.Requests.cs @@ -12,6 +12,8 @@ namespace IntelligenceX.OpenAI.Native; internal sealed partial class OpenAINativeTransport { private enum ToolWireFormat { + FunctionNestedParameters, + FunctionNestedInputSchema, CustomParameters, CustomInputSchema, FunctionFlatParameters, @@ -24,7 +26,7 @@ private enum ToolSchemaKey { } private JsonObject BuildRequestBody(string model, IReadOnlyList messages, string sessionId, ChatOptions options, - ToolWireFormat toolWireFormat = ToolWireFormat.CustomParameters) { + ToolWireFormat toolWireFormat = ToolWireFormat.FunctionNestedParameters) { var input = new JsonArray(); foreach (var message in messages) { input.Add(message); @@ -99,11 +101,19 @@ private JsonObject BuildRequestBody(string model, IReadOnlyList mess private static object SerializeToolChoice(ToolChoice choice, ToolWireFormat toolWireFormat) { if (string.Equals(choice.Type, "custom", StringComparison.OrdinalIgnoreCase)) { var name = choice.Name ?? string.Empty; - var isFunctionWireFormat = toolWireFormat == ToolWireFormat.FunctionFlatParameters || + var isFunctionWireFormat = toolWireFormat == ToolWireFormat.FunctionNestedParameters || + toolWireFormat == ToolWireFormat.FunctionNestedInputSchema || + toolWireFormat == ToolWireFormat.FunctionFlatParameters || toolWireFormat == ToolWireFormat.FunctionFlatInputSchema; if (isFunctionWireFormat) { - // When falling back to function-style tools, forced tool choice must also be expressed as function-style. - // Using the standard OpenAI schema: { type: "function", function: { name: "..." } }. + // Forced tool choice must match the wire format used for tool definitions. + if (toolWireFormat == ToolWireFormat.FunctionFlatParameters || + toolWireFormat == ToolWireFormat.FunctionFlatInputSchema) { + return new JsonObject() + .Add("type", "function") + .Add("name", name); + } + return new JsonObject() .Add("type", "function") .Add("function", new JsonObject().Add("name", name)); @@ -126,6 +136,21 @@ private static object SerializeToolChoice(ToolChoice choice, ToolWireFormat tool private static JsonObject SerializeToolDefinition(ToolDefinition tool, ToolWireFormat toolWireFormat) { switch (toolWireFormat) { + case ToolWireFormat.FunctionNestedInputSchema: + case ToolWireFormat.FunctionNestedParameters: { + var function = new JsonObject() + .Add("name", tool.Name); + if (!string.IsNullOrWhiteSpace(tool.Description)) { + function.Add("description", tool.Description); + } + if (tool.Parameters is not null) { + function.Add(toolWireFormat == ToolWireFormat.FunctionNestedInputSchema ? "input_schema" : "parameters", tool.Parameters); + } + + return new JsonObject() + .Add("type", "function") + .Add("function", function); + } case ToolWireFormat.CustomInputSchema: case ToolWireFormat.CustomParameters: { var obj = new JsonObject() diff --git a/IntelligenceX/Providers/OpenAI/Native/OpenAINativeTransport.ToolSchemaFallback.cs b/IntelligenceX/Providers/OpenAI/Native/OpenAINativeTransport.ToolSchemaFallback.cs index 8e3377677..fce669874 100644 --- a/IntelligenceX/Providers/OpenAI/Native/OpenAINativeTransport.ToolSchemaFallback.cs +++ b/IntelligenceX/Providers/OpenAI/Native/OpenAINativeTransport.ToolSchemaFallback.cs @@ -4,6 +4,72 @@ namespace IntelligenceX.OpenAI.Native; internal sealed partial class OpenAINativeTransport { + private static bool IsToolSchemaUnknownParameter(Exception? ex) { + if (ex is null) { + return false; + } + + // The transport typically throws InvalidOperationException for server validation errors, + // but callers can wrap it (including AggregateException). Unwrap defensively. + var pending = new Stack(); + pending.Push(ex); + while (pending.Count > 0) { + var current = pending.Pop(); + + if (current is OpenAINativeErrorResponseException native) { + if (native.StatusCode == System.Net.HttpStatusCode.BadRequest || + (int)native.StatusCode == 422 /* Unprocessable Entity (not available in older TFMs) */) { + if (!string.IsNullOrWhiteSpace(native.ErrorCode) && + native.ErrorCode!.IndexOf("unknown_parameter", StringComparison.OrdinalIgnoreCase) >= 0) { + var param = native.ErrorParam; + if (!string.IsNullOrWhiteSpace(param) && + param!.TrimStart().StartsWith("tools", StringComparison.OrdinalIgnoreCase)) { + return true; + } + } + } + } + + if (current is InvalidOperationException ioe) { + // Prefer structured diagnostic fields when available (so behavior doesn't depend on localized strings). + if (ioe.Data?["openai:native_transport"] is bool marker && marker) { + var code = ioe.Data?["openai:error_code"] as string; + var param = ioe.Data?["openai:error_param"] as string; + if (!string.IsNullOrWhiteSpace(code) && + code!.IndexOf("unknown_parameter", StringComparison.OrdinalIgnoreCase) >= 0 && + !string.IsNullOrWhiteSpace(param) && + param!.TrimStart().StartsWith("tools", StringComparison.OrdinalIgnoreCase)) { + return true; + } + } + + // Fallback for cases where we only have a message string. + var msg = ioe.Message; + if (!string.IsNullOrWhiteSpace(msg) && + (msg!.IndexOf("unknown parameter", StringComparison.OrdinalIgnoreCase) >= 0 || + msg.IndexOf("unknown field", StringComparison.OrdinalIgnoreCase) >= 0 || + msg.IndexOf("unrecognized request argument", StringComparison.OrdinalIgnoreCase) >= 0) && + msg.IndexOf("tools", StringComparison.OrdinalIgnoreCase) >= 0) { + return true; + } + } + + if (current is AggregateException agg) { + foreach (var inner in agg.InnerExceptions) { + if (inner is not null) { + pending.Push(inner); + } + } + } + + if (current.InnerException is not null) { + pending.Push(current.InnerException); + } + } + + return false; + } + private static bool TryGetToolSchemaKeyFallback(Exception? ex, out ToolSchemaKey fallbackKey) { fallbackKey = ToolSchemaKey.Parameters; if (ex is null) { @@ -104,8 +170,12 @@ private static bool TryGetToolSchemaKeyFallback(string? message, out ToolSchemaK // Server error messages vary; the stable signal is the field path that was rejected: // - tools[].parameters // - tools[].input_schema + // - tools[].function.parameters + // - tools[].function.input_schema // - tools..parameters (seen in some variants) // - tools..input_schema + // - tools..function.parameters + // - tools..function.input_schema fallbackKey = ToolSchemaKey.Parameters; if (string.IsNullOrWhiteSpace(message)) { return false; @@ -140,11 +210,22 @@ private static bool TryGetToolSchemaKeyFallback(string? message, out ToolSchemaK i++; if (TryReadIdentifier(text, i, out var identifier)) { - if (string.Equals(identifier, "parameters", StringComparison.OrdinalIgnoreCase)) { + if (string.Equals(identifier, "function", StringComparison.OrdinalIgnoreCase)) { + var j = i + identifier.Length; + if (j < text.Length && text[j] == '.' && TryReadIdentifier(text, j + 1, out var inner)) { + if (string.Equals(inner, "parameters", StringComparison.OrdinalIgnoreCase)) { + fallbackKey = ToolSchemaKey.InputSchema; + return true; + } + if (string.Equals(inner, "input_schema", StringComparison.OrdinalIgnoreCase)) { + fallbackKey = ToolSchemaKey.Parameters; + return true; + } + } + } else if (string.Equals(identifier, "parameters", StringComparison.OrdinalIgnoreCase)) { fallbackKey = ToolSchemaKey.InputSchema; return true; - } - if (string.Equals(identifier, "input_schema", StringComparison.OrdinalIgnoreCase)) { + } else if (string.Equals(identifier, "input_schema", StringComparison.OrdinalIgnoreCase)) { fallbackKey = ToolSchemaKey.Parameters; return true; } @@ -176,11 +257,22 @@ private static bool TryGetToolSchemaKeyFallback(string? message, out ToolSchemaK i++; if (TryReadIdentifier(text, i, out var identifier)) { - if (string.Equals(identifier, "parameters", StringComparison.OrdinalIgnoreCase)) { + if (string.Equals(identifier, "function", StringComparison.OrdinalIgnoreCase)) { + var j = i + identifier.Length; + if (j < text.Length && text[j] == '.' && TryReadIdentifier(text, j + 1, out var inner)) { + if (string.Equals(inner, "parameters", StringComparison.OrdinalIgnoreCase)) { + fallbackKey = ToolSchemaKey.InputSchema; + return true; + } + if (string.Equals(inner, "input_schema", StringComparison.OrdinalIgnoreCase)) { + fallbackKey = ToolSchemaKey.Parameters; + return true; + } + } + } else if (string.Equals(identifier, "parameters", StringComparison.OrdinalIgnoreCase)) { fallbackKey = ToolSchemaKey.InputSchema; return true; - } - if (string.Equals(identifier, "input_schema", StringComparison.OrdinalIgnoreCase)) { + } else if (string.Equals(identifier, "input_schema", StringComparison.OrdinalIgnoreCase)) { fallbackKey = ToolSchemaKey.Parameters; return true; } diff --git a/IntelligenceX/Providers/OpenAI/Native/OpenAINativeTransport.cs b/IntelligenceX/Providers/OpenAI/Native/OpenAINativeTransport.cs index 2c5ddd6f3..ed8650903 100644 --- a/IntelligenceX/Providers/OpenAI/Native/OpenAINativeTransport.cs +++ b/IntelligenceX/Providers/OpenAI/Native/OpenAINativeTransport.cs @@ -378,79 +378,42 @@ async Task SendWithWireFormatAsync(ToolWireFormat toolWireFormat, Json .ConfigureAwait(false); } - ToolSchemaKey retryKey; - attempted.Add(ToolWireFormat.CustomParameters); - try { - return await SendWithWireFormatAsync(ToolWireFormat.CustomParameters, body).ConfigureAwait(false); - } catch (OperationCanceledException) { - throw; - } catch (Exception ex) { - lastError = ExceptionDispatchInfo.Capture(ex); - if (!TryGetToolSchemaKeyFallback(ex, out retryKey)) { - throw; - } - } - - // Retry with the alternate custom-tool schema key first. - var retryFormat = retryKey == ToolSchemaKey.InputSchema ? ToolWireFormat.CustomInputSchema : ToolWireFormat.CustomParameters; - if (!attempted.Add(retryFormat)) { - lastError!.Throw(); - } + var candidates = new[] { + // Standard OpenAI schema for Responses API: tools[].function.{name,parameters} + ToolWireFormat.FunctionNestedParameters, + ToolWireFormat.FunctionNestedInputSchema, + + // Observed legacy variants: tools[].name at top-level + ToolWireFormat.FunctionFlatParameters, + ToolWireFormat.FunctionFlatInputSchema, + + // Observed ChatGPT-native custom tool schema + ToolWireFormat.CustomParameters, + ToolWireFormat.CustomInputSchema + }; - ToolSchemaKey retryKey2; - try { - return await SendWithWireFormatAsync(retryFormat).ConfigureAwait(false); - } catch (OperationCanceledException) { - throw; - } catch (Exception ex) { - lastError = ExceptionDispatchInfo.Capture(ex); - if (!TryGetToolSchemaKeyFallback(ex, out retryKey2)) { - throw; + for (var i = 0; i < candidates.Length; i++) { + var candidate = candidates[i]; + if (!attempted.Add(candidate)) { + continue; } - } - - // Some ChatGPT native variants don't accept custom tool schema fields at all. Fall back to function-style tools. - var initialFunctionFormat = retryKey2 == ToolSchemaKey.InputSchema - ? ToolWireFormat.FunctionFlatInputSchema - : ToolWireFormat.FunctionFlatParameters; - if (!attempted.Add(initialFunctionFormat)) { - lastError!.Throw(); - } - ToolSchemaKey functionRetryKey; - try { - return await SendWithWireFormatAsync(initialFunctionFormat).ConfigureAwait(false); - } catch (OperationCanceledException) { - throw; - } catch (Exception ex) { - lastError = ExceptionDispatchInfo.Capture(ex); - if (!TryGetToolSchemaKeyFallback(ex, out functionRetryKey)) { + try { + // The initial request body is already built by the caller using the default wire format. + var usePrebuilt = candidate == ToolWireFormat.FunctionNestedParameters; + return await SendWithWireFormatAsync(candidate, usePrebuilt ? body : null).ConfigureAwait(false); + } catch (OperationCanceledException) { throw; + } catch (Exception ex) { + lastError = ExceptionDispatchInfo.Capture(ex); + if (!IsToolSchemaUnknownParameter(ex)) { + throw; + } } } - // Retry function-style request with the alternate key, ensuring we don't resend the same format. - var functionRetryFormat = functionRetryKey == ToolSchemaKey.InputSchema - ? ToolWireFormat.FunctionFlatInputSchema - : ToolWireFormat.FunctionFlatParameters; - if (functionRetryFormat == initialFunctionFormat) { - functionRetryFormat = functionRetryFormat == ToolWireFormat.FunctionFlatInputSchema - ? ToolWireFormat.FunctionFlatParameters - : ToolWireFormat.FunctionFlatInputSchema; - } - - if (!attempted.Add(functionRetryFormat)) { - lastError!.Throw(); - } - - try { - return await SendWithWireFormatAsync(functionRetryFormat).ConfigureAwait(false); - } catch (OperationCanceledException) { - throw; - } catch (Exception ex) { - lastError = ExceptionDispatchInfo.Capture(ex); - throw; - } + lastError?.Throw(); + throw new InvalidOperationException("Tool schema fallback exhausted without capturing an exception."); } private void HandleStreamEvent(JsonObject evt, StringBuilder delta, ref string? status, ref JsonObject? completedResponse,