Skip to content

Commit de890ce

Browse files
committed
fix: allow managed local AI first start without endpoint
1 parent ccd64bb commit de890ce

3 files changed

Lines changed: 139 additions & 10 deletions

File tree

src/OpenClaw.Connection/LocalAi/LocalAiGatewayProviderDefinition.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,9 @@ private static bool JsonEquals(JsonElement left, JsonElement right)
8686
private static string BuildProviderJson(LocalAiResolvedInstall install, string apiKey)
8787
{
8888
ArgumentNullException.ThrowIfNull(install);
89+
LocalModelInfo model = GetQualifiedModel(install);
8990
Uri endpoint = install.Endpoint
9091
?? throw new InvalidOperationException("The verified Local AI endpoint is required.");
91-
LocalModelInfo model = LocalModelCatalog.Find(install.Manifest.ModelCatalogId)
92-
?? throw new InvalidDataException("The managed Local AI model is no longer qualified.");
93-
if (!string.Equals(model.Id, install.Manifest.ModelAlias, StringComparison.Ordinal))
94-
throw new InvalidDataException("The managed Local AI model alias does not match the qualified catalog.");
9592

9693
var value = new
9794
{
@@ -122,9 +119,19 @@ private static string BuildProviderJson(LocalAiResolvedInstall install, string a
122119
public static string BuildPrimaryModel(LocalAiResolvedInstall install)
123120
{
124121
ArgumentNullException.ThrowIfNull(install);
122+
_ = GetQualifiedModel(install);
125123
return $"llamacpp/{install.Manifest.ModelAlias}";
126124
}
127125

126+
private static LocalModelInfo GetQualifiedModel(LocalAiResolvedInstall install)
127+
{
128+
LocalModelInfo model = LocalModelCatalog.Find(install.Manifest.ModelCatalogId)
129+
?? throw new InvalidDataException("The managed Local AI model is no longer qualified.");
130+
if (!string.Equals(model.Id, install.Manifest.ModelAlias, StringComparison.Ordinal))
131+
throw new InvalidDataException("The managed Local AI model alias does not match the qualified catalog.");
132+
return model;
133+
}
134+
128135
public static void ValidateFallbackModel(string? model)
129136
=> LocalAiGatewayModelPolicy.ValidateFallbackModel(model);
130137

src/OpenClaw.Tray.WinUI/Services/LocalAiGatewayProviderCoordinator.cs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,20 +41,24 @@ public async Task<LocalAiEndpointLifecycleResult> QuiesceAsync(
4141
string managedPrimary;
4242
try
4343
{
44-
_ = LocalAiGatewayProviderDefinition.BuildProviderJson(install);
4544
managedPrimary = LocalAiGatewayProviderDefinition.BuildPrimaryModel(install);
4645
LocalAiGatewayProviderDefinition.ValidateFallbackModel(
4746
install.Manifest.GatewayFallbackModel);
47+
if (current.ProviderExists)
48+
{
49+
_ = LocalAiGatewayProviderDefinition.BuildProviderJson(install);
50+
if (!LocalAiGatewayProviderDefinition.MatchesProviderJson(
51+
current.ProviderJson!,
52+
install))
53+
{
54+
return Failed("The llamacpp provider was changed outside the companion; preserving it and refusing to cycle the managed endpoint.");
55+
}
56+
}
4857
}
4958
catch (Exception ex) when (ex is InvalidDataException or InvalidOperationException)
5059
{
5160
return Failed(ex.Message);
5261
}
53-
if (current.ProviderExists &&
54-
!LocalAiGatewayProviderDefinition.MatchesProviderJson(current.ProviderJson!, install))
55-
{
56-
return Failed("The llamacpp provider was changed outside the companion; preserving it and refusing to cycle the managed endpoint.");
57-
}
5862

5963
bool primaryIsManaged = current.PrimaryExists &&
6064
string.Equals(current.PrimaryModel, managedPrimary, StringComparison.Ordinal);

tests/OpenClaw.Tray.Tests/LocalAiGatewayProviderCoordinatorTests.cs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,112 @@ public async Task Quiesce_PreservesProviderDriftAndFailsClosed()
6363
Assert.DoesNotContain(commands.Calls, call => call.Contains("unset"));
6464
}
6565

66+
[Fact]
67+
public async Task Quiesce_NoProviderWithoutEndpoint_Succeeds()
68+
{
69+
LocalAiResolvedInstall install = InstallWithoutEndpoint(28_765);
70+
var commands = new FakeWslCommandRunner(providerJson: null);
71+
var coordinator = CreateCoordinator(commands);
72+
73+
LocalAiEndpointLifecycleResult result = await coordinator.QuiesceAsync(install);
74+
75+
Assert.True(result.Success);
76+
Assert.Null(commands.ProviderJson);
77+
Assert.Null(commands.PrimaryModel);
78+
Assert.DoesNotContain(commands.Calls, call => call.Contains("unset"));
79+
}
80+
81+
[Fact]
82+
public async Task Quiesce_ExistingProviderWithoutEndpoint_PreservesProviderAndFailsClosed()
83+
{
84+
LocalAiResolvedInstall runningInstall = Install(28_765);
85+
string provider = LocalAiGatewayProviderDefinition.BuildProviderJson(runningInstall);
86+
var commands = new FakeWslCommandRunner(
87+
provider,
88+
LocalAiGatewayProviderDefinition.BuildPrimaryModel(runningInstall));
89+
var coordinator = CreateCoordinator(commands);
90+
91+
LocalAiEndpointLifecycleResult result = await coordinator.QuiesceAsync(
92+
InstallWithoutEndpoint(28_765));
93+
94+
Assert.False(result.Success);
95+
Assert.Contains("verified Local AI endpoint is required", result.Detail, StringComparison.Ordinal);
96+
Assert.Equal(provider, commands.ProviderJson);
97+
Assert.Equal(LocalAiGatewayProviderDefinition.BuildPrimaryModel(runningInstall), commands.PrimaryModel);
98+
Assert.DoesNotContain(commands.Calls, call => call.Contains("unset"));
99+
}
100+
101+
[Theory]
102+
[InlineData(true)]
103+
[InlineData(false)]
104+
public async Task Quiesce_NoProviderWithUnqualifiedManagedModel_PreservesPrimaryAndFailsClosed(
105+
bool unknownCatalog)
106+
{
107+
LocalAiResolvedInstall valid = InstallWithoutEndpoint(28_765);
108+
LocalAiResolvedInstall tampered = valid with
109+
{
110+
Manifest = valid.Manifest with
111+
{
112+
ModelCatalogId = unknownCatalog ? "missing-model" : valid.Manifest.ModelCatalogId,
113+
ModelAlias = unknownCatalog ? valid.Manifest.ModelAlias : "tampered-model",
114+
},
115+
};
116+
string primary = $"llamacpp/{tampered.Manifest.ModelAlias}";
117+
var commands = new FakeWslCommandRunner(providerJson: null, primary);
118+
var coordinator = CreateCoordinator(commands);
119+
120+
LocalAiEndpointLifecycleResult result = await coordinator.QuiesceAsync(tampered);
121+
122+
Assert.False(result.Success);
123+
Assert.Contains("qualified", result.Detail, StringComparison.Ordinal);
124+
Assert.Null(commands.ProviderJson);
125+
Assert.Equal(primary, commands.PrimaryModel);
126+
Assert.DoesNotContain(commands.Calls, call => call.Contains("unset"));
127+
Assert.DoesNotContain(commands.Calls, call => call.Contains("/bin/sh"));
128+
}
129+
130+
[Fact]
131+
public async Task Quiesce_NoProviderWithoutEndpoint_UnsetsManagedPrimary()
132+
{
133+
LocalAiResolvedInstall install = InstallWithoutEndpoint(28_765);
134+
var commands = new FakeWslCommandRunner(
135+
providerJson: null,
136+
LocalAiGatewayProviderDefinition.BuildPrimaryModel(install));
137+
var coordinator = CreateCoordinator(commands);
138+
139+
LocalAiEndpointLifecycleResult result = await coordinator.QuiesceAsync(install);
140+
141+
Assert.True(result.Success);
142+
Assert.Null(commands.ProviderJson);
143+
Assert.Null(commands.PrimaryModel);
144+
Assert.Contains(commands.Calls, call =>
145+
call.Contains("unset") &&
146+
call.Contains(LocalAiGatewayProviderDefinition.PrimaryModelPath));
147+
}
148+
149+
[Fact]
150+
public async Task Quiesce_NoProviderWithoutEndpoint_RestoresFallbackPrimary()
151+
{
152+
LocalAiResolvedInstall install = InstallWithoutEndpoint(28_765, "openai/gpt-5");
153+
var commands = new FakeWslCommandRunner(
154+
providerJson: null,
155+
LocalAiGatewayProviderDefinition.BuildPrimaryModel(install))
156+
{
157+
PrimaryAfterApply = "openai/gpt-5",
158+
};
159+
var coordinator = CreateCoordinator(commands);
160+
161+
LocalAiEndpointLifecycleResult result = await coordinator.QuiesceAsync(install);
162+
163+
Assert.True(result.Success);
164+
Assert.Null(commands.ProviderJson);
165+
Assert.Equal("openai/gpt-5", commands.PrimaryModel);
166+
Assert.Contains(commands.Calls, call => call.Contains("/bin/sh"));
167+
Assert.DoesNotContain(commands.Calls, call =>
168+
call.Contains("unset") &&
169+
call.Contains(LocalAiGatewayProviderDefinition.ProviderPath));
170+
}
171+
66172
[Fact]
67173
public async Task Publish_UsesVerifiedEndpointAndNonDefaultManagedDistro()
68174
{
@@ -322,6 +428,18 @@ private static LocalAiResolvedInstall Install(int port, string? fallbackModel =
322428
return new(manifest, "llama-server.exe", "model.gguf", endpoint);
323429
}
324430

431+
private static LocalAiResolvedInstall InstallWithoutEndpoint(
432+
int port,
433+
string? fallbackModel = null)
434+
{
435+
LocalAiResolvedInstall install = Install(port, fallbackModel);
436+
return install with
437+
{
438+
Manifest = install.Manifest with { Endpoint = null },
439+
Endpoint = null,
440+
};
441+
}
442+
325443
private static string RedactApiKey(string value) => value.Replace(
326444
"\"api\":\"openai-completions\",\"apiKey\":\"llama-local\"",
327445
$"\"apiKey\":\"{LocalAiGatewayProviderDefinition.CliRedactedApiKey}\",\"api\":\"openai-completions\"",

0 commit comments

Comments
 (0)