From 9e5aee766ca7b1bb30e2c4c11e5452bb6b1e8efa Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Mon, 2 Nov 2026 18:42:28 +0100 Subject: [PATCH] Added tests which guard the JSON of providers, the runtime, and services before source generation --- .../QdrantEdgeClientImplementation.cs | 32 ++-- .../Tools/ERIClient/ERIClientBase.cs | 2 +- .../Tools/Services/RustService.APIKeys.cs | 12 +- .../Tools/Services/RustService.App.cs | 10 +- .../Tools/Services/RustService.Clipboard.cs | 2 +- .../Tools/Services/RustService.Databases.cs | 17 ++- .../Tools/Services/RustService.Enterprise.cs | 2 +- .../Tools/Services/RustService.Events.cs | 2 +- .../Tools/Services/RustService.FileSystem.cs | 24 +-- .../Tools/Services/RustService.Image.cs | 8 +- .../Tools/Services/RustService.Log.cs | 4 +- .../Tools/Services/RustService.Media.cs | 6 +- .../Tools/Services/RustService.Secrets.cs | 12 +- .../Tools/Services/RustService.Share.cs | 11 +- .../Tools/Services/RustService.Shortcuts.cs | 12 +- .../Tools/Services/RustService.Tokenizer.cs | 16 +- .../Tools/Services/RustService.Updates.cs | 2 +- .../Tools/Services/RustService.cs | 5 +- .../Fixtures/anthropic-messages-request.json | 1 + .../Fixtures/chat-completion-request.json | 1 + .../Fixtures/responses-request.json | 1 + app/Tests/Serialization/JsonContractTests.cs | 6 + app/Tests/Serialization/JsonDefaultsTests.cs | 25 ++- .../Serialization/JsonGoldenFileTests.cs | 2 +- app/Tests/Serialization/JsonGoldenRequest.cs | 15 ++ .../Serialization/JsonGoldenRequestTests.cs | 33 ++++ app/Tests/Serialization/JsonSampleRequests.cs | 143 ++++++++++++++++++ .../Serialization/JsonTestRoots.Providers.cs | 94 ++++++++++++ .../Serialization/JsonTestRoots.Runtime.cs | 103 +++++++++++++ .../Serialization/JsonTestRoots.Services.cs | 38 +++++ app/Tests/Serialization/JsonTestRoots.cs | 29 +++- 31 files changed, 577 insertions(+), 93 deletions(-) create mode 100644 app/Tests/Serialization/Fixtures/anthropic-messages-request.json create mode 100644 app/Tests/Serialization/Fixtures/chat-completion-request.json create mode 100644 app/Tests/Serialization/Fixtures/responses-request.json create mode 100644 app/Tests/Serialization/JsonGoldenRequest.cs create mode 100644 app/Tests/Serialization/JsonGoldenRequestTests.cs create mode 100644 app/Tests/Serialization/JsonSampleRequests.cs create mode 100644 app/Tests/Serialization/JsonTestRoots.Providers.cs create mode 100644 app/Tests/Serialization/JsonTestRoots.Runtime.cs create mode 100644 app/Tests/Serialization/JsonTestRoots.Services.cs diff --git a/app/MindWork AI Studio/Tools/Databases/VectorStore/QdrantEdgeClientImplementation.cs b/app/MindWork AI Studio/Tools/Databases/VectorStore/QdrantEdgeClientImplementation.cs index 140fcc346..6eec27bc9 100644 --- a/app/MindWork AI Studio/Tools/Databases/VectorStore/QdrantEdgeClientImplementation.cs +++ b/app/MindWork AI Studio/Tools/Databases/VectorStore/QdrantEdgeClientImplementation.cs @@ -215,31 +215,35 @@ public sealed class QdrantEdgeClientImplementation( return client; } - // ReSharper disable NotAccessedPositionalProperty.Local - private sealed record EnsureVectorStoreRequest(string StoreName, string DataSourceName, int VectorSize); + // + // The requests are internal rather than private, so that the JSON context of the runtime can name them. It + // generates the contracts they get written with, and a context sees no private type of another class. + // + // ReSharper disable NotAccessedPositionalProperty.Global + internal sealed record EnsureVectorStoreRequest(string StoreName, string DataSourceName, int VectorSize); - private sealed record CheckVectorStoreRequest(string StoreName); + internal sealed record CheckVectorStoreRequest(string StoreName); - private sealed record InsertEmbeddingRequest(string StoreName, IReadOnlyList Points); + internal sealed record InsertEmbeddingRequest(string StoreName, IReadOnlyList Points); /// The only points the search may return, or null for the whole store. - private sealed record SearchEmbeddingRequest(string StoreName, IReadOnlyList Vector, int MaxMatches, IReadOnlyList? PointIds, IReadOnlyList HiddenChunks); + internal sealed record SearchEmbeddingRequest(string StoreName, IReadOnlyList Vector, int MaxMatches, IReadOnlyList? PointIds, IReadOnlyList HiddenChunks); - private sealed record RetrieveVectorsRequest(string StoreName, IReadOnlyList PointIds); + internal sealed record RetrieveVectorsRequest(string StoreName, IReadOnlyList PointIds); - private sealed record DeleteEmbeddingByFileRequest(string StoreName, string FilePath, string ParentFileId, IReadOnlyCollection KeepPointIds); + internal sealed record DeleteEmbeddingByFileRequest(string StoreName, string FilePath, string ParentFileId, IReadOnlyCollection KeepPointIds); - private sealed record RelocateEmbeddingFileRequest(string StoreName, string ParentFileId, string AbsolutePath, string FileName, string RelativePath, string Fingerprint); + internal sealed record RelocateEmbeddingFileRequest(string StoreName, string ParentFileId, string AbsolutePath, string FileName, string RelativePath, string Fingerprint); - private sealed record OptimizeVectorStoreRequest(string StoreName); - - private sealed record DeleteVectorStoreRequest(string StoreName); + internal sealed record OptimizeVectorStoreRequest(string StoreName); + + internal sealed record DeleteVectorStoreRequest(string StoreName); /// /// Preparing a backup needs nothing but the request itself. /// - private sealed record PrepareBackupRequest; + internal sealed record PrepareBackupRequest; - private sealed record CopyStoreForBackupRequest(string BackupId, string StoreName); - // ReSharper restore NotAccessedPositionalProperty.Local + internal sealed record CopyStoreForBackupRequest(string BackupId, string StoreName); + // ReSharper restore NotAccessedPositionalProperty.Global } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs b/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs index 1a11a59f9..7d8f0cbe8 100644 --- a/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs +++ b/app/MindWork AI Studio/Tools/ERIClient/ERIClientBase.cs @@ -9,7 +9,7 @@ public abstract class ERIClientBase(IERIDataSource dataSource) : IDisposable { protected readonly IERIDataSource DataSource = dataSource; - protected static readonly JsonSerializerOptions JSON_OPTIONS = new() + internal static readonly JsonSerializerOptions JSON_OPTIONS = new() { WriteIndented = true, AllowTrailingCommas = true, diff --git a/app/MindWork AI Studio/Tools/Services/RustService.APIKeys.cs b/app/MindWork AI Studio/Tools/Services/RustService.APIKeys.cs index b842196fd..befcca6f3 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.APIKeys.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.APIKeys.cs @@ -63,7 +63,7 @@ public sealed partial class RustService private async Task GetAPIKeyByKey(string secretKey, bool isTrying) { var secretRequest = new SelectSecretRequest(secretKey, Environment.UserName, isTrying); - var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, JSON_OPTIONS); if (!result.IsSuccessStatusCode) { if(!isTrying) @@ -71,7 +71,7 @@ public sealed partial class RustService return new RequestedSecret(false, new EncryptedText(string.Empty), TB("Failed to get the API key due to an API issue.")); } - var secret = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var secret = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); if (secret.Success) this.logger!.LogDebug($"Successfully retrieved the API key for '{secretKey}'."); else if (isTrying) @@ -106,14 +106,14 @@ public sealed partial class RustService private async Task StoreEncryptedAPIKeyByKey(string secretKey, EncryptedText encryptedKey) { var request = new StoreSecretRequest(secretKey, Environment.UserName, encryptedKey); - var result = await this.http.PostAsJsonAsync("/secrets/store", request, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/secrets/store", request, JSON_OPTIONS); if (!result.IsSuccessStatusCode) { this.logger!.LogError($"Failed to store the API key for '{secretKey}' due to an API issue: '{result.StatusCode}'"); return new StoreSecretResponse(false, TB("Failed to store the API key due to an API issue.")); } - var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var state = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); if (!state.Success) this.logger!.LogError($"Failed to store the API key for '{secretKey}': '{state.Issue}'"); else @@ -149,14 +149,14 @@ public sealed partial class RustService private async Task DeleteAPIKeyByKey(string secretKey, bool isTrying = false) { var request = new SelectSecretRequest(secretKey, Environment.UserName, false); - var result = await this.http.PostAsJsonAsync("/secrets/delete", request, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/secrets/delete", request, JSON_OPTIONS); if (!result.IsSuccessStatusCode) { this.logger!.LogError($"Failed to delete the API key for '{secretKey}' due to an API issue: '{result.StatusCode}'"); return new DeleteSecretResponse{Success = false, WasEntryFound = false, Issue = TB("Failed to delete the API key due to an API issue.")}; } - var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var state = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); if (!state.Success && !isTrying) this.logger!.LogError($"Failed to delete the API key for '{secretKey}': '{state.Issue}'"); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.App.cs b/app/MindWork AI Studio/Tools/Services/RustService.App.cs index 45db32648..c6c0f64d7 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.App.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.App.cs @@ -122,7 +122,7 @@ public sealed partial class RustService public async Task GetRuntimeInfo() { - var response = await this.http.GetFromJsonAsync("/system/runtime/info", this.jsonRustSerializerOptions); + var response = await this.http.GetFromJsonAsync("/system/runtime/info", JSON_OPTIONS); return response; } @@ -140,7 +140,7 @@ public sealed partial class RustService return false; } - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (result is null || !result.Success) { this.logger?.LogError("Failed to exit the app: {Error}", result?.ErrorMessage ?? "Unknown error"); @@ -177,7 +177,7 @@ public sealed partial class RustService return false; } - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (result is null || !result.Success) { this.logger?.LogError("Failed to restart the app: {Error}", result?.ErrorMessage ?? "Unknown error"); @@ -203,7 +203,7 @@ public sealed partial class RustService { try { - var response = await this.http.PostAsJsonAsync("/app/interface-size", request, this.jsonRustSerializerOptions); + var response = await this.http.PostAsJsonAsync("/app/interface-size", request, JSON_OPTIONS); var result = await this.ReadInterfaceSizeResponse(response, "set the interface size"); if (result is not null) this.logger?.LogInformation("Set the interface sizes of {ScreenCount} screens. AI Studio shows with the size '{Size}' at {CurrentPercent} % and a zoom of {Zoom:0.###} on the screen '{ScreenKey}'.", request.Screens.Count, result.Size, result.CurrentPercent, result.Zoom, result.ScreenKey); @@ -243,7 +243,7 @@ public sealed partial class RustService return null; } - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (result is null || !result.Success) { this.logger?.LogError("Failed to {Action}: {Error}", action, result?.ErrorMessage ?? "Unknown error"); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Clipboard.cs b/app/MindWork AI Studio/Tools/Services/RustService.Clipboard.cs index 773f379f0..00121d7e1 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Clipboard.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Clipboard.cs @@ -28,7 +28,7 @@ public sealed partial class RustService return; } - var state = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var state = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (!state.Success) { this.logger!.LogError("Failed to copy the text to the clipboard."); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs b/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs index 406861b8d..08eb67600 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Databases.cs @@ -24,7 +24,7 @@ public sealed partial class RustService using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(45)); - var databaseInfo = await this.http.GetFromJsonAsync(infoPath, this.jsonRustSerializerOptions, cts.Token); + var databaseInfo = await this.http.GetFromJsonAsync(infoPath, JSON_OPTIONS, cts.Token); return databaseInfo ?? unavailableFactory("The database information response was empty."); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -52,10 +52,10 @@ public sealed partial class RustService using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromMinutes(5)); - using var response = await this.http.PostAsJsonAsync(path, request, this.jsonRustSerializerOptions, cts.Token); + using var response = await this.http.PostAsJsonAsync(path, request, JSON_OPTIONS, cts.Token); response.EnsureSuccessStatusCode(); - var operation = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions, cts.Token); + var operation = await response.Content.ReadFromJsonAsync(JSON_OPTIONS, cts.Token); if (operation is not { Success: true }) throw CreateDatabaseException(operation?.Issue, operation?.IssueCode, $"The {databaseName} operation failed."); } @@ -65,10 +65,10 @@ public sealed partial class RustService using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromMinutes(5)); - using var response = await this.http.PostAsJsonAsync(path, request, this.jsonRustSerializerOptions, cts.Token); + using var response = await this.http.PostAsJsonAsync(path, request, JSON_OPTIONS, cts.Token); response.EnsureSuccessStatusCode(); - var operation = await response.Content.ReadFromJsonAsync>(this.jsonRustSerializerOptions, cts.Token); + var operation = await response.Content.ReadFromJsonAsync>(JSON_OPTIONS, cts.Token); if (operation is not { Success: true }) throw CreateDatabaseException(operation?.Issue, operation?.IssueCode, $"The {databaseName} query failed."); @@ -94,7 +94,10 @@ public sealed partial class RustService }; } - private sealed record DatabaseOperationResponse(bool Success, string Issue, string IssueCode); + // + // Internal rather than private, so that the JSON context of the runtime can name them: + // + internal sealed record DatabaseOperationResponse(bool Success, string Issue, string IssueCode); - private sealed record DatabaseQueryResponse(bool Success, string Issue, string IssueCode, TResult? Data); + internal sealed record DatabaseQueryResponse(bool Success, string Issue, string IssueCode, TResult? Data); } diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs b/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs index f11556450..988ecacde 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Enterprise.cs @@ -39,7 +39,7 @@ public sealed partial class RustService throw new HttpRequestException($"Failed to query the enterprise configurations: '{result.StatusCode}'"); } - var configs = await result.Content.ReadFromJsonAsync>(this.jsonRustSerializerOptions); + var configs = await result.Content.ReadFromJsonAsync>(JSON_OPTIONS); if (configs is null) throw new InvalidOperationException("Failed to parse the enterprise configurations from Rust."); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Events.cs b/app/MindWork AI Studio/Tools/Services/RustService.Events.cs index ca93739d8..4818d60a6 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Events.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Events.cs @@ -35,7 +35,7 @@ public partial class RustService continue; // Deserialize the Tauri event: - var tauriEvent = JsonSerializer.Deserialize(line, this.jsonRustSerializerOptions); + var tauriEvent = JsonSerializer.Deserialize(line, JSON_OPTIONS); // Forward relevant events to the message bus: if (tauriEvent != default && tauriEvent.EventType diff --git a/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs b/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs index c9e1c81a1..9fe4ed89d 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.FileSystem.cs @@ -16,10 +16,10 @@ public sealed partial class RustService var startDirectory = initialDirectory is null ? null : PathTools.FindNearestExistingPath(initialDirectory); var result = startDirectory is null ? await this.http.PostAsync($"/select/directory?title={encodedTitle}", null) - : await this.http.PostAsJsonAsync($"/select/directory?title={encodedTitle}", new PreviousDirectory(startDirectory), this.jsonRustSerializerOptions); + : await this.http.PostAsJsonAsync($"/select/directory?title={encodedTitle}", new PreviousDirectory(startDirectory), JSON_OPTIONS); if (result.IsSuccessStatusCode) - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + return await result.Content.ReadFromJsonAsync(JSON_OPTIONS); this.logger!.LogError("Failed to select a directory: '{StatusCode}'", result.StatusCode); return new DirectorySelectionResponse(true, string.Empty); @@ -63,9 +63,9 @@ public sealed partial class RustService Filter = FileTypes.ToFileDialogFilter(filter) }; - var result = await this.http.PostAsJsonAsync("/select/file", payload, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/select/file", payload, JSON_OPTIONS); if (result.IsSuccessStatusCode) - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + return await result.Content.ReadFromJsonAsync(JSON_OPTIONS); this.logger!.LogError("Failed to select a file: '{StatusCode}'", result.StatusCode); return new FileSelectionResponse(true, string.Empty); @@ -87,9 +87,9 @@ public sealed partial class RustService Filter = FileTypes.ToFileDialogFilter(filter) }; - var result = await this.http.PostAsJsonAsync("/select/files", payload, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/select/files", payload, JSON_OPTIONS); if (result.IsSuccessStatusCode) - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + return await result.Content.ReadFromJsonAsync(JSON_OPTIONS); this.logger!.LogError("Failed to select files: '{StatusCode}'", result.StatusCode); return new FilesSelectionResponse(true, Array.Empty()); @@ -118,9 +118,9 @@ public sealed partial class RustService Filter = FileTypes.ToFileDialogFilter(filter) }; - var result = await this.http.PostAsJsonAsync("/save/file", payload, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/save/file", payload, JSON_OPTIONS); if (result.IsSuccessStatusCode) - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + return await result.Content.ReadFromJsonAsync(JSON_OPTIONS); this.logger!.LogError("Failed to select a file for writing operation: '{StatusCode}'", result.StatusCode); return new FileSaveResponse(true, string.Empty); @@ -133,7 +133,7 @@ public sealed partial class RustService HttpResponseMessage result; try { - result = await this.http.PostAsJsonAsync("/open/path", new OpenPathRequest(path), this.jsonRustSerializerOptions); + result = await this.http.PostAsJsonAsync("/open/path", new OpenPathRequest(path), JSON_OPTIONS); } catch (HttpRequestException e) { @@ -154,7 +154,7 @@ public sealed partial class RustService return new OpenPathResponse(false, string.Format(TB("The runtime file manager endpoint returned '{0}'."), result.StatusCode)); } - var response = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var response = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); var normalizedResponse = response.Success ? response : new OpenPathResponse(false, string.IsNullOrWhiteSpace(response.Issue) ? TB("The runtime file manager endpoint failed without details.") : response.Issue); @@ -187,7 +187,7 @@ public sealed partial class RustService HttpResponseMessage result; try { - result = await this.http.PostAsJsonAsync("/open/document", new OpenDocumentRequest(path, pageNumber), this.jsonRustSerializerOptions); + result = await this.http.PostAsJsonAsync("/open/document", new OpenDocumentRequest(path, pageNumber), JSON_OPTIONS); } catch (HttpRequestException e) { @@ -208,7 +208,7 @@ public sealed partial class RustService return new OpenDocumentResponse(false, false, string.Format(TB("The runtime document endpoint returned '{0}'."), result.StatusCode)); } - var response = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var response = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); if (response.Success) { // diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Image.cs b/app/MindWork AI Studio/Tools/Services/RustService.Image.cs index 541a903e2..fd60f29c6 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Image.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Image.cs @@ -20,10 +20,10 @@ public sealed partial class RustService bool optimize, CancellationToken token = default) { - using var response = await this.http.PostAsJsonAsync("/image/prepare", new { path, optimize }, this.jsonRustSerializerOptions, token); + using var response = await this.http.PostAsJsonAsync("/image/prepare", new { path, optimize }, JSON_OPTIONS, token); response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions, token) + return await response.Content.ReadFromJsonAsync(JSON_OPTIONS, token) ?? throw new InvalidDataException("The Rust image preparation returned an empty response."); } @@ -44,10 +44,10 @@ public sealed partial class RustService { try { - using var response = await this.http.PostAsJsonAsync("/image/prepare-for-model", new { path, maxBytes }, this.jsonRustSerializerOptions, token); + using var response = await this.http.PostAsJsonAsync("/image/prepare-for-model", new { path, maxBytes }, JSON_OPTIONS, token); response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions, token) + return await response.Content.ReadFromJsonAsync(JSON_OPTIONS, token) ?? PreparedModelImage.Failed(ModelImageErrorCode.UNKNOWN); } catch (Exception e) when (e is not OperationCanceledException) diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Log.cs b/app/MindWork AI Studio/Tools/Services/RustService.Log.cs index 5268310ea..cb5e30b28 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Log.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Log.cs @@ -10,7 +10,7 @@ public sealed partial class RustService /// The paths of the log files. public async Task GetLogPaths() { - return await this.http.GetFromJsonAsync("/log/paths", this.jsonRustSerializerOptions); + return await this.http.GetFromJsonAsync("/log/paths", JSON_OPTIONS); } /// @@ -33,7 +33,7 @@ public sealed partial class RustService // feed itself. The unobserved task exception handler in Program.cs remains the safety net here. // var request = new LogEventRequest(timestamp, level, category, message, exception, stackTrace); - _ = this.http.PostAsJsonAsync("/log/event", request, this.jsonRustSerializerOptions); + _ = this.http.PostAsJsonAsync("/log/event", request, JSON_OPTIONS); } catch { diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Media.cs b/app/MindWork AI Studio/Tools/Services/RustService.Media.cs index 85c6f3927..a339857e6 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Media.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Media.cs @@ -18,11 +18,11 @@ public partial class RustService using var response = await this.http.PostAsJsonAsync( "/media/jobs", new CreateMediaJobRequest(inputPath, outputPath, OpusBitrateBps: opusBitrateBps), - this.jsonRustSerializerOptions, + JSON_OPTIONS, token); response.EnsureSuccessStatusCode(); - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions, token); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS, token); return result?.JobId ?? throw new InvalidOperationException("The Rust runtime did not return a media job ID."); } @@ -49,7 +49,7 @@ public partial class RustService continue; var json = line["data:".Length..].Trim(); - var mediaEvent = JsonSerializer.Deserialize(json, this.jsonRustSerializerOptions); + var mediaEvent = JsonSerializer.Deserialize(json, JSON_OPTIONS); if (mediaEvent is not null) yield return mediaEvent; diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Secrets.cs b/app/MindWork AI Studio/Tools/Services/RustService.Secrets.cs index bc44d46cd..81c1495fc 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Secrets.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Secrets.cs @@ -65,14 +65,14 @@ public sealed partial class RustService var secretKey = SecretKey(secretId, storeType); var encryptedSecret = await this.encryptor!.Encrypt(secretData); var request = new StoreSecretRequest(secretKey, Environment.UserName, encryptedSecret); - var result = await this.http.PostAsJsonAsync("/secrets/store", request, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/secrets/store", request, JSON_OPTIONS); if (!result.IsSuccessStatusCode) { this.logger!.LogError($"Failed to store the secret data for '{secretKey}' due to an API issue: '{result.StatusCode}'"); return new StoreSecretResponse(false, TB("Failed to store the secret data due to an API issue.")); } - var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var state = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); if (!state.Success) this.logger!.LogError($"Failed to store the secret data for '{secretKey}': '{state.Issue}'"); @@ -104,7 +104,7 @@ public sealed partial class RustService private async Task GetSecretByKey(string secretKey, bool isTrying) { var secretRequest = new SelectSecretRequest(secretKey, Environment.UserName, isTrying); - var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/secrets/get", secretRequest, JSON_OPTIONS); if (!result.IsSuccessStatusCode) { if(isTrying) @@ -114,7 +114,7 @@ public sealed partial class RustService return new RequestedSecret(false, new EncryptedText(string.Empty), TB("Failed to get the secret data due to an API issue.")); } - var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var state = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); if (!state.Success) { // @@ -138,14 +138,14 @@ public sealed partial class RustService private async Task DeleteSecretByKey(string secretKey) { var request = new SelectSecretRequest(secretKey, Environment.UserName, false); - var result = await this.http.PostAsJsonAsync("/secrets/delete", request, this.jsonRustSerializerOptions); + var result = await this.http.PostAsJsonAsync("/secrets/delete", request, JSON_OPTIONS); if (!result.IsSuccessStatusCode) { this.logger!.LogError($"Failed to delete the secret data for '{secretKey}' due to an API issue: '{result.StatusCode}'"); return new DeleteSecretResponse{Success = false, WasEntryFound = false, Issue = TB("Failed to delete the secret data due to an API issue.")}; } - var state = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var state = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); if (!state.Success) this.logger!.LogError($"Failed to delete the secret data for '{secretKey}': '{state.Issue}'"); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Share.cs b/app/MindWork AI Studio/Tools/Services/RustService.Share.cs index a59dfdb43..ea3709afd 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Share.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Share.cs @@ -1,4 +1,4 @@ -// ReSharper disable NotAccessedPositionalProperty.Local +// ReSharper disable NotAccessedPositionalProperty.Global using AIStudio.Tools.Rust; namespace AIStudio.Tools.Services; @@ -14,14 +14,14 @@ public sealed partial class RustService { try { - using var response = await this.http.PostAsJsonAsync("/share/file", new ShareFileRequest(filePath), this.jsonRustSerializerOptions); + using var response = await this.http.PostAsJsonAsync("/share/file", new ShareFileRequest(filePath), JSON_OPTIONS); if (!response.IsSuccessStatusCode) { this.logger?.LogError($"The Rust runtime rejected the share request: {response.StatusCode}."); return new(ShareSheetOutcome.FAILED, $"The Rust runtime rejected the share request: {response.StatusCode}."); } - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (result is null) { this.logger?.LogError("The Rust runtime answered the share request without a result."); @@ -50,5 +50,8 @@ public sealed partial class RustService } } - private sealed record ShareFileRequest(string FilePath); + /// + /// Internal rather than private, so that the JSON context of the runtime can name it. + /// + internal sealed record ShareFileRequest(string FilePath); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs b/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs index 5273a05fc..4089d6316 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Shortcuts.cs @@ -18,7 +18,7 @@ public sealed partial class RustService try { var request = new RegisterShortcutRequest(shortcutId, shortcut, description, reconfigure); - var response = await this.http.PostAsJsonAsync("/shortcuts/register", request, this.jsonRustSerializerOptions); + var response = await this.http.PostAsJsonAsync("/shortcuts/register", request, JSON_OPTIONS); if (!response.IsSuccessStatusCode) { @@ -26,7 +26,7 @@ public sealed partial class RustService return ShortcutRegistrationResult.Failed(TB("The global shortcut could not be registered because the desktop service is unavailable.")); } - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (result is null || !result.Success) { this.logger?.LogError("Failed to register global shortcut '{ShortcutId}': {Error}", shortcutId, result?.ErrorMessage ?? "Unknown error"); @@ -53,7 +53,7 @@ public sealed partial class RustService try { var request = new ValidateShortcutRequest(shortcut); - var response = await this.http.PostAsJsonAsync("/shortcuts/validate", request, this.jsonRustSerializerOptions); + var response = await this.http.PostAsJsonAsync("/shortcuts/validate", request, JSON_OPTIONS); if (!response.IsSuccessStatusCode) { @@ -61,7 +61,7 @@ public sealed partial class RustService return new ShortcutValidationResult(false, "Network error during validation", false, string.Empty); } - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (result is null) return new ShortcutValidationResult(false, "Invalid response from server", false, string.Empty); @@ -91,7 +91,7 @@ public sealed partial class RustService return false; } - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (result is null || !result.Success) { this.logger?.LogError("Failed to suspend shortcut processing: {Error}", result?.ErrorMessage ?? "Unknown error"); @@ -123,7 +123,7 @@ public sealed partial class RustService return false; } - var result = await response.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var result = await response.Content.ReadFromJsonAsync(JSON_OPTIONS); if (result is null || !result.Success) { this.logger?.LogError("Failed to resume shortcut processing: {Error}", result?.ErrorMessage ?? "Unknown error"); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Tokenizer.cs b/app/MindWork AI Studio/Tools/Services/RustService.Tokenizer.cs index 46f8601af..580e857ac 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Tokenizer.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Tokenizer.cs @@ -17,7 +17,7 @@ public sealed partial class RustService : ITokenizerStorage { var result = await this.http.PostAsJsonAsync("/tokenizer/validate", new { file_path = filePath, - }, this.jsonRustSerializerOptions); + }, JSON_OPTIONS); if (!result.IsSuccessStatusCode) { @@ -25,7 +25,7 @@ public sealed partial class RustService : ITokenizerStorage return CreateUnavailableTokenizerResponse("An error occured while sending the path to the Rust framework for validation: "+result.StatusCode); } - var response = await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + var response = await result.Content.ReadFromJsonAsync(JSON_OPTIONS); return response; } @@ -36,7 +36,7 @@ public sealed partial class RustService : ITokenizerStorage var result = await this.http.PostAsJsonAsync("/tokenizer/store", new { model_id = modelId, file_path = filePath, - }, this.jsonRustSerializerOptions); + }, JSON_OPTIONS); if (!result.IsSuccessStatusCode) { @@ -44,7 +44,7 @@ public sealed partial class RustService : ITokenizerStorage return CreateUnavailableTokenizerResponse("An error occured while sending the path to the Rust framework for storing: "+result.StatusCode); } - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + return await result.Content.ReadFromJsonAsync(JSON_OPTIONS); } public async Task DeleteTokenizer(string modelId) @@ -52,7 +52,7 @@ public sealed partial class RustService : ITokenizerStorage this.logger!.LogInformation($"Deleting tokenizer for model '{modelId}'"); var result = await this.http.PostAsJsonAsync("/tokenizer/delete", new { model_id = modelId, - }, this.jsonRustSerializerOptions); + }, JSON_OPTIONS); if (!result.IsSuccessStatusCode) { @@ -60,7 +60,7 @@ public sealed partial class RustService : ITokenizerStorage return CreateUnavailableTokenizerResponse("An error occured while sending the tokenizer delete request to the Rust framework: "+result.StatusCode); } - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions); + return await result.Content.ReadFromJsonAsync(JSON_OPTIONS); } public Task GetTokenCount(AIStudio.Settings.Provider provider, string text, CancellationToken cancellationToken = default) => @@ -74,7 +74,7 @@ public sealed partial class RustService : ITokenizerStorage var result = await this.http.PostAsJsonAsync("/tokenizer/count", new { text, tokenizer_path = tokenizerPath, - }, this.jsonRustSerializerOptions, cancellationToken); + }, JSON_OPTIONS, cancellationToken); if (!result.IsSuccessStatusCode) { @@ -82,6 +82,6 @@ public sealed partial class RustService : ITokenizerStorage return CreateUnavailableTokenizerResponse("Error while getting token count from Rust service: "+result.StatusCode); } - return await result.Content.ReadFromJsonAsync(this.jsonRustSerializerOptions, cancellationToken); + return await result.Content.ReadFromJsonAsync(JSON_OPTIONS, cancellationToken); } } diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Updates.cs b/app/MindWork AI Studio/Tools/Services/RustService.Updates.cs index fdf2a2110..8c065ee34 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Updates.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Updates.cs @@ -9,7 +9,7 @@ public sealed partial class RustService try { var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45)); - var response = await this.http.GetFromJsonAsync("/updates/check", this.jsonRustSerializerOptions, cts.Token); + var response = await this.http.GetFromJsonAsync("/updates/check", JSON_OPTIONS, cts.Token); if (response == default) { diff --git a/app/MindWork AI Studio/Tools/Services/RustService.cs b/app/MindWork AI Studio/Tools/Services/RustService.cs index 4d9f4b97c..8fb76002c 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.cs @@ -34,7 +34,10 @@ public sealed partial class RustService : BackgroundService private readonly SemaphoreSlim userLanguageLock = new(1, 1); private readonly SemaphoreSlim userNameLock = new(1, 1); - private readonly JsonSerializerOptions jsonRustSerializerOptions = new() + /// + /// The JSON options of nearly every call to the runtime, which names its fields in snake case. + /// + internal static readonly JsonSerializerOptions JSON_OPTIONS = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, Converters = diff --git a/app/Tests/Serialization/Fixtures/anthropic-messages-request.json b/app/Tests/Serialization/Fixtures/anthropic-messages-request.json new file mode 100644 index 000000000..071345cc9 --- /dev/null +++ b/app/Tests/Serialization/Fixtures/anthropic-messages-request.json @@ -0,0 +1 @@ +{"model":"messages-model","messages":[{"content":"What is the weather?","role":"user"},{"content":[{"type":"text","text":"Compare these pictures."},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBORw0KGgo="}},{"type":"image","source":{"type":"url","url":"https://example.org/picture.png"}}],"role":"user"},{"content":[{"type":"text","text":"Let me search."},{"type":"tool_use","id":"toolu_1","name":"web_search","input":{"query":"weather"}}],"role":"assistant"},{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"It is sunny.","is_error":true}],"role":"user"}],"max_tokens":1024,"stream":true,"system":"You answer briefly.","tools":[{"name":"web_search","description":"Search the web.","strict":true,"input_schema":{"type":"object","properties":{"query":{"type":"string","description":"The search query."},"page":{"type":"integer","description":"The page, starting at 1."}},"required":["query"],"additionalProperties":false}}],"temperature":0.5,"top_k":40,"seed":12345678901,"stop":["END",7],"reasoning":{"effort":"low","summary":""},"logprobs":false} \ No newline at end of file diff --git a/app/Tests/Serialization/Fixtures/chat-completion-request.json b/app/Tests/Serialization/Fixtures/chat-completion-request.json new file mode 100644 index 000000000..7930cf85f --- /dev/null +++ b/app/Tests/Serialization/Fixtures/chat-completion-request.json @@ -0,0 +1 @@ +{"model":"chat-model","messages":[{"content":"You answer briefly.","role":"system"},{"content":[{"type":"text","text":"What do the pictures show?"},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}},{"type":"image_url","image_url":"https://example.org/picture.png"}],"role":"user"},{"role":"assistant","content":null,"reasoning_content":"The weather needs a search.","tool_calls":[{"id":"call_1","type":"function","function":{"name":"web_search","arguments":"{\u0022query\u0022:\u0022weather\u0022}"},"index":0}]},{"role":"tool","content":"It is sunny.","tool_call_id":"call_1"}],"stream":true,"tools":[{"type":"function","function":{"name":"web_search","description":"Search the web.","parameters":{"type":"object","properties":{"query":{"type":"string","description":"The search query."},"page":{"type":["integer","null"],"description":"The page, starting at 1."}},"required":["query","page"],"additionalProperties":false},"strict":true}}],"parallel_tool_calls":false,"stream_options":{"include_usage":true},"temperature":0.5,"top_k":40,"seed":12345678901,"stop":["END",7],"reasoning":{"effort":"low","summary":""},"logprobs":false} \ No newline at end of file diff --git a/app/Tests/Serialization/Fixtures/responses-request.json b/app/Tests/Serialization/Fixtures/responses-request.json new file mode 100644 index 000000000..eae34f16b --- /dev/null +++ b/app/Tests/Serialization/Fixtures/responses-request.json @@ -0,0 +1 @@ +{"model":"responses-model","input":[{"content":"You answer briefly.","role":"developer"},{"content":[{"type":"input_text","text":"What does the picture show?"},{"type":"input_image","image_url":"data:image/png;base64,iVBORw0KGgo="}],"role":"user"},{"type":"function_call","id":"fc_1","call_id":"call_1","name":"web_search","arguments":"{\u0022query\u0022:\u0022weather\u0022}"},{"type":"function_call_output","call_id":"call_1","output":"It is sunny."}],"stream":true,"store":false,"tools":[{"type":"function","name":"web_search","description":"Search the web.","parameters":{"type":"object","properties":{"query":{"type":"string","description":"The search query."},"page":{"type":["integer","null"],"description":"The page, starting at 1."}},"required":["query","page"],"additionalProperties":false},"strict":true}],"temperature":0.5,"top_k":40,"seed":12345678901,"stop":["END",7],"reasoning":{"effort":"low","summary":""},"logprobs":false} \ No newline at end of file diff --git a/app/Tests/Serialization/JsonContractTests.cs b/app/Tests/Serialization/JsonContractTests.cs index d88a05909..3f4ac8961 100644 --- a/app/Tests/Serialization/JsonContractTests.cs +++ b/app/Tests/Serialization/JsonContractTests.cs @@ -12,6 +12,12 @@ namespace AIStudio.Tests.Serialization; public sealed class JsonContractTests { [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.Stored))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ProviderRequests))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ProviderAnswers))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.RuntimeRequests))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.RuntimeAnswers))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ServiceRequests))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ServiceAnswers))] public void TheContractsMatchThoseOfReflection(JsonTestRoot root) { var baseline = JsonReflectionBaseline.Of(root.Options); diff --git a/app/Tests/Serialization/JsonDefaultsTests.cs b/app/Tests/Serialization/JsonDefaultsTests.cs index c5986ad24..cbdad19a5 100644 --- a/app/Tests/Serialization/JsonDefaultsTests.cs +++ b/app/Tests/Serialization/JsonDefaultsTests.cs @@ -7,11 +7,12 @@ namespace AIStudio.Tests.Serialization; /// Checks that a property missing in the JSON keeps the value a new object starts with. /// /// -/// Files written by an older version of AI Studio lack every property added since. Reading them has to give -/// those properties their defaults, as a new object has them, e.g., a sub-object of the settings instead of -/// null. Source generation in .NET 10 breaks this for init-only properties: it sets them all while creating -/// the object, and a property missing in the JSON gets the default of its type instead (dotnet/runtime#84484). -/// Hence, the types AI Studio stores use set instead of init.

