This commit is contained in:
Sergio Cadavid 2026-03-15 22:38:45 +00:00 committed by GitHub
commit 4fce7c6d62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 206 additions and 0 deletions

View File

@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(),
}));
vi.mock("../agents/model-selection.js", async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, buildAllowedModelSet: vi.fn() };
});
vi.mock("../terminal/note.js", () => ({ note: vi.fn() }));
import { loadModelCatalog } from "../agents/model-catalog.js";
import { buildAllowedModelSet } from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
import { note } from "../terminal/note.js";
import { noteSyntheticAllowlistGaps } from "./doctor-synthetic-allowlist.js";
const mockLoadModelCatalog = vi.mocked(loadModelCatalog);
const mockBuildAllowedModelSet = vi.mocked(buildAllowedModelSet);
const mockNote = vi.mocked(note);
function makeCfg(models?: Record<string, unknown>): OpenClawConfig {
return {
agents: { defaults: { models: models as OpenClawConfig["agents"]["defaults"]["models"] } },
} as OpenClawConfig;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("noteSyntheticAllowlistGaps", () => {
it("skips when no allowlist is configured", async () => {
await noteSyntheticAllowlistGaps(makeCfg());
expect(mockLoadModelCatalog).not.toHaveBeenCalled();
expect(mockNote).not.toHaveBeenCalled();
});
it("skips when allowlist is empty", async () => {
await noteSyntheticAllowlistGaps(makeCfg({}));
expect(mockLoadModelCatalog).not.toHaveBeenCalled();
expect(mockNote).not.toHaveBeenCalled();
});
it("skips when allowAny is true", async () => {
const catalog = [{ id: "gpt-5.4", name: "gpt-5.4", provider: "openai" }];
mockLoadModelCatalog.mockResolvedValue(catalog);
mockBuildAllowedModelSet.mockReturnValue({
allowAny: true,
allowedCatalog: catalog,
allowedKeys: new Set(["openai/gpt-5.4"]),
});
await noteSyntheticAllowlistGaps(makeCfg({ "anthropic/claude-opus-4-6": {} }));
expect(mockNote).not.toHaveBeenCalled();
});
it("does not warn when all synthetic models are allowlisted", async () => {
const catalog = [
{ id: "gpt-5.4", name: "gpt-5.4", provider: "openai-codex" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic" },
];
mockLoadModelCatalog.mockResolvedValue(catalog);
mockBuildAllowedModelSet.mockReturnValue({
allowAny: false,
allowedCatalog: catalog,
allowedKeys: new Set(["openai-codex/gpt-5.4", "anthropic/claude-opus-4-6"]),
});
await noteSyntheticAllowlistGaps(
makeCfg({ "openai-codex/gpt-5.4": {}, "anthropic/claude-opus-4-6": {} }),
);
expect(mockNote).not.toHaveBeenCalled();
});
it("warns about synthetic models missing from allowlist", async () => {
const catalog = [
{ id: "gpt-5.4", name: "gpt-5.4", provider: "openai-codex" },
{ id: "gpt-5.4", name: "gpt-5.4", provider: "openai" },
{ id: "gpt-5.4-pro", name: "gpt-5.4-pro", provider: "openai" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic" },
];
mockLoadModelCatalog.mockResolvedValue(catalog);
mockBuildAllowedModelSet.mockReturnValue({
allowAny: false,
allowedCatalog: [catalog[0], catalog[3]],
allowedKeys: new Set(["openai-codex/gpt-5.4", "anthropic/claude-opus-4-6"]),
});
await noteSyntheticAllowlistGaps(
makeCfg({ "openai-codex/gpt-5.4": {}, "anthropic/claude-opus-4-6": {} }),
);
expect(mockNote).toHaveBeenCalledTimes(1);
const noteText = mockNote.mock.calls[0][0];
expect(noteText).toContain("2 synthetic model");
expect(noteText).toContain("openai/gpt-5.4");
expect(noteText).toContain("openai/gpt-5.4-pro");
});
it("ignores non-synthetic providers", async () => {
const catalog = [
{ id: "gemini-3-flash", name: "Gemini 3 Flash", provider: "google" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic" },
];
mockLoadModelCatalog.mockResolvedValue(catalog);
mockBuildAllowedModelSet.mockReturnValue({
allowAny: false,
allowedCatalog: [catalog[1]],
allowedKeys: new Set(["anthropic/claude-opus-4-6"]),
});
await noteSyntheticAllowlistGaps(makeCfg({ "anthropic/claude-opus-4-6": {} }));
expect(mockNote).not.toHaveBeenCalled();
});
it("handles single synthetic gap with correct grammar", async () => {
const catalog = [{ id: "gpt-5.4", name: "gpt-5.4", provider: "openai" }];
mockLoadModelCatalog.mockResolvedValue(catalog);
mockBuildAllowedModelSet.mockReturnValue({
allowAny: false,
allowedCatalog: [],
allowedKeys: new Set(["anthropic/claude-opus-4-6"]),
});
await noteSyntheticAllowlistGaps(makeCfg({ "anthropic/claude-opus-4-6": {} }));
const noteText = mockNote.mock.calls[0][0];
expect(noteText).toContain("1 synthetic model available");
expect(noteText).not.toContain("models available");
});
});

View File

@ -0,0 +1,73 @@
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { buildAllowedModelSet, modelKey } from "../agents/model-selection.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { note } from "../terminal/note.js";
/**
* The exact provider/id pairs created by `applySyntheticCatalogFallbacks()`.
* Only these are checked to avoid false positives from real catalog models
* that happen to share the same provider.
*/
const SYNTHETIC_FALLBACK_KEYS = new Set([
"openai/gpt-5.4",
"openai/gpt-5.4-pro",
"openai-codex/gpt-5.4",
"openai-codex/gpt-5.3-codex-spark",
]);
/**
* Warn when synthetic catalog fallback models are present in the runtime
* catalog but missing from the operator's explicit model allowlist.
*
* Skips the check when no allowlist is configured (empty
* `agents.defaults.models` means all models are allowed).
*
* @see https://github.com/openclaw/openclaw/issues/39992
*/
export async function noteSyntheticAllowlistGaps(cfg: OpenClawConfig): Promise<void> {
const modelsConfig = cfg.agents?.defaults?.models;
if (!modelsConfig || Object.keys(modelsConfig).length === 0) {
return;
}
const catalog = await loadModelCatalog({ config: cfg });
const { allowAny, allowedKeys } = buildAllowedModelSet({
cfg,
catalog,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
if (allowAny) {
return; // all models are allowed; nothing to warn about
}
const gaps: Array<{ provider: string; id: string }> = [];
for (const entry of catalog) {
const key = modelKey(entry.provider, entry.id);
if (!SYNTHETIC_FALLBACK_KEYS.has(key)) {
continue;
}
if (!allowedKeys.has(key)) {
gaps.push(entry);
}
}
if (gaps.length === 0) {
return;
}
const lines = [
`${gaps.length} synthetic model${gaps.length === 1 ? "" : "s"} available but not in your allowlist:`,
];
for (const entry of gaps) {
lines.push(` - ${modelKey(entry.provider, entry.id)}`);
}
lines.push(
`To add: ${formatCliCommand("openclaw config set agents.defaults.models.<provider>/<model> '{}'")}`,
`Or edit the config file directly: ${formatCliCommand("openclaw config file")}`,
"(Models are functional but hidden from agents until allowlisted)",
);
note(lines.join("\n"), "Synthetic model allowlist");
}

View File

@ -55,6 +55,7 @@ import {
detectLegacyStateMigrations,
runLegacyStateMigrations,
} from "./doctor-state-migrations.js";
import { noteSyntheticAllowlistGaps } from "./doctor-synthetic-allowlist.js";
import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js";
import { maybeOfferUpdateBeforeDoctor } from "./doctor-update.js";
import { noteWorkspaceStatus } from "./doctor-workspace-status.js";
@ -306,6 +307,7 @@ export async function doctorCommand(
}
noteWorkspaceStatus(cfg);
await noteSyntheticAllowlistGaps(cfg);
await noteBootstrapFileSize(cfg);
// Check and fix shell completion