mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-16 21:02:51 +08:00
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:
+7
-5
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user