diff --git a/src/OpenClaw.Connection/LocalAi/LocalAiGatewayProviderDefinition.cs b/src/OpenClaw.Connection/LocalAi/LocalAiGatewayProviderDefinition.cs index 3522c72ee..7da330f59 100644 --- a/src/OpenClaw.Connection/LocalAi/LocalAiGatewayProviderDefinition.cs +++ b/src/OpenClaw.Connection/LocalAi/LocalAiGatewayProviderDefinition.cs @@ -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 { @@ -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); diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalAiGatewayProviderCoordinator.cs b/src/OpenClaw.Tray.WinUI/Services/LocalAiGatewayProviderCoordinator.cs index 30b29bde3..515150730 100644 --- a/src/OpenClaw.Tray.WinUI/Services/LocalAiGatewayProviderCoordinator.cs +++ b/src/OpenClaw.Tray.WinUI/Services/LocalAiGatewayProviderCoordinator.cs @@ -41,20 +41,24 @@ public async Task 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); diff --git a/tests/OpenClaw.Tray.Tests/LocalAiGatewayProviderCoordinatorTests.cs b/tests/OpenClaw.Tray.Tests/LocalAiGatewayProviderCoordinatorTests.cs index 50b82f299..1d15f6ed7 100644 --- a/tests/OpenClaw.Tray.Tests/LocalAiGatewayProviderCoordinatorTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalAiGatewayProviderCoordinatorTests.cs @@ -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() { @@ -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\"",