Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,9 @@ private static bool JsonEquals(JsonElement left, JsonElement right)
private static string BuildProviderJson(LocalAiResolvedInstall install, string apiKey)
{
ArgumentNullException.ThrowIfNull(install);
LocalModelInfo model = GetQualifiedModel(install);
Uri endpoint = install.Endpoint
?? throw new InvalidOperationException("The verified Local AI endpoint is required.");
LocalModelInfo model = LocalModelCatalog.Find(install.Manifest.ModelCatalogId)
?? throw new InvalidDataException("The managed Local AI model is no longer qualified.");
if (!string.Equals(model.Id, install.Manifest.ModelAlias, StringComparison.Ordinal))
throw new InvalidDataException("The managed Local AI model alias does not match the qualified catalog.");

var value = new
{
Expand Down Expand Up @@ -122,9 +119,19 @@ private static string BuildProviderJson(LocalAiResolvedInstall install, string a
public static string BuildPrimaryModel(LocalAiResolvedInstall install)
{
ArgumentNullException.ThrowIfNull(install);
_ = GetQualifiedModel(install);
return $"llamacpp/{install.Manifest.ModelAlias}";
}

private static LocalModelInfo GetQualifiedModel(LocalAiResolvedInstall install)
{
LocalModelInfo model = LocalModelCatalog.Find(install.Manifest.ModelCatalogId)
?? throw new InvalidDataException("The managed Local AI model is no longer qualified.");
if (!string.Equals(model.Id, install.Manifest.ModelAlias, StringComparison.Ordinal))
throw new InvalidDataException("The managed Local AI model alias does not match the qualified catalog.");
return model;
}

public static void ValidateFallbackModel(string? model)
=> LocalAiGatewayModelPolicy.ValidateFallbackModel(model);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,24 @@ public async Task<LocalAiEndpointLifecycleResult> QuiesceAsync(
string managedPrimary;
try
{
_ = LocalAiGatewayProviderDefinition.BuildProviderJson(install);
managedPrimary = LocalAiGatewayProviderDefinition.BuildPrimaryModel(install);
LocalAiGatewayProviderDefinition.ValidateFallbackModel(
install.Manifest.GatewayFallbackModel);
if (current.ProviderExists)
{
_ = LocalAiGatewayProviderDefinition.BuildProviderJson(install);
if (!LocalAiGatewayProviderDefinition.MatchesProviderJson(
current.ProviderJson!,
install))
{
return Failed("The llamacpp provider was changed outside the companion; preserving it and refusing to cycle the managed endpoint.");
}
}
}
catch (Exception ex) when (ex is InvalidDataException or InvalidOperationException)
{
return Failed(ex.Message);
}
if (current.ProviderExists &&
!LocalAiGatewayProviderDefinition.MatchesProviderJson(current.ProviderJson!, install))
{
return Failed("The llamacpp provider was changed outside the companion; preserving it and refusing to cycle the managed endpoint.");
}

bool primaryIsManaged = current.PrimaryExists &&
string.Equals(current.PrimaryModel, managedPrimary, StringComparison.Ordinal);
Expand Down
118 changes: 118 additions & 0 deletions tests/OpenClaw.Tray.Tests/LocalAiGatewayProviderCoordinatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,112 @@ public async Task Quiesce_PreservesProviderDriftAndFailsClosed()
Assert.DoesNotContain(commands.Calls, call => call.Contains("unset"));
}

[Fact]
public async Task Quiesce_NoProviderWithoutEndpoint_Succeeds()
{
LocalAiResolvedInstall install = InstallWithoutEndpoint(28_765);
var commands = new FakeWslCommandRunner(providerJson: null);
var coordinator = CreateCoordinator(commands);

LocalAiEndpointLifecycleResult result = await coordinator.QuiesceAsync(install);

Assert.True(result.Success);
Assert.Null(commands.ProviderJson);
Assert.Null(commands.PrimaryModel);
Assert.DoesNotContain(commands.Calls, call => call.Contains("unset"));
}

[Fact]
public async Task Quiesce_ExistingProviderWithoutEndpoint_PreservesProviderAndFailsClosed()
{
LocalAiResolvedInstall runningInstall = Install(28_765);
string provider = LocalAiGatewayProviderDefinition.BuildProviderJson(runningInstall);
var commands = new FakeWslCommandRunner(
provider,
LocalAiGatewayProviderDefinition.BuildPrimaryModel(runningInstall));
var coordinator = CreateCoordinator(commands);

LocalAiEndpointLifecycleResult result = await coordinator.QuiesceAsync(
InstallWithoutEndpoint(28_765));

Assert.False(result.Success);
Assert.Contains("verified Local AI endpoint is required", result.Detail, StringComparison.Ordinal);
Assert.Equal(provider, commands.ProviderJson);
Assert.Equal(LocalAiGatewayProviderDefinition.BuildPrimaryModel(runningInstall), commands.PrimaryModel);
Assert.DoesNotContain(commands.Calls, call => call.Contains("unset"));
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Quiesce_NoProviderWithUnqualifiedManagedModel_PreservesPrimaryAndFailsClosed(
bool unknownCatalog)
{
LocalAiResolvedInstall valid = InstallWithoutEndpoint(28_765);
LocalAiResolvedInstall tampered = valid with
{
Manifest = valid.Manifest with
{
ModelCatalogId = unknownCatalog ? "missing-model" : valid.Manifest.ModelCatalogId,
ModelAlias = unknownCatalog ? valid.Manifest.ModelAlias : "tampered-model",
},
};
string primary = $"llamacpp/{tampered.Manifest.ModelAlias}";
var commands = new FakeWslCommandRunner(providerJson: null, primary);
var coordinator = CreateCoordinator(commands);

LocalAiEndpointLifecycleResult result = await coordinator.QuiesceAsync(tampered);

Assert.False(result.Success);
Assert.Contains("qualified", result.Detail, StringComparison.Ordinal);
Assert.Null(commands.ProviderJson);
Assert.Equal(primary, commands.PrimaryModel);
Assert.DoesNotContain(commands.Calls, call => call.Contains("unset"));
Assert.DoesNotContain(commands.Calls, call => call.Contains("/bin/sh"));
}

[Fact]
public async Task Quiesce_NoProviderWithoutEndpoint_UnsetsManagedPrimary()
{
LocalAiResolvedInstall install = InstallWithoutEndpoint(28_765);
var commands = new FakeWslCommandRunner(
providerJson: null,
LocalAiGatewayProviderDefinition.BuildPrimaryModel(install));
var coordinator = CreateCoordinator(commands);

LocalAiEndpointLifecycleResult result = await coordinator.QuiesceAsync(install);

Assert.True(result.Success);
Assert.Null(commands.ProviderJson);
Assert.Null(commands.PrimaryModel);
Assert.Contains(commands.Calls, call =>
call.Contains("unset") &&
call.Contains(LocalAiGatewayProviderDefinition.PrimaryModelPath));
}

[Fact]
public async Task Quiesce_NoProviderWithoutEndpoint_RestoresFallbackPrimary()
{
LocalAiResolvedInstall install = InstallWithoutEndpoint(28_765, "openai/gpt-5");
var commands = new FakeWslCommandRunner(
providerJson: null,
LocalAiGatewayProviderDefinition.BuildPrimaryModel(install))
{
PrimaryAfterApply = "openai/gpt-5",
};
var coordinator = CreateCoordinator(commands);

LocalAiEndpointLifecycleResult result = await coordinator.QuiesceAsync(install);

Assert.True(result.Success);
Assert.Null(commands.ProviderJson);
Assert.Equal("openai/gpt-5", commands.PrimaryModel);
Assert.Contains(commands.Calls, call => call.Contains("/bin/sh"));
Assert.DoesNotContain(commands.Calls, call =>
call.Contains("unset") &&
call.Contains(LocalAiGatewayProviderDefinition.ProviderPath));
}

[Fact]
public async Task Publish_UsesVerifiedEndpointAndNonDefaultManagedDistro()
{
Expand Down Expand Up @@ -322,6 +428,18 @@ private static LocalAiResolvedInstall Install(int port, string? fallbackModel =
return new(manifest, "llama-server.exe", "model.gguf", endpoint);
}

private static LocalAiResolvedInstall InstallWithoutEndpoint(
int port,
string? fallbackModel = null)
{
LocalAiResolvedInstall install = Install(port, fallbackModel);
return install with
{
Manifest = install.Manifest with { Endpoint = null },
Endpoint = null,
};
}

private static string RedactApiKey(string value) => value.Replace(
"\"api\":\"openai-completions\",\"apiKey\":\"llama-local\"",
$"\"apiKey\":\"{LocalAiGatewayProviderDefinition.CliRedactedApiKey}\",\"api\":\"openai-completions\"",
Expand Down
Loading