Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions IntelligenceX.Tests/Program.OpenAI.NativeToolSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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() {
Expand All @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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");

Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ namespace IntelligenceX.OpenAI.Native;

internal sealed partial class OpenAINativeTransport {
private enum ToolWireFormat {
FunctionNestedParameters,
FunctionNestedInputSchema,
CustomParameters,
CustomInputSchema,
FunctionFlatParameters,
Expand All @@ -24,7 +26,7 @@ private enum ToolSchemaKey {
}

private JsonObject BuildRequestBody(string model, IReadOnlyList<JsonObject> 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);
Expand Down Expand Up @@ -99,11 +101,19 @@ private JsonObject BuildRequestBody(string model, IReadOnlyList<JsonObject> 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));
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Exception>();
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) {
Expand Down Expand Up @@ -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[<n>].parameters
// - tools[<n>].input_schema
// - tools[<n>].function.parameters
// - tools[<n>].function.input_schema
// - tools.<n>.parameters (seen in some variants)
// - tools.<n>.input_schema
// - tools.<n>.function.parameters
// - tools.<n>.function.input_schema
fallbackKey = ToolSchemaKey.Parameters;
if (string.IsNullOrWhiteSpace(message)) {
return false;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading