Address review findings on AI inference argument handling

- REST backend: only drop the JSON body fields an explicit call parameter
  actually overrides, so unrelated payload options survive.
- Accept every built-in integer encoding and booleans for call parameters
  instead of rejecting the whole Invoke for an Int64 max_tokens.
- Reject a null String Variant rather than inserting null into the
  non-nullable backend parameter dictionary.
- Run the invariant-formatting regression under a comma-decimal culture.
This commit is contained in:
Marc
2026-09-21 10:28:59 +02:00
parent 4e2c1f4af1
commit f8754f6189
5 changed files with 192 additions and 22 deletions
+7 -5
View File
@@ -220,11 +220,13 @@ current server implementation supports inline payloads only. It returns
backend with no request body.
Both built-in backends support `temperature` (0 through 2), `max_tokens`
(positive integer), and `top_p` (0 through 1). Parameters are converted using
the invariant culture, forwarded through synchronous, asynchronous, fallback,
and oversized-transfer execution, and rejected when malformed or unsupported.
For the REST chat-completions backend, explicit OPC UA parameters override the
corresponding fields in the JSON request body.
(positive integer), and `top_p` (0 through 1). Parameter values may be supplied
as strings, as any built-in integer or floating-point type, or as booleans, and
are converted using the invariant culture. They are forwarded through
synchronous, asynchronous, fallback, and oversized-transfer execution, and
rejected when malformed or unsupported. For the REST chat-completions backend,
an explicit OPC UA parameter overrides the corresponding field in the JSON
request body; body fields without a matching parameter are left unchanged.
The server publishes `SpecificationVersion` from the source-generated
`Opc.Ua.AI.ModelVersions.Target` constant, which is derived from the AI
@@ -373,9 +373,11 @@ namespace Opc.Ua.AI.Inference
foreach (JsonProperty property in document.RootElement.EnumerateObject())
{
if (property.NameEquals("temperature") ||
property.NameEquals("max_tokens") ||
property.NameEquals("top_p"))
// Only the fields an explicit call parameter overrides are dropped so
// that payload values the caller did not override survive unchanged.
if ((parameters.Temperature is not null && property.NameEquals("temperature")) ||
(parameters.MaxTokens is not null && property.NameEquals("max_tokens")) ||
(parameters.TopP is not null && property.NameEquals("top_p")))
{
continue;
}
+63 -3
View File
@@ -328,22 +328,41 @@ namespace Opc.Ua.AI.Server
private static bool TryFormatParameterValue(Variant value, out string formatted)
{
formatted = string.Empty;
// Each accessor matches exactly one built-in type, so every encoding a
// client may legitimately choose has to be probed individually.
if (value.TryGetValue(out string text))
{
if (text is null)
{
return false;
}
formatted = text;
return true;
}
if (value.TryGetValue(out double number))
{
if (!double.IsFinite(number))
{
return false;
}
formatted = number.ToString(CultureInfo.InvariantCulture);
return double.IsFinite(number);
return true;
}
if (value.TryGetValue(out float single))
{
if (!float.IsFinite(single))
{
return false;
}
formatted = single.ToString(CultureInfo.InvariantCulture);
return float.IsFinite(single);
return true;
}
if (value.TryGetValue(out int integer))
@@ -358,7 +377,48 @@ namespace Opc.Ua.AI.Server
return true;
}
formatted = string.Empty;
if (value.TryGetValue(out long signed64))
{
formatted = signed64.ToString(CultureInfo.InvariantCulture);
return true;
}
if (value.TryGetValue(out ulong unsigned64))
{
formatted = unsigned64.ToString(CultureInfo.InvariantCulture);
return true;
}
if (value.TryGetValue(out short signed16))
{
formatted = signed16.ToString(CultureInfo.InvariantCulture);
return true;
}
if (value.TryGetValue(out ushort unsigned16))
{
formatted = unsigned16.ToString(CultureInfo.InvariantCulture);
return true;
}
if (value.TryGetValue(out sbyte signed8))
{
formatted = signed8.ToString(CultureInfo.InvariantCulture);
return true;
}
if (value.TryGetValue(out byte unsigned8))
{
formatted = unsigned8.ToString(CultureInfo.InvariantCulture);
return true;
}
if (value.TryGetValue(out bool flag))
{
formatted = flag ? "true" : "false";
return true;
}
return false;
}
+90 -11
View File
@@ -42,7 +42,7 @@ namespace Opc.Ua.AI.Tests
{
[TestFixture]
[Category("AIModelManagement")]
[SetCulture("en-us")]
[SetCulture("de-DE")]
[SetUICulture("en-us")]
public sealed class InferenceArgumentTests
{
@@ -131,6 +131,82 @@ namespace Opc.Ua.AI.Tests
Assert.That(result.ServiceResult.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
}
[Test]
public async Task IntegerParametersAreAcceptedInEveryNumericEncodingAsync()
{
var backend = new FakeInferenceBackend("primary");
using AINodeManager nm = await CreateAsync(backend).ConfigureAwait(false);
DeploymentState deployment = nm.FindPredefinedNode<DeploymentState>(nm.PrimaryDeploymentId);
InvokeMethodStateResult result = await deployment.Invoke!.OnCallAsync!(
nm.SystemContext,
deployment.Invoke,
nm.PrimaryDeploymentId,
ByteString.From(Encoding.UTF8.GetBytes("{}")),
string.Empty,
"application/json",
[
Parameter("max_tokens", Variant.From(64L)),
Parameter("top_k", Variant.From((ushort)40)),
Parameter("seed", Variant.From(7UL)),
Parameter("width", Variant.From((short)512)),
Parameter("level", Variant.From((byte)3)),
Parameter("offset", Variant.From((sbyte)-2)),
Parameter("stream", Variant.From(false))
],
5000,
CancellationToken.None).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result.ServiceResult), Is.True);
IReadOnlyDictionary<string, string> forwarded = backend.Requests[0].Parameters;
Assert.Multiple(() =>
{
Assert.That(forwarded["max_tokens"], Is.EqualTo("64"));
Assert.That(forwarded["top_k"], Is.EqualTo("40"));
Assert.That(forwarded["seed"], Is.EqualTo("7"));
Assert.That(forwarded["width"], Is.EqualTo("512"));
Assert.That(forwarded["level"], Is.EqualTo("3"));
Assert.That(forwarded["offset"], Is.EqualTo("-2"));
Assert.That(forwarded["stream"], Is.EqualTo("false"));
});
}
[Test]
public async Task ParametersWithUnusableValuesAreRejectedAsync()
{
var backend = new FakeInferenceBackend("primary");
using AINodeManager nm = await CreateAsync(backend).ConfigureAwait(false);
DeploymentState deployment = nm.FindPredefinedNode<DeploymentState>(nm.PrimaryDeploymentId);
var rejected = new Dictionary<string, Variant>
{
["null string"] = Variant.From((string)null!),
["not a number"] = Variant.From(double.NaN),
["not a scalar"] = Variant.From(s_nonScalarValue)
};
foreach (KeyValuePair<string, Variant> candidate in rejected)
{
InvokeMethodStateResult result = await deployment.Invoke!.OnCallAsync!(
nm.SystemContext,
deployment.Invoke,
nm.PrimaryDeploymentId,
ByteString.From(Encoding.UTF8.GetBytes("{}")),
string.Empty,
"application/json",
[Parameter("temperature", candidate.Value)],
5000,
CancellationToken.None).ConfigureAwait(false);
Assert.That(
result.ServiceResult.StatusCode,
Is.EqualTo(StatusCodes.BadInvalidArgument),
candidate.Key + " must not reach the backend.");
}
Assert.That(backend.Requests, Is.Empty);
}
[Test]
public async Task RootSpecificationVersionUsesTheGeneratedModelVersionAsync()
{
@@ -150,19 +226,20 @@ namespace Opc.Ua.AI.Tests
{
return
[
new Opc.Ua.KeyValuePair
{
Key = new QualifiedName("temperature"),
Value = Variant.From(0.25)
},
new Opc.Ua.KeyValuePair
{
Key = new QualifiedName("max_tokens"),
Value = Variant.From(64)
}
Parameter("temperature", Variant.From(0.25)),
Parameter("max_tokens", Variant.From(64))
];
}
private static Opc.Ua.KeyValuePair Parameter(string name, Variant value)
{
return new Opc.Ua.KeyValuePair
{
Key = new QualifiedName(name),
Value = value
};
}
private static Task<AINodeManager> CreateAsync(IInferenceBackend backend)
{
return AIServerTestHarness.CreateAsync(
@@ -194,5 +271,7 @@ namespace Opc.Ua.AI.Tests
return ValueTask.FromResult(new BackendProbe { Reachable = true });
}
}
private static readonly int[] s_nonScalarValue = [1, 2];
}
}
@@ -230,6 +230,33 @@ namespace Opc.Ua.AI.Tests
});
}
[Test]
public async Task InvokeOverridesOnlyTheSuppliedParameterFields()
{
using var http = Http(
out StubHttpMessageHandler handler,
Json(HttpStatusCode.OK, """{"choices":[{"finish_reason":"stop"}]}"""));
using var backend = Backend(Options(), http);
await backend.InvokeAsync(
Request(
"""{"messages":[],"temperature":0.9,"max_tokens":5,"top_p":0.1}""",
parameters: new Dictionary<string, string> { ["temperature"] = "0.25" }),
CancellationToken.None).ConfigureAwait(false);
using JsonDocument document = JsonDocument.Parse(handler.Requests[0].Body!);
JsonElement request = document.RootElement;
Assert.Multiple(() =>
{
Assert.That(request.GetProperty("temperature").GetDouble(), Is.EqualTo(0.25d));
Assert.That(
request.GetProperty("max_tokens").GetInt32(),
Is.EqualTo(5),
"payload fields the caller did not override must survive.");
Assert.That(request.GetProperty("top_p").GetDouble(), Is.EqualTo(0.1d));
});
}
[Test]
public async Task InvokeReportsZeroUsageWhenEndpointReturnsNone()
{