+/// Files written by an older version of AI Studio lack every property added since, and an answer of a +/// provider, of the runtime, or of a service leaves out whatever it has nothing to say about. Reading them has +/// to give those properties their defaults, as a new object has them, e.g., a sub-object of the settings +/// instead of null. Source generation in .NET 10 breaks this for init-only properties: it sets them all while +/// creating the object, and a property missing in the JSON gets the default of its type instead +/// (dotnet/runtime#84484). Hence, the types AI Studio reads use set instead of init.

/// /// A new object is created the way reflection creates it: with its parameterless constructor, or, for a /// struct, as the default of the struct. Source generation passes the values of a positional record struct @@ -21,6 +22,9 @@ namespace AIStudio.Tests.Serialization; public sealed class JsonDefaultsTests { [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.Stored))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ProviderAnswers))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.RuntimeAnswers))] + [TestCaseSource(typeof(JsonTestRoots), nameof(JsonTestRoots.ServiceAnswers))] public void AnEmptyObjectGetsTheDefaultsOfANewOne(JsonTestRoot root) { var baseline = JsonReflectionBaseline.Of(root.Options); @@ -60,7 +64,14 @@ public sealed class JsonDefaultsTests Assert.That(failures, Is.Empty); } - private static string ValueAsJson(JsonPropertyInfo property, object instance, JsonSerializerOptions options) => JsonSerializer.Serialize(property.Get!(instance), property.PropertyType, options); + private static string ValueAsJson(JsonPropertyInfo property, object instance, JsonSerializerOptions options) => AsJson(property.Get!(instance), property.PropertyType, options); - private static string DefaultAsJson(Type type, JsonSerializerOptions options) => type.IsValueType ? JsonSerializer.Serialize(Activator.CreateInstance(type), type, options) : "null"; + private static string DefaultAsJson(Type type, JsonSerializerOptions options) => type.IsValueType ? AsJson(Activator.CreateInstance(type), type, options) : "null"; + + private static string AsJson(object? value, Type type, JsonSerializerOptions options) => value switch + { + // The default of a JsonElement holds no JSON at all, so it cannot be written: + JsonElement { ValueKind: JsonValueKind.Undefined } => "no JSON", + _ => JsonSerializer.Serialize(value, type, options), + }; } \ No newline at end of file diff --git a/app/Tests/Serialization/JsonGoldenFileTests.cs b/app/Tests/Serialization/JsonGoldenFileTests.cs index 1c43590aa..b9e8ca756 100644 --- a/app/Tests/Serialization/JsonGoldenFileTests.cs +++ b/app/Tests/Serialization/JsonGoldenFileTests.cs @@ -85,5 +85,5 @@ public sealed class JsonGoldenFileTests /// /// Read from the source tree, the way the other fixtures are, so the file needs no entry in the project file. /// - private static string ReadFixture(string fileName, [CallerFilePath] string sourceFilePath = "") => File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "Fixtures", fileName)); + internal static string ReadFixture(string fileName, [CallerFilePath] string sourceFilePath = "") => File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "Fixtures", fileName)); } \ No newline at end of file diff --git a/app/Tests/Serialization/JsonGoldenRequest.cs b/app/Tests/Serialization/JsonGoldenRequest.cs new file mode 100644 index 000000000..78e05a010 --- /dev/null +++ b/app/Tests/Serialization/JsonGoldenRequest.cs @@ -0,0 +1,15 @@ +using System.Text.Json; + +namespace AIStudio.Tests.Serialization; + +/// +/// A request in the fixtures, together with how AI Studio builds it and the options it writes it with. +/// +/// The name of the file in the folder Fixtures next to the tests, which holds the request as it got sent before. +/// Builds the request the way AI Studio does. +/// The options AI Studio writes the request with. +public sealed record JsonGoldenRequest(string FileName, Func Create, JsonSerializerOptions Options) +{ + /// + public override string ToString() => this.FileName; +} \ No newline at end of file diff --git a/app/Tests/Serialization/JsonGoldenRequestTests.cs b/app/Tests/Serialization/JsonGoldenRequestTests.cs new file mode 100644 index 000000000..0d8895a5a --- /dev/null +++ b/app/Tests/Serialization/JsonGoldenRequestTests.cs @@ -0,0 +1,33 @@ +using System.Text.Json; + +using AIStudio.Provider; + +namespace AIStudio.Tests.Serialization; + +/// +/// Writes requests the way AI Studio sends them, and checks that they get sent just as before. +/// +/// +/// The files in the folder Fixtures were written with the contracts of reflection, before AI Studio got +/// contracts of its own. A provider reads every byte of a request, and one which got written otherwise, e.g., +/// with a field under another name or without the tools, gets refused or misread. The requests are made up, +/// see . +/// +[TestFixture] +public sealed class JsonGoldenRequestTests +{ + private static IEnumerable GoldenRequests() + { + TestHost.PrepareStaticState(); + yield return new("chat-completion-request.json", JsonSampleRequests.ChatCompletion, ProviderJsonOptions.OPTIONS); + yield return new("responses-request.json", JsonSampleRequests.Responses, ProviderJsonOptions.OPTIONS); + yield return new("anthropic-messages-request.json", () => JsonSampleRequests.Anthropic(), ProviderJsonOptions.OPTIONS); + } + + [TestCaseSource(nameof(GoldenRequests))] + public void ARequestIsSentAsBefore(JsonGoldenRequest goldenRequest) + { + var request = goldenRequest.Create(); + Assert.That(JsonSerializer.Serialize(request, request.GetType(), goldenRequest.Options), Is.EqualTo(JsonGoldenFileTests.ReadFixture(goldenRequest.FileName))); + } +} \ No newline at end of file diff --git a/app/Tests/Serialization/JsonSampleRequests.cs b/app/Tests/Serialization/JsonSampleRequests.cs new file mode 100644 index 000000000..ab5171a27 --- /dev/null +++ b/app/Tests/Serialization/JsonSampleRequests.cs @@ -0,0 +1,143 @@ +using System.Text.Json; + +using AIStudio.Provider; +using AIStudio.Provider.Anthropic; +using AIStudio.Provider.OpenAI; +using AIStudio.Tools.ToolCallingSystem; + +using AnthropicImageUrl = AIStudio.Provider.Anthropic.SubContentImageUrl; +using OpenAIImageUrl = AIStudio.Provider.OpenAI.SubContentImageUrl; + +namespace AIStudio.Tests.Serialization; + +/// +/// Requests to LLM providers as AI Studio builds them, with every kind of content they carry. +/// +/// +/// The values are made up. What counts is that every part of a request holds one, so that its golden file shows +/// how each part gets written. +/// +public static class JsonSampleRequests +{ + /// + /// A tool with one required and one optional argument, the shape the web search has. + /// + public static ToolDefinition Tool() => new() + { + Id = "web_search", + Function = new() + { + Name = "web_search", + DescriptionForLLM = "Search the web.", + Strict = true, + Parameters = ToolParameterSchemaBuilder.Create() + .RequiredString("query", "The search query.") + .OptionalInteger("page", "The page, starting at 1.") + .Build(), + }, + }; + + /// + /// A request to a Chat Completions API, with a tool, a call of it, and its result. + /// + public static ChatCompletionAPIRequest ChatCompletion() => new("chat-model", Messages(), true) + { + Tools = [ProviderToolAdapters.ToChatCompletionTool(Tool(), hostEnforcesStrict: true)], + ParallelToolCalls = false, + AdditionalApiParameters = ApiParameters(), + }; + + /// + /// A request to the Responses API of OpenAI, with an item of an earlier answer and the result of a tool. + /// + public static ResponsesAPIRequest Responses() => new("responses-model", + [ + new TextMessage("You answer briefly.", "developer"), + new MultimodalMessage( + [ + new SubContentInputText(SubContentType.INPUT_TEXT, "What does the picture show?"), + new SubContentInputImage(SubContentType.INPUT_IMAGE, "data:image/png;base64,iVBORw0KGgo="), + ], "user"), + Parse("""{"type":"function_call","id":"fc_1","call_id":"call_1","name":"web_search","arguments":"{\"query\":\"weather\"}"}"""), + new ResponsesFunctionCallOutputItem { CallId = "call_1", Output = "It is sunny." }, + ], + true, false, [ProviderToolAdapters.ToResponsesTool(Tool())]) + { + AdditionalApiParameters = ApiParameters(), + }; + + /// + /// A request to the Messages API of Anthropic, with images from a URL and as data, and a tool round. + /// + public static ChatRequest Anthropic() => new("messages-model", + [ + new TextMessage("What is the weather?", "user"), + new MultimodalMessage( + [ + new SubContentText(SubContentType.TEXT, "Compare these pictures."), + new SubContentImage(SubContentType.IMAGE, new SubContentBase64Image { MediaType = "image/png", Data = "iVBORw0KGgo=" }), + new SubContentImage(SubContentType.IMAGE, new AnthropicImageUrl { Url = "https://example.org/picture.png" }), + ], "user"), + new AnthropicMessage( + [ + Parse("""{"type":"text","text":"Let me search."}"""), + Parse("""{"type":"tool_use","id":"toolu_1","name":"web_search","input":{"query":"weather"}}"""), + ]), + new AnthropicToolResultMessage([new AnthropicToolResultContent { ToolUseId = "toolu_1", Content = "It is sunny.", IsError = true }]), + ], + 1024, true, "You answer briefly.") + { + Tools = [ProviderToolAdapters.ToAnthropicTool(Tool())], + AdditionalApiParameters = ApiParameters(), + }; + + private static List Messages() => + [ + new TextMessage("You answer briefly.", "system"), + new MultimodalMessage( + [ + new SubContentText(SubContentType.TEXT, "What do the pictures show?"), + new SubContentImageUrlNested(SubContentType.IMAGE_URL, new SubContentImageUrlData { Url = "data:image/png;base64,iVBORw0KGgo=" }), + new OpenAIImageUrl(SubContentType.IMAGE_URL, "https://example.org/picture.png"), + ], "user"), + new AssistantToolCallMessage + { + Content = Parse("null"), + ReasoningContent = "The weather needs a search.", + ToolCalls = + [ + new ChatCompletionToolCall + { + Id = "call_1", + Function = new() { Name = "web_search", Arguments = """{"query":"weather"}""" }, + AdditionalMetadata = { ["index"] = Parse("0") }, + }, + ], + }, + new ToolResultMessage { Content = "It is sunny.", ToolCallId = "call_1" }, + ]; + + /// + /// Additional API parameters of every kind, read the way the provider settings read them. + /// + private static IDictionary ApiParameters() + { + if (!AdditionalApiParametersParser.TryParse(""" + "temperature": 0.5, + "top_k": 40, + "seed": 12345678901, + "stop": ["END", 7], + "reasoning": { "effort": "low", "summary": null }, + "logprobs": false + """, out var parameters, out var errorMessage)) + throw new InvalidOperationException(errorMessage); + + return parameters; + } + + private static JsonElement Parse(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } +} \ No newline at end of file diff --git a/app/Tests/Serialization/JsonTestRoots.Providers.cs b/app/Tests/Serialization/JsonTestRoots.Providers.cs new file mode 100644 index 000000000..78bf557bf --- /dev/null +++ b/app/Tests/Serialization/JsonTestRoots.Providers.cs @@ -0,0 +1,94 @@ +using System.Text.Json; + +using AIStudio.Provider; +using AIStudio.Provider.Anthropic; +using AIStudio.Provider.Google; +using AIStudio.Provider.Groq; +using AIStudio.Provider.HuggingFace; +using AIStudio.Provider.OpenAI; +using AIStudio.Provider.OpenRouter; +using AIStudio.Provider.Requesty; + +using AnthropicResponseStreamLine = AIStudio.Provider.Anthropic.ResponseStreamLine; +using FireworksResponseStreamLine = AIStudio.Provider.Fireworks.ResponseStreamLine; +using PerplexityResponseStreamLine = AIStudio.Provider.Perplexity.ResponseStreamLine; + +namespace AIStudio.Tests.Serialization; + +public static partial class JsonTestRoots +{ + /// + /// What AI Studio sends to LLM providers. + /// + public static IEnumerable ProviderRequests() + { + TestHost.PrepareStaticState(); + var options = ProviderJsonOptions.OPTIONS; + + yield return CaseWithNamespace(typeof(ChatCompletionAPIRequest), options); + yield return CaseWithNamespace(typeof(ResponsesAPIRequest), options); + yield return CaseWithNamespace(typeof(ChatRequest), options); + + // + // The messages and their parts, which converters write as the type they are of: + // + foreach (var type in new[] { typeof(IMessageBase), typeof(ISubContent), typeof(ISubContentImageUrl), typeof(ISubContentImageSource) }.SelectMany(ImplementationsOf)) + yield return CaseWithNamespace(type, options); + + // + // What a request holds as an object, which gets written as the type it is of, too. The additional API + // parameters hold the values the parser of the provider settings reads, and the input of the Responses API + // holds the items of an earlier answer as they came in: + // + foreach (var type in new[] { typeof(string), typeof(int), typeof(long), typeof(double), typeof(decimal), typeof(bool), typeof(Dictionary), typeof(List), typeof(JsonElement) }) + yield return Case(new(type, options), "value of a request"); + + yield return Case(new(ProviderToolAdapters.ToChatCompletionTool(JsonSampleRequests.Tool(), hostEnforcesStrict: true).GetType(), options), "tool of a Chat Completions API"); + yield return CaseWithNamespace(typeof(ResponsesFunctionTool), options); + yield return CaseWithNamespace(typeof(ResponsesFunctionCallOutputItem), options); + } + + /// + /// What AI Studio receives from LLM providers. + /// + public static IEnumerable ProviderAnswers() + { + TestHost.PrepareStaticState(); + var options = ProviderJsonOptions.OPTIONS; + + // The lists of models: + yield return CaseWithNamespace(typeof(AIStudio.Provider.OpenAI.ModelsResponse), options); + yield return CaseWithNamespace(typeof(AIStudio.Provider.Mistral.ModelsResponse), options); + yield return CaseWithNamespace(typeof(AIStudio.Provider.SelfHosted.ModelsResponse), options); + yield return CaseWithNamespace(typeof(AIStudio.Provider.HuggingFace.ModelsResponse), options); + yield return Case(new(typeof(IList), options), "HuggingFace"); + yield return CaseWithNamespace(typeof(RequestyModelsResponse), options); + yield return CaseWithNamespace(typeof(OpenRouterModelsResponse), options); + yield return CaseWithNamespace(typeof(GroqModelsResponse), options); + + // The lines of streamed answers: + yield return CaseWithNamespace(typeof(ChatCompletionDeltaStreamLine), options); + yield return CaseWithNamespace(typeof(ChatCompletionAnnotationStreamLine), options); + yield return CaseWithNamespace(typeof(ChatCompletionToolStreamLine), options); + yield return CaseWithNamespace(typeof(ResponsesDeltaStreamLine), options); + yield return CaseWithNamespace(typeof(ResponsesAnnotationStreamLine), options); + yield return CaseWithNamespace(typeof(ResponsesCompletedStreamLine), options); + yield return CaseWithNamespace(typeof(AnthropicResponseStreamLine), options); + yield return CaseWithNamespace(typeof(AnthropicStreamLine), options); + yield return CaseWithNamespace(typeof(FireworksResponseStreamLine), options); + yield return CaseWithNamespace(typeof(PerplexityResponseStreamLine), options); + + // + // The annotations, which a converter reads as the type their JSON tells: + // + foreach (var type in ImplementationsOf(typeof(Annotation))) + yield return CaseWithNamespace(type, options); + + // Transcriptions and embeddings, where a converter reads the embeddings of Google as one or as a list: + yield return CaseWithNamespace(typeof(TranscriptionResponse), options); + yield return CaseWithNamespace(typeof(EmbeddingResponse), options); + yield return CaseWithNamespace(typeof(GoogleEmbeddingResponse), options); + yield return CaseWithNamespace(typeof(GoogleEmbedding), options); + yield return Case(new(typeof(List), options), "Google"); + } +} \ No newline at end of file diff --git a/app/Tests/Serialization/JsonTestRoots.Runtime.cs b/app/Tests/Serialization/JsonTestRoots.Runtime.cs new file mode 100644 index 000000000..3c4c38fc3 --- /dev/null +++ b/app/Tests/Serialization/JsonTestRoots.Runtime.cs @@ -0,0 +1,103 @@ +using System.Reflection; +using System.Text.Json; + +using AIStudio.Tools; +using AIStudio.Tools.Databases.VectorStore; +using AIStudio.Tools.Rust; +using AIStudio.Tools.Services; + +namespace AIStudio.Tests.Serialization; + +public static partial class JsonTestRoots +{ + /// + /// What AI Studio sends to its runtime. + /// + public static IEnumerable RuntimeRequests() + { + TestHost.PrepareStaticState(); + var options = RustService.JSON_OPTIONS; + + yield return Case(new(typeof(SelectSecretRequest), options)); + yield return Case(new(typeof(StoreSecretRequest), options)); + yield return Case(new(typeof(RustService.ShareFileRequest), options)); + yield return Case(new(typeof(PreviousDirectory), options)); + yield return Case(new(typeof(SelectFileOptions), options)); + yield return Case(new(typeof(SaveFileOptions), options)); + yield return Case(new(typeof(OpenPathRequest), options)); + yield return Case(new(typeof(OpenDocumentRequest), options)); + yield return Case(new(typeof(RegisterShortcutRequest), options)); + yield return Case(new(typeof(ValidateShortcutRequest), options)); + yield return Case(new(typeof(SetInterfaceSizeRequest), options)); + yield return Case(new(typeof(CreateMediaJobRequest), options)); + yield return Case(new(typeof(LogEventRequest), options)); + + // Every request of the vector store: + foreach (var type in typeof(QdrantEdgeClientImplementation).GetNestedTypes(BindingFlags.NonPublic).Where(type => type.Name.EndsWith("Request", StringComparison.Ordinal)).OrderBy(type => type.Name, StringComparer.Ordinal)) + yield return Case(new(type, options)); + + // The prompt injection filter, which gets called with the defaults of HttpClient, i.e., with camel case: + yield return Case(new(typeof(SanitizePromptInjectionsRequest), JsonSerializerOptions.Web)); + yield return Case(new(typeof(SanitizePromptInjectionsBatchRequest), JsonSerializerOptions.Web)); + } + + /// + /// What AI Studio receives from its runtime. + /// + public static IEnumerable RuntimeAnswers() + { + TestHost.PrepareStaticState(); + var options = RustService.JSON_OPTIONS; + + yield return Case(new(typeof(RequestedSecret), options)); + yield return Case(new(typeof(StoreSecretResponse), options)); + yield return Case(new(typeof(DeleteSecretResponse), options)); + yield return Case(new(typeof(ShareFileResponse), options)); + yield return Case(new(typeof(ImagePrepareResponse), options)); + yield return Case(new(typeof(PreparedModelImage), options)); + yield return Case(new(typeof(TauriEvent), options)); + yield return Case(new(typeof(UpdateResponse), options)); + yield return Case(new(typeof(SetClipboardResponse), options)); + yield return Case(new(typeof(DirectorySelectionResponse), options)); + yield return Case(new(typeof(FileSelectionResponse), options)); + yield return Case(new(typeof(FilesSelectionResponse), options)); + yield return Case(new(typeof(FileSaveResponse), options)); + yield return Case(new(typeof(OpenPathResponse), options)); + yield return Case(new(typeof(OpenDocumentResponse), options)); + yield return Case(new(typeof(ShortcutRegistrationResult), options)); + yield return Case(new(typeof(ShortcutValidationResponse), options)); + yield return Case(new(typeof(ShortcutResponse), options)); + yield return Case(new(typeof(RuntimeInfoResponse), options)); + yield return Case(new(typeof(AppExitResponse), options)); + yield return Case(new(typeof(AppRestartResponse), options)); + yield return Case(new(typeof(InterfaceSizeResponse), options)); + yield return Case(new(typeof(CreateMediaJobResponse), options)); + yield return Case(new(typeof(MediaJobEvent), options)); + yield return Case(new(typeof(List), options)); + yield return Case(new(typeof(GetLogPathsResponse), options)); + yield return Case(new(typeof(TokenizerResponse), options)); + + // The vector store, with the result of every query its client sends: + yield return Case(new(typeof(QdrantEdgeInfo), options)); + yield return Case(new(typeof(RustService.DatabaseOperationResponse), options)); + yield return Case(new(typeof(RustService.DatabaseQueryResponse), options)); + yield return Case(new(typeof(RustService.DatabaseQueryResponse), options)); + yield return Case(new(typeof(RustService.DatabaseQueryResponse>), options)); + yield return Case(new(typeof(RustService.DatabaseQueryResponse>), options)); + yield return Case(new(typeof(RustService.DatabaseQueryResponse), options)); + yield return Case(new(typeof(RustService.DatabaseQueryResponse), options)); + + // + // The events of a file extraction, which get read with the defaults of System.Text.Json. A converter reads + // their metadata as the type the JSON tells: + // + yield return Case(new(typeof(ContentStreamSseEvent), JsonSerializerOptions.Default)); + yield return Case(new(typeof(string), JsonSerializerOptions.Default), "error of a file extraction"); + foreach (var type in ImplementationsOf(typeof(ContentStreamSseMetadata))) + yield return Case(new(type, JsonSerializerOptions.Default)); + + // The prompt injection filter, which gets called with the defaults of HttpClient, i.e., with camel case: + yield return Case(new(typeof(SanitizePromptInjectionsResponse), JsonSerializerOptions.Web)); + yield return Case(new(typeof(SanitizePromptInjectionsBatchResponse), JsonSerializerOptions.Web)); + } +} \ No newline at end of file diff --git a/app/Tests/Serialization/JsonTestRoots.Services.cs b/app/Tests/Serialization/JsonTestRoots.Services.cs new file mode 100644 index 000000000..2b70cd0cd --- /dev/null +++ b/app/Tests/Serialization/JsonTestRoots.Services.cs @@ -0,0 +1,38 @@ +using AIStudio.Tools.ERIClient; +using AIStudio.Tools.ERIClient.DataModel; +using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch; +using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Staan; +using AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Tavily; + +namespace AIStudio.Tests.Serialization; + +public static partial class JsonTestRoots +{ + /// + /// What AI Studio sends to ERI servers and to search services. + /// + public static IEnumerable ServiceRequests() + { + TestHost.PrepareStaticState(); + yield return Case(new(typeof(RetrievalRequest), ERIClientBase.JSON_OPTIONS)); + yield return Case(new(typeof(StaanSearchRequest), WebSearchJson.OPTIONS)); + yield return Case(new(typeof(TavilySearchRequest), WebSearchJson.OPTIONS)); + } + + /// + /// What AI Studio receives from ERI servers and from search services. + /// + public static IEnumerable ServiceAnswers() + { + TestHost.PrepareStaticState(); + yield return Case(new(typeof(List), ERIClientBase.JSON_OPTIONS)); + yield return Case(new(typeof(AuthResponse), ERIClientBase.JSON_OPTIONS)); + yield return Case(new(typeof(DataSourceInfo), ERIClientBase.JSON_OPTIONS)); + yield return Case(new(typeof(List), ERIClientBase.JSON_OPTIONS)); + yield return Case(new(typeof(List), ERIClientBase.JSON_OPTIONS)); + yield return Case(new(typeof(List), ERIClientBase.JSON_OPTIONS)); + yield return Case(new(typeof(SecurityRequirements), ERIClientBase.JSON_OPTIONS)); + yield return Case(new(typeof(StaanSearchResponse), WebSearchJson.OPTIONS)); + yield return Case(new(typeof(TavilySearchResponse), WebSearchJson.OPTIONS)); + } +} \ No newline at end of file diff --git a/app/Tests/Serialization/JsonTestRoots.cs b/app/Tests/Serialization/JsonTestRoots.cs index d2324b970..af7e0167f 100644 --- a/app/Tests/Serialization/JsonTestRoots.cs +++ b/app/Tests/Serialization/JsonTestRoots.cs @@ -1,3 +1,5 @@ +using System.Text.Json; + using AIStudio.Assistants.SWOTAnalysis; using AIStudio.Chat; using AIStudio.Settings; @@ -15,9 +17,11 @@ namespace AIStudio.Tests.Serialization; /// /// /// Only the outermost types belong here: whatever they hold gets checked along with them. Add a type whenever -/// AI Studio starts to store a new kind of file, with the options it stores the file with. +/// AI Studio starts to store a new kind of file or to exchange a new kind of message, with the options it uses +/// for them. What AI Studio sends is listed apart from what it receives: only what it reads has to keep the +/// defaults of a new object. /// -public static class JsonTestRoots +public static partial class JsonTestRoots { /// /// The data AI Studio keeps on the disk of the user. @@ -67,6 +71,27 @@ public static class JsonTestRoots return new TestCaseData(root).SetArgDisplayNames(purpose is null ? root.ToString() : $"{root}, {purpose}"); } + /// + /// Names the case after its type together with the last part of the namespace, which tells apart types of the + /// same name, e.g., the lists of models of two providers. + /// + private static TestCaseData CaseWithNamespace(Type type, JsonSerializerOptions options) + { + var typeNamespace = type.Namespace!; + return Case(new(type, options), typeNamespace[(typeNamespace.LastIndexOf('.') + 1)..]); + } + + /// + /// Returns the classes and structs of AI Studio which are the given type. + /// + /// + /// A converter which writes a value as the type it is of meets these types without any property naming them. + /// Listing them all here keeps a new one from going unchecked. + /// + private static IEnumerable ImplementationsOf(Type type) => typeof(Program).Assembly.GetTypes() + .Where(candidate => candidate is { IsAbstract: false, IsInterface: false, IsGenericTypeDefinition: false } && type.IsAssignableFrom(candidate)) + .OrderBy(candidate => candidate.FullName, StringComparer.Ordinal); + private static IEnumerable ManagedSettingValueTypes() { //