Skip to content

Commit a2c4437

Browse files
committed
fix(plugins): dedupe provider-prefixed model refs in runtime LLM allowlist policy
The plugin runtime LLM policy gate and the gateway fallback subagent policy gate each build allowlist keys and resolved-selection keys via ad-hoc `${normalized.provider}/${normalized.model}` concatenation. When a provider plugin manifest contributes `prefixWhenBare` (e.g. OpenRouter) and the resolved selection's `modelId` already carries the provider segment, the concatenation produces a double-prefixed key (`openrouter/openrouter/gpt-5.4-mini`). The user-typed allowlist entry does not carry the duplicate prefix, so `Set.has` misses and the diagnostic surfaces a confusing `openrouter/openrouter/...` string. Swap the five concat sites to the existing canonical `modelKey()` helper (`src/agents/model-ref-shared.ts`), which dedupes when `model` already starts with `${provider}/`. Both pair sides remain symmetric (allowlist Set construction + resolved lookup), so the policy gate keeps the same contract, and the diagnostic now surfaces the deduped, user-recognizable ref. Closes #84887.
1 parent bde07dd commit a2c4437

4 files changed

Lines changed: 74 additions & 7 deletions

File tree

src/gateway/server-plugins.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,36 @@ describe("loadGatewayPlugins", () => {
10311031
);
10321032
});
10331033

1034+
test("uses deduped resolved ref in fallback override deny diagnostic (#84887)", async () => {
1035+
const serverPlugins = serverPluginsModule;
1036+
const runtime = await createSubagentRuntime(serverPlugins, {
1037+
plugins: {
1038+
entries: {
1039+
"voice-call": {
1040+
subagent: {
1041+
allowModelOverride: true,
1042+
allowedModels: ["openrouter/gpt-5.4-mini"],
1043+
},
1044+
},
1045+
},
1046+
},
1047+
});
1048+
serverPlugins.setFallbackGatewayContext(createTestContext("fallback-prefixed-model-deny"));
1049+
await expect(
1050+
gatewayRequestScopeModule.withPluginRuntimePluginIdScope("voice-call", () =>
1051+
runtime.run({
1052+
sessionKey: "s-prefixed-model-deny",
1053+
message: "use trusted override",
1054+
provider: "openrouter",
1055+
model: "openrouter/gpt-5.5",
1056+
deliver: false,
1057+
}),
1058+
),
1059+
).rejects.toThrow(
1060+
'model override "openrouter/gpt-5.5" is not allowlisted for plugin "voice-call".',
1061+
);
1062+
});
1063+
10341064
test("uses least-privilege synthetic fallback scopes without admin", async () => {
10351065
const serverPlugins = serverPluginsModule;
10361066
const runtime = await createSubagentRuntime(serverPlugins);

src/gateway/server-plugins.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { randomUUID } from "node:crypto";
22
import { performance } from "node:perf_hooks";
3-
import { normalizeModelRef, parseModelRef } from "../agents/model-selection.js";
3+
import { modelKey, normalizeModelRef, parseModelRef } from "../agents/model-selection.js";
44
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
55
import type { OpenClawConfig } from "../config/types.openclaw.js";
66
import { normalizePluginsConfig } from "../plugins/config-state.js";
@@ -127,7 +127,7 @@ function normalizeAllowedModelRef(raw: string): string | null {
127127
return null;
128128
}
129129
const normalized = normalizeModelRef(providerRaw, modelRaw);
130-
return `${normalized.provider}/${normalized.model}`;
130+
return modelKey(normalized.provider, normalized.model);
131131
}
132132

133133
export function setPluginSubagentOverridePolicies(cfg: OpenClawConfig): void {
@@ -227,7 +227,7 @@ function resolveRequestedFallbackModelRef(params: {
227227
}): string | null {
228228
if (params.provider && params.model) {
229229
const normalizedRequest = normalizeModelRef(params.provider, params.model);
230-
return `${normalizedRequest.provider}/${normalizedRequest.model}`;
230+
return modelKey(normalizedRequest.provider, normalizedRequest.model);
231231
}
232232
const rawModel = params.model?.trim();
233233
if (!rawModel || !rawModel.includes("/")) {
@@ -237,7 +237,7 @@ function resolveRequestedFallbackModelRef(params: {
237237
if (!parsed?.provider || !parsed.model) {
238238
return null;
239239
}
240-
return `${parsed.provider}/${parsed.model}`;
240+
return modelKey(parsed.provider, parsed.model);
241241
}
242242

243243
// ── Internal gateway dispatch for plugin runtime ────────────────────

src/plugins/runtime/runtime-llm.runtime.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,43 @@ describe("runtime.llm.complete", () => {
306306
expect(hoisted.prepareSimpleCompletionModelForAgent).not.toHaveBeenCalled();
307307
});
308308

309+
it("uses the deduped resolved ref in the allowlist deny diagnostic (#84887)", async () => {
310+
hoisted.resolveSimpleCompletionSelectionForAgent.mockReturnValue({
311+
provider: "openrouter",
312+
modelId: "openrouter/gpt-5.5",
313+
agentDir: "/tmp/openclaw-agent",
314+
});
315+
316+
const runtimeContext = resolveContextEngineCapabilities({
317+
config: {
318+
...cfg,
319+
plugins: {
320+
entries: {
321+
"lossless-claw": {
322+
llm: {
323+
allowModelOverride: true,
324+
allowedModels: ["openrouter/gpt-5.4-mini"],
325+
},
326+
},
327+
},
328+
},
329+
},
330+
sessionKey: "agent:main:session:abc",
331+
contextEnginePluginId: "lossless-claw",
332+
purpose: "context-engine.compaction",
333+
});
334+
335+
await expect(
336+
runtimeContext.llm!.complete({
337+
model: "openrouter/gpt-5.5",
338+
messages: [{ role: "user", content: "summarize" }],
339+
}),
340+
).rejects.toThrow(
341+
'model override "openrouter/gpt-5.5" is not allowlisted for plugin "lossless-claw"',
342+
);
343+
expect(hoisted.prepareSimpleCompletionModelForAgent).not.toHaveBeenCalled();
344+
});
345+
309346
it("keeps context-engine attribution and host-derived policy inside plugin runtime scope", async () => {
310347
const runtimeContext = resolveContextEngineCapabilities({
311348
config: {

src/plugins/runtime/runtime-llm.runtime.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Api, Message } from "@earendil-works/pi-ai";
2-
import { normalizeModelRef } from "../../agents/model-selection.js";
2+
import { modelKey, normalizeModelRef } from "../../agents/model-selection.js";
33
import type { NormalizedUsage, UsageLike } from "../../agents/usage.js";
44
import { normalizeUsage } from "../../agents/usage.js";
55
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -235,7 +235,7 @@ function normalizeAllowedModelRef(raw: string): string | null {
235235
return null;
236236
}
237237
const normalized = normalizeModelRef(provider, model);
238-
return `${normalized.provider}/${normalized.model}`;
238+
return modelKey(normalized.provider, normalized.model);
239239
}
240240

241241
function buildPolicyFromEntry(entry: {
@@ -402,7 +402,7 @@ export function createRuntimeLlm(options: CreateRuntimeLlmOptions = {}): PluginR
402402
? normalizeModelRef(selection.provider, selection.modelId)
403403
: null;
404404
const resolvedModelRef = normalizedSelection
405-
? `${normalizedSelection.provider}/${normalizedSelection.model}`
405+
? modelKey(normalizedSelection.provider, normalizedSelection.model)
406406
: null;
407407
assertAllowedModelOverride({
408408
resolvedModelRef,

0 commit comments

Comments
 (0)