mirror of https://github.com/openclaw/openclaw.git
Merge 97f53ea1a3 into 392ddb56e2
This commit is contained in:
commit
4fce7c6d62
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue