diff --git a/Core/CivitaiSharp.Core.csproj b/Core/CivitaiSharp.Core.csproj
index 63f8841..4eb3857 100644
--- a/Core/CivitaiSharp.Core.csproj
+++ b/Core/CivitaiSharp.Core.csproj
@@ -8,6 +8,7 @@
true
true
+ true
true
diff --git a/Documentation/Guides/Configuration/SdkConfiguration.cs b/Documentation/Guides/Configuration/SdkConfiguration.cs
index 9514df5..8c0192f 100644
--- a/Documentation/Guides/Configuration/SdkConfiguration.cs
+++ b/Documentation/Guides/Configuration/SdkConfiguration.cs
@@ -24,7 +24,7 @@ public static void ConfigureSdk(string[] args)
var host = builder.Build();
- var sdkClient = host.Services.GetRequiredService();
+ var sdkClient = host.Services.GetRequiredService();
Console.WriteLine("SDK configuration example completed.");
}
}
diff --git a/Documentation/Guides/air-builder.md b/Documentation/Guides/air-builder.md
new file mode 100644
index 0000000..e791d67
--- /dev/null
+++ b/Documentation/Guides/air-builder.md
@@ -0,0 +1,385 @@
+---
+title: AIR Builder
+description: Learn how to use the AirBuilder to create AIR (Artificial Intelligence Resource) identifiers with a fluent API.
+---
+
+# AIR Builder
+
+The `AirBuilder` class provides a fluent, validated approach to constructing AIR (Artificial Intelligence Resource) identifiers for Civitai models and assets. It ensures all required properties are set and validates input before building the identifier.
+
+## Overview
+
+AIR identifiers uniquely identify AI model assets across different ecosystems and platforms. The format is:
+
+```
+urn:air:{ecosystem}:{type}:{source}:{modelId}@{versionId}
+```
+
+Example:
+```
+urn:air:sdxl:lora:civitai:328553@368189
+```
+
+## Getting Started
+
+### Installation
+
+The `AirBuilder` is part of the `CivitaiSharp.Sdk` package.
+
+```bash
+dotnet add package CivitaiSharp.Sdk --prerelease
+```
+
+### Basic Usage
+
+```csharp
+using CivitaiSharp.Sdk.Air;
+
+// Build an AIR identifier using the fluent API
+var builder = new AirBuilder();
+
+var airId = builder
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Lora)
+ .WithModelId(328553)
+ .WithVersionId(368189)
+ .Build();
+
+Console.WriteLine(airId.ToString());
+// Output: urn:air:sdxl:lora:civitai:328553@368189
+```
+
+## Builder Methods
+
+### WithEcosystem
+
+Sets the model ecosystem (required):
+
+```csharp
+builder.WithEcosystem(AirEcosystem.StableDiffusionXl);
+builder.WithEcosystem(AirEcosystem.Flux1);
+builder.WithEcosystem(AirEcosystem.Pony);
+```
+
+Available ecosystems:
+- `StableDiffusion1` - Stable Diffusion 1.x (sd1)
+- `StableDiffusion2` - Stable Diffusion 2.x (sd2)
+- `StableDiffusionXl` - Stable Diffusion XL (sdxl)
+- `Flux1` - FLUX.1 (flux1)
+- `Pony` - Pony Diffusion (pony)
+
+### WithAssetType
+
+Sets the asset type (required):
+
+```csharp
+builder.WithAssetType(AirAssetType.Lora);
+builder.WithAssetType(AirAssetType.Checkpoint);
+builder.WithAssetType(AirAssetType.Vae);
+```
+
+Available asset types:
+- `Checkpoint` - Full model checkpoint
+- `Lora` - LoRA (Low-Rank Adaptation)
+- `Lycoris` - LyCORIS network
+- `Vae` - VAE (Variational Autoencoder)
+- `Embedding` - Textual Inversion embedding
+- `Hypernetwork` - Hypernetwork
+
+### WithSource
+
+Sets the source platform (optional, defaults to `AirSource.Civitai`):
+
+```csharp
+// Explicitly set source (usually not needed)
+builder.WithSource(AirSource.Civitai);
+```
+
+### WithModelId
+
+Sets the model ID (required):
+
+```csharp
+builder.WithModelId(328553);
+
+// Must be greater than 0
+```
+
+### WithVersionId
+
+Sets the version ID (required):
+
+```csharp
+builder.WithVersionId(368189);
+
+// Must be greater than 0
+```
+
+### Reset / Reuse
+
+The `AirBuilder` is immutable and thread-safe: each `With*` method returns a new builder instance. There is no instance `Reset()` method. To "reset" or reuse a base configuration, either create a new `AirBuilder()` or keep a reusable base instance and call the fluent methods which return new instances.
+
+```csharp
+// Start from a base builder and derive per-item builders (recommended)
+var baseBuilder = new AirBuilder()
+ .WithEcosystem(AirEcosystem.Flux1)
+ .WithAssetType(AirAssetType.Lora);
+
+// For a new identifier, derive from the base and set IDs
+var air1 = baseBuilder
+ .WithModelId(123)
+ .WithVersionId(456)
+ .Build();
+
+// To "reset", simply start from a fresh builder or reuse baseBuilder
+var air2 = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Checkpoint)
+ .WithModelId(789)
+ .WithVersionId(101)
+ .Build();
+```
+
+### Build
+
+Constructs the `AirIdentifier` (validates all required properties are set):
+
+```csharp
+var airId = builder.Build();
+
+// Throws InvalidOperationException if:
+// - Ecosystem is not set
+// - AssetType is not set
+// - ModelId is not set
+// - VersionId is not set
+```
+
+## Validation
+
+The builder performs validation at two stages:
+
+### Input Validation
+
+Each property setter validates its input:
+
+```csharp
+// ModelId must be > 0
+builder.WithModelId(0); // Throws ArgumentOutOfRangeException
+
+// VersionId must be > 0
+builder.WithVersionId(-1); // Throws ArgumentOutOfRangeException
+
+```
+
+### Build Validation
+
+The `Build()` method ensures all required properties are set:
+
+```csharp
+var builder = new AirBuilder()
+ .WithEcosystem(AirEcosystem.Flux1)
+ .WithModelId(123);
+
+// Missing AssetType and VersionId
+var airId = builder.Build(); // Throws InvalidOperationException
+```
+
+## Complete Examples
+
+### Building from Civitai Model
+
+```csharp
+using CivitaiSharp.Core;
+using CivitaiSharp.Sdk.Air;
+
+public class ModelService(IApiClient apiClient)
+{
+ public async Task GetAirIdAsync(int modelId)
+ {
+ // Fetch model from Civitai
+ var result = await apiClient.Models.GetByIdAsync(modelId);
+ if (result is not Result.Success success)
+ return null;
+
+ var model = success.Data;
+ var version = model.ModelVersions?.FirstOrDefault();
+
+ if (version is null)
+ return null;
+
+ // Build AIR identifier
+ var builder = new AirBuilder();
+ return builder
+ .WithEcosystem(GetEcosystem(version.BaseModel))
+ .WithAssetType(GetAssetType(model.Type))
+ .WithModelId(model.Id)
+ .WithVersionId(version.Id)
+ .Build();
+ }
+
+ private AirEcosystem GetEcosystem(string baseModel) => baseModel switch
+ {
+ "SD 1.5" => AirEcosystem.StableDiffusion1,
+ "SDXL 1.0" => AirEcosystem.StableDiffusionXl,
+ "Flux.1" => AirEcosystem.Flux1,
+ "Pony" => AirEcosystem.Pony,
+ _ => AirEcosystem.StableDiffusion1
+ };
+
+ private AirAssetType GetAssetType(ModelType type) => type switch
+ {
+ ModelType.Checkpoint => AirAssetType.Checkpoint,
+ ModelType.Lora => AirAssetType.Lora,
+ ModelType.Vae => AirAssetType.Vae,
+ ModelType.TextualInversion => AirAssetType.Embedding,
+ ModelType.Hypernetwork => AirAssetType.Hypernetwork,
+ _ => AirAssetType.Checkpoint
+ };
+}
+```
+
+### Batch Building
+
+```csharp
+using CivitaiSharp.Sdk.Air;
+
+public class BatchProcessor
+{
+ public List BuildMultipleIdentifiers()
+ {
+ var builder = new AirBuilder();
+ var identifiers = new List();
+
+ // Build multiple identifiers efficiently
+ foreach (var (ecosystem, assetType, modelId, versionId) in GetModelData())
+ {
+ var airId = builder
+ .WithEcosystem(ecosystem)
+ .WithAssetType(assetType)
+ .WithModelId(modelId)
+ .WithVersionId(versionId)
+ .Build();
+
+ identifiers.Add(airId);
+
+ }
+
+ return identifiers;
+ }
+
+ private IEnumerable<(AirEcosystem, AirAssetType, long, long)> GetModelData()
+ {
+ yield return (AirEcosystem.StableDiffusionXl, AirAssetType.Lora, 328553, 368189);
+ yield return (AirEcosystem.Flux1, AirAssetType.Checkpoint, 123456, 789012);
+ yield return (AirEcosystem.Pony, AirAssetType.Lora, 111111, 222222);
+ }
+}
+```
+
+### Builder with Error Handling
+
+```csharp
+using CivitaiSharp.Sdk.Air;
+
+public AirIdentifier? TryBuildAirId(
+ AirEcosystem ecosystem,
+ AirAssetType assetType,
+ long modelId,
+ long versionId)
+{
+ try
+ {
+ var builder = new AirBuilder();
+ return builder
+ .WithEcosystem(ecosystem)
+ .WithAssetType(assetType)
+ .WithModelId(modelId)
+ .WithVersionId(versionId)
+ .Build();
+ }
+ catch (ArgumentOutOfRangeException ex)
+ {
+ Console.WriteLine($"Invalid ID: {ex.Message}");
+ return null;
+ }
+ catch (InvalidOperationException ex)
+ {
+ Console.WriteLine($"Missing required property: {ex.Message}");
+ return null;
+ }
+}
+```
+
+## Best Practices
+
+### Reuse Builders
+
+Reuse builder instances when creating multiple identifiers:
+
+```csharp
+// Good - derive per-item builders from a reusable base configuration
+var baseBuilder = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl);
+
+foreach (var data in modelData)
+{
+ var airId = baseBuilder
+ .WithAssetType(data.AssetType)
+ .WithModelId(data.ModelId)
+ .WithVersionId(data.VersionId)
+ .Build();
+
+ ProcessAirId(airId);
+}
+```
+
+### Validate Early
+
+Validate input before passing to builder methods:
+
+```csharp
+public AirIdentifier BuildFromUserInput(long modelId, long versionId)
+{
+ // Validate before building
+ if (modelId <= 0)
+ throw new ArgumentException("Model ID must be positive", nameof(modelId));
+
+ if (versionId <= 0)
+ throw new ArgumentException("Version ID must be positive", nameof(versionId));
+
+ return new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Lora)
+ .WithModelId(modelId)
+ .WithVersionId(versionId)
+ .Build();
+}
+```
+
+### Use Method Chaining
+
+Take advantage of the fluent API for concise code:
+
+```csharp
+// Preferred - fluent style
+var airId = new AirBuilder()
+ .WithEcosystem(AirEcosystem.Flux1)
+ .WithAssetType(AirAssetType.Lora)
+ .WithModelId(123)
+ .WithVersionId(456)
+ .Build();
+
+// Avoid - verbose style
+var builder = new AirBuilder();
+builder.WithEcosystem(AirEcosystem.Flux1);
+builder.WithAssetType(AirAssetType.Lora);
+builder.WithModelId(123);
+builder.WithVersionId(456);
+var airId = builder.Build();
+```
+
+## Related Resources
+
+- [AIR Identifier Guide](air-identifier.md) - Understanding AIR identifiers
+- [CivitaiSharp.Sdk Introduction](sdk-introduction.md) - Working with Civitai's AI orchestration platform
+- [Tools Introduction](tools-introduction.md) - Overview of CivitaiSharp.Tools utilities
diff --git a/Documentation/Guides/air-identifier.md b/Documentation/Guides/air-identifier.md
index e7314f5..4cd57d8 100644
--- a/Documentation/Guides/air-identifier.md
+++ b/Documentation/Guides/air-identifier.md
@@ -67,20 +67,20 @@ urn:air:model:leonardo:345435
## Using AIR with CivitaiSharp
-The CivitaiSharp.Sdk library provides utilities for working with AIR identifiers.
+The CivitaiSharp.Sdk library provides strongly-typed utilities for working with AIR identifiers.
### Parsing an AIR
```csharp
using CivitaiSharp.Sdk.Air;
-var air = AirUrn.Parse("urn:air:sdxl:lora:civitai:328553@368189");
+var air = AirIdentifier.Parse("urn:air:sdxl:lora:civitai:328553@368189");
-Console.WriteLine($"Ecosystem: {air.Ecosystem}"); // sdxl
-Console.WriteLine($"Type: {air.Type}"); // lora
-Console.WriteLine($"Source: {air.Source}"); // civitai
-Console.WriteLine($"Id: {air.Id}"); // 328553
-Console.WriteLine($"Version: {air.Version}"); // 368189
+Console.WriteLine($"Ecosystem: {air.Ecosystem}"); // StableDiffusionXl
+Console.WriteLine($"Asset Type: {air.AssetType}"); // Lora
+Console.WriteLine($"Source: {air.Source}"); // Civitai
+Console.WriteLine($"Model ID: {air.ModelId}"); // 328553
+Console.WriteLine($"Version ID: {air.VersionId}"); // 368189
```
### Creating an AIR
@@ -88,17 +88,42 @@ Console.WriteLine($"Version: {air.Version}"); // 368189
```csharp
using CivitaiSharp.Sdk.Air;
-var air = new AirUrn
-{
- Ecosystem = "sdxl",
- Type = "lora",
- Source = "civitai",
- Id = "328553",
- Version = "368189"
-};
+// Using the constructor
+var air = new AirIdentifier(
+ AirEcosystem.StableDiffusionXl,
+ AirAssetType.Lora,
+ AirSource.Civitai,
+ 328553,
+ 368189);
Console.WriteLine(air.ToString());
// Output: urn:air:sdxl:lora:civitai:328553@368189
+
+// Using the factory method (defaults to Civitai source)
+var air2 = AirIdentifier.Create(
+ AirEcosystem.StableDiffusionXl,
+ AirAssetType.Lora,
+ 328553,
+ 368189);
+```
+
+### Using the Builder Pattern
+
+For more complex scenarios, use the fluent builder:
+
+```csharp
+using CivitaiSharp.Sdk.Air;
+
+var air = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Lora)
+ .WithSource(AirSource.HuggingFace)
+ .WithModelId(328553)
+ .WithVersionId(368189)
+ .Build();
+
+Console.WriteLine(air.ToString());
+// Output: urn:air:sdxl:lora:huggingface:328553@368189
```
### Validating an AIR
@@ -106,7 +131,7 @@ Console.WriteLine(air.ToString());
```csharp
using CivitaiSharp.Sdk.Air;
-if (AirUrn.TryParse("urn:air:sdxl:lora:civitai:328553@368189", out var air))
+if (AirIdentifier.TryParse("urn:air:sdxl:lora:civitai:328553@368189", out var air))
{
Console.WriteLine($"Valid AIR: {air}");
}
@@ -116,6 +141,19 @@ else
}
```
+### Supported Sources
+
+CivitaiSharp supports four platforms as defined in the official AIR specification:
+
+| Source | Enum Value | Description |
+|--------|------------|-------------|
+| Civitai | `AirSource.Civitai` | Civitai platform resources |
+| Hugging Face | `AirSource.HuggingFace` | Hugging Face model hub |
+| OpenAI | `AirSource.OpenAi` | OpenAI models (DALL-E, GPT) |
+| Leonardo | `AirSource.Leonardo` | Leonardo.Ai platform |
+
+All sources are strongly typed as enums, providing compile-time safety and IDE intellisense.
+
## Common Use Cases
### Referencing Models in Applications
@@ -125,7 +163,7 @@ AIR provides a standardized way to reference models in your application configur
```json
{
"models": {
- "checkpoint": "urn:air:sdxl:model:civitai:101055@128078",
+ "checkpoint": "urn:air:sdxl:checkpoint:civitai:101055@128078",
"lora": "urn:air:sdxl:lora:civitai:328553@368189"
}
}
@@ -137,10 +175,20 @@ Since AIR is a universal identifier, it can be used to reference resources acros
```csharp
// Civitai resource
-var civitaiModel = AirUrn.Parse("urn:air:sdxl:model:civitai:101055@128078");
+var civitaiModel = AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:101055@128078");
// Hugging Face resource
-var hfModel = AirUrn.Parse("urn:air:model:huggingface:stabilityai/sdxl-vae");
+var hfModel = AirIdentifier.Parse("urn:air:sdxl:checkpoint:huggingface:100@200");
+
+// Switching sources programmatically
+var builder = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Checkpoint)
+ .WithModelId(101055)
+ .WithVersionId(128078);
+
+var civitai = builder.WithSource(AirSource.Civitai).Build();
+var leonardo = builder.WithSource(AirSource.Leonardo).Build();
```
## Next Steps
diff --git a/Documentation/Guides/configuration.md b/Documentation/Guides/configuration.md
index eca2e0e..c1735ad 100644
--- a/Documentation/Guides/configuration.md
+++ b/Documentation/Guides/configuration.md
@@ -54,7 +54,7 @@ Options are validated on assignment:
## CivitaiSharp.Sdk Configuration
-The `CivitaiSdkClientOptions` class configures the Generator SDK client.
+The `SdkClientOptions` class configures the Generator SDK client.
> [!IMPORTANT]
> Unlike CivitaiSharp.Core which can access public endpoints anonymously, the SDK **always requires authentication**. All Generator API operations require a valid API token.
diff --git a/Documentation/Guides/images.md b/Documentation/Guides/images.md
index a3b05af..0a9664a 100644
--- a/Documentation/Guides/images.md
+++ b/Documentation/Guides/images.md
@@ -147,7 +147,7 @@ if (image.Stats is { } stats)
## Pagination
-Use page-based pagination for images:
+Images use cursor-based pagination:
[!code-csharp[Program.cs](Images/Program.cs#L62-L85)]
diff --git a/Documentation/Guides/introduction.md b/Documentation/Guides/introduction.md
index 95ff05b..a1f3231 100644
--- a/Documentation/Guides/introduction.md
+++ b/Documentation/Guides/introduction.md
@@ -48,7 +48,6 @@ public class MyService(IApiClient apiClient)
.WhereType(ModelType.Lora)
.WhereTag("anime")
.ExecuteAsync(resultsLimit: 10);
- .ExecuteAsync();
if (result is Result>.Success success)
{
diff --git a/Documentation/Guides/sdk-introduction.md b/Documentation/Guides/sdk-introduction.md
index 8d93bc3..05b0e92 100644
--- a/Documentation/Guides/sdk-introduction.md
+++ b/Documentation/Guides/sdk-introduction.md
@@ -40,25 +40,19 @@ services.AddCivitaiSdk(options =>
### Basic Usage
```csharp
-public class ImageGenerationService(ICivitaiSdkClient sdkClient)
+public class ImageGenerationService(ISdkClient sdkClient)
{
public async Task GenerateImageAsync()
{
- var request = new TextToImageJobRequest
- {
- Model = "urn:air:sd1:checkpoint:civitai:4201@130072",
- Params = new ImageJobParams
- {
- Prompt = "a beautiful sunset over mountains",
- NegativePrompt = "blurry, low quality",
- Width = 512,
- Height = 512,
- Steps = 20,
- CfgScale = 7.0
- }
- };
-
- var result = await sdkClient.Jobs.SubmitAsync(request);
+ var result = await sdkClient.Jobs
+ .CreateTextToImage()
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("a beautiful sunset over mountains")
+ .WithNegativePrompt("blurry, low quality")
+ .WithSize(1024, 1024)
+ .WithSteps(30)
+ .WithCfgScale(7.5m)
+ .SubmitAsync();
if (result is Result.Success success)
{
@@ -74,15 +68,16 @@ public class ImageGenerationService(ICivitaiSdkClient sdkClient)
Submit and manage image generation jobs:
-- `SubmitAsync` - Submit a text-to-image generation job
-- `SubmitBatchAsync` - Submit multiple jobs as a batch
-- `GetByIdAsync` - Get job status by ID
-- `GetByTokenAsync` - Get job status by token
-- `QueryAsync` - Query multiple jobs with filters
-- `CancelByIdAsync` - Cancel a job by ID
-- `CancelByTokenAsync` - Cancel jobs by batch token
-- `TaintByIdAsync` - Mark a job as tainted by ID
-- `TaintByTokenAsync` - Mark jobs as tainted by batch token
+- `CreateTextToImage()` - Create a fluent builder for text-to-image jobs (recommended)
+- `SubmitAsync()` - Submit a text-to-image generation job
+- `SubmitBatchAsync()` - Submit multiple jobs as a batch
+- `GetByIdAsync()` - Get job status by ID
+- `GetByTokenAsync()` - Get job status by token
+- `QueryAsync()` - Query multiple jobs with filters
+- `CancelByIdAsync()` - Cancel a job by ID
+- `CancelByTokenAsync()` - Cancel jobs by batch token
+- `TaintByIdAsync()` - Mark a job as tainted by ID
+- `TaintByTokenAsync()` - Mark jobs as tainted by batch token
### Coverage Service
diff --git a/Documentation/Guides/tools-introduction.md b/Documentation/Guides/tools-introduction.md
index 6e16bd4..b50441e 100644
--- a/Documentation/Guides/tools-introduction.md
+++ b/Documentation/Guides/tools-introduction.md
@@ -9,6 +9,7 @@ CivitaiSharp.Tools provides utility functionality for working with Civitai resou
## Key Features
+- **AIR Builder** - Fluent API for constructing AIR (Artificial Intelligence Resource) identifiers with validation
- **File Hashing** - Compute SHA256, SHA512, BLAKE3, and CRC32 hashes for file verification
- **Download Management** - Download images and model files with configurable path patterns and hash verification
- **HTML Parsing** - Convert Civitai's HTML descriptions to Markdown or plain text
@@ -142,6 +143,7 @@ Configure download behavior via appsettings.json:
## Guides
+- [AIR Builder](air-builder.md) - Build AIR identifiers with a fluent API
- [File Hashing](file-hashing.md) - Compute and verify file hashes
- [Downloading Files](downloading-files.md) - Download images and model files
- [HTML Parsing](html-parsing.md) - Convert descriptions to Markdown or plain text
diff --git a/Documentation/toc.yml b/Documentation/toc.yml
index ed9a578..bc6efae 100644
--- a/Documentation/toc.yml
+++ b/Documentation/toc.yml
@@ -2,8 +2,6 @@ items:
- name: Home
href: index.html
- name: Guides
- href: Guides/
- topicHref: Guides/index.md
+ href: Guides/index.md
- name: API Reference
- href: Api/
- topicHref: Api/index.md
+ href: Api/index.md
diff --git a/NUGET.md b/NUGET.md
index e5f68aa..94efc33 100644
--- a/NUGET.md
+++ b/NUGET.md
@@ -448,12 +448,31 @@ else
## 6. Features
- **Modern .NET 10** - Built with nullable reference types, records, and primary constructors
-- **Fluent Query Builders** - Type-safe, immutable builders for constructing API requests
-- **Result Pattern** - Explicit success/failure handling with discriminated union
-- **Built-in Resilience** - Retry policies, circuit breakers, rate limiting, and timeouts
+- **AOT-Ready** - Full Native AOT and trimming support with source-generated JSON serialization
+- **Fluent Query Builders** - Type-safe, immutable, thread-safe builders for constructing API requests
+- **Result Pattern** - Explicit success/failure handling without exceptions for expected errors
+- **Built-in Resilience** - Standard resilience handler with retry policies, circuit breakers, rate limiting, and timeouts
- **Dependency Injection** - First-class support for `IHttpClientFactory` and Microsoft DI
-- **Streaming Downloads** - Memory-efficient response handling with `ResponseHeadersRead`
-- **Explicit JSON Contract** - All model properties use `[JsonPropertyName]` for type safety
+
+### AOT and Trimming Support
+
+All CivitaiSharp packages are fully compatible with Native AOT compilation and IL trimming:
+
+- **Source-Generated JSON** - Uses `System.Text.Json` source generators (`JsonSerializerContext`) for reflection-free serialization
+- **Trim-Safe** - All packages have `true` and `true`
+- **No Runtime Reflection** - All type metadata is generated at compile-time
+
+To publish with AOT:
+
+```shell
+dotnet publish -c Release -r win-x64 /p:PublishAot=true
+```
+
+To publish with trimming:
+
+```shell
+dotnet publish -c Release -r win-x64 /p:PublishTrimmed=true
+```
## 7. Documentation
- [API Reference](https://CivitaiSharp.Mewyk.com/Docs/api/)
diff --git a/Sdk/Air/AirBuilder.cs b/Sdk/Air/AirBuilder.cs
new file mode 100644
index 0000000..cb8726e
--- /dev/null
+++ b/Sdk/Air/AirBuilder.cs
@@ -0,0 +1,181 @@
+namespace CivitaiSharp.Sdk.Air;
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+
+///
+/// Immutable, thread-safe builder for constructing instances with validation.
+/// Each fluent method returns a new builder instance, allowing safe reuse and caching of base configurations.
+///
+///
+///
+/// This builder simplifies the construction of AIR (Artificial Intelligence Resource) identifiers
+/// by providing a clear, step-by-step fluent interface. Use this when you want to construct
+/// AIR identifiers programmatically with compile-time guidance and runtime validation.
+///
+///
+/// This builder follows the same immutable pattern as RequestBuilder in CivitaiSharp.Core.
+/// Each method returns a new instance, making it inherently thread-safe.
+///
+///
+///
+///
+/// var air = new AirBuilder()
+/// .WithEcosystem(AirEcosystem.StableDiffusionXl)
+/// .WithAssetType(AirAssetType.Lora)
+/// .WithModelId(328553)
+/// .WithVersionId(368189)
+/// .Build();
+///
+/// Console.WriteLine(air.ToString());
+/// // Output: urn:air:sdxl:lora:civitai:328553@368189
+///
+///
+public sealed record AirBuilder
+{
+ private readonly AirEcosystem? _ecosystem;
+ private readonly AirAssetType? _assetType;
+ private readonly AirSource _source;
+ private readonly long? _modelId;
+ private readonly long? _versionId;
+
+ ///
+ /// Initializes a new instance of the record with default values.
+ ///
+ public AirBuilder()
+ : this(ecosystem: null, assetType: null, source: AirIdentifier.DefaultSource, modelId: null, versionId: null)
+ {
+ }
+
+ private AirBuilder(
+ AirEcosystem? ecosystem,
+ AirAssetType? assetType,
+ AirSource source,
+ long? modelId,
+ long? versionId)
+ {
+ _ecosystem = ecosystem;
+ _assetType = assetType;
+ _source = source;
+ _modelId = modelId;
+ _versionId = versionId;
+ }
+
+ ///
+ /// Sets the ecosystem (e.g., Stable Diffusion XL, FLUX.1).
+ ///
+ /// The model ecosystem.
+ /// A new builder instance with the ecosystem set.
+ public AirBuilder WithEcosystem(AirEcosystem ecosystem) =>
+ new(ecosystem, _assetType, _source, _modelId, _versionId);
+
+ ///
+ /// Sets the asset type (e.g., Checkpoint, LoRA, Embedding).
+ ///
+ /// The asset type.
+ /// A new builder instance with the asset type set.
+ public AirBuilder WithAssetType(AirAssetType assetType) =>
+ new(_ecosystem, assetType, _source, _modelId, _versionId);
+
+ ///
+ /// Sets the source platform for the resource.
+ ///
+ /// The source platform. Defaults to if not specified.
+ /// A new builder instance with the source set.
+ public AirBuilder WithSource(AirSource source) =>
+ new(_ecosystem, _assetType, source, _modelId, _versionId);
+
+ ///
+ /// Sets the model ID.
+ ///
+ /// The model ID. Must be greater than 0.
+ /// A new builder instance with the model ID set.
+ /// Thrown when modelId is less than 1.
+ public AirBuilder WithModelId(long modelId)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(modelId, 1);
+ return new(_ecosystem, _assetType, _source, modelId, _versionId);
+ }
+
+ ///
+ /// Sets the version ID.
+ ///
+ /// The version ID. Must be greater than 0.
+ /// A new builder instance with the version ID set.
+ /// Thrown when versionId is less than 1.
+ public AirBuilder WithVersionId(long versionId)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(versionId, 1);
+ return new(_ecosystem, _assetType, _source, _modelId, versionId);
+ }
+
+ ///
+ /// Builds the with the configured values.
+ ///
+ /// A new instance.
+ ///
+ /// Thrown when required properties (, ,
+ /// , ) have not been set.
+ ///
+ public AirIdentifier Build()
+ {
+ if (!_ecosystem.HasValue)
+ {
+ throw new InvalidOperationException(
+ $"{nameof(AirIdentifier.Ecosystem)} must be set before building. Use {nameof(WithEcosystem)}() to specify the model ecosystem.");
+ }
+
+ if (!_assetType.HasValue)
+ {
+ throw new InvalidOperationException(
+ $"{nameof(AirIdentifier.AssetType)} must be set before building. Use {nameof(WithAssetType)}() to specify the asset type.");
+ }
+
+ if (!_modelId.HasValue)
+ {
+ throw new InvalidOperationException(
+ $"{nameof(AirIdentifier.ModelId)} must be set before building. Use {nameof(WithModelId)}() to specify the model ID.");
+ }
+
+ if (!_versionId.HasValue)
+ {
+ throw new InvalidOperationException(
+ $"{nameof(AirIdentifier.VersionId)} must be set before building. Use {nameof(WithVersionId)}() to specify the version ID.");
+ }
+
+ return new AirIdentifier(
+ _ecosystem.Value,
+ _assetType.Value,
+ _source,
+ _modelId.Value,
+ _versionId.Value);
+ }
+
+ ///
+ /// Attempts to build the with the configured values.
+ ///
+ /// When this method returns , contains the built ;
+ /// otherwise, contains the default value.
+ /// if all required properties were set and the identifier was built successfully; otherwise, .
+ ///
+ /// Use this method when you prefer to handle missing required properties without exceptions.
+ /// All properties (, ,
+ /// , ) must be set for the build to succeed.
+ ///
+ public bool TryBuild([MaybeNullWhen(false)] out AirIdentifier result)
+ {
+ if (!_ecosystem.HasValue || !_assetType.HasValue || !_modelId.HasValue || !_versionId.HasValue)
+ {
+ result = default;
+ return false;
+ }
+
+ result = new AirIdentifier(
+ _ecosystem.Value,
+ _assetType.Value,
+ _source,
+ _modelId.Value,
+ _versionId.Value);
+ return true;
+ }
+}
diff --git a/Sdk/Air/AirIdentifier.cs b/Sdk/Air/AirIdentifier.cs
index 54ecc21..a4ce980 100644
--- a/Sdk/Air/AirIdentifier.cs
+++ b/Sdk/Air/AirIdentifier.cs
@@ -15,7 +15,7 @@ namespace CivitaiSharp.Sdk.Air;
///
/// The default source for Civitai assets.
///
- public const string CivitaiSource = "civitai";
+ public const AirSource DefaultSource = AirSource.Civitai;
///
/// Compiled regex pattern for parsing AIR identifiers.
@@ -23,11 +23,6 @@ namespace CivitaiSharp.Sdk.Air;
[GeneratedRegex(@"^urn:air:([a-z0-9]+):([a-z]+):([a-z]+):(\d+)@(\d+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
private static partial Regex AirPattern();
- ///
- /// Cached lowercase source for hash code computation to avoid allocation.
- ///
- private readonly string _sourceLower;
-
///
/// Gets the model ecosystem (e.g., sd1, sdxl, flux1).
///
@@ -39,9 +34,9 @@ namespace CivitaiSharp.Sdk.Air;
public AirAssetType AssetType { get; }
///
- /// Gets the source of the asset. Currently always "civitai".
+ /// Gets the source platform of the asset.
///
- public string Source { get; }
+ public AirSource Source { get; }
///
/// Gets the model ID on Civitai.
@@ -58,26 +53,23 @@ namespace CivitaiSharp.Sdk.Air;
///
/// The model ecosystem.
/// The asset type.
- /// The source (typically "civitai").
+ /// The source platform.
/// The model ID.
/// The version ID.
- /// Thrown when source is null or whitespace.
/// Thrown when modelId or versionId is less than 1.
public AirIdentifier(
AirEcosystem ecosystem,
AirAssetType assetType,
- string source,
+ AirSource source,
long modelId,
long versionId)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(source);
ArgumentOutOfRangeException.ThrowIfLessThan(modelId, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(versionId, 1);
Ecosystem = ecosystem;
AssetType = assetType;
Source = source;
- _sourceLower = source.ToLowerInvariant();
ModelId = modelId;
VersionId = versionId;
}
@@ -95,7 +87,7 @@ public static AirIdentifier Create(
AirAssetType assetType,
long modelId,
long versionId) =>
- new(ecosystem, assetType, CivitaiSource, modelId, versionId);
+ new(ecosystem, assetType, DefaultSource, modelId, versionId);
///
/// Attempts to parse a string as an AIR identifier.
@@ -120,7 +112,7 @@ public static bool TryParse(string? value, out AirIdentifier result)
var ecosystemStr = match.Groups[1].Value;
var typeStr = match.Groups[2].Value;
- var source = match.Groups[3].Value.ToLowerInvariant();
+ var sourceStr = match.Groups[3].Value;
var modelIdStr = match.Groups[4].Value;
var versionIdStr = match.Groups[5].Value;
@@ -134,6 +126,11 @@ public static bool TryParse(string? value, out AirIdentifier result)
return false;
}
+ if (!EnumExtensions.TryParseFromApiString(sourceStr, out var source))
+ {
+ return false;
+ }
+
if (!long.TryParse(modelIdStr, out var modelId) || modelId < 1)
{
return false;
@@ -153,7 +150,8 @@ public static bool TryParse(string? value, out AirIdentifier result)
///
/// The string to parse.
/// The parsed .
- /// Thrown when value is null or whitespace.
+ /// Thrown when value is null.
+ /// Thrown when value is empty or whitespace.
/// Thrown when value is not a valid AIR identifier.
public static AirIdentifier Parse(string value)
{
@@ -183,13 +181,13 @@ static bool IParsable.TryParse(
///
/// The AIR identifier string in the format urn:air:{ecosystem}:{type}:{source}:{modelId}@{versionId}.
public override string ToString() =>
- $"urn:air:{Ecosystem.ToApiString()}:{AssetType.ToApiString()}:{_sourceLower}:{ModelId}@{VersionId}";
+ $"urn:air:{Ecosystem.ToApiString()}:{AssetType.ToApiString()}:{Source.ToApiString()}:{ModelId}@{VersionId}";
///
public bool Equals(AirIdentifier other) =>
Ecosystem == other.Ecosystem &&
AssetType == other.AssetType &&
- string.Equals(_sourceLower, other._sourceLower, StringComparison.Ordinal) &&
+ Source == other.Source &&
ModelId == other.ModelId &&
VersionId == other.VersionId;
@@ -197,7 +195,7 @@ public bool Equals(AirIdentifier other) =>
public override bool Equals(object? obj) => obj is AirIdentifier other && Equals(other);
///
- public override int GetHashCode() => HashCode.Combine(Ecosystem, AssetType, _sourceLower, ModelId, VersionId);
+ public override int GetHashCode() => HashCode.Combine(Ecosystem, AssetType, Source, ModelId, VersionId);
///
/// Determines whether two AIR identifiers are equal.
diff --git a/Sdk/Air/AirSource.cs b/Sdk/Air/AirSource.cs
new file mode 100644
index 0000000..aefc54f
--- /dev/null
+++ b/Sdk/Air/AirSource.cs
@@ -0,0 +1,34 @@
+namespace CivitaiSharp.Sdk.Air;
+
+///
+/// Supported source platforms in the AIR (Artificial Intelligence Resource) identifier system.
+///
+///
+/// API string mappings are defined in .
+///
+public enum AirSource
+{
+ ///
+ /// Civitai platform (https://civitai.com).
+ /// Maps to API value "civitai".
+ ///
+ Civitai,
+
+ ///
+ /// Hugging Face platform (https://huggingface.co).
+ /// Maps to API value "huggingface".
+ ///
+ HuggingFace,
+
+ ///
+ /// OpenAI platform (https://openai.com).
+ /// Maps to API value "openai".
+ ///
+ OpenAi,
+
+ ///
+ /// Leonardo.Ai platform (https://leonardo.ai).
+ /// Maps to API value "leonardo".
+ ///
+ Leonardo
+}
diff --git a/Sdk/CivitaiSharp.Sdk.csproj b/Sdk/CivitaiSharp.Sdk.csproj
index 09fdc84..88a2a76 100644
--- a/Sdk/CivitaiSharp.Sdk.csproj
+++ b/Sdk/CivitaiSharp.Sdk.csproj
@@ -9,6 +9,7 @@
true
+ true
true
diff --git a/Sdk/Extensions/SdkApiStringRegistry.cs b/Sdk/Extensions/SdkApiStringRegistry.cs
index 9673124..128d6c0 100644
--- a/Sdk/Extensions/SdkApiStringRegistry.cs
+++ b/Sdk/Extensions/SdkApiStringRegistry.cs
@@ -7,7 +7,7 @@ namespace CivitaiSharp.Sdk.Extensions;
///
/// Registers SDK-specific enum mappings with the Core's .
-/// Called automatically when is invoked.
+/// Called automatically when is invoked.
///
///
///
@@ -134,5 +134,14 @@ private static void Initialize()
[AirEcosystem.Flux1] = "flux1",
[AirEcosystem.Pony] = "pony",
});
+
+ // Register AirSource mappings
+ ApiStringRegistry.Register(new Dictionary
+ {
+ [AirSource.Civitai] = "civitai",
+ [AirSource.HuggingFace] = "huggingface",
+ [AirSource.OpenAi] = "openai",
+ [AirSource.Leonardo] = "leonardo",
+ });
}
}
diff --git a/Sdk/Extensions/ServiceCollectionExtensions.cs b/Sdk/Extensions/ServiceCollectionExtensions.cs
index 14c2f08..3ef0ffd 100644
--- a/Sdk/Extensions/ServiceCollectionExtensions.cs
+++ b/Sdk/Extensions/ServiceCollectionExtensions.cs
@@ -24,7 +24,7 @@ public static class ServiceCollectionExtensions
/// Registers the Civitai SDK client and related services with the specified configuration action.
///
/// The service collection to register services into.
- /// Action to configure .
+ /// Action to configure .
/// The service collection for chaining.
/// Thrown if or is null.
///
@@ -37,7 +37,7 @@ public static class ServiceCollectionExtensions
///
public static IServiceCollection AddCivitaiSdk(
this IServiceCollection services,
- Action configure)
+ Action configure)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
@@ -54,8 +54,8 @@ public static IServiceCollection AddCivitaiSdk(
/// The configuration source containing SDK settings.
/// Optional section name to read from configuration. Defaults to "CivitaiSdk".
/// The service collection for chaining.
- /// Thrown if or is null.
- /// Thrown if is null or whitespace.
+ /// Thrown if , , or is null.
+ /// Thrown if is empty or whitespace.
///
///
/// // appsettings.json:
@@ -77,7 +77,7 @@ public static IServiceCollection AddCivitaiSdk(
ArgumentNullException.ThrowIfNull(configuration);
ArgumentException.ThrowIfNullOrWhiteSpace(sectionName);
- services.Configure(configuration.GetSection(sectionName));
+ services.Configure(configuration.GetSection(sectionName));
RegisterSdkServices(services);
return services;
}
@@ -87,7 +87,7 @@ public static IServiceCollection AddCivitaiSdk(
///
///
///
- /// Registering and as singletons is correct
+ /// Registering and as singletons is correct
/// for this library. The HTTP client factory pattern () is used,
/// which properly manages lifetimes and avoids socket exhaustion.
///
@@ -103,7 +103,7 @@ private static void RegisterSdkServices(IServiceCollection services)
services.AddHttpClient(nameof(SdkHttpClient), (serviceProvider, client) =>
{
- var options = serviceProvider.GetRequiredService>().Value;
+ var options = serviceProvider.GetRequiredService>().Value;
ConfigureHttpClient(client, options);
})
.AddStandardResilienceHandler();
@@ -116,18 +116,18 @@ private static void RegisterSdkServices(IServiceCollection services)
return new SdkHttpClient(httpClient, logger);
});
- services.AddSingleton(serviceProvider =>
+ services.AddSingleton(serviceProvider =>
{
var httpClient = serviceProvider.GetRequiredService();
- var options = serviceProvider.GetRequiredService>().Value;
- return new CivitaiSdkClient(httpClient, options);
+ var options = serviceProvider.GetRequiredService>().Value;
+ return new SdkClient(httpClient, options);
});
}
///
/// Configures the HTTP client base address, headers, and authentication.
///
- private static void ConfigureHttpClient(HttpClient client, CivitaiSdkClientOptions options)
+ private static void ConfigureHttpClient(HttpClient client, SdkClientOptions options)
{
options.Validate();
diff --git a/Sdk/Http/QueryStringBuilder.cs b/Sdk/Http/QueryStringBuilder.cs
index 066f79f..05bf298 100644
--- a/Sdk/Http/QueryStringBuilder.cs
+++ b/Sdk/Http/QueryStringBuilder.cs
@@ -8,6 +8,16 @@ namespace CivitaiSharp.Sdk.Http;
/// Builder for constructing URL query strings with proper encoding.
/// This class is mutable and not thread-safe.
///
+///
+///
+/// var query = new QueryStringBuilder()
+/// .Append("token", "abc123")
+/// .AppendIf("wait", true)
+/// .Append("startDate", DateTime.Now)
+/// .BuildUri("/api/v1/jobs");
+/// // Result: "/api/v1/jobs?token=abc123&wait=true&startDate=2025-12-06T19:00:00.0000000Z"
+///
+///
internal sealed class QueryStringBuilder
{
private readonly StringBuilder _builder = new();
diff --git a/Sdk/ICivitaiSdkClient.cs b/Sdk/ISdkClient.cs
similarity index 63%
rename from Sdk/ICivitaiSdkClient.cs
rename to Sdk/ISdkClient.cs
index 34f9f07..d3d5d92 100644
--- a/Sdk/ICivitaiSdkClient.cs
+++ b/Sdk/ISdkClient.cs
@@ -7,20 +7,20 @@ namespace CivitaiSharp.Sdk;
/// model availability checking, and usage tracking. For the public API (models, images, tags, creators),
/// use instead.
///
-public interface ICivitaiSdkClient
+public interface ISdkClient
{
///
- /// Provides access to image generation jobs: submit, query, cancel, and retrieve results.
+ /// Image generation jobs: submit, query, cancel, and retrieve results.
///
IJobsService Jobs { get; }
///
- /// Provides model and resource availability checks to verify assets are ready before submitting jobs.
+ /// Model and resource availability checks before submitting jobs.
///
ICoverageService Coverage { get; }
///
- /// Provides API consumption tracking: credit usage, job counts, and consumption history.
+ /// API consumption tracking: credits, job counts, and history.
///
IUsageService Usage { get; }
}
diff --git a/Sdk/Json/Converters/AirIdentifierConverter.cs b/Sdk/Json/Converters/AirIdentifierConverter.cs
new file mode 100644
index 0000000..9228255
--- /dev/null
+++ b/Sdk/Json/Converters/AirIdentifierConverter.cs
@@ -0,0 +1,36 @@
+namespace CivitaiSharp.Sdk.Json.Converters;
+
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using CivitaiSharp.Sdk.Air;
+
+///
+/// JSON converter for that serializes to/from AIR strings.
+///
+public sealed class AirIdentifierConverter : JsonConverter
+{
+ ///
+ public override AirIdentifier Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ var value = reader.GetString();
+
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ throw new JsonException("AIR identifier cannot be null or empty.");
+ }
+
+ if (!AirIdentifier.TryParse(value, out var result))
+ {
+ throw new JsonException($"Invalid AIR identifier format: '{value}'. Expected format: urn:air:{{ecosystem}}:{{type}}:{{source}}:{{modelId}}@{{versionId}}");
+ }
+
+ return result;
+ }
+
+ ///
+ public override void Write(Utf8JsonWriter writer, AirIdentifier value, JsonSerializerOptions options)
+ {
+ writer.WriteStringValue(value.ToString());
+ }
+}
diff --git a/Sdk/Json/Converters/ControlNetPreprocessorConverter.cs b/Sdk/Json/Converters/ControlNetPreprocessorConverter.cs
index d70df40..a9b5721 100644
--- a/Sdk/Json/Converters/ControlNetPreprocessorConverter.cs
+++ b/Sdk/Json/Converters/ControlNetPreprocessorConverter.cs
@@ -82,6 +82,7 @@ public override void Write(Utf8JsonWriter writer, ControlNetPreprocessor value,
///
/// AOT-compatible JSON converter for nullable .
+/// Handles null values by writing JSON null and reading null tokens appropriately.
///
internal sealed class NullableControlNetPreprocessorConverter : JsonConverter
{
diff --git a/Sdk/Json/Converters/ProviderAssetAvailabilityDictionaryConverter.cs b/Sdk/Json/Converters/ProviderAssetAvailabilityDictionaryConverter.cs
index 382acd6..54baf63 100644
--- a/Sdk/Json/Converters/ProviderAssetAvailabilityDictionaryConverter.cs
+++ b/Sdk/Json/Converters/ProviderAssetAvailabilityDictionaryConverter.cs
@@ -4,15 +4,16 @@ namespace CivitaiSharp.Sdk.Json.Converters;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
+using CivitaiSharp.Sdk.Air;
using CivitaiSharp.Sdk.Models.Coverage;
///
-/// AOT-compatible JSON converter for where TValue is .
+/// AOT-compatible JSON converter for where TKey is and TValue is .
///
-internal sealed class ProviderAssetAvailabilityDictionaryConverter : JsonConverter>
+internal sealed class ProviderAssetAvailabilityDictionaryConverter : JsonConverter>
{
///
- public override IReadOnlyDictionary Read(
+ public override IReadOnlyDictionary Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
@@ -22,7 +23,7 @@ public override IReadOnlyDictionary Read(
throw new JsonException("Expected start of object for dictionary.");
}
- var dictionary = new Dictionary();
+ var dictionary = new Dictionary();
while (reader.Read())
{
@@ -36,7 +37,12 @@ public override IReadOnlyDictionary Read(
throw new JsonException("Expected property name.");
}
- var key = reader.GetString()!;
+ var keyString = reader.GetString()!;
+ if (!AirIdentifier.TryParse(keyString, out var key))
+ {
+ throw new JsonException($"Invalid AIR identifier key: '{keyString}'");
+ }
+
reader.Read();
var availability = JsonSerializer.Deserialize(ref reader, SdkJsonContext.Default.ProviderAssetAvailability);
@@ -52,14 +58,14 @@ public override IReadOnlyDictionary Read(
///
public override void Write(
Utf8JsonWriter writer,
- IReadOnlyDictionary value,
+ IReadOnlyDictionary value,
JsonSerializerOptions options)
{
writer.WriteStartObject();
foreach (var kvp in value)
{
- writer.WritePropertyName(kvp.Key);
+ writer.WritePropertyName(kvp.Key.ToString());
JsonSerializer.Serialize(writer, kvp.Value, SdkJsonContext.Default.ProviderAssetAvailability);
}
diff --git a/Sdk/Json/Converters/SchedulerConverter.cs b/Sdk/Json/Converters/SchedulerConverter.cs
index a4929d9..4f29e59 100644
--- a/Sdk/Json/Converters/SchedulerConverter.cs
+++ b/Sdk/Json/Converters/SchedulerConverter.cs
@@ -76,6 +76,7 @@ public override void Write(Utf8JsonWriter writer, Scheduler value, JsonSerialize
///
/// AOT-compatible JSON converter for nullable .
+/// Handles null values by writing JSON null and reading null tokens appropriately.
///
internal sealed class NullableSchedulerConverter : JsonConverter
{
diff --git a/Sdk/Json/SdkJsonContext.cs b/Sdk/Json/SdkJsonContext.cs
index 2faf33e..7a19c11 100644
--- a/Sdk/Json/SdkJsonContext.cs
+++ b/Sdk/Json/SdkJsonContext.cs
@@ -4,6 +4,7 @@ namespace CivitaiSharp.Sdk.Json;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
+using CivitaiSharp.Sdk.Air;
using CivitaiSharp.Sdk.Json.Converters;
using CivitaiSharp.Sdk.Models.Coverage;
using CivitaiSharp.Sdk.Models.Jobs;
@@ -21,6 +22,7 @@ namespace CivitaiSharp.Sdk.Json;
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
Converters = [
+ typeof(AirIdentifierConverter),
typeof(SchedulerConverter),
typeof(NullableSchedulerConverter),
typeof(NetworkTypeConverter),
@@ -37,7 +39,6 @@ namespace CivitaiSharp.Sdk.Json;
[JsonSerializable(typeof(ImageJobNetworkParams))]
[JsonSerializable(typeof(ImageJobControlNet))]
[JsonSerializable(typeof(Priority))]
-[JsonSerializable(typeof(Priority?))]
// Job response types
[JsonSerializable(typeof(JobStatus))]
[JsonSerializable(typeof(JobStatusCollection))]
@@ -48,6 +49,8 @@ namespace CivitaiSharp.Sdk.Json;
[JsonSerializable(typeof(Dictionary))]
// Usage types
[JsonSerializable(typeof(ConsumptionDetails))]
+// AIR types
+[JsonSerializable(typeof(AirIdentifier))]
// Collection types
[JsonSerializable(typeof(IReadOnlyList))]
[JsonSerializable(typeof(List))]
@@ -55,8 +58,8 @@ namespace CivitaiSharp.Sdk.Json;
[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(IReadOnlyList))]
[JsonSerializable(typeof(List))]
-[JsonSerializable(typeof(IReadOnlyDictionary))]
-[JsonSerializable(typeof(Dictionary))]
+[JsonSerializable(typeof(IReadOnlyDictionary))]
+[JsonSerializable(typeof(Dictionary))]
[JsonSerializable(typeof(IReadOnlyDictionary))]
[JsonSerializable(typeof(Dictionary))]
// Primitive types for properties
diff --git a/Sdk/Models/Jobs/BatchJobRequest.cs b/Sdk/Models/Jobs/BatchJobRequest.cs
index 08e6d3c..afe12ec 100644
--- a/Sdk/Models/Jobs/BatchJobRequest.cs
+++ b/Sdk/Models/Jobs/BatchJobRequest.cs
@@ -6,7 +6,7 @@ namespace CivitaiSharp.Sdk.Models.Jobs;
///
/// Request model for batch job submission.
///
-internal sealed class BatchJobRequest
+public sealed class BatchJobRequest
{
///
/// Gets or sets the jobs to submit in this batch.
diff --git a/Sdk/Models/Jobs/ImageJobControlNet.cs b/Sdk/Models/Jobs/ImageJobControlNet.cs
index dc86ad8..922f148 100644
--- a/Sdk/Models/Jobs/ImageJobControlNet.cs
+++ b/Sdk/Models/Jobs/ImageJobControlNet.cs
@@ -1,5 +1,6 @@
namespace CivitaiSharp.Sdk.Models.Jobs;
+using System;
using System.Text.Json.Serialization;
using CivitaiSharp.Sdk.Enums;
using CivitaiSharp.Sdk.Json.Converters;
@@ -61,4 +62,28 @@ public sealed class ImageJobControlNet
///
[JsonPropertyName("endStep")]
public decimal? EndStep { get; init; }
+
+ ///
+ /// Validates that exactly one of or is provided.
+ ///
+ ///
+ /// Thrown when neither or both properties are provided.
+ ///
+ public void Validate()
+ {
+ var hasUrl = !string.IsNullOrWhiteSpace(ImageUrl);
+ var hasImage = !string.IsNullOrWhiteSpace(Image);
+
+ if (!hasUrl && !hasImage)
+ {
+ throw new InvalidOperationException(
+ "Either ImageUrl or Image must be provided for ControlNet configuration.");
+ }
+
+ if (hasUrl && hasImage)
+ {
+ throw new InvalidOperationException(
+ "Only one of ImageUrl or Image can be provided for ControlNet configuration, not both.");
+ }
+ }
}
diff --git a/Sdk/Models/Jobs/Priority.cs b/Sdk/Models/Jobs/Priority.cs
index 272344c..632f337 100644
--- a/Sdk/Models/Jobs/Priority.cs
+++ b/Sdk/Models/Jobs/Priority.cs
@@ -5,10 +5,12 @@ namespace CivitaiSharp.Sdk.Models.Jobs;
///
/// Priority configuration for job scheduling.
///
-///
-/// This type is a struct for better memory efficiency as it contains only a single decimal value.
-///
-public readonly struct Priority
+///
+/// The priority modifier value. Higher values increase priority. Maps to JSON property "modifier".
+/// Range: 0.1-10.0, default: 1.0.
+///
+public sealed record Priority(
+ [property: JsonPropertyName("modifier")] decimal Modifier)
{
///
/// The default priority modifier value.
@@ -16,21 +18,14 @@ public readonly struct Priority
public const decimal DefaultModifier = 1.0m;
///
- /// Initializes a new instance of the struct with the default modifier.
+ /// The minimum allowed priority modifier value.
///
- public Priority() => Modifier = DefaultModifier;
+ public const decimal MinModifier = 0.1m;
///
- /// Initializes a new instance of the struct with the specified modifier.
+ /// The maximum allowed priority modifier value.
///
- /// The priority modifier value.
- public Priority(decimal modifier) => Modifier = modifier;
-
- ///
- /// Gets the priority modifier. Higher values increase priority.
- ///
- [JsonPropertyName("modifier")]
- public decimal Modifier { get; init; }
+ public const decimal MaxModifier = 10.0m;
///
/// Gets the default priority configuration.
diff --git a/Sdk/Models/Jobs/TextToImageJobRequest.cs b/Sdk/Models/Jobs/TextToImageJobRequest.cs
index 091a90b..8fc6219 100644
--- a/Sdk/Models/Jobs/TextToImageJobRequest.cs
+++ b/Sdk/Models/Jobs/TextToImageJobRequest.cs
@@ -4,6 +4,7 @@ namespace CivitaiSharp.Sdk.Models.Jobs;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
+using CivitaiSharp.Sdk.Air;
///
/// Request model for text-to-image generation jobs.
@@ -26,7 +27,7 @@ public sealed class TextToImageJobRequest
///
/// urn:air:sdxl:checkpoint:civitai:4201@130072
[JsonPropertyName("model")]
- public required string Model { get; init; }
+ public required AirIdentifier Model { get; init; }
///
/// Gets or sets the generation parameters. Required.
@@ -39,7 +40,7 @@ public sealed class TextToImageJobRequest
/// Key is the AIR identifier, value is the network configuration.
///
[JsonPropertyName("additionalNetworks")]
- public IReadOnlyDictionary? AdditionalNetworks { get; init; }
+ public IReadOnlyDictionary? AdditionalNetworks { get; init; }
///
/// Gets or sets ControlNet configurations for guided generation.
diff --git a/Sdk/Request/ImageJobControlNetBuilder.cs b/Sdk/Request/ImageJobControlNetBuilder.cs
new file mode 100644
index 0000000..323abd2
--- /dev/null
+++ b/Sdk/Request/ImageJobControlNetBuilder.cs
@@ -0,0 +1,130 @@
+namespace CivitaiSharp.Sdk.Request;
+
+using CivitaiSharp.Sdk.Enums;
+using CivitaiSharp.Sdk.Models.Jobs;
+
+///
+/// Fluent builder for constructing instances.
+///
+///
+/// This builder follows an immutable design pattern. Each method returns a new instance
+/// with the updated configuration, making it thread-safe and cacheable.
+///
+public sealed record ImageJobControlNetBuilder(
+ string? ImageUrl = null,
+ string? Image = null,
+ ControlNetPreprocessor? Preprocessor = null,
+ decimal? Weight = null,
+ decimal? StartStep = null,
+ decimal? EndStep = null)
+{
+
+ ///
+ /// Creates a new instance.
+ ///
+ /// A new builder instance.
+ public static ImageJobControlNetBuilder Create() => new();
+
+ ///
+ /// Sets the URL of the control image.
+ ///
+ /// The control image URL.
+ /// A new builder instance with the updated image URL.
+ ///
+ /// Provide either this or , not both.
+ ///
+ public ImageJobControlNetBuilder WithImageUrl(string imageUrl)
+ => this with { ImageUrl = imageUrl, Image = null };
+
+ ///
+ /// Sets the base64-encoded control image data.
+ ///
+ /// The base64-encoded image data.
+ /// A new builder instance with the updated image data.
+ ///
+ ///
+ /// Provide either this or , not both.
+ ///
+ ///
+ /// Format: data:image/png;base64,iVBORw0KGgo... or raw base64 string.
+ ///
+ ///
+ public ImageJobControlNetBuilder WithImageData(string imageData)
+ => this with { Image = imageData, ImageUrl = null };
+
+ ///
+ /// Sets the preprocessor to apply to the control image.
+ ///
+ /// The preprocessor type.
+ /// A new builder instance with the updated preprocessor.
+ public ImageJobControlNetBuilder WithPreprocessor(ControlNetPreprocessor preprocessor)
+ => this with { Preprocessor = preprocessor };
+
+ ///
+ /// Sets the weight/influence of this ControlNet.
+ ///
+ /// The weight value. Range: 0.0-2.0, default: 1.0.
+ /// A new builder instance with the updated weight.
+ public ImageJobControlNetBuilder WithWeight(decimal weight)
+ => this with { Weight = weight };
+
+ ///
+ /// Sets the starting step for ControlNet influence.
+ ///
+ /// The start step. Range: 0.0-1.0.
+ /// A new builder instance with the updated start step.
+ public ImageJobControlNetBuilder WithStartStep(decimal startStep)
+ => this with { StartStep = startStep };
+
+ ///
+ /// Sets the ending step for ControlNet influence.
+ ///
+ /// The end step. Range: 0.0-1.0.
+ /// A new builder instance with the updated end step.
+ public ImageJobControlNetBuilder WithEndStep(decimal endStep)
+ => this with { EndStep = endStep };
+
+ ///
+ /// Sets the step range for ControlNet influence.
+ ///
+ /// The start step. Range: 0.0-1.0.
+ /// The end step. Range: 0.0-1.0.
+ /// A new builder instance with the updated step range.
+ public ImageJobControlNetBuilder WithStepRange(decimal startStep, decimal endStep)
+ => this with { StartStep = startStep, EndStep = endStep };
+
+ ///
+ /// Builds the instance.
+ ///
+ /// The configured .
+ ///
+ /// Thrown when neither or both image source properties are provided.
+ ///
+ public ImageJobControlNet Build()
+ {
+ var hasUrl = !string.IsNullOrWhiteSpace(ImageUrl);
+ var hasImage = !string.IsNullOrWhiteSpace(Image);
+
+ if (!hasUrl && !hasImage)
+ {
+ throw new InvalidOperationException(
+ "Either ImageUrl or ImageData must be provided. Use WithImageUrl() or WithImageData().");
+ }
+
+ if (hasUrl && hasImage)
+ {
+ throw new InvalidOperationException(
+ "Cannot provide both ImageUrl and ImageData. Use only one method.");
+ }
+
+ return new ImageJobControlNet
+ {
+ ImageUrl = ImageUrl,
+ Image = Image,
+ Preprocessor = Preprocessor,
+ Weight = Weight,
+ StartStep = StartStep,
+ EndStep = EndStep
+ };
+ }
+}
diff --git a/Sdk/Request/ImageJobNetworkParamsBuilder.cs b/Sdk/Request/ImageJobNetworkParamsBuilder.cs
new file mode 100644
index 0000000..58334b3
--- /dev/null
+++ b/Sdk/Request/ImageJobNetworkParamsBuilder.cs
@@ -0,0 +1,78 @@
+namespace CivitaiSharp.Sdk.Request;
+
+using CivitaiSharp.Sdk.Enums;
+using CivitaiSharp.Sdk.Models.Jobs;
+
+///
+/// Fluent builder for constructing instances.
+///
+///
+/// This builder follows an immutable design pattern. Each method returns a new instance
+/// with the updated configuration, making it thread-safe and cacheable.
+///
+public sealed record ImageJobNetworkParamsBuilder(
+ NetworkType? Type = null,
+ decimal? Strength = null,
+ string? TriggerWord = null,
+ decimal? ClipStrength = null)
+{
+
+ ///
+ /// Creates a new instance.
+ ///
+ /// A new builder instance.
+ public static ImageJobNetworkParamsBuilder Create() => new();
+
+ ///
+ /// Sets the network type.
+ ///
+ /// The network type (LoRA, embedding, etc.). Required.
+ /// A new builder instance with the updated type.
+ public ImageJobNetworkParamsBuilder WithType(NetworkType type)
+ => this with { Type = type };
+
+ ///
+ /// Sets the strength/weight of the network.
+ ///
+ /// The strength value. Typically 0.0-2.0, default: 1.0.
+ /// A new builder instance with the updated strength.
+ public ImageJobNetworkParamsBuilder WithStrength(decimal strength)
+ => this with { Strength = strength };
+
+ ///
+ /// Sets the trigger words for this network.
+ ///
+ /// The trigger word(s) to activate the network.
+ /// A new builder instance with the updated trigger word.
+ public ImageJobNetworkParamsBuilder WithTriggerWord(string triggerWord)
+ => this with { TriggerWord = triggerWord };
+
+ ///
+ /// Sets the CLIP strength for text encoder influence.
+ ///
+ /// The CLIP strength. Range: 0.0-2.0.
+ /// A new builder instance with the updated CLIP strength.
+ public ImageJobNetworkParamsBuilder WithClipStrength(decimal clipStrength)
+ => this with { ClipStrength = clipStrength };
+
+ ///
+ /// Builds the instance.
+ ///
+ /// The configured .
+ /// Thrown when required properties are missing.
+ public ImageJobNetworkParams Build()
+ {
+ if (Type == null)
+ {
+ throw new InvalidOperationException("Type is required. Use WithType() to set it.");
+ }
+
+ return new ImageJobNetworkParams
+ {
+ Type = Type.Value,
+ Strength = Strength,
+ TriggerWord = TriggerWord,
+ ClipStrength = ClipStrength
+ };
+ }
+}
diff --git a/Sdk/Request/ImageJobParamsBuilder.cs b/Sdk/Request/ImageJobParamsBuilder.cs
new file mode 100644
index 0000000..6c423ed
--- /dev/null
+++ b/Sdk/Request/ImageJobParamsBuilder.cs
@@ -0,0 +1,165 @@
+namespace CivitaiSharp.Sdk.Request;
+
+using CivitaiSharp.Sdk.Enums;
+using CivitaiSharp.Sdk.Models.Jobs;
+
+///
+/// Fluent builder for constructing instances.
+///
+///
+/// This builder follows an immutable design pattern. Each method returns a new instance
+/// with the updated configuration, making it thread-safe and cacheable.
+///
+public sealed record ImageJobParamsBuilder(
+ string? Prompt = null,
+ string? NegativePrompt = null,
+ Scheduler? Scheduler = null,
+ int? Steps = null,
+ decimal? CfgScale = null,
+ int? Width = null,
+ int? Height = null,
+ long? Seed = null,
+ int? ClipSkip = null,
+ string? Image = null,
+ decimal? Strength = null)
+{
+ ///
+ /// Creates a new instance.
+ ///
+ /// A new builder instance.
+ public static ImageJobParamsBuilder Create() => new();
+
+ ///
+ /// Sets the positive prompt for image generation.
+ ///
+ /// The prompt text describing what to generate. Required.
+ /// A new builder instance with the updated prompt.
+ public ImageJobParamsBuilder WithPrompt(string prompt)
+ => this with { Prompt = prompt };
+
+ ///
+ /// Sets the negative prompt describing what to avoid in the generated image.
+ ///
+ /// The negative prompt text.
+ /// A new builder instance with the updated negative prompt.
+ public ImageJobParamsBuilder WithNegativePrompt(string negativePrompt)
+ => this with { NegativePrompt = negativePrompt };
+
+ ///
+ /// Sets the sampling algorithm to use.
+ ///
+ /// The scheduler/sampler.
+ /// A new builder instance with the updated scheduler.
+ public ImageJobParamsBuilder WithScheduler(Scheduler scheduler)
+ => this with { Scheduler = scheduler };
+
+ ///
+ /// Sets the number of sampling steps.
+ ///
+ /// The step count. Range: 1-100, default: 20.
+ /// A new builder instance with the updated steps.
+ public ImageJobParamsBuilder WithSteps(int steps)
+ => this with { Steps = steps };
+
+ ///
+ /// Sets the classifier-free guidance scale.
+ ///
+ /// The CFG scale. Range: 1-30, default: 7.0.
+ /// A new builder instance with the updated CFG scale.
+ ///
+ /// Higher values make the image more closely match the prompt but may reduce quality.
+ ///
+ public ImageJobParamsBuilder WithCfgScale(decimal cfgScale)
+ => this with { CfgScale = cfgScale };
+
+ ///
+ /// Sets the image dimensions.
+ ///
+ /// The width in pixels. Must be a multiple of 8. Range: 64-2048.
+ /// The height in pixels. Must be a multiple of 8. Range: 64-2048.
+ /// A new builder instance with the updated dimensions.
+ public ImageJobParamsBuilder WithSize(int width, int height)
+ => this with { Width = width, Height = height };
+
+ ///
+ /// Sets the image width.
+ ///
+ /// The width in pixels. Must be a multiple of 8. Range: 64-2048.
+ /// A new builder instance with the updated width.
+ public ImageJobParamsBuilder WithWidth(int width)
+ => this with { Width = width };
+
+ ///
+ /// Sets the image height.
+ ///
+ /// The height in pixels. Must be a multiple of 8. Range: 64-2048.
+ /// A new builder instance with the updated height.
+ public ImageJobParamsBuilder WithHeight(int height)
+ => this with { Height = height };
+
+ ///
+ /// Sets the random seed for reproducible generation.
+ ///
+ /// The seed value.
+ /// A new builder instance with the updated seed.
+ public ImageJobParamsBuilder WithSeed(long seed)
+ => this with { Seed = seed };
+
+ ///
+ /// Sets the number of CLIP layers to skip.
+ ///
+ /// The number of layers to skip. Range: 1-12.
+ /// A new builder instance with the updated CLIP skip.
+ ///
+ /// A value of 2 is commonly used for anime/Pony models.
+ ///
+ public ImageJobParamsBuilder WithClipSkip(int clipSkip)
+ => this with { ClipSkip = clipSkip };
+
+ ///
+ /// Sets the source image URL for image-to-image generation.
+ ///
+ /// The source image URL.
+ /// A new builder instance with the updated source image.
+ public ImageJobParamsBuilder WithSourceImage(string imageUrl)
+ => this with { Image = imageUrl };
+
+ ///
+ /// Sets the denoising strength for image-to-image generation.
+ ///
+ /// The strength value. Range: 0.0-1.0.
+ /// A new builder instance with the updated strength.
+ ///
+ /// Lower values preserve more of the source image; higher values allow more change.
+ ///
+ public ImageJobParamsBuilder WithStrength(decimal strength)
+ => this with { Strength = strength };
+
+ ///
+ /// Builds the instance.
+ ///
+ /// The configured .
+ /// Thrown when required properties are missing.
+ public ImageJobParams Build()
+ {
+ if (string.IsNullOrWhiteSpace(Prompt))
+ {
+ throw new InvalidOperationException("Prompt is required. Use WithPrompt() to set it.");
+ }
+
+ return new ImageJobParams
+ {
+ Prompt = Prompt,
+ NegativePrompt = NegativePrompt,
+ Scheduler = Scheduler,
+ Steps = Steps,
+ CfgScale = CfgScale,
+ Width = Width,
+ Height = Height,
+ Seed = Seed,
+ ClipSkip = ClipSkip,
+ Image = Image,
+ Strength = Strength
+ };
+ }
+}
diff --git a/Sdk/Request/TextToImageJobBuilder.cs b/Sdk/Request/TextToImageJobBuilder.cs
new file mode 100644
index 0000000..65d6cab
--- /dev/null
+++ b/Sdk/Request/TextToImageJobBuilder.cs
@@ -0,0 +1,362 @@
+namespace CivitaiSharp.Sdk.Request;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using CivitaiSharp.Core.Response;
+using CivitaiSharp.Sdk.Air;
+using CivitaiSharp.Sdk.Models.Jobs;
+using CivitaiSharp.Sdk.Models.Results;
+using CivitaiSharp.Sdk.Services;
+
+///
+/// Fluent builder for constructing and submitting text-to-image generation jobs.
+///
+///
+/// This builder follows an immutable design pattern. Each method returns a new instance
+/// with the updated configuration, making it thread-safe and cacheable.
+///
+public sealed record TextToImageJobBuilder(
+ IJobsService? JobsService = null,
+ AirIdentifier? Model = null,
+ ImageJobParamsBuilder? ParamsBuilder = null,
+ Dictionary? AdditionalNetworks = null,
+ List? ControlNets = null,
+ int? Quantity = null,
+ Priority? Priority = null,
+ Dictionary? Properties = null,
+ string? CallbackUrl = null,
+ int? Retries = null,
+ string? Timeout = null,
+ int? ClipSkip = null)
+{
+
+ ///
+ /// Creates a new instance with the jobs service for submission.
+ ///
+ /// The jobs service for submitting jobs.
+ /// A new builder instance.
+ public static TextToImageJobBuilder Create(IJobsService jobsService)
+ {
+ ArgumentNullException.ThrowIfNull(jobsService);
+ return new TextToImageJobBuilder(JobsService: jobsService);
+ }
+
+ ///
+ /// Sets the base model to use for generation.
+ ///
+ /// The AIR identifier for the model. Required.
+ /// A new builder instance with the updated model.
+ /// urn:air:sdxl:checkpoint:civitai:4201@130072
+ public TextToImageJobBuilder WithModel(AirIdentifier model)
+ => this with { Model = model };
+
+ ///
+ /// Sets the positive prompt for image generation.
+ ///
+ /// The prompt text describing what to generate. Required.
+ /// A new builder instance with the updated prompt.
+ public TextToImageJobBuilder WithPrompt(string prompt)
+ {
+ var builder = ParamsBuilder ?? ImageJobParamsBuilder.Create();
+ return this with { ParamsBuilder = builder.WithPrompt(prompt) };
+ }
+
+ ///
+ /// Sets the negative prompt describing what to avoid.
+ ///
+ /// The negative prompt text.
+ /// A new builder instance with the updated negative prompt.
+ public TextToImageJobBuilder WithNegativePrompt(string negativePrompt)
+ {
+ var builder = ParamsBuilder ?? ImageJobParamsBuilder.Create();
+ return this with { ParamsBuilder = builder.WithNegativePrompt(negativePrompt) };
+ }
+
+ ///
+ /// Sets the image dimensions.
+ ///
+ /// The width in pixels. Must be a multiple of 8. Range: 64-2048.
+ /// The height in pixels. Must be a multiple of 8. Range: 64-2048.
+ /// A new builder instance with the updated dimensions.
+ public TextToImageJobBuilder WithSize(int width, int height)
+ {
+ var builder = ParamsBuilder ?? ImageJobParamsBuilder.Create();
+ return this with { ParamsBuilder = builder.WithSize(width, height) };
+ }
+
+ ///
+ /// Sets the number of sampling steps.
+ ///
+ /// The step count. Range: 1-100, default: 20.
+ /// A new builder instance with the updated steps.
+ public TextToImageJobBuilder WithSteps(int steps)
+ {
+ var builder = ParamsBuilder ?? ImageJobParamsBuilder.Create();
+ return this with { ParamsBuilder = builder.WithSteps(steps) };
+ }
+
+ ///
+ /// Sets the classifier-free guidance scale.
+ ///
+ /// The CFG scale. Range: 1-30, default: 7.0.
+ /// A new builder instance with the updated CFG scale.
+ public TextToImageJobBuilder WithCfgScale(decimal cfgScale)
+ {
+ var builder = ParamsBuilder ?? ImageJobParamsBuilder.Create();
+ return this with { ParamsBuilder = builder.WithCfgScale(cfgScale) };
+ }
+
+ ///
+ /// Sets the random seed for reproducible generation.
+ ///
+ /// The seed value.
+ /// A new builder instance with the updated seed.
+ public TextToImageJobBuilder WithSeed(long seed)
+ {
+ var builder = ParamsBuilder ?? ImageJobParamsBuilder.Create();
+ return this with { ParamsBuilder = builder.WithSeed(seed) };
+ }
+
+ ///
+ /// Configures generation parameters using a custom builder.
+ ///
+ /// The configured parameters builder.
+ /// A new builder instance with the updated parameters.
+ public TextToImageJobBuilder WithParams(ImageJobParamsBuilder paramsBuilder)
+ => this with { ParamsBuilder = paramsBuilder };
+
+ ///
+ /// Configures generation parameters using a configuration action.
+ ///
+ /// Action to configure the parameters builder.
+ /// A new builder instance with the updated parameters.
+ public TextToImageJobBuilder WithParams(Func configure)
+ {
+ ArgumentNullException.ThrowIfNull(configure);
+ var builder = ParamsBuilder ?? ImageJobParamsBuilder.Create();
+ return this with { ParamsBuilder = configure(builder) };
+ }
+
+ ///
+ /// Adds an additional network (LoRA, embedding, etc.) to the generation.
+ ///
+ /// The AIR identifier for the network.
+ /// The network configuration.
+ /// A new builder instance with the added network.
+ public TextToImageJobBuilder AddAdditionalNetwork(AirIdentifier network, ImageJobNetworkParams networkParams)
+ {
+ ArgumentNullException.ThrowIfNull(networkParams);
+ var networks = AdditionalNetworks != null
+ ? new Dictionary(AdditionalNetworks)
+ : new Dictionary();
+ networks[network] = networkParams;
+ return this with { AdditionalNetworks = networks };
+ }
+
+ ///
+ /// Adds an additional network (LoRA, embedding, etc.) using a builder.
+ ///
+ /// The AIR identifier for the network.
+ /// The configured network parameters builder.
+ /// A new builder instance with the added network.
+ public TextToImageJobBuilder AddAdditionalNetwork(AirIdentifier network, ImageJobNetworkParamsBuilder networkBuilder)
+ {
+ ArgumentNullException.ThrowIfNull(networkBuilder);
+ return AddAdditionalNetwork(network, networkBuilder.Build());
+ }
+
+ ///
+ /// Adds an additional network (LoRA, embedding, etc.) using a configuration action.
+ ///
+ /// The AIR identifier for the network.
+ /// Action to configure the network parameters builder.
+ /// A new builder instance with the added network.
+ public TextToImageJobBuilder AddAdditionalNetwork(AirIdentifier network, Func configure)
+ {
+ ArgumentNullException.ThrowIfNull(configure);
+ var builder = configure(ImageJobNetworkParamsBuilder.Create());
+ return AddAdditionalNetwork(network, builder.Build());
+ }
+
+ ///
+ /// Adds a ControlNet configuration for guided generation.
+ ///
+ /// The ControlNet configuration.
+ /// A new builder instance with the added ControlNet.
+ public TextToImageJobBuilder AddControlNet(ImageJobControlNet controlNet)
+ {
+ ArgumentNullException.ThrowIfNull(controlNet);
+ var controlNets = ControlNets != null
+ ? new List(ControlNets)
+ : new List();
+ controlNets.Add(controlNet);
+ return this with { ControlNets = controlNets };
+ }
+
+ ///
+ /// Adds a ControlNet configuration using a builder.
+ ///
+ /// The configured ControlNet builder.
+ /// A new builder instance with the added ControlNet.
+ public TextToImageJobBuilder AddControlNet(ImageJobControlNetBuilder controlNetBuilder)
+ {
+ ArgumentNullException.ThrowIfNull(controlNetBuilder);
+ return AddControlNet(controlNetBuilder.Build());
+ }
+
+ ///
+ /// Adds a ControlNet configuration using a configuration action.
+ ///
+ /// Action to configure the ControlNet builder.
+ /// A new builder instance with the added ControlNet.
+ public TextToImageJobBuilder AddControlNet(Func configure)
+ {
+ ArgumentNullException.ThrowIfNull(configure);
+ var builder = configure(ImageJobControlNetBuilder.Create());
+ return AddControlNet(builder.Build());
+ }
+
+ ///
+ /// Sets the number of images to generate.
+ ///
+ /// The quantity. Range: 1-10, default: 1.
+ /// A new builder instance with the updated quantity.
+ public TextToImageJobBuilder WithQuantity(int quantity)
+ => this with { Quantity = quantity };
+
+ ///
+ /// Sets the priority configuration for job scheduling.
+ ///
+ /// The priority configuration.
+ /// A new builder instance with the updated priority.
+ public TextToImageJobBuilder WithPriority(Priority priority)
+ => this with { Priority = priority };
+
+ ///
+ /// Adds a custom property for job tracking and querying.
+ ///
+ /// The property key.
+ /// The property value (must be JSON-serializable).
+ /// A new builder instance with the added property.
+ public TextToImageJobBuilder AddProperty(string key, JsonElement value)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(key);
+ var properties = Properties != null
+ ? new Dictionary(Properties)
+ : new Dictionary();
+ properties[key] = value;
+ return this with { Properties = properties };
+ }
+
+ ///
+ /// Sets the webhook URL to call when the job completes.
+ ///
+ /// The webhook URL.
+ /// A new builder instance with the updated callback URL.
+ public TextToImageJobBuilder WithCallbackUrl(string callbackUrl)
+ => this with { CallbackUrl = callbackUrl };
+
+ ///
+ /// Sets the number of automatic retries on failure.
+ ///
+ /// The number of retry attempts. Default: 0.
+ /// A new builder instance with the updated retry attempts.
+ public TextToImageJobBuilder WithRetries(int retries)
+ => this with { Retries = retries };
+
+ ///
+ /// Sets the job timeout.
+ ///
+ /// The timeout duration. Format: "HH:mm:ss". Default: "00:10:00".
+ /// A new builder instance with the updated timeout.
+ public TextToImageJobBuilder WithTimeout(string timeout)
+ => this with { Timeout = timeout };
+
+ ///
+ /// Sets the job timeout.
+ ///
+ /// The timeout duration. Default: 10 minutes.
+ /// A new builder instance with the updated timeout.
+ public TextToImageJobBuilder WithTimeout(TimeSpan timeout)
+ => this with { Timeout = timeout.ToString(@"hh\:mm\:ss") };
+
+ ///
+ /// Sets the number of CLIP layers to skip.
+ ///
+ /// The number of layers to skip. Range: 1-12.
+ /// A new builder instance with the updated CLIP skip.
+ ///
+ /// A value of 2 is commonly used for anime/Pony models.
+ /// This can also be set via .
+ ///
+ public TextToImageJobBuilder WithClipSkip(int clipSkip)
+ => this with { ClipSkip = clipSkip };
+
+ ///
+ /// Builds the instance.
+ ///
+ /// The configured .
+ /// Thrown when required properties are missing.
+ public TextToImageJobRequest Build()
+ {
+ if (Model == null)
+ {
+ throw new InvalidOperationException("Model is required. Use WithModel() to set it.");
+ }
+
+ if (ParamsBuilder == null)
+ {
+ throw new InvalidOperationException("Parameters are required. Use WithPrompt() or WithParams() to set them.");
+ }
+
+ return new TextToImageJobRequest
+ {
+ Model = Model.Value,
+ Params = ParamsBuilder.Build(),
+ AdditionalNetworks = AdditionalNetworks?.Count > 0
+ ? AdditionalNetworks
+ : null,
+ ControlNets = ControlNets?.Count > 0
+ ? ControlNets.AsReadOnly()
+ : null,
+ Quantity = Quantity,
+ Priority = Priority,
+ Properties = Properties?.Count > 0
+ ? Properties
+ : null,
+ CallbackUrl = CallbackUrl,
+ Retries = Retries,
+ Timeout = Timeout,
+ ClipSkip = ClipSkip
+ };
+ }
+
+ ///
+ /// Submits the job to the Civitai API.
+ ///
+ /// If true, blocks until the job completes (up to ~10 minutes).
+ /// If true, includes the original job specification in the response.
+ /// Token to cancel the asynchronous operation.
+ /// A task containing the job status collection with a token for polling.
+ ///
+ /// Thrown when the builder was not created via the jobs service factory method.
+ ///
+ public Task> SubmitAsync(
+ bool wait = false,
+ bool detailed = false,
+ CancellationToken cancellationToken = default)
+ {
+ if (JobsService == null)
+ {
+ throw new InvalidOperationException(
+ "Cannot submit job. This builder was not created via IJobsService.CreateTextToImage().");
+ }
+
+ var request = Build();
+ return JobsService.SubmitAsync(request, wait, detailed, cancellationToken);
+ }
+}
diff --git a/Sdk/CivitaiSdkClient.cs b/Sdk/SdkClient.cs
similarity index 83%
rename from Sdk/CivitaiSdkClient.cs
rename to Sdk/SdkClient.cs
index 3be4722..565c621 100644
--- a/Sdk/CivitaiSdkClient.cs
+++ b/Sdk/SdkClient.cs
@@ -8,18 +8,18 @@ namespace CivitaiSharp.Sdk;
/// Primary client facade for the Civitai Generator SDK. Provides access to image generation,
/// model availability checking, and usage tracking via the orchestration endpoints.
/// Obtain an instance through dependency injection using
-/// .
+/// .
///
-public sealed class CivitaiSdkClient : ICivitaiSdkClient
+public sealed class SdkClient : ISdkClient
{
///
- /// Initializes a new instance of the class.
+ /// Initializes a new instance of the class.
/// Internal to enforce dependency injection usage.
///
/// The HTTP client used to make API requests.
/// The SDK client options.
/// Thrown when or is null.
- internal CivitaiSdkClient(SdkHttpClient httpClient, CivitaiSdkClientOptions options)
+ internal SdkClient(SdkHttpClient httpClient, SdkClientOptions options)
{
ArgumentNullException.ThrowIfNull(httpClient);
ArgumentNullException.ThrowIfNull(options);
diff --git a/Sdk/CivitaiSdkClientOptions.cs b/Sdk/SdkClientOptions.cs
similarity index 85%
rename from Sdk/CivitaiSdkClientOptions.cs
rename to Sdk/SdkClientOptions.cs
index 9ef1aa4..687e9e1 100644
--- a/Sdk/CivitaiSdkClientOptions.cs
+++ b/Sdk/SdkClientOptions.cs
@@ -7,7 +7,7 @@ namespace CivitaiSharp.Sdk;
/// Unlike the public API used by CivitaiSharp.Core, the Generator API requires authentication
/// for all requests, so the property must be set before the client can be used.
///
-public sealed class CivitaiSdkClientOptions
+public sealed class SdkClientOptions
{
///
/// Default base URL for the Civitai Orchestration API.
@@ -36,13 +36,10 @@ public sealed class CivitaiSdkClientOptions
private int _timeoutSeconds = DefaultTimeoutSeconds;
///
- /// Gets or sets the API token for authentication.
- /// This is required for all SDK operations.
+ /// API token for authentication (required). Obtain from https://civitai.com/user/account
///
- ///
- /// Obtain your API token from https://civitai.com/user/account
- ///
- /// Thrown when value is null or whitespace.
+ /// Thrown when value is null.
+ /// Thrown when value is empty or whitespace.
public required string ApiToken
{
get => _apiToken;
@@ -54,9 +51,10 @@ public required string ApiToken
}
///
- /// Gets or sets the base URL for the Civitai Orchestration API.
+ /// Base URL for the Civitai Orchestration API.
///
- /// Thrown when value is null, whitespace, or not a valid absolute URI.
+ /// Thrown when value is null.
+ /// Thrown when value is empty, whitespace, or not a valid absolute URI.
public string BaseUrl
{
get => _baseUrl;
@@ -73,9 +71,10 @@ public string BaseUrl
}
///
- /// Gets or sets the API version path segment (e.g., "v1").
+ /// API version path segment (e.g., "v1").
///
- /// Thrown when value is null or whitespace.
+ /// Thrown when value is null.
+ /// Thrown when value is empty or whitespace.
public string ApiVersion
{
get => _apiVersion;
@@ -129,7 +128,8 @@ public void Validate()
///
/// The relative endpoint path (e.g., "jobs", "coverage").
/// The full API path including version prefix and consumer path.
- /// Thrown if relativePath is null or whitespace.
+ /// Thrown if relativePath is null.
+ /// Thrown if relativePath is empty or whitespace.
public string GetApiPath(string relativePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(relativePath);
diff --git a/Sdk/Services/CoverageService.cs b/Sdk/Services/CoverageService.cs
index a850ed7..fab106d 100644
--- a/Sdk/Services/CoverageService.cs
+++ b/Sdk/Services/CoverageService.cs
@@ -6,57 +6,45 @@ namespace CivitaiSharp.Sdk.Services;
using System.Threading;
using System.Threading.Tasks;
using CivitaiSharp.Core.Response;
+using CivitaiSharp.Sdk.Air;
using CivitaiSharp.Sdk.Http;
using CivitaiSharp.Sdk.Models.Coverage;
///
/// Implementation of the Coverage service for checking model availability.
///
-internal sealed class CoverageService : ICoverageService
+///
+/// This service is registered as a singleton and holds references to the HTTP client and options.
+/// It is created via dependency injection and should not be instantiated directly.
+///
+/// The HTTP client for API requests.
+/// The SDK client options.
+/// Thrown when or is null.
+internal sealed class CoverageService(SdkHttpClient httpClient, SdkClientOptions options) : ICoverageService
{
- private readonly SdkHttpClient _httpClient;
- private readonly CivitaiSdkClientOptions _options;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The HTTP client for API requests.
- /// The SDK client options.
- /// Thrown when or is null.
- internal CoverageService(SdkHttpClient httpClient, CivitaiSdkClientOptions options)
- {
- ArgumentNullException.ThrowIfNull(httpClient);
- ArgumentNullException.ThrowIfNull(options);
-
- _httpClient = httpClient;
- _options = options;
- }
-
///
- public Task>> GetAsync(
- IEnumerable models,
+ public Task>> GetAsync(
+ IEnumerable models,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(models);
// Avoid double enumeration by checking if already a list
- var modelList = models as IReadOnlyList ?? models.ToList();
+ var modelList = models as IReadOnlyList ?? models.ToList();
if (modelList.Count == 0)
{
throw new ArgumentException("At least one model is required.", nameof(models));
}
var uri = BuildCoverageUri(modelList);
- return _httpClient.GetAsync>(uri, cancellationToken);
+ return httpClient.GetAsync>(uri, cancellationToken);
}
///
public async Task> GetAsync(
- string model,
+ AirIdentifier model,
CancellationToken cancellationToken = default)
{
- ArgumentException.ThrowIfNullOrWhiteSpace(model);
-
var result = await GetAsync([model], cancellationToken).ConfigureAwait(false);
return result.Match>(
@@ -73,14 +61,14 @@ public async Task> GetAsync(
onFailure: error => new Result.Failure(error));
}
- private string BuildCoverageUri(IReadOnlyList models)
+ private string BuildCoverageUri(IReadOnlyList models)
{
- var path = _options.GetApiPath("coverage");
+ var path = options.GetApiPath("coverage");
var query = new QueryStringBuilder();
foreach (var model in models)
{
- query.Append("model", model);
+ query.Append("model", model.ToString());
}
return query.BuildUri(path);
diff --git a/Sdk/Services/ICoverageService.cs b/Sdk/Services/ICoverageService.cs
index a2852fa..87b586d 100644
--- a/Sdk/Services/ICoverageService.cs
+++ b/Sdk/Services/ICoverageService.cs
@@ -4,6 +4,7 @@ namespace CivitaiSharp.Sdk.Services;
using System.Threading;
using System.Threading.Tasks;
using CivitaiSharp.Core.Response;
+using CivitaiSharp.Sdk.Air;
using CivitaiSharp.Sdk.Models.Coverage;
///
@@ -16,11 +17,9 @@ public interface ICoverageService
///
/// The AIR identifiers of the models to check.
/// Token to cancel the asynchronous operation.
- ///
- /// A task containing a dictionary mapping AIR identifiers to their availability information.
- ///
- Task>> GetAsync(
- IEnumerable models,
+ /// Dictionary mapping AIR identifiers to their availability information.
+ Task>> GetAsync(
+ IEnumerable models,
CancellationToken cancellationToken = default);
///
@@ -28,8 +27,8 @@ Task>> GetAsync(
///
/// The AIR identifier of the model to check.
/// Token to cancel the asynchronous operation.
- /// A task containing the availability information for the model.
+ /// Availability information for the model.
Task> GetAsync(
- string model,
+ AirIdentifier model,
CancellationToken cancellationToken = default);
}
diff --git a/Sdk/Services/IJobsService.cs b/Sdk/Services/IJobsService.cs
index bd274c6..a71a27e 100644
--- a/Sdk/Services/IJobsService.cs
+++ b/Sdk/Services/IJobsService.cs
@@ -7,12 +7,30 @@ namespace CivitaiSharp.Sdk.Services;
using CivitaiSharp.Core.Response;
using CivitaiSharp.Sdk.Models.Jobs;
using CivitaiSharp.Sdk.Models.Results;
+using CivitaiSharp.Sdk.Request;
///
/// Service for submitting, tracking, and managing image generation jobs.
///
public interface IJobsService
{
+ ///
+ /// Creates a fluent builder for text-to-image generation jobs with compile-time validation.
+ ///
+ /// A new instance.
+ ///
+ ///
+ /// var result = await client.Jobs
+ /// .CreateTextToImage()
+ /// .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ /// .WithPrompt("A beautiful sunset over mountains")
+ /// .WithSize(1024, 1024)
+ /// .WithSteps(30)
+ /// .SubmitAsync();
+ ///
+ ///
+ TextToImageJobBuilder CreateTextToImage();
+
///
/// Submits a single text-to-image generation job.
///
diff --git a/Sdk/Services/IUsageService.cs b/Sdk/Services/IUsageService.cs
index 91ddd05..49ae281 100644
--- a/Sdk/Services/IUsageService.cs
+++ b/Sdk/Services/IUsageService.cs
@@ -14,10 +14,10 @@ public interface IUsageService
///
/// Gets account consumption statistics for the specified period.
///
- /// Optional start date for the reporting period (ISO 8601 format).
- /// Optional end date for the reporting period (ISO 8601 format).
+ /// Start date for reporting period (ISO 8601 format).
+ /// End date for reporting period (ISO 8601 format).
/// Token to cancel the asynchronous operation.
- /// A task containing the consumption details.
+ /// Consumption details.
Task> GetConsumptionAsync(
DateTime? startDate = null,
DateTime? endDate = null,
diff --git a/Sdk/Services/JobsService.cs b/Sdk/Services/JobsService.cs
index 7d8002a..f4766ff 100644
--- a/Sdk/Services/JobsService.cs
+++ b/Sdk/Services/JobsService.cs
@@ -9,29 +9,23 @@ namespace CivitaiSharp.Sdk.Services;
using CivitaiSharp.Sdk.Http;
using CivitaiSharp.Sdk.Models.Jobs;
using CivitaiSharp.Sdk.Models.Results;
+using CivitaiSharp.Sdk.Request;
///
/// Implementation of the Jobs service for managing image generation jobs.
///
-internal sealed class JobsService : IJobsService
+///
+/// This service is registered as a singleton and holds references to the HTTP client and options.
+/// It is created via dependency injection and should not be instantiated directly.
+///
+/// The HTTP client for API requests.
+/// The SDK client options.
+/// Thrown when or is null.
+internal sealed class JobsService(SdkHttpClient httpClient, SdkClientOptions options) : IJobsService
{
- private readonly SdkHttpClient _httpClient;
- private readonly CivitaiSdkClientOptions _options;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The HTTP client for API requests.
- /// The SDK client options.
- /// Thrown when or is null.
- internal JobsService(SdkHttpClient httpClient, CivitaiSdkClientOptions options)
- {
- ArgumentNullException.ThrowIfNull(httpClient);
- ArgumentNullException.ThrowIfNull(options);
-
- _httpClient = httpClient;
- _options = options;
- }
+ ///
+ public TextToImageJobBuilder CreateTextToImage()
+ => TextToImageJobBuilder.Create(this);
///
public Task> SubmitAsync(
@@ -42,8 +36,17 @@ public Task> SubmitAsync(
{
ArgumentNullException.ThrowIfNull(request);
+ // Validate ControlNet configurations if present
+ if (request.ControlNets is not null)
+ {
+ foreach (var controlNet in request.ControlNets)
+ {
+ controlNet.Validate();
+ }
+ }
+
var uri = BuildJobsUri(wait, detailed);
- return _httpClient.PostAsync(uri, request, cancellationToken);
+ return httpClient.PostAsync(uri, request, cancellationToken);
}
///
@@ -62,9 +65,21 @@ public Task> SubmitBatchAsync(
throw new ArgumentException("At least one job request is required.", nameof(requests));
}
+ // Validate all ControlNet configurations if present
+ foreach (var request in jobsList)
+ {
+ if (request.ControlNets is not null)
+ {
+ foreach (var controlNet in request.ControlNets)
+ {
+ controlNet.Validate();
+ }
+ }
+ }
+
var uri = BuildJobsUri(wait, detailed);
var batchRequest = new BatchJobRequest { Jobs = jobsList };
- return _httpClient.PostAsync(uri, batchRequest, cancellationToken);
+ return httpClient.PostAsync(uri, batchRequest, cancellationToken);
}
///
@@ -76,7 +91,7 @@ public Task> GetByIdAsync(
ArgumentOutOfRangeException.ThrowIfEqual(jobId, Guid.Empty, nameof(jobId));
var uri = BuildUri($"jobs/{jobId}", detailed: detailed);
- return _httpClient.GetAsync(uri, cancellationToken);
+ return httpClient.GetAsync(uri, cancellationToken);
}
///
@@ -89,7 +104,7 @@ public Task> GetByTokenAsync(
ArgumentException.ThrowIfNullOrWhiteSpace(token);
var uri = BuildUri("jobs", token: token, wait: wait, detailed: detailed);
- return _httpClient.GetAsync(uri, cancellationToken);
+ return httpClient.GetAsync(uri, cancellationToken);
}
///
@@ -101,7 +116,7 @@ public Task> QueryAsync(
ArgumentNullException.ThrowIfNull(request);
var uri = BuildUri("jobs/query", detailed: detailed);
- return _httpClient.PostAsync(uri, request, cancellationToken);
+ return httpClient.PostAsync(uri, request, cancellationToken);
}
///
@@ -113,7 +128,7 @@ public Task> CancelByIdAsync(
ArgumentOutOfRangeException.ThrowIfEqual(jobId, Guid.Empty, nameof(jobId));
var uri = BuildUri($"jobs/{jobId}", force: force);
- return _httpClient.DeleteAsync(uri, cancellationToken);
+ return httpClient.DeleteAsync(uri, cancellationToken);
}
///
@@ -125,7 +140,7 @@ public Task> CancelByTokenAsync(
ArgumentException.ThrowIfNullOrWhiteSpace(token);
var uri = BuildUri("jobs", token: token, force: force);
- return _httpClient.DeleteAsync(uri, cancellationToken);
+ return httpClient.DeleteAsync(uri, cancellationToken);
}
///
@@ -135,8 +150,8 @@ public Task> TaintByIdAsync(
{
ArgumentOutOfRangeException.ThrowIfEqual(jobId, Guid.Empty, nameof(jobId));
- var uri = _options.GetApiPath($"jobs/{jobId}/taint");
- return _httpClient.PutAsync(uri, cancellationToken);
+ var uri = options.GetApiPath($"jobs/{jobId}/taint");
+ return httpClient.PutAsync(uri, cancellationToken);
}
///
@@ -147,7 +162,7 @@ public Task> TaintByTokenAsync(
ArgumentException.ThrowIfNullOrWhiteSpace(token);
var uri = BuildUri("jobs/taint", token: token);
- return _httpClient.PutAsync(uri, cancellationToken);
+ return httpClient.PutAsync(uri, cancellationToken);
}
private string BuildJobsUri(bool wait, bool detailed)
@@ -155,7 +170,7 @@ private string BuildJobsUri(bool wait, bool detailed)
var query = new QueryStringBuilder()
.AppendIf("wait", wait)
.AppendIf("detailed", detailed);
- return query.BuildUri(_options.GetApiPath("jobs"));
+ return query.BuildUri(options.GetApiPath("jobs"));
}
private string BuildUri(
@@ -170,6 +185,6 @@ private string BuildUri(
.AppendIf("wait", wait)
.AppendIf("detailed", detailed)
.Append("force", force);
- return query.BuildUri(_options.GetApiPath(relativePath));
+ return query.BuildUri(options.GetApiPath(relativePath));
}
}
diff --git a/Sdk/Services/UsageService.cs b/Sdk/Services/UsageService.cs
index 860efba..eefc8c1 100644
--- a/Sdk/Services/UsageService.cs
+++ b/Sdk/Services/UsageService.cs
@@ -10,26 +10,15 @@ namespace CivitaiSharp.Sdk.Services;
///
/// Implementation of the Usage service for monitoring account consumption.
///
-internal sealed class UsageService : IUsageService
+///
+/// This service is registered as a singleton and holds references to the HTTP client and options.
+/// It is created via dependency injection and should not be instantiated directly.
+///
+/// The HTTP client for API requests.
+/// The SDK client options.
+/// Thrown when or is null.
+internal sealed class UsageService(SdkHttpClient httpClient, SdkClientOptions options) : IUsageService
{
- private readonly SdkHttpClient _httpClient;
- private readonly CivitaiSdkClientOptions _options;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The HTTP client for API requests.
- /// The SDK client options.
- /// Thrown when or is null.
- internal UsageService(SdkHttpClient httpClient, CivitaiSdkClientOptions options)
- {
- ArgumentNullException.ThrowIfNull(httpClient);
- ArgumentNullException.ThrowIfNull(options);
-
- _httpClient = httpClient;
- _options = options;
- }
-
///
public Task> GetConsumptionAsync(
DateTime? startDate = null,
@@ -40,7 +29,7 @@ public Task> GetConsumptionAsync(
.Append("startDate", startDate)
.Append("endDate", endDate);
- var uri = query.BuildUri(_options.GetApiPath("consumption"));
- return _httpClient.GetAsync(uri, cancellationToken);
+ var uri = query.BuildUri(options.GetApiPath("consumption"));
+ return httpClient.GetAsync(uri, cancellationToken);
}
}
diff --git a/Tests/CivitaiSharp.Sdk.Tests/Air/AirBuilderTests.cs b/Tests/CivitaiSharp.Sdk.Tests/Air/AirBuilderTests.cs
new file mode 100644
index 0000000..8be49aa
--- /dev/null
+++ b/Tests/CivitaiSharp.Sdk.Tests/Air/AirBuilderTests.cs
@@ -0,0 +1,594 @@
+namespace CivitaiSharp.Sdk.Tests.Air;
+
+using System;
+using CivitaiSharp.Sdk.Air;
+using Xunit;
+
+public sealed class AirBuilderTests : IClassFixture
+{
+ #region Successful Build Tests
+
+ [Fact]
+ public void WhenAllRequiredPropertiesAreSetThenBuildSucceeds()
+ {
+ // Arrange
+ var builder = new AirBuilder();
+
+ // Act
+ var result = builder
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Lora)
+ .WithModelId(328553)
+ .WithVersionId(368189)
+ .Build();
+
+ // Assert
+ Assert.Equal(AirEcosystem.StableDiffusionXl, result.Ecosystem);
+ Assert.Equal(AirAssetType.Lora, result.AssetType);
+ Assert.Equal(AirSource.Civitai, result.Source);
+ Assert.Equal(328553, result.ModelId);
+ Assert.Equal(368189, result.VersionId);
+ }
+
+ [Fact]
+ public void WhenBuildingWithDefaultSourceThenSourceIsCivitai()
+ {
+ // Arrange & Act
+ var result = new AirBuilder()
+ .WithEcosystem(AirEcosystem.Flux1)
+ .WithAssetType(AirAssetType.Checkpoint)
+ .WithModelId(12345)
+ .WithVersionId(67890)
+ .Build();
+
+ // Assert
+ Assert.Equal(AirSource.Civitai, result.Source);
+ }
+
+ [Fact]
+ public void WhenBuildingWithCustomSourceThenSourceIsSet()
+ {
+ // Arrange & Act
+ var result = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusion1)
+ .WithAssetType(AirAssetType.Vae)
+ .WithSource(AirSource.HuggingFace)
+ .WithModelId(100)
+ .WithVersionId(200)
+ .Build();
+
+ // Assert
+ Assert.Equal(AirSource.HuggingFace, result.Source);
+ }
+
+ [Fact]
+ public void WhenBuildingWithAllEcosystemsThenAllSucceed()
+ {
+ // Arrange
+ var ecosystems = new[]
+ {
+ AirEcosystem.StableDiffusion1,
+ AirEcosystem.StableDiffusion2,
+ AirEcosystem.StableDiffusionXl,
+ AirEcosystem.Flux1,
+ AirEcosystem.Pony
+ };
+
+ // Act & Assert
+ foreach (var ecosystem in ecosystems)
+ {
+ var result = new AirBuilder()
+ .WithEcosystem(ecosystem)
+ .WithAssetType(AirAssetType.Checkpoint)
+ .WithModelId(1)
+ .WithVersionId(1)
+ .Build();
+
+ Assert.Equal(ecosystem, result.Ecosystem);
+ }
+ }
+
+ [Fact]
+ public void WhenBuildingWithAllAssetTypesThenAllSucceed()
+ {
+ // Arrange
+ var assetTypes = new[]
+ {
+ AirAssetType.Checkpoint,
+ AirAssetType.Lora,
+ AirAssetType.Lycoris,
+ AirAssetType.Vae,
+ AirAssetType.Embedding,
+ AirAssetType.Hypernetwork
+ };
+
+ // Act & Assert
+ foreach (var assetType in assetTypes)
+ {
+ var result = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(assetType)
+ .WithModelId(1)
+ .WithVersionId(1)
+ .Build();
+
+ Assert.Equal(assetType, result.AssetType);
+ }
+ }
+
+ #endregion
+
+ #region Fluent API Chaining Tests
+
+ [Fact]
+ public void WhenUsingFluentApiThenMethodsChainingWorks()
+ {
+ // Arrange & Act
+ var result = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Lora)
+ .WithModelId(328553)
+ .WithVersionId(368189)
+ .WithSource(AirSource.Civitai)
+ .Build();
+
+ // Assert
+ Assert.Equal(AirEcosystem.StableDiffusionXl, result.Ecosystem);
+ Assert.Equal(AirAssetType.Lora, result.AssetType);
+ }
+
+ [Fact]
+ public void WhenSettingPropertiesInDifferentOrderThenBuildSucceeds()
+ {
+ // Arrange & Act
+ var result = new AirBuilder()
+ .WithVersionId(368189)
+ .WithModelId(328553)
+ .WithAssetType(AirAssetType.Lora)
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .Build();
+
+ // Assert
+ Assert.Equal(AirEcosystem.StableDiffusionXl, result.Ecosystem);
+ Assert.Equal(AirAssetType.Lora, result.AssetType);
+ Assert.Equal(328553, result.ModelId);
+ Assert.Equal(368189, result.VersionId);
+ }
+
+ #endregion
+
+ #region Immutability Tests
+
+ [Fact]
+ public void WhenWithMethodIsCalledThenOriginalBuilderIsUnchanged()
+ {
+ // Arrange
+ var original = new AirBuilder();
+
+ // Act
+ var modified = original
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Lora)
+ .WithModelId(328553)
+ .WithVersionId(368189);
+
+ // Assert - original should still fail to build (unchanged)
+ Assert.Throws(() => original.Build());
+
+ // modified should build successfully
+ var result = modified.Build();
+ Assert.Equal(AirEcosystem.StableDiffusionXl, result.Ecosystem);
+ }
+
+ [Fact]
+ public void WhenBuildingFromSameBaseBuilderThenResultsAreIndependent()
+ {
+ // Arrange
+ var baseBuilder = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Lora);
+
+ // Act - create two different configurations from the same base
+ var air1 = baseBuilder
+ .WithModelId(100)
+ .WithVersionId(200)
+ .Build();
+
+ var air2 = baseBuilder
+ .WithModelId(300)
+ .WithVersionId(400)
+ .Build();
+
+ // Assert - both should have SDXL/Lora but different IDs
+ Assert.Equal(AirEcosystem.StableDiffusionXl, air1.Ecosystem);
+ Assert.Equal(AirEcosystem.StableDiffusionXl, air2.Ecosystem);
+ Assert.Equal(100, air1.ModelId);
+ Assert.Equal(300, air2.ModelId);
+ }
+
+ [Fact]
+ public void WhenNewBuilderIsCreatedThenSourceDefaultsToCivitai()
+ {
+ // Arrange & Act
+ var result = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Lora)
+ .WithModelId(1)
+ .WithVersionId(1)
+ .Build();
+
+ // Assert
+ Assert.Equal(AirSource.Civitai, result.Source);
+ }
+
+ #endregion
+
+ #region TryBuild Tests
+
+ [Fact]
+ public void WhenAllRequiredPropertiesAreSetThenTryBuildSucceeds()
+ {
+ // Arrange
+ var builder = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Lora)
+ .WithModelId(328553)
+ .WithVersionId(368189);
+
+ // Act
+ var success = builder.TryBuild(out var result);
+
+ // Assert
+ Assert.True(success);
+ Assert.Equal(AirEcosystem.StableDiffusionXl, result.Ecosystem);
+ Assert.Equal(AirAssetType.Lora, result.AssetType);
+ Assert.Equal(AirSource.Civitai, result.Source);
+ Assert.Equal(328553, result.ModelId);
+ Assert.Equal(368189, result.VersionId);
+ }
+
+ [Fact]
+ public void WhenEcosystemIsMissingThenTryBuildReturnsFalse()
+ {
+ // Arrange
+ var builder = new AirBuilder()
+ .WithAssetType(AirAssetType.Lora)
+ .WithModelId(328553)
+ .WithVersionId(368189);
+
+ // Act
+ var success = builder.TryBuild(out var result);
+
+ // Assert
+ Assert.False(success);
+ Assert.Equal(default, result);
+ }
+
+ [Fact]
+ public void WhenAssetTypeIsMissingThenTryBuildReturnsFalse()
+ {
+ // Arrange
+ var builder = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithModelId(328553)
+ .WithVersionId(368189);
+
+ // Act
+ var success = builder.TryBuild(out _);
+
+ // Assert
+ Assert.False(success);
+ }
+
+ [Fact]
+ public void WhenModelIdIsMissingThenTryBuildReturnsFalse()
+ {
+ // Arrange
+ var builder = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Lora)
+ .WithVersionId(368189);
+
+ // Act
+ var success = builder.TryBuild(out _);
+
+ // Assert
+ Assert.False(success);
+ }
+
+ [Fact]
+ public void WhenVersionIdIsMissingThenTryBuildReturnsFalse()
+ {
+ // Arrange
+ var builder = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Lora)
+ .WithModelId(328553);
+
+ // Act
+ var success = builder.TryBuild(out _);
+
+ // Assert
+ Assert.False(success);
+ }
+
+ [Fact]
+ public void WhenNoPropertiesAreSetThenTryBuildReturnsFalse()
+ {
+ // Arrange
+ var builder = new AirBuilder();
+
+ // Act
+ var success = builder.TryBuild(out _);
+
+ // Assert
+ Assert.False(success);
+ }
+
+ [Fact]
+ public void WhenTryBuildSucceedsThenResultMatchesBuild()
+ {
+ // Arrange
+ var builder = new AirBuilder()
+ .WithEcosystem(AirEcosystem.Flux1)
+ .WithAssetType(AirAssetType.Checkpoint)
+ .WithSource(AirSource.HuggingFace)
+ .WithModelId(4201)
+ .WithVersionId(130072);
+
+ // Act
+ var buildResult = builder.Build();
+ var tryBuildSuccess = builder.TryBuild(out var tryBuildResult);
+
+ // Assert
+ Assert.True(tryBuildSuccess);
+ Assert.Equal(buildResult, tryBuildResult);
+ }
+
+ #endregion
+
+ #region Validation Tests - Missing Required Properties
+
+ [Fact]
+ public void WhenBuildingWithoutEcosystemThenThrowsInvalidOperationException()
+ {
+ // Arrange
+ var builder = new AirBuilder()
+ .WithAssetType(AirAssetType.Lora)
+ .WithModelId(328553)
+ .WithVersionId(368189);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => builder.Build());
+ Assert.Contains("Ecosystem", exception.Message);
+ Assert.Contains("WithEcosystem", exception.Message);
+ }
+
+ [Fact]
+ public void WhenBuildingWithoutAssetTypeThenThrowsInvalidOperationException()
+ {
+ // Arrange
+ var builder = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithModelId(328553)
+ .WithVersionId(368189);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => builder.Build());
+ Assert.Contains("AssetType", exception.Message);
+ Assert.Contains("WithAssetType", exception.Message);
+ }
+
+ [Fact]
+ public void WhenBuildingWithoutModelIdThenThrowsInvalidOperationException()
+ {
+ // Arrange
+ var builder = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Lora)
+ .WithVersionId(368189);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => builder.Build());
+ Assert.Contains("ModelId", exception.Message);
+ Assert.Contains("WithModelId", exception.Message);
+ }
+
+ [Fact]
+ public void WhenBuildingWithoutVersionIdThenThrowsInvalidOperationException()
+ {
+ // Arrange
+ var builder = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Lora)
+ .WithModelId(328553);
+
+ // Act & Assert
+ var exception = Assert.Throws(() => builder.Build());
+ Assert.Contains("VersionId", exception.Message);
+ Assert.Contains("WithVersionId", exception.Message);
+ }
+
+ #endregion
+
+ #region Validation Tests - Invalid Values
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(-100)]
+ public void WhenSettingInvalidModelIdThenThrowsArgumentOutOfRangeException(long invalidModelId)
+ {
+ // Arrange
+ var builder = new AirBuilder();
+
+ // Act & Assert
+ Assert.Throws(() => builder.WithModelId(invalidModelId));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(-100)]
+ public void WhenSettingInvalidVersionIdThenThrowsArgumentOutOfRangeException(long invalidVersionId)
+ {
+ // Arrange
+ var builder = new AirBuilder();
+
+ // Act & Assert
+ Assert.Throws(() => builder.WithVersionId(invalidVersionId));
+ }
+
+ [Fact]
+ public void WhenSettingAllSourceTypesThenBuildSucceeds()
+ {
+ // Arrange
+ var sources = new[]
+ {
+ AirSource.Civitai,
+ AirSource.HuggingFace,
+ AirSource.OpenAi,
+ AirSource.Leonardo
+ };
+
+ // Act & Assert
+ foreach (var source in sources)
+ {
+ var result = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Checkpoint)
+ .WithSource(source)
+ .WithModelId(1)
+ .WithVersionId(1)
+ .Build();
+
+ Assert.Equal(source, result.Source);
+ }
+ }
+
+ #endregion
+
+ #region Edge Case Tests
+
+ [Fact]
+ public void WhenSettingLargeModelIdThenBuildSucceeds()
+ {
+ // Arrange & Act
+ var result = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Lora)
+ .WithModelId(long.MaxValue)
+ .WithVersionId(1)
+ .Build();
+
+ // Assert
+ Assert.Equal(long.MaxValue, result.ModelId);
+ }
+
+ [Fact]
+ public void WhenSettingLargeVersionIdThenBuildSucceeds()
+ {
+ // Arrange & Act
+ var result = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Lora)
+ .WithModelId(1)
+ .WithVersionId(long.MaxValue)
+ .Build();
+
+ // Assert
+ Assert.Equal(long.MaxValue, result.VersionId);
+ }
+
+ [Fact]
+ public void WhenOverwritingPropertyThenLastValueIsUsed()
+ {
+ // Arrange & Act
+ var result = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusion1)
+ .WithEcosystem(AirEcosystem.Flux1) // Overwrite
+ .WithAssetType(AirAssetType.Checkpoint)
+ .WithModelId(100)
+ .WithModelId(200) // Overwrite
+ .WithVersionId(1)
+ .Build();
+
+ // Assert
+ Assert.Equal(AirEcosystem.Flux1, result.Ecosystem);
+ Assert.Equal(200, result.ModelId);
+ }
+
+ #endregion
+
+ #region Integration Tests with AirIdentifier
+
+ [Fact]
+ public void WhenBuiltAirIdentifierIsConvertedToStringThenFormatIsCorrect()
+ {
+ // Arrange & Act
+ var air = new AirBuilder()
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Lora)
+ .WithModelId(328553)
+ .WithVersionId(368189)
+ .Build();
+
+ var airString = air.ToString();
+
+ // Assert
+ Assert.Equal("urn:air:sdxl:lora:civitai:328553@368189", airString);
+ }
+
+ [Fact]
+ public void WhenBuiltAirIdentifierCanBeParsedBackThenValuesMatch()
+ {
+ // Arrange
+ var original = new AirBuilder()
+ .WithEcosystem(AirEcosystem.Flux1)
+ .WithAssetType(AirAssetType.Checkpoint)
+ .WithModelId(4201)
+ .WithVersionId(130072)
+ .Build();
+
+ // Act
+ var airString = original.ToString();
+ var parsed = AirIdentifier.Parse(airString);
+
+ // Assert
+ Assert.Equal(original.Ecosystem, parsed.Ecosystem);
+ Assert.Equal(original.AssetType, parsed.AssetType);
+ Assert.Equal(original.Source, parsed.Source);
+ Assert.Equal(original.ModelId, parsed.ModelId);
+ Assert.Equal(original.VersionId, parsed.VersionId);
+ }
+
+ [Fact]
+ public void WhenBuildingMultipleAirIdentifiersFromSameBuilderThenAllAreIndependent()
+ {
+ // Arrange - immutable builder allows safe reuse
+ var baseBuilder = new AirBuilder();
+
+ // Act
+ var air1 = baseBuilder
+ .WithEcosystem(AirEcosystem.StableDiffusionXl)
+ .WithAssetType(AirAssetType.Lora)
+ .WithModelId(100)
+ .WithVersionId(200)
+ .Build();
+
+ var air2 = baseBuilder
+ .WithEcosystem(AirEcosystem.Flux1)
+ .WithAssetType(AirAssetType.Checkpoint)
+ .WithModelId(300)
+ .WithVersionId(400)
+ .Build();
+
+ // Assert
+ Assert.NotEqual(air1.Ecosystem, air2.Ecosystem);
+ Assert.NotEqual(air1.AssetType, air2.AssetType);
+ Assert.NotEqual(air1.ModelId, air2.ModelId);
+ Assert.NotEqual(air1.VersionId, air2.VersionId);
+ }
+
+ #endregion
+}
diff --git a/Tests/CivitaiSharp.Sdk.Tests/Air/AirIdentifierTests.cs b/Tests/CivitaiSharp.Sdk.Tests/Air/AirIdentifierTests.cs
index 79f4037..b14800b 100644
--- a/Tests/CivitaiSharp.Sdk.Tests/Air/AirIdentifierTests.cs
+++ b/Tests/CivitaiSharp.Sdk.Tests/Air/AirIdentifierTests.cs
@@ -14,54 +14,18 @@ public void WhenCreatingWithValidParametersThenPropertiesAreSet()
var air = new AirIdentifier(
AirEcosystem.StableDiffusionXl,
AirAssetType.Checkpoint,
- "civitai",
+ AirSource.Civitai,
4201,
130072);
// Assert
Assert.Equal(AirEcosystem.StableDiffusionXl, air.Ecosystem);
Assert.Equal(AirAssetType.Checkpoint, air.AssetType);
- Assert.Equal("civitai", air.Source);
+ Assert.Equal(AirSource.Civitai, air.Source);
Assert.Equal(4201, air.ModelId);
Assert.Equal(130072, air.VersionId);
}
- [Fact]
- public void WhenCreatingWithNullSourceThenThrowsArgumentNullException()
- {
- // Act & Assert
- Assert.Throws(() => new AirIdentifier(
- AirEcosystem.StableDiffusionXl,
- AirAssetType.Checkpoint,
- null!,
- 4201,
- 130072));
- }
-
- [Fact]
- public void WhenCreatingWithEmptySourceThenThrowsArgumentException()
- {
- // Act & Assert
- Assert.Throws(() => new AirIdentifier(
- AirEcosystem.StableDiffusionXl,
- AirAssetType.Checkpoint,
- "",
- 4201,
- 130072));
- }
-
- [Fact]
- public void WhenCreatingWithWhitespaceSourceThenThrowsArgumentException()
- {
- // Act & Assert
- Assert.Throws(() => new AirIdentifier(
- AirEcosystem.StableDiffusionXl,
- AirAssetType.Checkpoint,
- " ",
- 4201,
- 130072));
- }
-
[Fact]
public void WhenCreatingWithZeroModelIdThenThrowsArgumentOutOfRangeException()
{
@@ -69,7 +33,7 @@ public void WhenCreatingWithZeroModelIdThenThrowsArgumentOutOfRangeException()
Assert.Throws(() => new AirIdentifier(
AirEcosystem.StableDiffusionXl,
AirAssetType.Checkpoint,
- "civitai",
+ AirSource.Civitai,
0,
130072));
}
@@ -81,7 +45,7 @@ public void WhenCreatingWithNegativeModelIdThenThrowsArgumentOutOfRangeException
Assert.Throws(() => new AirIdentifier(
AirEcosystem.StableDiffusionXl,
AirAssetType.Checkpoint,
- "civitai",
+ AirSource.Civitai,
-1,
130072));
}
@@ -93,7 +57,7 @@ public void WhenCreatingWithZeroVersionIdThenThrowsArgumentOutOfRangeException()
Assert.Throws(() => new AirIdentifier(
AirEcosystem.StableDiffusionXl,
AirAssetType.Checkpoint,
- "civitai",
+ AirSource.Civitai,
4201,
0));
}
@@ -105,7 +69,7 @@ public void WhenCreatingWithNegativeVersionIdThenThrowsArgumentOutOfRangeExcepti
Assert.Throws(() => new AirIdentifier(
AirEcosystem.StableDiffusionXl,
AirAssetType.Checkpoint,
- "civitai",
+ AirSource.Civitai,
4201,
-1));
}
@@ -125,7 +89,7 @@ public void WhenCreatingViaFactoryMethodThenSourceIsCivitai()
67890);
// Assert
- Assert.Equal("civitai", air.Source);
+ Assert.Equal(AirSource.Civitai, air.Source);
Assert.Equal(AirEcosystem.Flux1, air.Ecosystem);
Assert.Equal(AirAssetType.Lora, air.AssetType);
Assert.Equal(12345, air.ModelId);
@@ -149,22 +113,26 @@ public void WhenParsingValidAirStringThenReturnsTrue()
Assert.True(success);
Assert.Equal(AirEcosystem.StableDiffusionXl, result.Ecosystem);
Assert.Equal(AirAssetType.Checkpoint, result.AssetType);
- Assert.Equal("civitai", result.Source);
+ Assert.Equal(AirSource.Civitai, result.Source);
Assert.Equal(4201, result.ModelId);
Assert.Equal(130072, result.VersionId);
}
[Theory]
- [InlineData("urn:air:sd1:lora:civitai:100@200", AirEcosystem.StableDiffusion1, AirAssetType.Lora)]
- [InlineData("urn:air:sd2:vae:civitai:300@400", AirEcosystem.StableDiffusion2, AirAssetType.Vae)]
- [InlineData("urn:air:flux1:checkpoint:civitai:500@600", AirEcosystem.Flux1, AirAssetType.Checkpoint)]
- [InlineData("urn:air:pony:embedding:civitai:700@800", AirEcosystem.Pony, AirAssetType.Embedding)]
- [InlineData("urn:air:sdxl:lycoris:civitai:900@1000", AirEcosystem.StableDiffusionXl, AirAssetType.Lycoris)]
- [InlineData("urn:air:sdxl:hypernet:civitai:1100@1200", AirEcosystem.StableDiffusionXl, AirAssetType.Hypernetwork)]
+ [InlineData("urn:air:sd1:lora:civitai:100@200", AirEcosystem.StableDiffusion1, AirAssetType.Lora, AirSource.Civitai)]
+ [InlineData("urn:air:sd2:vae:civitai:300@400", AirEcosystem.StableDiffusion2, AirAssetType.Vae, AirSource.Civitai)]
+ [InlineData("urn:air:flux1:checkpoint:civitai:500@600", AirEcosystem.Flux1, AirAssetType.Checkpoint, AirSource.Civitai)]
+ [InlineData("urn:air:pony:embedding:civitai:700@800", AirEcosystem.Pony, AirAssetType.Embedding, AirSource.Civitai)]
+ [InlineData("urn:air:sdxl:lycoris:civitai:900@1000", AirEcosystem.StableDiffusionXl, AirAssetType.Lycoris, AirSource.Civitai)]
+ [InlineData("urn:air:sdxl:hypernet:civitai:1100@1200", AirEcosystem.StableDiffusionXl, AirAssetType.Hypernetwork, AirSource.Civitai)]
+ [InlineData("urn:air:sdxl:checkpoint:huggingface:100@200", AirEcosystem.StableDiffusionXl, AirAssetType.Checkpoint, AirSource.HuggingFace)]
+ [InlineData("urn:air:flux1:lora:openai:300@400", AirEcosystem.Flux1, AirAssetType.Lora, AirSource.OpenAi)]
+ [InlineData("urn:air:sd1:checkpoint:leonardo:500@600", AirEcosystem.StableDiffusion1, AirAssetType.Checkpoint, AirSource.Leonardo)]
public void WhenParsingVariousValidAirStringsThenParsesCorrectly(
string airString,
AirEcosystem expectedEcosystem,
- AirAssetType expectedAssetType)
+ AirAssetType expectedAssetType,
+ AirSource expectedSource)
{
// Act
var success = AirIdentifier.TryParse(airString, out var result);
@@ -173,6 +141,7 @@ public void WhenParsingVariousValidAirStringsThenParsesCorrectly(
Assert.True(success);
Assert.Equal(expectedEcosystem, result.Ecosystem);
Assert.Equal(expectedAssetType, result.AssetType);
+ Assert.Equal(expectedSource, result.Source);
}
[Fact]
@@ -225,6 +194,8 @@ public void WhenParsingWhitespaceStringThenReturnsFalse()
[InlineData("not-an-air-string")]
[InlineData("urn:air:invalid:checkpoint:civitai:4201@130072")]
[InlineData("urn:air:sdxl:invalid:civitai:4201@130072")]
+ [InlineData("urn:air:sdxl:checkpoint:unsupported:4201@130072")] // Invalid source
+ [InlineData("urn:air:sdxl:checkpoint:github:4201@130072")] // Invalid source
[InlineData("urn:air:sdxl:checkpoint:civitai:abc@130072")]
[InlineData("urn:air:sdxl:checkpoint:civitai:4201@abc")]
[InlineData("urn:air:sdxl:checkpoint:civitai:0@130072")]
@@ -386,15 +357,16 @@ public void WhenComparingDifferentVersionIdsThenReturnsFalse()
}
[Fact]
- public void WhenComparingWithSourceCaseDifferenceThenReturnsTrue()
+ public void WhenComparingDifferentSourcesThenReturnsFalse()
{
- // Arrange - Source comparison should be case-insensitive
- var air1 = new AirIdentifier(AirEcosystem.StableDiffusionXl, AirAssetType.Checkpoint, "civitai", 4201, 130072);
- var air2 = new AirIdentifier(AirEcosystem.StableDiffusionXl, AirAssetType.Checkpoint, "CIVITAI", 4201, 130072);
+ // Arrange
+ var air1 = new AirIdentifier(AirEcosystem.StableDiffusionXl, AirAssetType.Checkpoint, AirSource.Civitai, 4201, 130072);
+ var air2 = new AirIdentifier(AirEcosystem.StableDiffusionXl, AirAssetType.Checkpoint, AirSource.HuggingFace, 4201, 130072);
// Act & Assert
- Assert.True(air1.Equals(air2));
- Assert.Equal(air1.GetHashCode(), air2.GetHashCode());
+ Assert.False(air1.Equals(air2));
+ Assert.False(air1 == air2);
+ Assert.True(air1 != air2);
}
[Fact]
diff --git a/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobControlNetBuilderTests.cs b/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobControlNetBuilderTests.cs
new file mode 100644
index 0000000..5f7f679
--- /dev/null
+++ b/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobControlNetBuilderTests.cs
@@ -0,0 +1,151 @@
+namespace CivitaiSharp.Sdk.Tests.Request;
+
+using CivitaiSharp.Sdk.Enums;
+using CivitaiSharp.Sdk.Request;
+using Xunit;
+
+public sealed class ImageJobControlNetBuilderTests
+{
+ [Fact]
+ public void Create_ReturnsNewBuilderInstance()
+ {
+ var builder = ImageJobControlNetBuilder.Create();
+
+ Assert.NotNull(builder);
+ }
+
+ [Fact]
+ public void WithImageUrl_SetsImageUrlAndClearsImageData()
+ {
+ var builder = ImageJobControlNetBuilder.Create()
+ .WithImageUrl("https://example.com/control.png");
+
+ var result = builder.Build();
+
+ Assert.Equal("https://example.com/control.png", result.ImageUrl);
+ Assert.Null(result.Image);
+ }
+
+ [Fact]
+ public void WithImageData_SetsImageDataAndClearsImageUrl()
+ {
+ var builder = ImageJobControlNetBuilder.Create()
+ .WithImageData("data:image/png;base64,iVBORw0KGgo=");
+
+ var result = builder.Build();
+
+ Assert.Equal("data:image/png;base64,iVBORw0KGgo=", result.Image);
+ Assert.Null(result.ImageUrl);
+ }
+
+ [Fact]
+ public void WithImageUrl_AfterWithImageData_OverwritesImageData()
+ {
+ var builder = ImageJobControlNetBuilder.Create()
+ .WithImageData("data:image/png;base64,iVBORw0KGgo=")
+ .WithImageUrl("https://example.com/control.png");
+
+ var result = builder.Build();
+
+ Assert.Equal("https://example.com/control.png", result.ImageUrl);
+ Assert.Null(result.Image);
+ }
+
+ [Fact]
+ public void WithPreprocessor_SetsPreprocessorValue()
+ {
+ var builder = ImageJobControlNetBuilder.Create()
+ .WithImageUrl("https://example.com/control.png")
+ .WithPreprocessor(ControlNetPreprocessor.Canny);
+
+ var result = builder.Build();
+
+ Assert.Equal(ControlNetPreprocessor.Canny, result.Preprocessor);
+ }
+
+ [Fact]
+ public void WithWeight_SetsWeightValue()
+ {
+ var builder = ImageJobControlNetBuilder.Create()
+ .WithImageUrl("https://example.com/control.png")
+ .WithWeight(0.9m);
+
+ var result = builder.Build();
+
+ Assert.Equal(0.9m, result.Weight);
+ }
+
+ [Fact]
+ public void WithStartStep_SetsStartStepValue()
+ {
+ var builder = ImageJobControlNetBuilder.Create()
+ .WithImageUrl("https://example.com/control.png")
+ .WithStartStep(0.1m);
+
+ var result = builder.Build();
+
+ Assert.Equal(0.1m, result.StartStep);
+ }
+
+ [Fact]
+ public void WithEndStep_SetsEndStepValue()
+ {
+ var builder = ImageJobControlNetBuilder.Create()
+ .WithImageUrl("https://example.com/control.png")
+ .WithEndStep(0.9m);
+
+ var result = builder.Build();
+
+ Assert.Equal(0.9m, result.EndStep);
+ }
+
+ [Fact]
+ public void WithStepRange_SetsBothStartAndEndStep()
+ {
+ var builder = ImageJobControlNetBuilder.Create()
+ .WithImageUrl("https://example.com/control.png")
+ .WithStepRange(0.2m, 0.8m);
+
+ var result = builder.Build();
+
+ Assert.Equal(0.2m, result.StartStep);
+ Assert.Equal(0.8m, result.EndStep);
+ }
+
+ [Fact]
+ public void Build_WithoutImageSource_ThrowsInvalidOperationException()
+ {
+ var builder = ImageJobControlNetBuilder.Create();
+
+ var exception = Assert.Throws(() => builder.Build());
+ Assert.Contains("Either ImageUrl or ImageData must be provided", exception.Message);
+ }
+
+ [Fact]
+ public void BuilderIsImmutable_ReturnsNewInstanceOnEachMethod()
+ {
+ var builder1 = ImageJobControlNetBuilder.Create();
+ var builder2 = builder1.WithImageUrl("https://example.com/control.png");
+ var builder3 = builder2.WithWeight(0.5m);
+
+ Assert.NotSame(builder1, builder2);
+ Assert.NotSame(builder2, builder3);
+ }
+
+ [Fact]
+ public void CompleteFluentAPI_BuildsCorrectControlNet()
+ {
+ var result = ImageJobControlNetBuilder.Create()
+ .WithImageUrl("https://example.com/control.png")
+ .WithPreprocessor(ControlNetPreprocessor.Depth)
+ .WithWeight(0.85m)
+ .WithStepRange(0.1m, 0.9m)
+ .Build();
+
+ Assert.Equal("https://example.com/control.png", result.ImageUrl);
+ Assert.Equal(ControlNetPreprocessor.Depth, result.Preprocessor);
+ Assert.Equal(0.85m, result.Weight);
+ Assert.Equal(0.1m, result.StartStep);
+ Assert.Equal(0.9m, result.EndStep);
+ }
+}
diff --git a/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobNetworkParamsBuilderTests.cs b/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobNetworkParamsBuilderTests.cs
new file mode 100644
index 0000000..237dbb0
--- /dev/null
+++ b/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobNetworkParamsBuilderTests.cs
@@ -0,0 +1,99 @@
+namespace CivitaiSharp.Sdk.Tests.Request;
+
+using CivitaiSharp.Sdk.Enums;
+using CivitaiSharp.Sdk.Request;
+using Xunit;
+
+public sealed class ImageJobNetworkParamsBuilderTests
+{
+ [Fact]
+ public void Create_ReturnsNewBuilderInstance()
+ {
+ var builder = ImageJobNetworkParamsBuilder.Create();
+
+ Assert.NotNull(builder);
+ }
+
+ [Fact]
+ public void WithType_SetsTypeValue()
+ {
+ var builder = ImageJobNetworkParamsBuilder.Create()
+ .WithType(NetworkType.Lora);
+
+ var result = builder.Build();
+
+ Assert.Equal(NetworkType.Lora, result.Type);
+ }
+
+ [Fact]
+ public void WithStrength_SetsStrengthValue()
+ {
+ var builder = ImageJobNetworkParamsBuilder.Create()
+ .WithType(NetworkType.Lora)
+ .WithStrength(0.8m);
+
+ var result = builder.Build();
+
+ Assert.Equal(0.8m, result.Strength);
+ }
+
+ [Fact]
+ public void WithTriggerWord_SetsTriggerWordValue()
+ {
+ var builder = ImageJobNetworkParamsBuilder.Create()
+ .WithType(NetworkType.Lora)
+ .WithTriggerWord("anime style");
+
+ var result = builder.Build();
+
+ Assert.Equal("anime style", result.TriggerWord);
+ }
+
+ [Fact]
+ public void WithClipStrength_SetsClipStrengthValue()
+ {
+ var builder = ImageJobNetworkParamsBuilder.Create()
+ .WithType(NetworkType.Lora)
+ .WithClipStrength(1.2m);
+
+ var result = builder.Build();
+
+ Assert.Equal(1.2m, result.ClipStrength);
+ }
+
+ [Fact]
+ public void Build_WithoutType_ThrowsInvalidOperationException()
+ {
+ var builder = ImageJobNetworkParamsBuilder.Create();
+
+ var exception = Assert.Throws(() => builder.Build());
+ Assert.Contains("Type is required", exception.Message);
+ }
+
+ [Fact]
+ public void BuilderIsImmutable_ReturnsNewInstanceOnEachMethod()
+ {
+ var builder1 = ImageJobNetworkParamsBuilder.Create();
+ var builder2 = builder1.WithType(NetworkType.Lora);
+ var builder3 = builder2.WithStrength(0.5m);
+
+ Assert.NotSame(builder1, builder2);
+ Assert.NotSame(builder2, builder3);
+ }
+
+ [Fact]
+ public void CompleteFluentAPI_BuildsCorrectNetworkParams()
+ {
+ var result = ImageJobNetworkParamsBuilder.Create()
+ .WithType(NetworkType.Lora)
+ .WithStrength(0.9m)
+ .WithTriggerWord("cyberpunk")
+ .WithClipStrength(1.1m)
+ .Build();
+
+ Assert.Equal(NetworkType.Lora, result.Type);
+ Assert.Equal(0.9m, result.Strength);
+ Assert.Equal("cyberpunk", result.TriggerWord);
+ Assert.Equal(1.1m, result.ClipStrength);
+ }
+}
diff --git a/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobParamsBuilderTests.cs b/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobParamsBuilderTests.cs
new file mode 100644
index 0000000..31ea44f
--- /dev/null
+++ b/Tests/CivitaiSharp.Sdk.Tests/Request/ImageJobParamsBuilderTests.cs
@@ -0,0 +1,207 @@
+namespace CivitaiSharp.Sdk.Tests.Request;
+
+using CivitaiSharp.Sdk.Enums;
+using CivitaiSharp.Sdk.Request;
+using Xunit;
+
+public sealed class ImageJobParamsBuilderTests
+{
+ [Fact]
+ public void Create_ReturnsNewBuilderInstance()
+ {
+ var builder = ImageJobParamsBuilder.Create();
+
+ Assert.NotNull(builder);
+ }
+
+ [Fact]
+ public void WithPrompt_SetsPromptValue()
+ {
+ var builder = ImageJobParamsBuilder.Create()
+ .WithPrompt("test prompt");
+
+ var result = builder.Build();
+
+ Assert.Equal("test prompt", result.Prompt);
+ }
+
+ [Fact]
+ public void WithNegativePrompt_SetsNegativePromptValue()
+ {
+ var builder = ImageJobParamsBuilder.Create()
+ .WithPrompt("positive")
+ .WithNegativePrompt("negative");
+
+ var result = builder.Build();
+
+ Assert.Equal("negative", result.NegativePrompt);
+ }
+
+ [Fact]
+ public void WithScheduler_SetsSchedulerValue()
+ {
+ var builder = ImageJobParamsBuilder.Create()
+ .WithPrompt("test")
+ .WithScheduler(Scheduler.EulerAncestral);
+
+ var result = builder.Build();
+
+ Assert.Equal(Scheduler.EulerAncestral, result.Scheduler);
+ }
+
+ [Fact]
+ public void WithSteps_SetsStepsValue()
+ {
+ var builder = ImageJobParamsBuilder.Create()
+ .WithPrompt("test")
+ .WithSteps(30);
+
+ var result = builder.Build();
+
+ Assert.Equal(30, result.Steps);
+ }
+
+ [Fact]
+ public void WithCfgScale_SetsCfgScaleValue()
+ {
+ var builder = ImageJobParamsBuilder.Create()
+ .WithPrompt("test")
+ .WithCfgScale(7.5m);
+
+ var result = builder.Build();
+
+ Assert.Equal(7.5m, result.CfgScale);
+ }
+
+ [Fact]
+ public void WithSize_SetsBothWidthAndHeight()
+ {
+ var builder = ImageJobParamsBuilder.Create()
+ .WithPrompt("test")
+ .WithSize(1024, 768);
+
+ var result = builder.Build();
+
+ Assert.Equal(1024, result.Width);
+ Assert.Equal(768, result.Height);
+ }
+
+ [Fact]
+ public void WithWidth_SetsWidthOnly()
+ {
+ var builder = ImageJobParamsBuilder.Create()
+ .WithPrompt("test")
+ .WithWidth(512);
+
+ var result = builder.Build();
+
+ Assert.Equal(512, result.Width);
+ Assert.Null(result.Height);
+ }
+
+ [Fact]
+ public void WithHeight_SetsHeightOnly()
+ {
+ var builder = ImageJobParamsBuilder.Create()
+ .WithPrompt("test")
+ .WithHeight(768);
+
+ var result = builder.Build();
+
+ Assert.Equal(768, result.Height);
+ Assert.Null(result.Width);
+ }
+
+ [Fact]
+ public void WithSeed_SetsSeedValue()
+ {
+ var builder = ImageJobParamsBuilder.Create()
+ .WithPrompt("test")
+ .WithSeed(123456789L);
+
+ var result = builder.Build();
+
+ Assert.Equal(123456789L, result.Seed);
+ }
+
+ [Fact]
+ public void WithClipSkip_SetsClipSkipValue()
+ {
+ var builder = ImageJobParamsBuilder.Create()
+ .WithPrompt("test")
+ .WithClipSkip(2);
+
+ var result = builder.Build();
+
+ Assert.Equal(2, result.ClipSkip);
+ }
+
+ [Fact]
+ public void WithSourceImage_SetsImageUrl()
+ {
+ var builder = ImageJobParamsBuilder.Create()
+ .WithPrompt("test")
+ .WithSourceImage("https://example.com/image.png");
+
+ var result = builder.Build();
+
+ Assert.Equal("https://example.com/image.png", result.Image);
+ }
+
+ [Fact]
+ public void WithStrength_SetsStrengthValue()
+ {
+ var builder = ImageJobParamsBuilder.Create()
+ .WithPrompt("test")
+ .WithStrength(0.7m);
+
+ var result = builder.Build();
+
+ Assert.Equal(0.7m, result.Strength);
+ }
+
+ [Fact]
+ public void Build_WithoutPrompt_ThrowsInvalidOperationException()
+ {
+ var builder = ImageJobParamsBuilder.Create();
+
+ var exception = Assert.Throws(() => builder.Build());
+ Assert.Contains("Prompt is required", exception.Message);
+ }
+
+ [Fact]
+ public void BuilderIsImmutable_ReturnsNewInstanceOnEachMethod()
+ {
+ var builder1 = ImageJobParamsBuilder.Create();
+ var builder2 = builder1.WithPrompt("test");
+ var builder3 = builder2.WithSteps(30);
+
+ Assert.NotSame(builder1, builder2);
+ Assert.NotSame(builder2, builder3);
+ }
+
+ [Fact]
+ public void CompleteFluentAPI_BuildsCorrectParams()
+ {
+ var result = ImageJobParamsBuilder.Create()
+ .WithPrompt("A beautiful sunset")
+ .WithNegativePrompt("blurry, low quality")
+ .WithScheduler(Scheduler.DpmPlusPlus2M)
+ .WithSteps(25)
+ .WithCfgScale(7.0m)
+ .WithSize(1024, 1024)
+ .WithSeed(42)
+ .WithClipSkip(2)
+ .Build();
+
+ Assert.Equal("A beautiful sunset", result.Prompt);
+ Assert.Equal("blurry, low quality", result.NegativePrompt);
+ Assert.Equal(Scheduler.DpmPlusPlus2M, result.Scheduler);
+ Assert.Equal(25, result.Steps);
+ Assert.Equal(7.0m, result.CfgScale);
+ Assert.Equal(1024, result.Width);
+ Assert.Equal(1024, result.Height);
+ Assert.Equal(42, result.Seed);
+ Assert.Equal(2, result.ClipSkip);
+ }
+}
diff --git a/Tests/CivitaiSharp.Sdk.Tests/Request/TextToImageJobBuilderTests.cs b/Tests/CivitaiSharp.Sdk.Tests/Request/TextToImageJobBuilderTests.cs
new file mode 100644
index 0000000..df462c6
--- /dev/null
+++ b/Tests/CivitaiSharp.Sdk.Tests/Request/TextToImageJobBuilderTests.cs
@@ -0,0 +1,437 @@
+namespace CivitaiSharp.Sdk.Tests.Request;
+
+using System.Text.Json;
+using CivitaiSharp.Sdk.Air;
+using CivitaiSharp.Sdk.Enums;
+using CivitaiSharp.Sdk.Models.Jobs;
+using CivitaiSharp.Sdk.Request;
+using CivitaiSharp.Sdk.Services;
+using NSubstitute;
+using Xunit;
+
+public sealed class TextToImageJobBuilderTests : IClassFixture
+{
+ private readonly IJobsService _mockJobsService;
+
+ public TextToImageJobBuilderTests()
+ {
+ _mockJobsService = Substitute.For();
+ }
+
+ [Fact]
+ public void Create_WithJobsService_ReturnsBuilderWithService()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService);
+
+ Assert.NotNull(builder);
+ Assert.NotNull(builder.JobsService);
+ }
+
+ [Fact]
+ public void WithModel_SetsModelValue()
+ {
+ var model = AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072");
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(model)
+ .WithPrompt("test");
+
+ var result = builder.Build();
+
+ Assert.Equal(model, result.Model);
+ }
+
+ [Fact]
+ public void WithPrompt_SetsPromptInParams()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("A beautiful landscape");
+
+ var result = builder.Build();
+
+ Assert.Equal("A beautiful landscape", result.Params.Prompt);
+ }
+
+ [Fact]
+ public void WithNegativePrompt_SetsNegativePromptInParams()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("positive")
+ .WithNegativePrompt("negative");
+
+ var result = builder.Build();
+
+ Assert.Equal("negative", result.Params.NegativePrompt);
+ }
+
+ [Fact]
+ public void WithSize_SetsSizeInParams()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .WithSize(1024, 768);
+
+ var result = builder.Build();
+
+ Assert.Equal(1024, result.Params.Width);
+ Assert.Equal(768, result.Params.Height);
+ }
+
+ [Fact]
+ public void WithSteps_SetsStepsInParams()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .WithSteps(30);
+
+ var result = builder.Build();
+
+ Assert.Equal(30, result.Params.Steps);
+ }
+
+ [Fact]
+ public void WithCfgScale_SetsCfgScaleInParams()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .WithCfgScale(7.5m);
+
+ var result = builder.Build();
+
+ Assert.Equal(7.5m, result.Params.CfgScale);
+ }
+
+ [Fact]
+ public void WithSeed_SetsSeedInParams()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .WithSeed(42);
+
+ var result = builder.Build();
+
+ Assert.Equal(42, result.Params.Seed);
+ }
+
+ [Fact]
+ public void WithParams_UsesProvidedBuilder()
+ {
+ var paramsBuilder = ImageJobParamsBuilder.Create()
+ .WithPrompt("custom prompt")
+ .WithSteps(25);
+
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithParams(paramsBuilder);
+
+ var result = builder.Build();
+
+ Assert.Equal("custom prompt", result.Params.Prompt);
+ Assert.Equal(25, result.Params.Steps);
+ }
+
+ [Fact]
+ public void WithParams_WithConfigureAction_ConfiguresParams()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithParams(p => p
+ .WithPrompt("configured")
+ .WithSteps(20)
+ .WithCfgScale(7.0m));
+
+ var result = builder.Build();
+
+ Assert.Equal("configured", result.Params.Prompt);
+ Assert.Equal(20, result.Params.Steps);
+ Assert.Equal(7.0m, result.Params.CfgScale);
+ }
+
+ [Fact]
+ public void AddAdditionalNetwork_WithParams_AddsNetworkToDictionary()
+ {
+ var networkId = AirIdentifier.Parse("urn:air:sdxl:lora:civitai:1234@5678");
+ var networkParams = new ImageJobNetworkParams { Type = NetworkType.Lora };
+
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .AddAdditionalNetwork(networkId, networkParams);
+
+ var result = builder.Build();
+
+ Assert.NotNull(result.AdditionalNetworks);
+ Assert.Single(result.AdditionalNetworks);
+ Assert.Equal(NetworkType.Lora, result.AdditionalNetworks[networkId].Type);
+ }
+
+ [Fact]
+ public void AddAdditionalNetwork_WithBuilder_AddsNetworkUsingBuilder()
+ {
+ var networkId = AirIdentifier.Parse("urn:air:sdxl:lora:civitai:1234@5678");
+ var networkBuilder = ImageJobNetworkParamsBuilder.Create()
+ .WithType(NetworkType.Lora)
+ .WithStrength(0.8m);
+
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .AddAdditionalNetwork(networkId, networkBuilder);
+
+ var result = builder.Build();
+
+ Assert.NotNull(result.AdditionalNetworks);
+ Assert.Equal(0.8m, result.AdditionalNetworks[networkId].Strength);
+ }
+
+ [Fact]
+ public void AddAdditionalNetwork_WithConfigureAction_AddsNetworkUsingAction()
+ {
+ var networkId = AirIdentifier.Parse("urn:air:sdxl:lora:civitai:1234@5678");
+
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .AddAdditionalNetwork(networkId, n => n
+ .WithType(NetworkType.Lora)
+ .WithTriggerWord("anime"));
+
+ var result = builder.Build();
+
+ Assert.NotNull(result.AdditionalNetworks);
+ Assert.Equal("anime", result.AdditionalNetworks[networkId].TriggerWord);
+ }
+
+ [Fact]
+ public void AddControlNet_WithControlNet_AddsToList()
+ {
+ var controlNet = new ImageJobControlNet { ImageUrl = "https://example.com/control.png" };
+
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .AddControlNet(controlNet);
+
+ var result = builder.Build();
+
+ Assert.NotNull(result.ControlNets);
+ Assert.Single(result.ControlNets);
+ Assert.Equal("https://example.com/control.png", result.ControlNets[0].ImageUrl);
+ }
+
+ [Fact]
+ public void AddControlNet_WithBuilder_AddsUsingBuilder()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .AddControlNet(ImageJobControlNetBuilder.Create()
+ .WithImageUrl("https://example.com/control.png")
+ .WithWeight(0.9m));
+
+ var result = builder.Build();
+
+ Assert.NotNull(result.ControlNets);
+ Assert.Equal(0.9m, result.ControlNets[0].Weight);
+ }
+
+ [Fact]
+ public void AddControlNet_WithConfigureAction_AddsUsingAction()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .AddControlNet(c => c
+ .WithImageUrl("https://example.com/control.png")
+ .WithPreprocessor(ControlNetPreprocessor.Canny));
+
+ var result = builder.Build();
+
+ Assert.NotNull(result.ControlNets);
+ Assert.Equal(ControlNetPreprocessor.Canny, result.ControlNets[0].Preprocessor);
+ }
+
+ [Fact]
+ public void WithQuantity_SetsQuantityValue()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .WithQuantity(4);
+
+ var result = builder.Build();
+
+ Assert.Equal(4, result.Quantity);
+ }
+
+ [Fact]
+ public void WithPriority_SetsPriorityValue()
+ {
+ var priority = new Priority(5.0m);
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .WithPriority(priority);
+
+ var result = builder.Build();
+
+ Assert.Equal(priority, result.Priority);
+ }
+
+ [Fact]
+ public void AddProperty_AddsPropertyToDictionary()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .AddProperty("key1", JsonDocument.Parse("\"value1\"").RootElement);
+
+ var result = builder.Build();
+
+ Assert.NotNull(result.Properties);
+ Assert.Single(result.Properties);
+ Assert.Equal("value1", result.Properties["key1"].GetString());
+ }
+
+ [Fact]
+ public void WithCallbackUrl_SetsCallbackUrlValue()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .WithCallbackUrl("https://webhook.example.com");
+
+ var result = builder.Build();
+
+ Assert.Equal("https://webhook.example.com", result.CallbackUrl);
+ }
+
+ [Fact]
+ public void WithRetries_SetsRetriesValue()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .WithRetries(3);
+
+ var result = builder.Build();
+
+ Assert.Equal(3, result.Retries);
+ }
+
+ [Fact]
+ public void WithTimeout_String_SetsTimeoutValue()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .WithTimeout("00:15:00");
+
+ var result = builder.Build();
+
+ Assert.Equal("00:15:00", result.Timeout);
+ }
+
+ [Fact]
+ public void WithTimeout_TimeSpan_FormatsAndSetsTimeout()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .WithTimeout(TimeSpan.FromMinutes(20));
+
+ var result = builder.Build();
+
+ Assert.Equal("00:20:00", result.Timeout);
+ }
+
+ [Fact]
+ public void WithClipSkip_SetsClipSkipValue()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"))
+ .WithPrompt("test")
+ .WithClipSkip(2);
+
+ var result = builder.Build();
+
+ Assert.Equal(2, result.ClipSkip);
+ }
+
+ [Fact]
+ public void Build_WithoutModel_ThrowsInvalidOperationException()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithPrompt("test");
+
+ var exception = Assert.Throws(() => builder.Build());
+ Assert.Contains("Model is required", exception.Message);
+ }
+
+ [Fact]
+ public void Build_WithoutPrompt_ThrowsInvalidOperationException()
+ {
+ var builder = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"));
+
+ var exception = Assert.Throws(() => builder.Build());
+ Assert.Contains("Parameters are required", exception.Message);
+ }
+
+ [Fact]
+ public void BuilderIsImmutable_ReturnsNewInstanceOnEachMethod()
+ {
+ var builder1 = TextToImageJobBuilder.Create(_mockJobsService);
+ var builder2 = builder1.WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"));
+ var builder3 = builder2.WithPrompt("test");
+
+ Assert.NotSame(builder1, builder2);
+ Assert.NotSame(builder2, builder3);
+ }
+
+ [Fact]
+ public void CompleteFluentAPI_BuildsCorrectRequest()
+ {
+ var model = AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072");
+ var networkId = AirIdentifier.Parse("urn:air:sdxl:lora:civitai:1234@5678");
+
+ var result = TextToImageJobBuilder.Create(_mockJobsService)
+ .WithModel(model)
+ .WithPrompt("A beautiful sunset over mountains")
+ .WithNegativePrompt("blurry, low quality")
+ .WithSize(1024, 1024)
+ .WithSteps(30)
+ .WithCfgScale(7.5m)
+ .WithSeed(42)
+ .AddAdditionalNetwork(networkId, n => n
+ .WithType(NetworkType.Lora)
+ .WithStrength(0.8m))
+ .AddControlNet(c => c
+ .WithImageUrl("https://example.com/control.png")
+ .WithWeight(0.9m))
+ .WithQuantity(2)
+ .WithCallbackUrl("https://webhook.example.com")
+ .Build();
+
+ Assert.Equal(model, result.Model);
+ Assert.Equal("A beautiful sunset over mountains", result.Params.Prompt);
+ Assert.Equal("blurry, low quality", result.Params.NegativePrompt);
+ Assert.Equal(1024, result.Params.Width);
+ Assert.Equal(1024, result.Params.Height);
+ Assert.Equal(30, result.Params.Steps);
+ Assert.Equal(7.5m, result.Params.CfgScale);
+ Assert.Equal(42, result.Params.Seed);
+ Assert.NotNull(result.AdditionalNetworks);
+ Assert.Single(result.AdditionalNetworks);
+ Assert.NotNull(result.ControlNets);
+ Assert.Single(result.ControlNets);
+ Assert.Equal(2, result.Quantity);
+ Assert.Equal("https://webhook.example.com", result.CallbackUrl);
+ }
+}
+
diff --git a/Tests/CivitaiSharp.Sdk.Tests/CivitaiSdkClientOptionsTests.cs b/Tests/CivitaiSharp.Sdk.Tests/SdkClientOptionsTests.cs
similarity index 67%
rename from Tests/CivitaiSharp.Sdk.Tests/CivitaiSdkClientOptionsTests.cs
rename to Tests/CivitaiSharp.Sdk.Tests/SdkClientOptionsTests.cs
index dd95233..4fcd533 100644
--- a/Tests/CivitaiSharp.Sdk.Tests/CivitaiSdkClientOptionsTests.cs
+++ b/Tests/CivitaiSharp.Sdk.Tests/SdkClientOptionsTests.cs
@@ -2,7 +2,7 @@ namespace CivitaiSharp.Sdk.Tests;
using Xunit;
-public sealed class CivitaiSdkClientOptionsTests
+public sealed class SdkClientOptionsTests
{
#region Default Values Tests
@@ -10,12 +10,12 @@ public sealed class CivitaiSdkClientOptionsTests
public void WhenCreatingOptionsWithDefaultsThenDefaultValuesAreUsed()
{
// Arrange & Act
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Assert
- Assert.Equal(CivitaiSdkClientOptions.DefaultBaseUrl, options.BaseUrl);
- Assert.Equal(CivitaiSdkClientOptions.DefaultApiVersion, options.ApiVersion);
- Assert.Equal(CivitaiSdkClientOptions.DefaultTimeoutSeconds, options.TimeoutSeconds);
+ Assert.Equal(SdkClientOptions.DefaultBaseUrl, options.BaseUrl);
+ Assert.Equal(SdkClientOptions.DefaultApiVersion, options.ApiVersion);
+ Assert.Equal(SdkClientOptions.DefaultTimeoutSeconds, options.TimeoutSeconds);
Assert.Equal("test-token", options.ApiToken);
}
@@ -23,10 +23,10 @@ public void WhenCreatingOptionsWithDefaultsThenDefaultValuesAreUsed()
public void WhenCheckingDefaultConstantsThenValuesAreCorrect()
{
// Assert
- Assert.Equal("https://orchestration.civitai.com", CivitaiSdkClientOptions.DefaultBaseUrl);
- Assert.Equal("v1", CivitaiSdkClientOptions.DefaultApiVersion);
- Assert.Equal(600, CivitaiSdkClientOptions.DefaultTimeoutSeconds);
- Assert.Equal(1800, CivitaiSdkClientOptions.MaxTimeoutSeconds);
+ Assert.Equal("https://orchestration.civitai.com", SdkClientOptions.DefaultBaseUrl);
+ Assert.Equal("v1", SdkClientOptions.DefaultApiVersion);
+ Assert.Equal(600, SdkClientOptions.DefaultTimeoutSeconds);
+ Assert.Equal(1800, SdkClientOptions.MaxTimeoutSeconds);
}
#endregion
@@ -37,7 +37,7 @@ public void WhenCheckingDefaultConstantsThenValuesAreCorrect()
public void WhenSettingValidApiTokenThenTokenIsStored()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "initial-token" };
+ var options = new SdkClientOptions { ApiToken = "initial-token" };
// Act
options.ApiToken = "new-valid-token";
@@ -50,7 +50,7 @@ public void WhenSettingValidApiTokenThenTokenIsStored()
public void WhenSettingNullApiTokenThenThrowsArgumentNullException()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "initial-token" };
+ var options = new SdkClientOptions { ApiToken = "initial-token" };
// Act & Assert
Assert.Throws(() => options.ApiToken = null!);
@@ -60,7 +60,7 @@ public void WhenSettingNullApiTokenThenThrowsArgumentNullException()
public void WhenSettingEmptyApiTokenThenThrowsArgumentException()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "initial-token" };
+ var options = new SdkClientOptions { ApiToken = "initial-token" };
// Act & Assert
Assert.Throws(() => options.ApiToken = "");
@@ -70,7 +70,7 @@ public void WhenSettingEmptyApiTokenThenThrowsArgumentException()
public void WhenSettingWhitespaceApiTokenThenThrowsArgumentException()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "initial-token" };
+ var options = new SdkClientOptions { ApiToken = "initial-token" };
// Act & Assert
Assert.Throws(() => options.ApiToken = " ");
@@ -84,7 +84,7 @@ public void WhenSettingWhitespaceApiTokenThenThrowsArgumentException()
public void WhenSettingValidBaseUrlThenUrlIsStored()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
const string expectedUrl = "https://api.example.com";
// Act
@@ -98,7 +98,7 @@ public void WhenSettingValidBaseUrlThenUrlIsStored()
public void WhenSettingBaseUrlWithTrailingSlashThenSlashIsRemoved()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Act
options.BaseUrl = "https://api.example.com/";
@@ -111,7 +111,7 @@ public void WhenSettingBaseUrlWithTrailingSlashThenSlashIsRemoved()
public void WhenSettingNullBaseUrlThenThrowsArgumentNullException()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Act & Assert
Assert.Throws(() => options.BaseUrl = null!);
@@ -121,7 +121,7 @@ public void WhenSettingNullBaseUrlThenThrowsArgumentNullException()
public void WhenSettingEmptyBaseUrlThenThrowsArgumentException()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Act & Assert
Assert.Throws(() => options.BaseUrl = "");
@@ -131,7 +131,7 @@ public void WhenSettingEmptyBaseUrlThenThrowsArgumentException()
public void WhenSettingWhitespaceBaseUrlThenThrowsArgumentException()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Act & Assert
Assert.Throws(() => options.BaseUrl = " ");
@@ -141,7 +141,7 @@ public void WhenSettingWhitespaceBaseUrlThenThrowsArgumentException()
public void WhenSettingInvalidUrlThenThrowsArgumentException()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Act & Assert
Assert.Throws(() => options.BaseUrl = "not-a-valid-url");
@@ -151,7 +151,7 @@ public void WhenSettingInvalidUrlThenThrowsArgumentException()
public void WhenSettingNonHttpUrlThenThrowsArgumentException()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Act & Assert
Assert.Throws(() => options.BaseUrl = "ftp://example.com");
@@ -161,7 +161,7 @@ public void WhenSettingNonHttpUrlThenThrowsArgumentException()
public void WhenSettingHttpUrlThenUrlIsAccepted()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Act
options.BaseUrl = "http://localhost:8080";
@@ -178,7 +178,7 @@ public void WhenSettingHttpUrlThenUrlIsAccepted()
public void WhenSettingValidApiVersionThenVersionIsStored()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Act
options.ApiVersion = "v2";
@@ -191,7 +191,7 @@ public void WhenSettingValidApiVersionThenVersionIsStored()
public void WhenSettingNullApiVersionThenThrowsArgumentNullException()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Act & Assert
Assert.Throws(() => options.ApiVersion = null!);
@@ -201,7 +201,7 @@ public void WhenSettingNullApiVersionThenThrowsArgumentNullException()
public void WhenSettingEmptyApiVersionThenThrowsArgumentException()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Act & Assert
Assert.Throws(() => options.ApiVersion = "");
@@ -211,7 +211,7 @@ public void WhenSettingEmptyApiVersionThenThrowsArgumentException()
public void WhenSettingWhitespaceApiVersionThenThrowsArgumentException()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Act & Assert
Assert.Throws(() => options.ApiVersion = " ");
@@ -225,7 +225,7 @@ public void WhenSettingWhitespaceApiVersionThenThrowsArgumentException()
public void WhenSettingValidTimeoutThenTimeoutIsStored()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Act
options.TimeoutSeconds = 300;
@@ -238,7 +238,7 @@ public void WhenSettingValidTimeoutThenTimeoutIsStored()
public void WhenSettingMinimumTimeoutThenTimeoutIsAccepted()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Act
options.TimeoutSeconds = 1;
@@ -251,20 +251,20 @@ public void WhenSettingMinimumTimeoutThenTimeoutIsAccepted()
public void WhenSettingMaximumTimeoutThenTimeoutIsAccepted()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Act
- options.TimeoutSeconds = CivitaiSdkClientOptions.MaxTimeoutSeconds;
+ options.TimeoutSeconds = SdkClientOptions.MaxTimeoutSeconds;
// Assert
- Assert.Equal(CivitaiSdkClientOptions.MaxTimeoutSeconds, options.TimeoutSeconds);
+ Assert.Equal(SdkClientOptions.MaxTimeoutSeconds, options.TimeoutSeconds);
}
[Fact]
public void WhenSettingZeroTimeoutThenThrowsArgumentOutOfRangeException()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Act & Assert
Assert.Throws(() => options.TimeoutSeconds = 0);
@@ -274,7 +274,7 @@ public void WhenSettingZeroTimeoutThenThrowsArgumentOutOfRangeException()
public void WhenSettingNegativeTimeoutThenThrowsArgumentOutOfRangeException()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Act & Assert
Assert.Throws(() => options.TimeoutSeconds = -1);
@@ -284,10 +284,10 @@ public void WhenSettingNegativeTimeoutThenThrowsArgumentOutOfRangeException()
public void WhenSettingTimeoutAboveMaximumThenThrowsArgumentOutOfRangeException()
{
// Arrange
- var options = new CivitaiSdkClientOptions { ApiToken = "test-token" };
+ var options = new SdkClientOptions { ApiToken = "test-token" };
// Act & Assert
- Assert.Throws(() => options.TimeoutSeconds = CivitaiSdkClientOptions.MaxTimeoutSeconds + 1);
+ Assert.Throws(() => options.TimeoutSeconds = SdkClientOptions.MaxTimeoutSeconds + 1);
}
#endregion
diff --git a/Tests/CivitaiSharp.Tools.Tests/ToolsTestFixture.cs b/Tests/CivitaiSharp.Tools.Tests/ToolsTestFixture.cs
new file mode 100644
index 0000000..2f5cb22
--- /dev/null
+++ b/Tests/CivitaiSharp.Tools.Tests/ToolsTestFixture.cs
@@ -0,0 +1,20 @@
+namespace CivitaiSharp.Tools.Tests;
+
+///
+/// Shared test fixture for Tools tests.
+///
+///
+/// Tools no longer depends on SDK types. The AirBuilder has been moved to the SDK project.
+/// This fixture remains available for any shared test initialization needs.
+///
+public sealed class ToolsTestFixture
+{
+ ///
+ /// Initializes the Tools test fixture.
+ ///
+ public ToolsTestFixture()
+ {
+ // Tools tests no longer require SDK initialization
+ // since AirBuilder has been moved to the SDK project
+ }
+}
diff --git a/Tools/CivitaiSharp.Tools.csproj b/Tools/CivitaiSharp.Tools.csproj
index aaad525..b82dbb5 100644
--- a/Tools/CivitaiSharp.Tools.csproj
+++ b/Tools/CivitaiSharp.Tools.csproj
@@ -8,6 +8,7 @@
true
true
+ true
true