save progress
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Inference.LlmProviders;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public class LlmInferenceCacheTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CacheKey_UsesInvariantCulture()
|
||||
{
|
||||
var originalCulture = CultureInfo.CurrentCulture;
|
||||
var originalUiCulture = CultureInfo.CurrentUICulture;
|
||||
|
||||
try
|
||||
{
|
||||
var options = Options.Create(new LlmInferenceCacheOptions
|
||||
{
|
||||
DeterministicOnly = false,
|
||||
DefaultTtl = TimeSpan.FromMinutes(5),
|
||||
ShortTtl = TimeSpan.FromMinutes(5)
|
||||
});
|
||||
var cache = new InMemoryLlmInferenceCache(options, NullLogger<InMemoryLlmInferenceCache>.Instance, new FakeTimeProvider());
|
||||
|
||||
var request = new LlmCompletionRequest
|
||||
{
|
||||
UserPrompt = "hello",
|
||||
Temperature = 0.1,
|
||||
MaxTokens = 10,
|
||||
Model = "model-x"
|
||||
};
|
||||
var result = new LlmCompletionResult
|
||||
{
|
||||
Content = "ok",
|
||||
ModelId = "model-x",
|
||||
ProviderId = "openai",
|
||||
Deterministic = false
|
||||
};
|
||||
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
CultureInfo.CurrentUICulture = new CultureInfo("de-DE");
|
||||
await cache.SetAsync(request, "openai", result, CancellationToken.None);
|
||||
|
||||
CultureInfo.CurrentCulture = new CultureInfo("en-US");
|
||||
CultureInfo.CurrentUICulture = new CultureInfo("en-US");
|
||||
var cached = await cache.TryGetAsync(request, "openai", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(cached);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = originalCulture;
|
||||
CultureInfo.CurrentUICulture = originalUiCulture;
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SlidingExpiration_ExtendsExpiry()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 30, 12, 0, 0, TimeSpan.Zero));
|
||||
var options = Options.Create(new LlmInferenceCacheOptions
|
||||
{
|
||||
DeterministicOnly = false,
|
||||
SlidingExpiration = true,
|
||||
DefaultTtl = TimeSpan.FromMinutes(10),
|
||||
MaxTtl = TimeSpan.FromMinutes(30)
|
||||
});
|
||||
var cache = new InMemoryLlmInferenceCache(options, NullLogger<InMemoryLlmInferenceCache>.Instance, timeProvider);
|
||||
|
||||
var request = new LlmCompletionRequest
|
||||
{
|
||||
UserPrompt = "hello",
|
||||
Temperature = 0.0,
|
||||
MaxTokens = 10,
|
||||
Model = "model-x"
|
||||
};
|
||||
var result = new LlmCompletionResult
|
||||
{
|
||||
Content = "ok",
|
||||
ModelId = "model-x",
|
||||
ProviderId = "openai",
|
||||
Deterministic = true
|
||||
};
|
||||
|
||||
await cache.SetAsync(request, "openai", result, CancellationToken.None);
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(9));
|
||||
var first = await cache.TryGetAsync(request, "openai", CancellationToken.None);
|
||||
Assert.NotNull(first);
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(6));
|
||||
var second = await cache.TryGetAsync(request, "openai", CancellationToken.None);
|
||||
Assert.NotNull(second);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task MaxEntries_EvictsOldestEntries()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 12, 30, 13, 0, 0, TimeSpan.Zero));
|
||||
var options = Options.Create(new LlmInferenceCacheOptions
|
||||
{
|
||||
DeterministicOnly = false,
|
||||
MaxEntries = 1,
|
||||
DefaultTtl = TimeSpan.FromMinutes(10),
|
||||
ShortTtl = TimeSpan.FromMinutes(10)
|
||||
});
|
||||
var cache = new InMemoryLlmInferenceCache(options, NullLogger<InMemoryLlmInferenceCache>.Instance, timeProvider);
|
||||
|
||||
var request1 = new LlmCompletionRequest
|
||||
{
|
||||
UserPrompt = "hello",
|
||||
Temperature = 0.0,
|
||||
MaxTokens = 10,
|
||||
Model = "model-x"
|
||||
};
|
||||
var request2 = new LlmCompletionRequest
|
||||
{
|
||||
UserPrompt = "world",
|
||||
Temperature = 0.0,
|
||||
MaxTokens = 10,
|
||||
Model = "model-x"
|
||||
};
|
||||
var result = new LlmCompletionResult
|
||||
{
|
||||
Content = "ok",
|
||||
ModelId = "model-x",
|
||||
ProviderId = "openai",
|
||||
Deterministic = true
|
||||
};
|
||||
|
||||
await cache.SetAsync(request1, "openai", result, CancellationToken.None);
|
||||
timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await cache.SetAsync(request2, "openai", result, CancellationToken.None);
|
||||
|
||||
var evicted = await cache.TryGetAsync(request1, "openai", CancellationToken.None);
|
||||
var retained = await cache.TryGetAsync(request2, "openai", CancellationToken.None);
|
||||
|
||||
Assert.Null(evicted);
|
||||
Assert.NotNull(retained);
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset current;
|
||||
|
||||
public FakeTimeProvider()
|
||||
: this(DateTimeOffset.UtcNow)
|
||||
{
|
||||
}
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset start)
|
||||
{
|
||||
current = start;
|
||||
}
|
||||
|
||||
public void Advance(TimeSpan delta)
|
||||
{
|
||||
current = current.Add(delta);
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => current;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using StellaOps.AdvisoryAI.Inference.LlmProviders;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public class LlmProviderConfigValidationTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void OpenAiConfigValidation_FailsWithoutApiKey()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["enabled"] = "true",
|
||||
["api:baseUrl"] = "https://api.openai.com/v1"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var plugin = new OpenAiLlmProviderPlugin();
|
||||
var validation = plugin.ValidateConfiguration(configuration);
|
||||
|
||||
Assert.False(validation.IsValid);
|
||||
Assert.Contains(validation.Errors, error => error.Contains("API key", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void OpenAiConfigValidation_WarnsWhenDisabled()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["enabled"] = "false"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var plugin = new OpenAiLlmProviderPlugin();
|
||||
var validation = plugin.ValidateConfiguration(configuration);
|
||||
|
||||
Assert.True(validation.IsValid);
|
||||
Assert.Contains(validation.Warnings, warning => warning.Contains("disabled", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.AdvisoryAI.Inference;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public class SignedModelBundleManagerTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SignBundleAsync_UsesDeterministicTimestamp()
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-ai", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
|
||||
try
|
||||
{
|
||||
var manifestPath = Path.Combine(tempRoot, "manifest.json");
|
||||
await File.WriteAllTextAsync(manifestPath, CreateManifestJson(), CancellationToken.None);
|
||||
|
||||
var fixedTime = new DateTimeOffset(2025, 12, 31, 12, 34, 56, TimeSpan.Zero);
|
||||
var manager = new SignedModelBundleManager(new FakeTimeProvider(fixedTime));
|
||||
var signer = new FakeSigner("key-1", "ed25519");
|
||||
|
||||
var result = await manager.SignBundleAsync(tempRoot, signer, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.StartsWith("ed25519-20251231123456-", result.SignatureId, StringComparison.Ordinal);
|
||||
|
||||
var envelopePath = Path.Combine(tempRoot, "signature.dsse");
|
||||
var envelopeJson = await File.ReadAllTextAsync(envelopePath, CancellationToken.None);
|
||||
var envelope = JsonSerializer.Deserialize<ModelBundleSignatureEnvelope>(envelopeJson);
|
||||
Assert.NotNull(envelope);
|
||||
|
||||
var payloadJson = Encoding.UTF8.GetString(Convert.FromBase64String(envelope!.Payload));
|
||||
using var document = JsonDocument.Parse(payloadJson);
|
||||
var signedAt = document.RootElement.GetProperty("signed_at").GetString();
|
||||
Assert.Equal("2025-12-31T12:34:56.0000000+00:00", signedAt);
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, CancellationToken.None);
|
||||
using var manifestDoc = JsonDocument.Parse(manifestJson);
|
||||
Assert.Equal(result.SignatureId, manifestDoc.RootElement.GetProperty("signature_id").GetString());
|
||||
Assert.Equal("ed25519", manifestDoc.RootElement.GetProperty("crypto_scheme").GetString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(tempRoot))
|
||||
{
|
||||
Directory.Delete(tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateManifestJson()
|
||||
{
|
||||
return """
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"name": "test-model",
|
||||
"description": "fixture",
|
||||
"license": "MIT",
|
||||
"size_category": "small",
|
||||
"quantizations": ["q4"],
|
||||
"files": [
|
||||
{ "path": "model.bin", "digest": "abc", "size": 1, "type": "model" }
|
||||
],
|
||||
"created_at": "2025-12-01T00:00:00Z"
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private sealed class FakeSigner : IModelBundleSigner
|
||||
{
|
||||
public FakeSigner(string keyId, string scheme)
|
||||
{
|
||||
KeyId = keyId;
|
||||
CryptoScheme = scheme;
|
||||
}
|
||||
|
||||
public string KeyId { get; }
|
||||
public string CryptoScheme { get; }
|
||||
|
||||
public Task<byte[]> SignAsync(byte[] data, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(Encoding.UTF8.GetBytes("sig"));
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset fixedNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset fixedNow) => this.fixedNow = fixedNow;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => fixedNow;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user