diff --git a/src/commands/doctor-synthetic-allowlist.test.ts b/src/commands/doctor-synthetic-allowlist.test.ts new file mode 100644 index 00000000000..a5a66717729 --- /dev/null +++ b/src/commands/doctor-synthetic-allowlist.test.ts @@ -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): 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"); + }); +}); diff --git a/src/commands/doctor-synthetic-allowlist.ts b/src/commands/doctor-synthetic-allowlist.ts new file mode 100644 index 00000000000..9c01a9b2a21 --- /dev/null +++ b/src/commands/doctor-synthetic-allowlist.ts @@ -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 { + 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./ '{}'")}`, + `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"); +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index bdde2781ff9..41b20dee038 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -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