diff --git a/Documentation/Guides/sdk-coverage.md b/Documentation/Guides/sdk-coverage.md new file mode 100644 index 0000000..77287ee --- /dev/null +++ b/Documentation/Guides/sdk-coverage.md @@ -0,0 +1,325 @@ +--- +title: Coverage Service +description: Learn how to check model and resource availability on the Civitai generation infrastructure before submitting jobs. +--- + +# Coverage Service + +The Coverage service allows you to check the availability of AI models and resources across the Civitai generation infrastructure before submitting jobs. This helps prevent job failures due to unavailable resources. + +## Overview + +The Coverage service provides methods to: +- Check availability of single or multiple models +- Identify which providers support specific models +- Get queue depth information for resource planning + +## Basic Usage + +### Check Single Model Availability + +```csharp +var model = AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"); + +var result = await sdkClient.Coverage.GetAsync(model); + +if (result is Result.Success success) +{ + Console.WriteLine($"Available: {success.Data.Available}"); + foreach (var provider in success.Data.Providers) + { + Console.WriteLine($"Provider: {provider.Key}"); + Console.WriteLine($" Available: {provider.Value.Available}"); + Console.WriteLine($" Queue Position: {provider.Value.QueuePosition}"); + } +} +``` + +### Check Multiple Models + +```csharp +var models = new[] +{ + AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"), + AirIdentifier.Parse("urn:air:sdxl:lora:civitai:328553@368189"), + AirIdentifier.Parse("urn:air:sd1:vae:civitai:22354@123456") +}; + +var result = await sdkClient.Coverage.GetAsync(models); + +if (result is Result>.Success success) +{ + foreach (var (model, availability) in success.Data) + { + Console.WriteLine($"{model}: {availability.Available}"); + } +} +``` + +## Understanding Results + +### ProviderAssetAvailability + +The main result type containing overall availability and provider-specific details: + +| Property | Type | Description | +|----------|------|-------------| +| `Available` | `bool` | Whether the resource is available on any provider | +| `Providers` | `Dictionary` | Provider-specific availability details | + +### ProviderJobStatus + +Provider-specific information: + +| Property | Type | Description | +|----------|------|-------------| +| `Available` | `bool` | Whether this specific provider has the resource | +| `QueuePosition` | `int?` | Current queue depth (null if not available) | + +## Practical Examples + +### Pre-flight Check Before Job Submission + +```csharp +public async Task> GenerateWithValidationAsync( + AirIdentifier model, + string prompt) +{ + // Check availability first + var coverageResult = await sdkClient.Coverage.GetAsync(model); + + if (coverageResult is not Result.Success coverageSuccess) + { + return Result.FromError( + "Failed to check model availability", + coverageResult.Error); + } + + if (!coverageSuccess.Data.Available) + { + return Result.FromApiError( + "Model not available on generation infrastructure"); + } + + // Model is available, proceed with job submission + return await sdkClient.Jobs + .CreateTextToImage() + .WithModel(model) + .WithPrompt(prompt) + .WithSize(1024, 1024) + .ExecuteAsync(); +} +``` + +### Check All Resources Before Complex Job + +```csharp +public async Task ValidateJobResourcesAsync( + AirIdentifier baseModel, + IEnumerable loras) +{ + // Combine all resources + var allResources = loras.Prepend(baseModel).ToArray(); + + // Check coverage + var result = await sdkClient.Coverage.GetAsync(allResources); + + if (result is not Result>.Success success) + { + Console.WriteLine("Failed to check coverage"); + return false; + } + + // Verify all resources are available + var unavailable = success.Data + .Where(kvp => !kvp.Value.Available) + .Select(kvp => kvp.Key) + .ToArray(); + + if (unavailable.Length > 0) + { + Console.WriteLine("Unavailable resources:"); + foreach (var resource in unavailable) + { + Console.WriteLine($" - {resource}"); + } + return false; + } + + return true; +} +``` + +### Select Provider Based on Queue Depth + +```csharp +public async Task FindBestProviderAsync(AirIdentifier model) +{ + var result = await sdkClient.Coverage.GetAsync(model); + + if (result is not Result.Success success) + { + return null; + } + + // Find provider with shortest queue + var bestProvider = success.Data.Providers + .Where(p => p.Value.Available) + .OrderBy(p => p.Value.QueuePosition ?? int.MaxValue) + .Select(p => p.Key) + .FirstOrDefault(); + + if (bestProvider is not null) + { + var queuePos = success.Data.Providers[bestProvider].QueuePosition; + Console.WriteLine($"Best provider: {bestProvider} (Queue: {queuePos ?? 0})"); + } + + return bestProvider; +} +``` + +## Error Handling + +Handle coverage check failures gracefully: + +```csharp +var result = await sdkClient.Coverage.GetAsync(model); + +switch (result) +{ + case Result.Success success: + if (success.Data.Available) + { + Console.WriteLine("Model is available"); + } + else + { + Console.WriteLine("Model not available on any provider"); + } + break; + + case Result.ApiError apiError: + Console.WriteLine($"API Error: {apiError.Message}"); + // Proceed anyway - coverage check is optional + break; + + case Result.NetworkError networkError: + Console.WriteLine($"Network Error: {networkError.Exception.Message}"); + // Retry or proceed with caution + break; +} +``` + +## Best Practices + +### 1. Cache Coverage Results + +Coverage rarely changes rapidly - cache results to reduce API calls: + +```csharp +private readonly Dictionary _coverageCache = new(); +private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5); + +public async Task IsCachedAvailableAsync(AirIdentifier model) +{ + if (_coverageCache.TryGetValue(model, out var cached)) + { + if (DateTime.UtcNow - cached.Checked < _cacheDuration) + { + return cached.Available; + } + } + + var result = await sdkClient.Coverage.GetAsync(model); + + if (result is Result.Success success) + { + _coverageCache[model] = (DateTime.UtcNow, success.Data.Available); + return success.Data.Available; + } + + return false; +} +``` + +### 2. Batch Checks When Possible + +Check multiple resources in one call: + +```csharp +// Good - single API call +var allResources = new[] { baseModel }.Concat(loras); +await sdkClient.Coverage.GetAsync(allResources); + +// Less efficient - multiple API calls +foreach (var resource in allResources) +{ + await sdkClient.Coverage.GetAsync(resource); +} +``` + +### 3. Make Coverage Optional + +Coverage checks add latency - make them optional based on context: + +```csharp +public async Task> GenerateAsync( + AirIdentifier model, + string prompt, + bool validateCoverage = false) +{ + if (validateCoverage) + { + var coverageResult = await sdkClient.Coverage.GetAsync(model); + if (coverageResult is Result.Success { Data.Available: false }) + { + return Result.FromApiError("Model not available"); + } + } + + return await sdkClient.Jobs + .CreateTextToImage() + .WithModel(model) + .WithPrompt(prompt) + .ExecuteAsync(); +} +``` + +### 4. Use for Resource Discovery + +Identify which resources are consistently available: + +```csharp +public async Task> GetAvailableModelsAsync( + IEnumerable candidates) +{ + var result = await sdkClient.Coverage.GetAsync(candidates); + + if (result is not Result>.Success success) + { + return Array.Empty(); + } + + return success.Data + .Where(kvp => kvp.Value.Available) + .Select(kvp => kvp.Key) + .ToArray(); +} +``` + +## API Reference + +### Methods + +| Method | Parameters | Returns | Description | +|--------|------------|---------|-------------| +| `GetAsync` | `IEnumerable models, CancellationToken` | `Result>` | Check availability of multiple models | +| `GetAsync` | `AirIdentifier model, CancellationToken` | `Result` | Check availability of a single model | + +## Next Steps + +- [Jobs Service](sdk-jobs.md) - Submit jobs with validated resources +- [Usage Service](sdk-usage.md) - Monitor API consumption +- [AIR Identifiers](air-identifier.md) - Learn about model identifiers +- [Error Handling](error-handling.md) - Comprehensive error handling patterns diff --git a/Documentation/Guides/sdk-introduction.md b/Documentation/Guides/sdk-introduction.md index 05b0e92..f577417 100644 --- a/Documentation/Guides/sdk-introduction.md +++ b/Documentation/Guides/sdk-introduction.md @@ -52,7 +52,7 @@ public class ImageGenerationService(ISdkClient sdkClient) .WithSize(1024, 1024) .WithSteps(30) .WithCfgScale(7.5m) - .SubmitAsync(); + .ExecuteAsync(); if (result is Result.Success success) { @@ -64,20 +64,47 @@ public class ImageGenerationService(ISdkClient sdkClient) ## Services -### Jobs Service +### Jobs Operations -Submit and manage image generation jobs: +Create and manage image generation jobs using fluent builders: -- `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 +**Creating Jobs:** +- `CreateTextToImage()` - Returns a `TextToImageBuilder` for configuring and submitting jobs + +**Querying Jobs:** +- `Query` - Returns a cached `JobQueryBuilder` for fluent job queries + - `WithDetailed()` - Include original job specifications in response + - `WithWait()` - Block until jobs complete (up to ~10 minutes) + - `WhereProperty(key, value)` - Filter by custom properties + - `GetByIdAsync(Guid id)` - Get job status by ID + - `GetByTokenAsync(string token)` - Get job status by token + - `ExecuteAsync()` - Query jobs by custom properties + +**Job Management:** +- `Query.CancelAsync(Guid id)` - Cancel a specific job by ID +- `Query.CancelAsync(string token)` - Cancel all jobs in a batch by token +- `Query.TaintAsync(Guid id)` - Mark a job as tainted by ID +- `Query.TaintAsync(string token)` - Mark all jobs in a batch as tainted by token + +**Example - Querying Jobs:** +```csharp +// Query by ID with detailed information +var result = await sdkClient.Jobs.Query + .WithDetailed() + .GetByIdAsync(jobId); + +// Query by token and wait for completion (blocks up to ~10 minutes) +var result = await sdkClient.Jobs.Query + .WithWait() + .WithDetailed() + .GetByTokenAsync(token); + +// Query by custom properties +var result = await sdkClient.Jobs.Query + .WhereProperty("userId", JsonSerializer.SerializeToElement("12345")) + .WhereProperty("environment", JsonSerializer.SerializeToElement("production")) + .ExecuteAsync(); +``` ### Coverage Service @@ -93,5 +120,9 @@ Monitor API consumption: ## Next Steps +- [Jobs Service](sdk-jobs.md) - Comprehensive guide to creating and querying jobs +- [Coverage Service](sdk-coverage.md) - Check model and resource availability +- [Usage Service](sdk-usage.md) - Monitor API consumption and credits - [Configuration](configuration.md) - Configure SDK client options - [Quick Start](quick-start.md) - Step-by-step guide to your first image generation +- [AIR Identifiers](air-identifier.md) - Learn about model resource identifiers diff --git a/Documentation/Guides/sdk-jobs.md b/Documentation/Guides/sdk-jobs.md new file mode 100644 index 0000000..ab0202d --- /dev/null +++ b/Documentation/Guides/sdk-jobs.md @@ -0,0 +1,520 @@ +--- +title: Jobs Service +description: Learn how to submit, track, and manage image generation jobs using the CivitaiSharp.Sdk Jobs service with fluent builders. +--- + +# Jobs Service + +The Jobs service provides comprehensive functionality for submitting, tracking, and managing image generation jobs through the Civitai Generator API. + +## Overview + +The Jobs service provides two fluent builders: + +1. **TextToImageBuilder** - Fluent, immutable builder for creating and submitting jobs (accessed via `CreateTextToImage()`) +2. **JobQueryBuilder** - Fluent, immutable builder for querying and managing jobs (accessed via `Query` property) + +Both builders follow CivitaiSharp's immutable, thread-safe design pattern. + +## Creating Jobs + +### Basic Text-to-Image Generation + +Use the `CreateTextToImage()` method to get a fluent builder: + +```csharp +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) + .ExecuteAsync(); + +if (result is Result.Success success) +{ + Console.WriteLine($"Job submitted with token: {success.Data.Token}"); + foreach (var job in success.Data.Jobs) + { + Console.WriteLine($"Job ID: {job.JobId}, Status: {job.Status}"); + } +} +``` + +### Advanced Configuration + +Configure additional parameters for more control: + +```csharp +var result = await sdkClient.Jobs + .CreateTextToImage() + .WithModel(model) + .WithPrompt("detailed portrait") + .WithSeed(12345) + .WithSteps(50) + .WithCfgScale(8.5m) + .WithQuantity(4) // Generate 4 images + .WithClipSkip(2) + .WithCallbackUrl("https://myapp.com/webhook") + .WithRetries(3) + .ExecuteAsync(); +``` + +### Using Additional Networks (LoRAs) + +Add LoRAs and other networks to enhance generation: + +```csharp +var lora = AirIdentifier.Parse("urn:air:sdxl:lora:civitai:123456@789"); + +var result = await sdkClient.Jobs + .CreateTextToImage() + .WithModel(baseModel) + .WithPrompt("character portrait") + .WithAdditionalNetwork(lora, builder => builder + .WithStrength(0.8m) + .WithTriggerWord("character")) + .WithAdditionalNetwork(anotherLora, builder => builder + .WithStrength(0.5m)) + .ExecuteAsync(); +``` + +### Using ControlNet + +Guide generation with ControlNet: + +```csharp +var result = await sdkClient.Jobs + .CreateTextToImage() + .WithModel(model) + .WithPrompt("person standing") + .WithControlNet(builder => builder + .WithModel(controlNetModel) + .WithImage("https://example.com/pose.png") + .WithWeight(1.0m) + .WithStartingControlStep(0.0m) + .WithEndingControlStep(1.0m)) + .ExecuteAsync(); +``` + +### Batch Job Submission + +Submit multiple jobs at once: + +```csharp +var job1 = sdkClient.Jobs + .CreateTextToImage() + .WithModel(model1) + .WithPrompt("landscape"); + +var job2 = sdkClient.Jobs + .CreateTextToImage() + .WithModel(model2) + .WithPrompt("portrait"); + +var result = await job1.ExecuteBatchAsync([job2]); + +if (result is Result.Success success) +{ + Console.WriteLine($"Batch submitted with token: {success.Data.Token}"); +} +``` + +## Querying Jobs + +### Query by Job ID + +Retrieve a specific job's status: + +```csharp +var result = await sdkClient.Jobs.Query + .WithDetailed() + .GetByIdAsync(jobId); + +if (result is Result.Success success) +{ + Console.WriteLine($"Status: {success.Data.Status}"); + if (success.Data.Result?.BlobUrl is not null) + { + Console.WriteLine($"Image URL: {success.Data.Result.BlobUrl}"); + } +} +``` + +### Query by Token + +Retrieve all jobs from a batch submission: + +```csharp +var result = await sdkClient.Jobs.Query + .GetByTokenAsync(token); + +if (result is Result.Success success) +{ + foreach (var job in success.Data.Jobs) + { + Console.WriteLine($"{job.JobId}: {job.Status}"); + } +} +``` + +### Wait for Completion + +Block until jobs complete (up to ~10 minutes): + +```csharp +var result = await sdkClient.Jobs.Query + .WithWait() + .WithDetailed() + .GetByTokenAsync(token); + +// Jobs will be in completed/failed state when this returns +if (result is Result.Success success) +{ + var completed = success.Data.Jobs.Where(j => j.Status == "succeeded"); + Console.WriteLine($"Completed: {completed.Count()} jobs"); +} +``` + +### Query by Custom Properties + +Filter jobs using custom properties set during submission: + +```csharp +// When submitting, add custom properties +var submitResult = await sdkClient.Jobs + .CreateTextToImage() + .WithModel(model) + .WithPrompt("landscape") + .WithProperty("userId", JsonSerializer.SerializeToElement("12345")) + .WithProperty("environment", JsonSerializer.SerializeToElement("production")) + .WithProperty("requestId", JsonSerializer.SerializeToElement(789)) + .ExecuteAsync(); + +// Later, query by those properties +var queryResult = await sdkClient.Jobs.Query + .WhereProperty("userId", JsonSerializer.SerializeToElement("12345")) + .WhereProperty("environment", JsonSerializer.SerializeToElement("production")) + .ExecuteAsync(); + +if (queryResult is Result.Success success) +{ + Console.WriteLine($"Found {success.Data.Jobs.Count} matching jobs"); +} +``` + +### Query Multiple Property Types + +The WhereProperties method accepts a dictionary of JsonElement values for flexible filtering: + +```csharp +var propertyFilters = new Dictionary +{ + ["category"] = JsonSerializer.SerializeToElement("portrait"), + ["priority"] = JsonSerializer.SerializeToElement(5), + ["highQuality"] = JsonSerializer.SerializeToElement(true) +}; + +var result = await sdkClient.Jobs.Query + .WithDetailed() + .WhereProperties(propertyFilters) + .ExecuteAsync(); +``` + +### Query with Advanced JsonElement + +For complex property values, use `JsonElement` directly: + +```csharp +using var doc = JsonDocument.Parse(@"{""nested"": {""value"": 123}}"); +var element = doc.RootElement.GetProperty("nested"); + +var result = await sdkClient.Jobs.Query + .WhereProperty("complexData", element) + .ExecuteAsync(); +``` + +## Job Management + +### Cancel Jobs + +Cancel by ID: + +```csharp +var result = await sdkClient.Jobs.Query.CancelAsync(jobId, force: true); + +if (result is Result.Success) +{ + Console.WriteLine("Job cancelled successfully"); +} +``` + +Cancel by token: + +```csharp +var result = await sdkClient.Jobs.Query.CancelAsync(token, force: true); +``` + +### Taint Jobs + +Mark jobs as tainted (for quality control): + +```csharp +await sdkClient.Jobs.Query.TaintAsync(jobId); +await sdkClient.Jobs.Query.TaintAsync(token); +``` + +## Jobs Operations API Reference + +### JobQueryBuilder Methods + +These methods are accessed through `sdkClient.Jobs.Query`: + +| Method | Parameters | Description | +|--------|------------|-------------| +| `WithDetailed()` | - | Returns a new builder configured to include detailed job specifications | +| `WithWait()` | - | Returns a new builder configured to wait for completion (blocks up to ~10 min) | +| `WhereProperty` | `string key, JsonElement value` | Returns a new builder with an added property filter (uses AND logic) | +| `WhereProperties` | `IReadOnlyDictionary properties` | Returns a new builder with multiple property filters added | +| `GetByIdAsync` | `Guid jobId, CancellationToken` | Get status of a specific job by its ID | +| `GetByTokenAsync` | `string token, CancellationToken` | Get status of jobs by batch token | +| `ExecuteAsync` | `CancellationToken` | Query jobs matching all configured property filters (at least one required) | +| `CancelAsync` | `Guid jobId, bool force, CancellationToken` | Cancel a specific job by ID | +| `CancelAsync` | `string token, bool force, CancellationToken` | Cancel all jobs in a batch by token | +| `TaintAsync` | `Guid jobId, CancellationToken` | Mark a specific job as tainted by ID | +| `TaintAsync` | `string token, CancellationToken` | Mark all jobs in a batch as tainted by token | + +### TextToImageBuilder Methods + +Accessed through `sdkClient.Jobs.CreateTextToImage()`: + +#### Required Parameters + +| Method | Description | +|--------|-------------| +| `WithModel(AirIdentifier)` | Set the base model (required) | +| `WithPrompt(string)` | Set the positive prompt (required) | + +#### Image Parameters + +| Method | Description | +|--------|-------------| +| `WithNegativePrompt(string)` | Set negative prompt | +| `WithSize(int, int)` | Set width and height (must be multiples of 8, range: 64-2048) | +| `WithSteps(int)` | Set sampling steps (range: 1-100, default: 20) | +| `WithCfgScale(decimal)` | Set CFG scale (range: 1-30, default: 7) | +| `WithSeed(long)` | Set seed for reproducibility | +| `WithClipSkip(int)` | Set CLIP skip layers (range: 1-12) | + +#### Additional Networks + +| Method | Description | +|--------|-------------| +| `WithAdditionalNetwork(AirIdentifier, ImageJobNetworkParams)` | Add LoRA or embedding with network configuration | +| `WithAdditionalNetwork(AirIdentifier, ImageJobNetworkParamsBuilder)` | Add LoRA or embedding using a builder | +| `WithAdditionalNetwork(AirIdentifier, Func)` | Add LoRA or embedding using a configuration action | + +### ControlNet + +| Method | Description | +|--------|-------------| +| `WithControlNet(ImageJobControlNet)` | Add ControlNet configuration | +| `WithControlNet(ImageJobControlNetBuilder)` | Add ControlNet using a builder | +| `WithControlNet(Func)` | Add ControlNet using a configuration action | + +#### Job Configuration + +| Method | Description | +|--------|-------------| +| `WithQuantity(int)` | Number of images to generate (range: 1-10, default: 1) | +| `WithPriority(Priority)` | Set job priority configuration | +| `WithCallbackUrl(string)` | Set webhook URL for completion notification | +| `WithRetries(int)` | Set automatic retry count on failure (default: 0) | +| `WithTimeout(string)` | Set job timeout in HH:mm:ss format (default: "00:10:00") | + +#### Custom Properties + +| Method | Description | +|--------|-------------| +| `WithProperty(string, JsonElement)` | Add custom property for tracking/querying (use `JsonSerializer.SerializeToElement()` to convert values) | + +#### Execution + +| Method | Description | +|--------|-------------| +| `ExecuteAsync(CancellationToken)` | Submit the job and return job status collection with polling token | +| `ExecuteBatchAsync(IEnumerable, CancellationToken)` | Submit multiple jobs as a batch | + +## Builder Design Principles + +### Immutability + +The TextToImageBuilder is an immutable record. Each method returns a new instance: + +```csharp +var baseJob = sdkClient.Jobs.CreateTextToImage() + .WithModel(model) + .WithSize(1024, 1024); + +// Both are independent - original is unchanged +var job1 = baseJob.WithPrompt("landscape"); +var job2 = baseJob.WithPrompt("portrait"); +``` + +### Thread Safety + +Because the builder is immutable, it's thread-safe and can be shared: + +```csharp +// Safe to share across threads +private readonly TextToImageJobBuilder _baseJob; + +public MyService(ISdkClient client) +{ + _baseJob = client.Jobs + .CreateTextToImage() + .WithModel(model) + .WithSize(1024, 1024) + .WithSteps(30); +} + +public Task> GenerateAsync(string prompt) + => _baseJob.WithPrompt(prompt).ExecuteAsync(); +``` + +### Validation + +The builder validates parameters immediately: + +```csharp +// Throws ArgumentException - prompt cannot be empty +builder.WithPrompt(""); + +// Throws ArgumentOutOfRangeException - steps must be 1-100 +// (validated in ImageJobParamsBuilder) +builder.WithParams(p => p.WithSteps(0)); + +// Throws InvalidOperationException - model and prompt required +await sdkClient.Jobs.CreateTextToImage().ExecuteAsync(); +``` + +## Best Practices + +### Use the Fluent Builder + +Always use `CreateTextToImage()` for type-safe, validated job creation: + +```csharp +// Recommended - type-safe, validated, immutable +await sdkClient.Jobs + .CreateTextToImage() + .WithModel(model) + .WithPrompt("landscape") + .ExecuteAsync(); + +// Not recommended - manual construction requires JsonElement handling +var request = new TextToImageJobRequest +{ + Model = model, + Params = new ImageJobParams { Prompt = "landscape" } +}; +// No direct submit method exists for manually constructed requests +``` + +### Cache Base Configurations + +Take advantage of immutability to cache common configurations: + +```csharp +// Cache common configurations +private readonly TextToImageJobBuilder _baseJob; + +public ImageService(ISdkClient client) +{ + _baseJob = client.Jobs + .CreateTextToImage() + .WithModel(commonModel) + .WithSize(1024, 1024) + .WithSteps(30); +} + +public Task> GenerateAsync(string prompt) + => _baseJob.WithPrompt(prompt).ExecuteAsync(); +``` + +### Use Custom Properties for Tracking + +Add metadata to jobs for easy filtering: + +```csharp +await sdkClient.Jobs + .CreateTextToImage() + .WithModel(model) + .WithPrompt(prompt) + .WithProperty("userId", JsonSerializer.SerializeToElement(userId)) + .WithProperty("sessionId", JsonSerializer.SerializeToElement(sessionId)) + .WithProperty("timestamp", JsonSerializer.SerializeToElement(DateTime.UtcNow.Ticks)) + .ExecuteAsync(); +``` + +### Handle Async Operations Properly + +Jobs are asynchronous - use appropriate polling strategies: + +```csharp +// Good - properly async with polling +var submitResult = await sdkClient.Jobs + .CreateTextToImage() + .WithModel(model) + .WithPrompt(prompt) + .ExecuteAsync(); + +if (submitResult is Result.Success success) +{ + var token = success.Data.Token; + + // Option 1: Poll with delay + await Task.Delay(5000); + var status = await sdkClient.Jobs.Query.GetByTokenAsync(token); + + // Option 2: Use wait parameter (blocks up to ~10 min) + var completed = await sdkClient.Jobs.Query + .WithWait() + .GetByTokenAsync(token); +} +``` + +## Error Handling + +All methods return `Result` for consistent error handling: + +```csharp +var result = await sdkClient.Jobs + .CreateTextToImage() + .WithModel(model) + .WithPrompt(prompt) + .ExecuteAsync(); + +switch (result) +{ + case Result.Success success: + Console.WriteLine($"Submitted: {success.Data.Token}"); + break; + + case Result.ApiError apiError: + Console.WriteLine($"API Error: {apiError.Message}"); + break; + + case Result.NetworkError networkError: + Console.WriteLine($"Network Error: {networkError.Exception.Message}"); + break; +} +``` + +## Next Steps + +- [SDK Introduction](sdk-introduction.md) - Overview of all SDK services +- [AIR Identifiers](air-identifier.md) - Learn about model identifiers +- [Error Handling](error-handling.md) - Comprehensive error handling patterns diff --git a/Documentation/Guides/sdk-usage.md b/Documentation/Guides/sdk-usage.md new file mode 100644 index 0000000..119f8ac --- /dev/null +++ b/Documentation/Guides/sdk-usage.md @@ -0,0 +1,412 @@ +--- +title: Usage Service +description: Monitor API consumption, track credits, and analyze account usage with the CivitaiSharp.Sdk Usage service. +--- + +# Usage Service + +The Usage service provides comprehensive monitoring of your Civitai Generator API consumption, including job counts, credit usage, and detailed breakdowns by time period. + +## Overview + +The Usage service allows you to: +- Track total API consumption over time +- Monitor credit usage and job counts +- Analyze usage patterns by date range +- Plan resource allocation based on historical data + +## Basic Usage + +### Get Current Consumption + +```csharp +var result = await sdkClient.Usage.GetConsumptionAsync(); + +if (result is Result.Success success) +{ + Console.WriteLine($"Total Jobs: {success.Data.TotalJobs}"); + Console.WriteLine($"Total Credits: {success.Data.TotalCredits}"); + Console.WriteLine($"Date Range: {success.Data.StartDate} to {success.Data.EndDate}"); +} +``` + +### Get Consumption for Specific Period + +```csharp +var startDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); +var endDate = new DateTime(2025, 1, 31, 23, 59, 59, DateTimeKind.Utc); + +var result = await sdkClient.Usage.GetConsumptionAsync(startDate, endDate); + +if (result is Result.Success success) +{ + Console.WriteLine($"January 2025 Usage:"); + Console.WriteLine($" Jobs: {success.Data.TotalJobs}"); + Console.WriteLine($" Credits: {success.Data.TotalCredits}"); +} +``` + +## Understanding Results + +### ConsumptionDetails + +The main result type containing consumption statistics: + +| Property | Type | Description | +|----------|------|-------------| +| `StartDate` | `DateTime` | Start of the reporting period (UTC) | +| `EndDate` | `DateTime` | End of the reporting period (UTC) | +| `TotalJobs` | `int` | Total number of jobs submitted | +| `TotalCredits` | `decimal` | Total credits consumed | +| `JobsByType` | `Dictionary?` | Job counts by type (e.g., "textToImage") | +| `CreditsByType` | `Dictionary?` | Credit usage by job type | + +## Practical Examples + +### Monitor Daily Usage + +```csharp +public async Task GetTodayUsageAsync() +{ + var today = DateTime.UtcNow.Date; + var tomorrow = today.AddDays(1); + + var result = await sdkClient.Usage.GetConsumptionAsync(today, tomorrow); + + if (result is Result.Success success) + { + return success.Data; + } + + Console.WriteLine("Failed to retrieve usage data"); + return null; +} +``` + +### Track Monthly Trends + +```csharp +public async Task ShowMonthlyTrendsAsync() +{ + var currentMonth = DateTime.UtcNow.Date.AddDays(1 - DateTime.UtcNow.Day); + + for (int i = 0; i < 6; i++) + { + var month = currentMonth.AddMonths(-i); + var nextMonth = month.AddMonths(1); + + var result = await sdkClient.Usage.GetConsumptionAsync(month, nextMonth); + + if (result is Result.Success success) + { + Console.WriteLine($"{month:yyyy-MM}:"); + Console.WriteLine($" Jobs: {success.Data.TotalJobs,6}"); + Console.WriteLine($" Credits: {success.Data.TotalCredits,8:F2}"); + } + } +} +``` + +### Calculate Average Cost Per Job + +```csharp +public async Task GetAverageCostPerJobAsync(DateTime start, DateTime end) +{ + var result = await sdkClient.Usage.GetConsumptionAsync(start, end); + + if (result is Result.Success success) + { + if (success.Data.TotalJobs == 0) + { + return null; + } + + var avgCost = success.Data.TotalCredits / success.Data.TotalJobs; + Console.WriteLine($"Average cost per job: {avgCost:F3} credits"); + return avgCost; + } + + return null; +} +``` + +### Budget Monitoring + +```csharp +public async Task CheckBudgetAsync(decimal monthlyBudget) +{ + var monthStart = DateTime.UtcNow.Date.AddDays(1 - DateTime.UtcNow.Day); + var monthEnd = monthStart.AddMonths(1); + + var result = await sdkClient.Usage.GetConsumptionAsync(monthStart, monthEnd); + + if (result is not Result.Success success) + { + Console.WriteLine("Failed to check budget"); + return false; + } + + var percentUsed = (success.Data.TotalCredits / monthlyBudget) * 100; + var remaining = monthlyBudget - success.Data.TotalCredits; + + Console.WriteLine($"Budget Status:"); + Console.WriteLine($" Used: {success.Data.TotalCredits:F2} / {monthlyBudget:F2} credits ({percentUsed:F1}%)"); + Console.WriteLine($" Remaining: {remaining:F2} credits"); + + if (percentUsed >= 90) + { + Console.WriteLine("WARNING: Over 90% of budget used!"); + return false; + } + else if (percentUsed >= 75) + { + Console.WriteLine("CAUTION: Over 75% of budget used"); + return false; + } + + return true; +} +``` + +### Usage by Job Type Analysis + +```csharp +public async Task AnalyzeJobTypesAsync(DateTime start, DateTime end) +{ + var result = await sdkClient.Usage.GetConsumptionAsync(start, end); + + if (result is not Result.Success success) + { + return; + } + + if (success.Data.JobsByType is null || success.Data.CreditsByType is null) + { + Console.WriteLine("Detailed breakdown not available"); + return; + } + + Console.WriteLine("Usage by Job Type:"); + Console.WriteLine($"{"Type",-20} {"Jobs",10} {"Credits",12} {"Avg Cost",12}"); + Console.WriteLine(new string('-', 60)); + + foreach (var (jobType, count) in success.Data.JobsByType) + { + var credits = success.Data.CreditsByType.GetValueOrDefault(jobType, 0); + var avgCost = count > 0 ? credits / count : 0; + + Console.WriteLine($"{jobType,-20} {count,10} {credits,12:F2} {avgCost,12:F3}"); + } +} +``` + +### Rate Limiting Protection + +```csharp +private DateTime? _lastUsageCheck; +private ConsumptionDetails? _cachedUsage; + +public async Task CanSubmitJobAsync(decimal creditCost) +{ + // Cache usage checks (avoid API spam) + if (_lastUsageCheck is null || + DateTime.UtcNow - _lastUsageCheck.Value > TimeSpan.FromMinutes(5)) + { + var result = await sdkClient.Usage.GetConsumptionAsync(); + + if (result is Result.Success success) + { + _cachedUsage = success.Data; + _lastUsageCheck = DateTime.UtcNow; + } + } + + if (_cachedUsage is null) + { + // If we can't check usage, allow the job + return true; + } + + // Example: limit to 1000 credits per day + const decimal dailyLimit = 1000m; + var todayStart = DateTime.UtcNow.Date; + + // Note: This is simplified - in production, track daily usage separately + if (_cachedUsage.TotalCredits + creditCost > dailyLimit) + { + Console.WriteLine($"Daily limit would be exceeded: {_cachedUsage.TotalCredits + creditCost:F2} / {dailyLimit:F2}"); + return false; + } + + return true; +} +``` + +## Error Handling + +Handle usage query failures gracefully: + +```csharp +var result = await sdkClient.Usage.GetConsumptionAsync(); + +switch (result) +{ + case Result.Success success: + Console.WriteLine($"Current usage: {success.Data.TotalCredits:F2} credits"); + break; + + case Result.ApiError apiError: + Console.WriteLine($"API Error: {apiError.Message}"); + // Usage tracking is non-critical, continue operation + break; + + case Result.NetworkError networkError: + Console.WriteLine($"Network Error: {networkError.Exception.Message}"); + // Consider caching previous values or using defaults + break; +} +``` + +## Best Practices + +### 1. Cache Usage Data + +Usage changes slowly - cache results to reduce API calls: + +```csharp +private class UsageCache +{ + public ConsumptionDetails Data { get; set; } = null!; + public DateTime LastUpdate { get; set; } +} + +private UsageCache? _usageCache; +private readonly TimeSpan _cacheExpiry = TimeSpan.FromMinutes(5); + +public async Task GetCachedUsageAsync() +{ + if (_usageCache is not null && + DateTime.UtcNow - _usageCache.LastUpdate < _cacheExpiry) + { + return _usageCache.Data; + } + + var result = await sdkClient.Usage.GetConsumptionAsync(); + + if (result is Result.Success success) + { + _usageCache = new UsageCache + { + Data = success.Data, + LastUpdate = DateTime.UtcNow + }; + return success.Data; + } + + return null; +} +``` + +### 2. Use UTC for Date Ranges + +Always use UTC dates to avoid timezone confusion: + +```csharp +// Good - explicit UTC +var start = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); +var end = new DateTime(2025, 1, 31, 23, 59, 59, DateTimeKind.Utc); +await sdkClient.Usage.GetConsumptionAsync(start, end); + +// Bad - local time can cause issues +var start = new DateTime(2025, 1, 1); +await sdkClient.Usage.GetConsumptionAsync(start, start.AddMonths(1)); +``` + +### 3. Separate Monitoring from Business Logic + +Keep usage monitoring decoupled from core functionality: + +```csharp +// Usage monitoring shouldn't block job submission +public async Task> GenerateAsync(string prompt) +{ + // Monitor usage asynchronously (fire and forget) + _ = Task.Run(async () => + { + try + { + var usage = await sdkClient.Usage.GetConsumptionAsync(); + // Log, alert, or update dashboard + } + catch (Exception ex) + { + // Log error but don't propagate + Console.WriteLine($"Usage monitoring failed: {ex.Message}"); + } + }); + + // Continue with job submission + return await sdkClient.Jobs + .CreateTextToImage() + .WithModel(model) + .WithPrompt(prompt) + .ExecuteAsync(); +} +``` + +### 4. Set Up Usage Alerts + +Implement proactive alerting: + +```csharp +public async Task CheckUsageAlertsAsync(decimal warningThreshold, decimal criticalThreshold) +{ + var monthStart = DateTime.UtcNow.Date.AddDays(1 - DateTime.UtcNow.Day); + var result = await sdkClient.Usage.GetConsumptionAsync(monthStart, DateTime.UtcNow); + + if (result is not Result.Success success) + { + return; + } + + var usage = success.Data.TotalCredits; + + if (usage >= criticalThreshold) + { + await SendAlert("CRITICAL", $"Usage: {usage:F2} / {criticalThreshold:F2}"); + } + else if (usage >= warningThreshold) + { + await SendAlert("WARNING", $"Usage: {usage:F2} / {warningThreshold:F2}"); + } +} + +private Task SendAlert(string level, string message) +{ + // Send email, Slack notification, etc. + Console.WriteLine($"[{level}] {message}"); + return Task.CompletedTask; +} +``` + +## API Reference + +### Methods + +| Method | Parameters | Returns | Description | +|--------|------------|---------|-------------| +| `GetConsumptionAsync` | `DateTime? startDate, DateTime? endDate, CancellationToken` | `Result` | Get consumption statistics for specified period (defaults to all-time if dates not provided) | + +### Notes + +- All dates should be in UTC +- If `startDate` is null, uses beginning of time +- If `endDate` is null, uses current time +- Results may be cached by the API for a few minutes + +## Next Steps + +- [Jobs Service](sdk-jobs.md) - Submit and manage generation jobs +- [Coverage Service](sdk-coverage.md) - Check resource availability +- [SDK Introduction](sdk-introduction.md) - Overview of all SDK services +- [Error Handling](error-handling.md) - Comprehensive error handling patterns diff --git a/Documentation/Guides/toc.yml b/Documentation/Guides/toc.yml index ae15f05..a0527b5 100644 --- a/Documentation/Guides/toc.yml +++ b/Documentation/Guides/toc.yml @@ -40,6 +40,12 @@ items: items: - name: Introduction href: sdk-introduction.md + - name: Jobs Service + href: sdk-jobs.md + - name: Coverage Service + href: sdk-coverage.md + - name: Usage Service + href: sdk-usage.md - name: Tools Library expanded: true diff --git a/NUGET.md b/NUGET.md index 94efc33..2ddac78 100644 --- a/NUGET.md +++ b/NUGET.md @@ -416,6 +416,150 @@ var markdown = model.GetDescriptionAsMarkdown(); var plainText = modelVersion.GetDescriptionAsPlainText(); ``` +**SDK - Image Generation (Jobs)** + +```csharp +using CivitaiSharp.Sdk; +using CivitaiSharp.Sdk.Extensions; +using CivitaiSharp.Sdk.Air; +using Microsoft.Extensions.DependencyInjection; + +var services = new ServiceCollection(); +services.AddCivitaiSdk(options => +{ + options.ApiToken = "your-api-token"; // Required for SDK +}); + +await using var provider = services.BuildServiceProvider(); +var sdkClient = provider.GetRequiredService(); + +// Create a text-to-image job +var model = AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"); + +var result = await sdkClient.Jobs + .CreateTextToImage() + .WithModel(model) + .WithPrompt("a beautiful sunset over mountains, highly detailed") + .WithNegativePrompt("blurry, low quality") + .WithSize(1024, 1024) + .WithSteps(30) + .WithCfgScale(7.5m) + .WithSeed(12345) + .ExecuteAsync(); + +if (result is Result.Success success) +{ + var token = success.Data.Token; + Console.WriteLine($"Job submitted: {token}"); + + // Query job status + var statusResult = await sdkClient.Jobs.Query + .WithDetailed() + .GetByTokenAsync(token); + + if (statusResult is Result.Success statusSuccess) + { + foreach (var job in statusSuccess.Data.Jobs) + { + Console.WriteLine($"Job {job.JobId}: {job.Status}"); + } + } +} + +// Wait for job completion (blocks up to ~10 minutes) +var completedResult = await sdkClient.Jobs.Query + .WithWait() + .WithDetailed() + .GetByTokenAsync(token); + +// Query jobs by custom properties +var queryResult = await sdkClient.Jobs.Query + .WhereProperty("userId", JsonSerializer.SerializeToElement("12345")) + .WhereProperty("environment", JsonSerializer.SerializeToElement("production")) + .ExecuteAsync(); +``` + +**SDK - Coverage Service** + +```csharp +using CivitaiSharp.Sdk; +using CivitaiSharp.Sdk.Air; + +// Check if a model is available before submitting a job +var model = AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"); +var lora = AirIdentifier.Parse("urn:air:sdxl:lora:civitai:328553@368189"); + +// Check single model +var coverageResult = await sdkClient.Coverage.GetAsync(model); + +if (coverageResult is Result.Success coverage) +{ + if (coverage.Data.Available) + { + Console.WriteLine("Model is available!"); + foreach (var (provider, status) in coverage.Data.Providers) + { + Console.WriteLine($" {provider}: Queue position {status.QueuePosition}"); + } + } + else + { + Console.WriteLine("Model not available on any provider"); + } +} + +// Check multiple resources at once +var resources = new[] { model, lora }; +var batchResult = await sdkClient.Coverage.GetAsync(resources); + +if (batchResult is Result>.Success batch) +{ + foreach (var (resource, availability) in batch.Data) + { + Console.WriteLine($"{resource}: {availability.Available}"); + } +} +``` + +**SDK - Usage Service** + +```csharp +using CivitaiSharp.Sdk; + +// Get current account usage +var usageResult = await sdkClient.Usage.GetConsumptionAsync(); + +if (usageResult is Result.Success usage) +{ + Console.WriteLine($"Total Jobs: {usage.Data.TotalJobs}"); + Console.WriteLine($"Total Credits: {usage.Data.TotalCredits:F2}"); + Console.WriteLine($"Period: {usage.Data.StartDate} to {usage.Data.EndDate}"); +} + +// Get usage for specific date range +var startDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); +var endDate = new DateTime(2025, 1, 31, 23, 59, 59, DateTimeKind.Utc); + +var monthlyResult = await sdkClient.Usage.GetConsumptionAsync(startDate, endDate); + +if (monthlyResult is Result.Success monthly) +{ + Console.WriteLine($"January 2025:"); + Console.WriteLine($" Jobs: {monthly.Data.TotalJobs}"); + Console.WriteLine($" Credits: {monthly.Data.TotalCredits:F2}"); + + if (monthly.Data.JobsByType is not null) + { + Console.WriteLine(" Breakdown by type:"); + foreach (var (jobType, count) in monthly.Data.JobsByType) + { + var credits = monthly.Data.CreditsByType?.GetValueOrDefault(jobType, 0) ?? 0; + Console.WriteLine($" {jobType}: {count} jobs, {credits:F2} credits"); + } + } +} +``` + **Error Handling** ```csharp diff --git a/README.es-AR.md b/README.es-AR.md index 614a606..92c1727 100644 --- a/README.es-AR.md +++ b/README.es-AR.md @@ -495,6 +495,159 @@ var plainText = modelVersion.GetDescriptionAsPlainText(); +
+SDK - Generación de Imágenes (Jobs) + +```csharp +using CivitaiSharp.Sdk; +using CivitaiSharp.Sdk.Extensions; +using CivitaiSharp.Sdk.Air; +using Microsoft.Extensions.DependencyInjection; + +var services = new ServiceCollection(); +services.AddCivitaiSdk(options => +{ + options.ApiToken = "tu-token-api"; // Requerido para SDK +}); + +await using var provider = services.BuildServiceProvider(); +var sdkClient = provider.GetRequiredService(); + +// Crear un trabajo de texto a imagen +var model = AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"); + +var result = await sdkClient.Jobs + .CreateTextToImage() + .WithModel(model) + .WithPrompt("una hermosa puesta de sol sobre montañas, muy detallada") + .WithNegativePrompt("borroso, baja calidad") + .WithSize(1024, 1024) + .WithSteps(30) + .WithCfgScale(7.5m) + .WithSeed(12345) + .ExecuteAsync(); + +if (result is Result.Success success) +{ + var token = success.Data.Token; + Console.WriteLine($"Trabajo enviado: {token}"); + + // Consultar estado del trabajo + var statusResult = await sdkClient.Jobs.Query + .WithDetailed() + .GetByTokenAsync(token); + + if (statusResult is Result.Success statusSuccess) + { + foreach (var job in statusSuccess.Data.Jobs) + { + Console.WriteLine($"Trabajo {job.JobId}: {job.Status}"); + } + } +} + +// Esperar finalización del trabajo (bloquea hasta ~10 minutos) +var completedResult = await sdkClient.Jobs.Query + .WithWait() + .WithDetailed() + .GetByTokenAsync(token); + +// Consultar trabajos por propiedades personalizadas +var queryResult = await sdkClient.Jobs.Query + .WhereProperty("userId", JsonSerializer.SerializeToElement("12345")) + .WhereProperty("environment", JsonSerializer.SerializeToElement("production")) + .ExecuteAsync(); +``` + +
+ +
+SDK - Servicio de Cobertura + +```csharp +using CivitaiSharp.Sdk; +using CivitaiSharp.Sdk.Air; + +// Verificar si un modelo está disponible antes de enviar un trabajo +var model = AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"); +var lora = AirIdentifier.Parse("urn:air:sdxl:lora:civitai:328553@368189"); + +// Verificar un solo modelo +var coverageResult = await sdkClient.Coverage.GetAsync(model); + +if (coverageResult is Result.Success coverage) +{ + if (coverage.Data.Available) + { + Console.WriteLine("¡Modelo disponible!"); + foreach (var (provider, status) in coverage.Data.Providers) + { + Console.WriteLine($" {provider}: Posición en cola {status.QueuePosition}"); + } + } + else + { + Console.WriteLine("Modelo no disponible en ningún proveedor"); + } +} + +// Verificar múltiples recursos a la vez +var resources = new[] { model, lora }; +var batchResult = await sdkClient.Coverage.GetAsync(resources); + +if (batchResult is Result>.Success batch) +{ + foreach (var (resource, availability) in batch.Data) + { + Console.WriteLine($"{resource}: {availability.Available}"); + } +} +``` + +
+ +
+SDK - Servicio de Uso + +```csharp +using CivitaiSharp.Sdk; + +// Obtener uso actual de la cuenta +var usageResult = await sdkClient.Usage.GetConsumptionAsync(); + +if (usageResult is Result.Success usage) +{ + Console.WriteLine($"Total de Trabajos: {usage.Data.TotalJobs}"); + Console.WriteLine($"Total de Créditos: {usage.Data.TotalCredits:F2}"); + Console.WriteLine($"Período: {usage.Data.StartDate} a {usage.Data.EndDate}"); +} + +// Obtener uso para rango de fechas específico +var startDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); +var endDate = new DateTime(2025, 1, 31, 23, 59, 59, DateTimeKind.Utc); + +var monthlyResult = await sdkClient.Usage.GetConsumptionAsync(startDate, endDate); + +if (monthlyResult is Result.Success monthly) +{ + Console.WriteLine($"Enero 2025:"); + Console.WriteLine($" Trabajos: {monthly.Data.TotalJobs}"); + Console.WriteLine($" Créditos: {monthly.Data.TotalCredits:F2}"); + + if (monthly.Data.JobsByType is not null) + { + Console.WriteLine(" Desglose por tipo:"); + foreach (var (jobType, count) in monthly.Data.JobsByType) + { + var credits = monthly.Data.CreditsByType?.GetValueOrDefault(jobType, 0) ?? 0; + Console.WriteLine($" {jobType}: {count} trabajos, {credits:F2} créditos"); + } + } +} +``` + +
+
Manejo de Errores diff --git a/README.ja.md b/README.ja.md index a2e2ae4..6cecb2a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -495,6 +495,159 @@ var plainText = modelVersion.GetDescriptionAsPlainText();
+
+SDK - 画像生成(Jobs) + +```csharp +using CivitaiSharp.Sdk; +using CivitaiSharp.Sdk.Extensions; +using CivitaiSharp.Sdk.Air; +using Microsoft.Extensions.DependencyInjection; + +var services = new ServiceCollection(); +services.AddCivitaiSdk(options => +{ + options.ApiToken = "your-api-token"; // SDKには必須 +}); + +await using var provider = services.BuildServiceProvider(); +var sdkClient = provider.GetRequiredService(); + +// テキストから画像へのジョブを作成 +var model = AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"); + +var result = await sdkClient.Jobs + .CreateTextToImage() + .WithModel(model) + .WithPrompt("山々の上の美しい夕焼け、非常に詳細") + .WithNegativePrompt("ぼやけた、低品質") + .WithSize(1024, 1024) + .WithSteps(30) + .WithCfgScale(7.5m) + .WithSeed(12345) + .ExecuteAsync(); + +if (result is Result.Success success) +{ + var token = success.Data.Token; + Console.WriteLine($"ジョブ送信済み: {token}"); + + // ジョブステータスを照会 + var statusResult = await sdkClient.Jobs.Query + .WithDetailed() + .GetByTokenAsync(token); + + if (statusResult is Result.Success statusSuccess) + { + foreach (var job in statusSuccess.Data.Jobs) + { + Console.WriteLine($"ジョブ {job.JobId}: {job.Status}"); + } + } +} + +// ジョブ完了を待機(最大約10分間ブロック) +var completedResult = await sdkClient.Jobs.Query + .WithWait() + .WithDetailed() + .GetByTokenAsync(token); + +// カスタムプロパティでジョブを照会 +var queryResult = await sdkClient.Jobs.Query + .WhereProperty("userId", JsonSerializer.SerializeToElement("12345")) + .WhereProperty("environment", JsonSerializer.SerializeToElement("production")) + .ExecuteAsync(); +``` + +
+ +
+SDK - カバレッジサービス + +```csharp +using CivitaiSharp.Sdk; +using CivitaiSharp.Sdk.Air; + +// ジョブを送信する前にモデルが利用可能かチェック +var model = AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"); +var lora = AirIdentifier.Parse("urn:air:sdxl:lora:civitai:328553@368189"); + +// 単一モデルをチェック +var coverageResult = await sdkClient.Coverage.GetAsync(model); + +if (coverageResult is Result.Success coverage) +{ + if (coverage.Data.Available) + { + Console.WriteLine("モデルは利用可能です!"); + foreach (var (provider, status) in coverage.Data.Providers) + { + Console.WriteLine($" {provider}: キュー位置 {status.QueuePosition}"); + } + } + else + { + Console.WriteLine("どのプロバイダーでもモデルは利用できません"); + } +} + +// 複数のリソースを一度にチェック +var resources = new[] { model, lora }; +var batchResult = await sdkClient.Coverage.GetAsync(resources); + +if (batchResult is Result>.Success batch) +{ + foreach (var (resource, availability) in batch.Data) + { + Console.WriteLine($"{resource}: {availability.Available}"); + } +} +``` + +
+ +
+SDK - 使用状況サービス + +```csharp +using CivitaiSharp.Sdk; + +// 現在のアカウント使用状況を取得 +var usageResult = await sdkClient.Usage.GetConsumptionAsync(); + +if (usageResult is Result.Success usage) +{ + Console.WriteLine($"総ジョブ数: {usage.Data.TotalJobs}"); + Console.WriteLine($"総クレジット数: {usage.Data.TotalCredits:F2}"); + Console.WriteLine($"期間: {usage.Data.StartDate} から {usage.Data.EndDate}"); +} + +// 特定の日付範囲の使用状況を取得 +var startDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); +var endDate = new DateTime(2025, 1, 31, 23, 59, 59, DateTimeKind.Utc); + +var monthlyResult = await sdkClient.Usage.GetConsumptionAsync(startDate, endDate); + +if (monthlyResult is Result.Success monthly) +{ + Console.WriteLine($"2025年1月:"); + Console.WriteLine($" ジョブ: {monthly.Data.TotalJobs}"); + Console.WriteLine($" クレジット: {monthly.Data.TotalCredits:F2}"); + + if (monthly.Data.JobsByType is not null) + { + Console.WriteLine(" タイプ別内訳:"); + foreach (var (jobType, count) in monthly.Data.JobsByType) + { + var credits = monthly.Data.CreditsByType?.GetValueOrDefault(jobType, 0) ?? 0; + Console.WriteLine($" {jobType}: {count} ジョブ、{credits:F2} クレジット"); + } + } +} +``` + +
+
エラーハンドリング diff --git a/README.md b/README.md index f0e4b63..a7a5a5f 100644 --- a/README.md +++ b/README.md @@ -490,6 +490,159 @@ var plainText = modelVersion.GetDescriptionAsPlainText();
+
+SDK - Image Generation (Jobs) + +```csharp +using CivitaiSharp.Sdk; +using CivitaiSharp.Sdk.Extensions; +using CivitaiSharp.Sdk.Air; +using Microsoft.Extensions.DependencyInjection; + +var services = new ServiceCollection(); +services.AddCivitaiSdk(options => +{ + options.ApiToken = "your-api-token"; // Required for SDK +}); + +await using var provider = services.BuildServiceProvider(); +var sdkClient = provider.GetRequiredService(); + +// Create a text-to-image job +var model = AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"); + +var result = await sdkClient.Jobs + .CreateTextToImage() + .WithModel(model) + .WithPrompt("a beautiful sunset over mountains, highly detailed") + .WithNegativePrompt("blurry, low quality") + .WithSize(1024, 1024) + .WithSteps(30) + .WithCfgScale(7.5m) + .WithSeed(12345) + .ExecuteAsync(); + +if (result is Result.Success success) +{ + var token = success.Data.Token; + Console.WriteLine($"Job submitted: {token}"); + + // Query job status + var statusResult = await sdkClient.Jobs.Query + .WithDetailed() + .GetByTokenAsync(token); + + if (statusResult is Result.Success statusSuccess) + { + foreach (var job in statusSuccess.Data.Jobs) + { + Console.WriteLine($"Job {job.JobId}: {job.Status}"); + } + } +} + +// Wait for job completion (blocks up to ~10 minutes) +var completedResult = await sdkClient.Jobs.Query + .WithWait() + .WithDetailed() + .GetByTokenAsync(token); + +// Query jobs by custom properties +var queryResult = await sdkClient.Jobs.Query + .WhereProperty("userId", JsonSerializer.SerializeToElement("12345")) + .WhereProperty("environment", JsonSerializer.SerializeToElement("production")) + .ExecuteAsync(); +``` + +
+ +
+SDK - Coverage Service + +```csharp +using CivitaiSharp.Sdk; +using CivitaiSharp.Sdk.Air; + +// Check if a model is available before submitting a job +var model = AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072"); +var lora = AirIdentifier.Parse("urn:air:sdxl:lora:civitai:328553@368189"); + +// Check single model +var coverageResult = await sdkClient.Coverage.GetAsync(model); + +if (coverageResult is Result.Success coverage) +{ + if (coverage.Data.Available) + { + Console.WriteLine("Model is available!"); + foreach (var (provider, status) in coverage.Data.Providers) + { + Console.WriteLine($" {provider}: Queue position {status.QueuePosition}"); + } + } + else + { + Console.WriteLine("Model not available on any provider"); + } +} + +// Check multiple resources at once +var resources = new[] { model, lora }; +var batchResult = await sdkClient.Coverage.GetAsync(resources); + +if (batchResult is Result>.Success batch) +{ + foreach (var (resource, availability) in batch.Data) + { + Console.WriteLine($"{resource}: {availability.Available}"); + } +} +``` + +
+ +
+SDK - Usage Service + +```csharp +using CivitaiSharp.Sdk; + +// Get current account usage +var usageResult = await sdkClient.Usage.GetConsumptionAsync(); + +if (usageResult is Result.Success usage) +{ + Console.WriteLine($"Total Jobs: {usage.Data.TotalJobs}"); + Console.WriteLine($"Total Credits: {usage.Data.TotalCredits:F2}"); + Console.WriteLine($"Period: {usage.Data.StartDate} to {usage.Data.EndDate}"); +} + +// Get usage for specific date range +var startDate = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); +var endDate = new DateTime(2025, 1, 31, 23, 59, 59, DateTimeKind.Utc); + +var monthlyResult = await sdkClient.Usage.GetConsumptionAsync(startDate, endDate); + +if (monthlyResult is Result.Success monthly) +{ + Console.WriteLine($"January 2025:"); + Console.WriteLine($" Jobs: {monthly.Data.TotalJobs}"); + Console.WriteLine($" Credits: {monthly.Data.TotalCredits:F2}"); + + if (monthly.Data.JobsByType is not null) + { + Console.WriteLine(" Breakdown by type:"); + foreach (var (jobType, count) in monthly.Data.JobsByType) + { + var credits = monthly.Data.CreditsByType?.GetValueOrDefault(jobType, 0) ?? 0; + Console.WriteLine($" {jobType}: {count} jobs, {credits:F2} credits"); + } + } +} +``` + +
+
Error Handling diff --git a/Sdk/Extensions/SdkApiStringRegistry.cs b/Sdk/Extensions/SdkApiStringRegistry.cs index 128d6c0..d6a975e 100644 --- a/Sdk/Extensions/SdkApiStringRegistry.cs +++ b/Sdk/Extensions/SdkApiStringRegistry.cs @@ -1,6 +1,7 @@ namespace CivitaiSharp.Sdk.Extensions; using System.Collections.Generic; +using System.Threading; using CivitaiSharp.Core.Extensions; using CivitaiSharp.Sdk.Air; using CivitaiSharp.Sdk.Enums; @@ -18,7 +19,7 @@ namespace CivitaiSharp.Sdk.Extensions; internal static class SdkApiStringRegistry { private static bool _initialized; - private static readonly object InitializationLock = new(); + private static readonly Lock InitializationLock = new(); /// /// Ensures the SDK enum mappings are registered with the Core registry. diff --git a/Sdk/ISdkClient.cs b/Sdk/ISdkClient.cs index d3d5d92..b4db6ed 100644 --- a/Sdk/ISdkClient.cs +++ b/Sdk/ISdkClient.cs @@ -1,5 +1,6 @@ namespace CivitaiSharp.Sdk; +using CivitaiSharp.Sdk.Request; using CivitaiSharp.Sdk.Services; /// @@ -10,9 +11,9 @@ namespace CivitaiSharp.Sdk; public interface ISdkClient { /// - /// Image generation jobs: submit, query, cancel, and retrieve results. + /// Image generation job operations: create, query, cancel, and monitor jobs. /// - IJobsService Jobs { get; } + JobsBuilder Jobs { get; } /// /// Model and resource availability checks before submitting jobs. diff --git a/Sdk/Models/Jobs/TextToImageJobRequest.cs b/Sdk/Models/Jobs/TextToImageJobRequest.cs index 8fc6219..a5f7f44 100644 --- a/Sdk/Models/Jobs/TextToImageJobRequest.cs +++ b/Sdk/Models/Jobs/TextToImageJobRequest.cs @@ -20,7 +20,7 @@ public sealed class TextToImageJobRequest /// Gets the job type discriminator. Always . /// [JsonPropertyName("$type")] - public string Type => JobType; + public static string Type => JobType; /// /// Gets or sets the base model AIR identifier. Required. diff --git a/Sdk/Request/JobQueryBuilder.cs b/Sdk/Request/JobQueryBuilder.cs new file mode 100644 index 0000000..d81764e --- /dev/null +++ b/Sdk/Request/JobQueryBuilder.cs @@ -0,0 +1,243 @@ +namespace CivitaiSharp.Sdk.Request; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using CivitaiSharp.Core.Response; +using CivitaiSharp.Sdk.Http; +using CivitaiSharp.Sdk.Models.Jobs; +using CivitaiSharp.Sdk.Models.Results; + +/// +/// Immutable, thread-safe builder for querying and managing job status. +/// Each fluent method returns a new builder instance, allowing safe reuse and caching of base configurations. +/// +public sealed record JobQueryBuilder +{ + private readonly SdkHttpClient _httpClient; + private readonly SdkClientOptions _options; + private readonly bool _wait; + private readonly bool _detailed; + private readonly ImmutableDictionary? _propertyFilters; + + /// + /// Initializes a new instance of the class. + /// Internal to enforce creation through JobsBuilder. + /// + /// The HTTP client used to execute requests. + /// The SDK client options. + /// Thrown when httpClient or options is null. + internal JobQueryBuilder(SdkHttpClient httpClient, SdkClientOptions options) + : this( + httpClient ?? throw new ArgumentNullException(nameof(httpClient)), + options ?? throw new ArgumentNullException(nameof(options)), + wait: false, + detailed: false, + propertyFilters: null) + { + } + + private JobQueryBuilder( + SdkHttpClient httpClient, + SdkClientOptions options, + bool wait, + bool detailed, + ImmutableDictionary? propertyFilters) + { + _httpClient = httpClient; + _options = options; + _wait = wait; + _detailed = detailed; + _propertyFilters = propertyFilters; + } + + /// + /// Configures the query to include detailed job specifications in responses. + /// + /// A new builder instance with detailed mode enabled. + public JobQueryBuilder WithDetailed() + => new(_httpClient, _options, _wait, detailed: true, _propertyFilters); + + /// + /// Configures the query to wait for job completion (blocks up to ~10 minutes). + /// + /// A new builder instance with wait mode enabled. + /// + /// When enabled, the API will block until jobs complete or timeout (~10 minutes). + /// This is useful when you need immediate results without manual polling. + /// + public JobQueryBuilder WithWait() + => new(_httpClient, _options, wait: true, _detailed, _propertyFilters); + + /// + /// Adds a custom property filter to the query. + /// + /// The property key to filter by. + /// The property value to match (must be JSON-serializable). + /// A new builder instance with the added property filter. + /// + /// Multiple property filters are combined with AND logic - all must match. + /// Use to create JsonElement values. + /// + public JobQueryBuilder WhereProperty(string key, JsonElement value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + var filters = _propertyFilters ?? []; + return new(_httpClient, _options, _wait, _detailed, filters.SetItem(key, value)); + } + + /// + /// Adds multiple custom property filters to the query. + /// + /// Dictionary of property key-value pairs to filter by. + /// A new builder instance with the added property filters. + /// + /// Multiple property filters are combined with AND logic - all must match. + /// + public JobQueryBuilder WhereProperties(IReadOnlyDictionary properties) + { + ArgumentNullException.ThrowIfNull(properties); + + if (properties.Count == 0) + { + return this; + } + + var filters = _propertyFilters ?? []; + foreach (var kvp in properties) + { + filters = filters.SetItem(kvp.Key, kvp.Value); + } + + return new(_httpClient, _options, _wait, _detailed, filters); + } + + /// + /// Gets the status of a specific job by its unique identifier. + /// + /// The unique job identifier. + /// Token to cancel the asynchronous operation. + /// A task containing the job status. + /// Thrown if jobId is empty. + public Task> GetByIdAsync(Guid jobId, CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfEqual(jobId, Guid.Empty, nameof(jobId)); + + var uri = BuildUri($"jobs/{jobId}"); + return _httpClient.GetAsync(uri, cancellationToken); + } + + /// + /// Gets the status of jobs by their batch token from a previous submission. + /// + /// The batch token from a previous job submission. + /// Token to cancel the asynchronous operation. + /// A task containing the job status collection. + /// Thrown if token is null or whitespace. + public Task> GetByTokenAsync(string token, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(token); + + var uri = BuildUri("jobs", token: token); + return _httpClient.GetAsync(uri, cancellationToken); + } + + /// + /// Executes a query for jobs matching the configured property filters. + /// + /// Token to cancel the asynchronous operation. + /// A task containing the matching jobs. + /// Thrown if no property filters are configured. + /// + /// Use or to add filters before calling this method. + /// + public Task> ExecuteAsync(CancellationToken cancellationToken = default) + { + if (_propertyFilters is null || _propertyFilters.Count == 0) + { + throw new InvalidOperationException("At least one property filter must be specified. Use WhereProperty() or WhereProperties() to add filters."); + } + + var request = new QueryJobsRequest { Properties = _propertyFilters }; + var uri = BuildUri("jobs/query"); + return _httpClient.PostAsync(uri, request, cancellationToken); + } + + /// + /// Cancels a specific job by its unique identifier. + /// + /// The unique job identifier. + /// If true, cancels even if the job is processing. Default is true. + /// Token to cancel the asynchronous operation. + /// A task representing the cancellation operation. + /// Thrown if jobId is empty. + public Task> CancelAsync(Guid jobId, bool force = true, CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfEqual(jobId, Guid.Empty, nameof(jobId)); + + var uri = BuildUri($"jobs/{jobId}", force: force); + return _httpClient.DeleteAsync(uri, cancellationToken); + } + + /// + /// Cancels jobs by their batch token. + /// + /// The batch token from a previous job submission. + /// If true, cancels even if jobs are processing. Default is true. + /// Token to cancel the asynchronous operation. + /// A task representing the cancellation operation. + /// Thrown if token is null or whitespace. + public Task> CancelAsync(string token, bool force = true, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(token); + + var uri = BuildUri("jobs", token: token, force: force); + return _httpClient.DeleteAsync(uri, cancellationToken); + } + + /// + /// Marks a specific job as tainted by its unique identifier. + /// + /// The unique job identifier. + /// Token to cancel the asynchronous operation. + /// A task representing the taint operation. + /// Thrown if jobId is empty. + public Task> TaintAsync(Guid jobId, CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfEqual(jobId, Guid.Empty, nameof(jobId)); + + var uri = _options.GetApiPath($"jobs/{jobId}/taint"); + return _httpClient.PutAsync(uri, cancellationToken); + } + + /// + /// Marks jobs as tainted by their batch token. + /// + /// The batch token from a previous job submission. + /// Token to cancel the asynchronous operation. + /// A task representing the taint operation. + /// Thrown if token is null or whitespace. + public Task> TaintAsync(string token, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(token); + + var uri = BuildUri("jobs/taint", token: token); + return _httpClient.PutAsync(uri, cancellationToken); + } + + private string BuildUri( + string relativePath, + string? token = null, + bool? force = null) + { + var query = new QueryStringBuilder() + .Append("token", token) + .AppendIf("wait", _wait) + .AppendIf("detailed", _detailed) + .Append("force", force); + return query.BuildUri(_options.GetApiPath(relativePath)); + } +} diff --git a/Sdk/Request/JobsBuilder.cs b/Sdk/Request/JobsBuilder.cs new file mode 100644 index 0000000..da2cbfc --- /dev/null +++ b/Sdk/Request/JobsBuilder.cs @@ -0,0 +1,81 @@ +namespace CivitaiSharp.Sdk.Request; + +using System; +using CivitaiSharp.Sdk; +using CivitaiSharp.Sdk.Http; + +/// +/// Entry point for job creation and querying operations. +/// Provides factory methods for creating new jobs and a fluent query builder for retrieving job status. +/// +public sealed record JobsBuilder +{ + private readonly SdkHttpClient _httpClient; + private readonly SdkClientOptions _options; + private readonly JobQueryBuilder _queryBuilder; + + /// + /// Initializes a new instance of the class. + /// Internal to enforce creation through SdkClient. + /// + /// The HTTP client used to execute requests. + /// The SDK client options. + /// Thrown when httpClient or options is null. + internal JobsBuilder(SdkHttpClient httpClient, SdkClientOptions options) + { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(options); + + _httpClient = httpClient; + _options = options; + _queryBuilder = new JobQueryBuilder(httpClient, options); + } + + /// + /// Gets a cached, immutable, thread-safe query builder for retrieving and managing job status. + /// + /// + /// This property returns a cached builder instance that can be safely reused. + /// Each fluent method on the builder returns a new instance with the updated configuration. + /// + /// + /// + /// // Query by ID with detailed information + /// var result = await sdkClient.Jobs.Query + /// .WithDetailed() + /// .GetByIdAsync(jobId); + /// + /// // Wait for completion + /// var result = await sdkClient.Jobs.Query + /// .WithWait() + /// .GetByTokenAsync(token); + /// + /// // Query by custom properties + /// var result = await sdkClient.Jobs.Query + /// .WhereProperty("userId", JsonSerializer.SerializeToElement("12345")) + /// .ExecuteAsync(); + /// + /// + public JobQueryBuilder Query => _queryBuilder; + + /// + /// Creates a new text-to-image job builder for submitting generation requests. + /// + /// A new instance. + /// + /// Use the returned builder to configure generation parameters (model, prompt, size, etc.) + /// and call to submit the job. + /// + /// + /// + /// var result = await sdkClient.Jobs + /// .CreateTextToImage() + /// .WithModel(AirIdentifier.Parse("urn:air:sdxl:checkpoint:civitai:4201@130072")) + /// .WithPrompt("a beautiful landscape") + /// .WithSize(1024, 1024) + /// .ExecuteAsync(); + /// + /// + public TextToImageBuilder CreateTextToImage() + => new(_httpClient, _options); +} diff --git a/Sdk/Request/TextToImageBuilder.cs b/Sdk/Request/TextToImageBuilder.cs new file mode 100644 index 0000000..a2dbf32 --- /dev/null +++ b/Sdk/Request/TextToImageBuilder.cs @@ -0,0 +1,441 @@ +namespace CivitaiSharp.Sdk.Request; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +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.Http; +using CivitaiSharp.Sdk.Models.Jobs; +using CivitaiSharp.Sdk.Models.Results; + +/// +/// Immutable, thread-safe builder for constructing and submitting text-to-image generation jobs. +/// Each fluent method returns a new builder instance, allowing safe reuse and caching of base configurations. +/// +public sealed record TextToImageBuilder +{ + private readonly SdkHttpClient _httpClient; + private readonly SdkClientOptions _options; + private readonly AirIdentifier? _model; + private readonly ImageJobParamsBuilder? _paramsBuilder; + private readonly ImmutableDictionary? _additionalNetworks; + private readonly ImmutableList? _controlNets; + private readonly int? _quantity; + private readonly Priority? _priority; + private readonly ImmutableDictionary? _properties; + private readonly string? _callbackUrl; + private readonly int? _retries; + private readonly string? _timeout; + private readonly int? _clipSkip; + + /// + /// Initializes a new instance of the class. + /// This constructor is internal to enforce creation through JobsBuilder. + /// + /// The HTTP client used to execute requests. + /// The SDK client options. + /// Thrown when httpClient or options is null. + internal TextToImageBuilder( + SdkHttpClient httpClient, + SdkClientOptions options) + : this( + httpClient ?? throw new ArgumentNullException(nameof(httpClient)), + options ?? throw new ArgumentNullException(nameof(options)), + model: null, + paramsBuilder: null, + additionalNetworks: null, + controlNets: null, + quantity: null, + priority: null, + properties: null, + callbackUrl: null, + retries: null, + timeout: null, + clipSkip: null) + { + } + + private TextToImageBuilder( + SdkHttpClient httpClient, + SdkClientOptions options, + AirIdentifier? model, + ImageJobParamsBuilder? paramsBuilder, + ImmutableDictionary? additionalNetworks, + ImmutableList? controlNets, + int? quantity, + Priority? priority, + ImmutableDictionary? properties, + string? callbackUrl, + int? retries, + string? timeout, + int? clipSkip) + { + _httpClient = httpClient; + _options = options; + _model = model; + _paramsBuilder = paramsBuilder; + _additionalNetworks = additionalNetworks; + _controlNets = controlNets; + _quantity = quantity; + _priority = priority; + _properties = properties; + _callbackUrl = callbackUrl; + _retries = retries; + _timeout = timeout; + _clipSkip = clipSkip; + } + + /// + /// 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 TextToImageBuilder WithModel(AirIdentifier model) + => new(_httpClient, _options, model, _paramsBuilder, _additionalNetworks, _controlNets, _quantity, _priority, _properties, _callbackUrl, _retries, _timeout, _clipSkip); + + /// + /// Sets the positive prompt for image generation. + /// + /// The prompt text describing what to generate. Required. + /// A new builder instance with the updated prompt. + public TextToImageBuilder WithPrompt(string prompt) + { + var builder = _paramsBuilder ?? ImageJobParamsBuilder.Create(); + return new(_httpClient, _options, _model, builder.WithPrompt(prompt), _additionalNetworks, _controlNets, _quantity, _priority, _properties, _callbackUrl, _retries, _timeout, _clipSkip); + } + + /// + /// Sets the negative prompt describing what to avoid. + /// + /// The negative prompt text. + /// A new builder instance with the updated negative prompt. + public TextToImageBuilder WithNegativePrompt(string negativePrompt) + { + var builder = _paramsBuilder ?? ImageJobParamsBuilder.Create(); + return new(_httpClient, _options, _model, builder.WithNegativePrompt(negativePrompt), _additionalNetworks, _controlNets, _quantity, _priority, _properties, _callbackUrl, _retries, _timeout, _clipSkip); + } + + /// + /// 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 TextToImageBuilder WithSize(int width, int height) + { + var builder = _paramsBuilder ?? ImageJobParamsBuilder.Create(); + return new(_httpClient, _options, _model, builder.WithSize(width, height), _additionalNetworks, _controlNets, _quantity, _priority, _properties, _callbackUrl, _retries, _timeout, _clipSkip); + } + + /// + /// Sets the number of sampling steps. + /// + /// The step count. Range: 1-100, default: 20. + /// A new builder instance with the updated steps. + public TextToImageBuilder WithSteps(int steps) + { + var builder = _paramsBuilder ?? ImageJobParamsBuilder.Create(); + return new(_httpClient, _options, _model, builder.WithSteps(steps), _additionalNetworks, _controlNets, _quantity, _priority, _properties, _callbackUrl, _retries, _timeout, _clipSkip); + } + + /// + /// 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 TextToImageBuilder WithCfgScale(decimal cfgScale) + { + var builder = _paramsBuilder ?? ImageJobParamsBuilder.Create(); + return new(_httpClient, _options, _model, builder.WithCfgScale(cfgScale), _additionalNetworks, _controlNets, _quantity, _priority, _properties, _callbackUrl, _retries, _timeout, _clipSkip); + } + + /// + /// Sets the random seed for reproducible generation. + /// + /// The seed value. + /// A new builder instance with the updated seed. + public TextToImageBuilder WithSeed(long seed) + { + var builder = _paramsBuilder ?? ImageJobParamsBuilder.Create(); + return new(_httpClient, _options, _model, builder.WithSeed(seed), _additionalNetworks, _controlNets, _quantity, _priority, _properties, _callbackUrl, _retries, _timeout, _clipSkip); + } + + /// + /// Configures generation parameters using a custom builder. + /// + /// The configured parameters builder. + /// A new builder instance with the updated parameters. + public TextToImageBuilder WithParams(ImageJobParamsBuilder paramsBuilder) + => new(_httpClient, _options, _model, paramsBuilder, _additionalNetworks, _controlNets, _quantity, _priority, _properties, _callbackUrl, _retries, _timeout, _clipSkip); + + /// + /// Configures generation parameters using a configuration action. + /// + /// Action to configure the parameters builder. + /// A new builder instance with the updated parameters. + public TextToImageBuilder WithParams(Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + var builder = _paramsBuilder ?? ImageJobParamsBuilder.Create(); + return new(_httpClient, _options, _model, configure(builder), _additionalNetworks, _controlNets, _quantity, _priority, _properties, _callbackUrl, _retries, _timeout, _clipSkip); + } + + /// + /// 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 TextToImageBuilder WithAdditionalNetwork(AirIdentifier network, ImageJobNetworkParams networkParams) + { + ArgumentNullException.ThrowIfNull(networkParams); + var networks = _additionalNetworks ?? []; + return new(_httpClient, _options, _model, _paramsBuilder, networks.SetItem(network, networkParams), _controlNets, _quantity, _priority, _properties, _callbackUrl, _retries, _timeout, _clipSkip); + } + + /// + /// 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 TextToImageBuilder WithAdditionalNetwork(AirIdentifier network, ImageJobNetworkParamsBuilder networkBuilder) + { + ArgumentNullException.ThrowIfNull(networkBuilder); + return WithAdditionalNetwork(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 TextToImageBuilder WithAdditionalNetwork(AirIdentifier network, Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + var builder = configure(ImageJobNetworkParamsBuilder.Create()); + return WithAdditionalNetwork(network, builder.Build()); + } + + /// + /// Adds a ControlNet configuration for guided generation. + /// + /// The ControlNet configuration. + /// A new builder instance with the added ControlNet. + public TextToImageBuilder WithControlNet(ImageJobControlNet controlNet) + { + ArgumentNullException.ThrowIfNull(controlNet); + var controlNets = _controlNets ?? []; + return new(_httpClient, _options, _model, _paramsBuilder, _additionalNetworks, controlNets.Add(controlNet), _quantity, _priority, _properties, _callbackUrl, _retries, _timeout, _clipSkip); + } + + /// + /// Adds a ControlNet configuration using a builder. + /// + /// The configured ControlNet builder. + /// A new builder instance with the added ControlNet. + public TextToImageBuilder WithControlNet(ImageJobControlNetBuilder controlNetBuilder) + { + ArgumentNullException.ThrowIfNull(controlNetBuilder); + return WithControlNet(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 TextToImageBuilder WithControlNet(Func configure) + { + ArgumentNullException.ThrowIfNull(configure); + var builder = configure(ImageJobControlNetBuilder.Create()); + return WithControlNet(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 TextToImageBuilder WithQuantity(int quantity) + => new(_httpClient, _options, _model, _paramsBuilder, _additionalNetworks, _controlNets, quantity, _priority, _properties, _callbackUrl, _retries, _timeout, _clipSkip); + + /// + /// Sets the priority configuration for job scheduling. + /// + /// The priority configuration. + /// A new builder instance with the updated priority. + public TextToImageBuilder WithPriority(Priority priority) + => new(_httpClient, _options, _model, _paramsBuilder, _additionalNetworks, _controlNets, _quantity, priority, _properties, _callbackUrl, _retries, _timeout, _clipSkip); + + /// + /// 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 TextToImageBuilder WithProperty(string key, JsonElement value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + var properties = _properties ?? []; + return new(_httpClient, _options, _model, _paramsBuilder, _additionalNetworks, _controlNets, _quantity, _priority, properties.SetItem(key, value), _callbackUrl, _retries, _timeout, _clipSkip); + } + + /// + /// Sets the webhook URL to call when the job completes. + /// + /// The webhook URL. + /// A new builder instance with the updated callback URL. + public TextToImageBuilder WithCallbackUrl(string callbackUrl) + => new(_httpClient, _options, _model, _paramsBuilder, _additionalNetworks, _controlNets, _quantity, _priority, _properties, callbackUrl, _retries, _timeout, _clipSkip); + + /// + /// 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 TextToImageBuilder WithRetries(int retries) + => new(_httpClient, _options, _model, _paramsBuilder, _additionalNetworks, _controlNets, _quantity, _priority, _properties, _callbackUrl, retries, _timeout, _clipSkip); + + /// + /// Sets the job timeout. + /// + /// The timeout duration. Format: "HH:mm:ss". Default: "00:10:00". + /// A new builder instance with the updated timeout. + public TextToImageBuilder WithTimeout(string timeout) + => new(_httpClient, _options, _model, _paramsBuilder, _additionalNetworks, _controlNets, _quantity, _priority, _properties, _callbackUrl, _retries, timeout, _clipSkip); + + /// + /// Sets the job timeout. + /// + /// The timeout duration. Default: 10 minutes. + /// A new builder instance with the updated timeout. + public TextToImageBuilder WithTimeout(TimeSpan timeout) + => new(_httpClient, _options, _model, _paramsBuilder, _additionalNetworks, _controlNets, _quantity, _priority, _properties, _callbackUrl, _retries, timeout.ToString(@"hh\:mm\:ss"), _clipSkip); + + /// + /// 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 TextToImageBuilder WithClipSkip(int clipSkip) + => new(_httpClient, _options, _model, _paramsBuilder, _additionalNetworks, _controlNets, _quantity, _priority, _properties, _callbackUrl, _retries, _timeout, clipSkip); + + /// + /// Executes the job submission to the Civitai Generator API. + /// + /// Token to cancel the asynchronous operation. + /// A task containing the job status collection with a token for polling. + /// Thrown when required properties are missing. + public Task> ExecuteAsync(CancellationToken cancellationToken = default) + { + 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."); + } + + var request = new TextToImageJobRequest + { + Model = _model.Value, + Params = _paramsBuilder.Build(), + AdditionalNetworks = _additionalNetworks?.Count > 0 ? _additionalNetworks : null, + ControlNets = _controlNets?.Count > 0 ? _controlNets : null, + Quantity = _quantity, + Priority = _priority, + Properties = _properties?.Count > 0 ? _properties : null, + CallbackUrl = _callbackUrl, + Retries = _retries, + Timeout = _timeout, + ClipSkip = _clipSkip + }; + + // Validate ControlNet configurations if present + if (request.ControlNets is not null) + { + foreach (var controlNet in request.ControlNets) + { + controlNet.Validate(); + } + } + + var uri = _options.GetApiPath("jobs"); + return _httpClient.PostAsync(uri, request, cancellationToken); + } + + /// + /// Executes multiple job submissions as a batch to the Civitai Generator API. + /// + /// Additional configured job builders to submit in the same batch. + /// Token to cancel the asynchronous operation. + /// A task containing the job status collection with a token for polling all jobs. + /// Thrown when required properties are missing. + public Task> ExecuteBatchAsync( + IEnumerable additionalJobs, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(additionalJobs); + + // Build all requests including this one + var allBuilders = new[] { this }.Concat(additionalJobs).ToList(); + var requests = new List(); + + foreach (var builder in allBuilders) + { + if (builder._model == null) + { + throw new InvalidOperationException("All jobs must have a model set. Use WithModel() on all builders."); + } + + if (builder._paramsBuilder == null) + { + throw new InvalidOperationException("All jobs must have parameters set. Use WithPrompt() or WithParams() on all builders."); + } + + var request = new TextToImageJobRequest + { + Model = builder._model.Value, + Params = builder._paramsBuilder.Build(), + AdditionalNetworks = builder._additionalNetworks?.Count > 0 ? builder._additionalNetworks : null, + ControlNets = builder._controlNets?.Count > 0 ? builder._controlNets : null, + Quantity = builder._quantity, + Priority = builder._priority, + Properties = builder._properties?.Count > 0 ? builder._properties : null, + CallbackUrl = builder._callbackUrl, + Retries = builder._retries, + Timeout = builder._timeout, + ClipSkip = builder._clipSkip + }; + + // Validate ControlNet configurations + if (request.ControlNets is not null) + { + foreach (var controlNet in request.ControlNets) + { + controlNet.Validate(); + } + } + + requests.Add(request); + } + + var batchRequest = new BatchJobRequest { Jobs = requests }; + var uri = _options.GetApiPath("jobs"); + return _httpClient.PostAsync(uri, batchRequest, cancellationToken); + } +} diff --git a/Sdk/Request/TextToImageJobBuilder.cs b/Sdk/Request/TextToImageJobBuilder.cs deleted file mode 100644 index 65d6cab..0000000 --- a/Sdk/Request/TextToImageJobBuilder.cs +++ /dev/null @@ -1,362 +0,0 @@ -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/SdkClient.cs b/Sdk/SdkClient.cs index 565c621..a8373be 100644 --- a/Sdk/SdkClient.cs +++ b/Sdk/SdkClient.cs @@ -2,11 +2,12 @@ namespace CivitaiSharp.Sdk; using System; using CivitaiSharp.Sdk.Http; +using CivitaiSharp.Sdk.Request; using CivitaiSharp.Sdk.Services; /// -/// Primary client facade for the Civitai Generator SDK. Provides access to image generation, -/// model availability checking, and usage tracking via the orchestration endpoints. +/// Primary client facade for the Civitai Generator SDK. All properties return cached, immutable, +/// thread-safe instances that can be safely shared across threads. /// Obtain an instance through dependency injection using /// . /// @@ -24,13 +25,13 @@ internal SdkClient(SdkHttpClient httpClient, SdkClientOptions options) ArgumentNullException.ThrowIfNull(httpClient); ArgumentNullException.ThrowIfNull(options); - Jobs = new JobsService(httpClient, options); + Jobs = new JobsBuilder(httpClient, options); Coverage = new CoverageService(httpClient, options); Usage = new UsageService(httpClient, options); } /// - public IJobsService Jobs { get; } + public JobsBuilder Jobs { get; } /// public ICoverageService Coverage { get; } diff --git a/Sdk/Services/IJobsService.cs b/Sdk/Services/IJobsService.cs deleted file mode 100644 index a71a27e..0000000 --- a/Sdk/Services/IJobsService.cs +++ /dev/null @@ -1,143 +0,0 @@ -namespace CivitaiSharp.Sdk.Services; - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -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. - /// - /// The job request. - /// 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. - Task> SubmitAsync( - TextToImageJobRequest request, - bool wait = false, - bool detailed = false, - CancellationToken cancellationToken = default); - - /// - /// Submits multiple text-to-image generation jobs as a batch. - /// - /// The job requests to submit. - /// If true, blocks until all jobs complete (up to ~10 minutes). - /// If true, includes the original job specifications in the response. - /// Token to cancel the asynchronous operation. - /// A task containing the job status collection with a token for polling. - Task> SubmitBatchAsync( - IEnumerable requests, - bool wait = false, - bool detailed = false, - CancellationToken cancellationToken = default); - - /// - /// Gets the status of a specific job by its ID. - /// - /// The unique job identifier. - /// If true, includes the original job specification in the response. - /// Token to cancel the asynchronous operation. - /// A task containing the job status. - Task> GetByIdAsync( - Guid jobId, - bool detailed = false, - CancellationToken cancellationToken = default); - - /// - /// Gets the status of jobs by their batch token. - /// - /// The batch token from a previous job submission. - /// If true, blocks until all jobs complete (up to ~10 minutes). - /// If true, includes the original job specifications in the response. - /// Token to cancel the asynchronous operation. - /// A task containing the job status collection. - Task> GetByTokenAsync( - string token, - bool wait = false, - bool detailed = false, - CancellationToken cancellationToken = default); - - /// - /// Queries jobs by custom properties. - /// - /// The query request containing property filters. - /// If true, includes the original job specifications in the response. - /// Token to cancel the asynchronous operation. - /// A task containing the matching jobs. - Task> QueryAsync( - QueryJobsRequest request, - bool detailed = false, - CancellationToken cancellationToken = default); - - /// - /// Cancels a specific job by its ID. - /// - /// The unique job identifier. - /// If true, cancels even if the job is processing. Default is true. - /// Token to cancel the asynchronous operation. - /// A task representing the cancellation operation. - Task> CancelByIdAsync( - Guid jobId, - bool force = true, - CancellationToken cancellationToken = default); - - /// - /// Cancels jobs by their batch token. - /// - /// The batch token from a previous job submission. - /// If true, cancels even if jobs are processing. Default is true. - /// Token to cancel the asynchronous operation. - /// A task representing the cancellation operation. - Task> CancelByTokenAsync( - string token, - bool force = true, - CancellationToken cancellationToken = default); - - /// - /// Marks a specific job as tainted by its ID. - /// - /// The unique job identifier. - /// Token to cancel the asynchronous operation. - /// A task representing the taint operation. - Task> TaintByIdAsync( - Guid jobId, - CancellationToken cancellationToken = default); - - /// - /// Marks jobs as tainted by their batch token. - /// - /// The batch token from a previous job submission. - /// Token to cancel the asynchronous operation. - /// A task representing the taint operation. - Task> TaintByTokenAsync( - string token, - CancellationToken cancellationToken = default); -} diff --git a/Sdk/Services/JobsService.cs b/Sdk/Services/JobsService.cs deleted file mode 100644 index f4766ff..0000000 --- a/Sdk/Services/JobsService.cs +++ /dev/null @@ -1,190 +0,0 @@ -namespace CivitaiSharp.Sdk.Services; - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using CivitaiSharp.Core.Response; -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. -/// -/// -/// 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 -{ - /// - public TextToImageJobBuilder CreateTextToImage() - => TextToImageJobBuilder.Create(this); - - /// - public Task> SubmitAsync( - TextToImageJobRequest request, - bool wait = false, - bool detailed = false, - CancellationToken cancellationToken = default) - { - 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); - } - - /// - public Task> SubmitBatchAsync( - IEnumerable requests, - bool wait = false, - bool detailed = false, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(requests); - - // Materialize to list to validate and for the batch request - var jobsList = requests as IReadOnlyList ?? requests.ToList(); - if (jobsList.Count == 0) - { - 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); - } - - /// - public Task> GetByIdAsync( - Guid jobId, - bool detailed = false, - CancellationToken cancellationToken = default) - { - ArgumentOutOfRangeException.ThrowIfEqual(jobId, Guid.Empty, nameof(jobId)); - - var uri = BuildUri($"jobs/{jobId}", detailed: detailed); - return httpClient.GetAsync(uri, cancellationToken); - } - - /// - public Task> GetByTokenAsync( - string token, - bool wait = false, - bool detailed = false, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(token); - - var uri = BuildUri("jobs", token: token, wait: wait, detailed: detailed); - return httpClient.GetAsync(uri, cancellationToken); - } - - /// - public Task> QueryAsync( - QueryJobsRequest request, - bool detailed = false, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - - var uri = BuildUri("jobs/query", detailed: detailed); - return httpClient.PostAsync(uri, request, cancellationToken); - } - - /// - public Task> CancelByIdAsync( - Guid jobId, - bool force = true, - CancellationToken cancellationToken = default) - { - ArgumentOutOfRangeException.ThrowIfEqual(jobId, Guid.Empty, nameof(jobId)); - - var uri = BuildUri($"jobs/{jobId}", force: force); - return httpClient.DeleteAsync(uri, cancellationToken); - } - - /// - public Task> CancelByTokenAsync( - string token, - bool force = true, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(token); - - var uri = BuildUri("jobs", token: token, force: force); - return httpClient.DeleteAsync(uri, cancellationToken); - } - - /// - public Task> TaintByIdAsync( - Guid jobId, - CancellationToken cancellationToken = default) - { - ArgumentOutOfRangeException.ThrowIfEqual(jobId, Guid.Empty, nameof(jobId)); - - var uri = options.GetApiPath($"jobs/{jobId}/taint"); - return httpClient.PutAsync(uri, cancellationToken); - } - - /// - public Task> TaintByTokenAsync( - string token, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(token); - - var uri = BuildUri("jobs/taint", token: token); - return httpClient.PutAsync(uri, cancellationToken); - } - - private string BuildJobsUri(bool wait, bool detailed) - { - var query = new QueryStringBuilder() - .AppendIf("wait", wait) - .AppendIf("detailed", detailed); - return query.BuildUri(options.GetApiPath("jobs")); - } - - private string BuildUri( - string relativePath, - string? token = null, - bool wait = false, - bool detailed = false, - bool? force = null) - { - var query = new QueryStringBuilder() - .Append("token", token) - .AppendIf("wait", wait) - .AppendIf("detailed", detailed) - .Append("force", force); - return query.BuildUri(options.GetApiPath(relativePath)); - } -} diff --git a/Tests/CivitaiSharp.Sdk.Tests/Request/TextToImageJobBuilderTests.cs b/Tests/CivitaiSharp.Sdk.Tests/Request/TextToImageJobBuilderTests.cs deleted file mode 100644 index df462c6..0000000 --- a/Tests/CivitaiSharp.Sdk.Tests/Request/TextToImageJobBuilderTests.cs +++ /dev/null @@ -1,437 +0,0 @@ -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); - } -} -