mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
fix(config): protect model config merges
This commit is contained in:
@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Config/models: merge provider-scoped model allowlist updates and protect model/provider map writes from accidental full replacement, adding `config set --merge` for additive updates and `--replace` for intentional clobbers. Fixes #65920, #68392, and #68653.
|
||||
- Agents/Pi auth: preserve AWS SDK-authenticated Bedrock runs for IMDS and task-role setups, clear stale refresh timers on sentinel fallback, and log unexpected runtime-auth prep failures instead of silently leaving the provider unauthenticated. Thanks @wirjo.
|
||||
- Config/gateway: recover configs accidentally prefixed with non-JSON output during gateway startup or `openclaw doctor --fix`, preserving the clobbered file as a backup while leaving normal config reads read-only.
|
||||
- Agents/GitHub Copilot: normalize connection-bound Responses item IDs in the Copilot provider wrapper so replayed histories no longer fail after the upstream connection changes. (#69362) Thanks @Menci.
|
||||
|
||||
@@ -38,6 +38,7 @@ openclaw config get browser.executablePath
|
||||
openclaw config set browser.executablePath "/usr/bin/google-chrome"
|
||||
openclaw config set agents.defaults.heartbeat.every "2h"
|
||||
openclaw config set agents.list[0].tools.exec.node "node-id-or-name"
|
||||
openclaw config set agents.defaults.models '{"openai-codex/gpt-5.4":{}}' --strict-json --merge
|
||||
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN
|
||||
openclaw config set secrets.providers.vaultfile --provider-source file --provider-path /etc/openclaw/secrets.json --provider-mode json
|
||||
openclaw config unset plugins.entries.brave.config.webSearch.apiKey
|
||||
@@ -105,6 +106,22 @@ openclaw config set channels.whatsapp.groups '["*"]' --strict-json
|
||||
|
||||
`config get <path> --json` prints the raw value as JSON instead of terminal-formatted text.
|
||||
|
||||
Object assignment replaces the target path by default. Protected map/list paths
|
||||
that commonly hold user-added entries, such as `agents.defaults.models`,
|
||||
`models.providers`, `models.providers.<id>.models`, `plugins.entries`, and
|
||||
`auth.profiles`, refuse replacements that would remove existing entries unless
|
||||
you pass `--replace`.
|
||||
|
||||
Use `--merge` when adding entries to those maps:
|
||||
|
||||
```bash
|
||||
openclaw config set agents.defaults.models '{"openai-codex/gpt-5.4":{}}' --strict-json --merge
|
||||
openclaw config set models.providers.ollama.models '[{"id":"llama3.2","name":"Llama 3.2"}]' --strict-json --merge
|
||||
```
|
||||
|
||||
Use `--replace` only when you intentionally want the provided value to become
|
||||
the complete target value.
|
||||
|
||||
## `config set` modes
|
||||
|
||||
`openclaw config set` supports four assignment styles:
|
||||
|
||||
@@ -11,6 +11,8 @@ Interactive prompt to set up credentials, devices, and agent defaults.
|
||||
|
||||
Note: The **Model** section now includes a multi-select for the
|
||||
`agents.defaults.models` allowlist (what shows up in `/model` and the model picker).
|
||||
Provider-scoped setup choices merge their selected models into the existing
|
||||
allowlist instead of replacing unrelated providers already in the config.
|
||||
|
||||
When configure starts from a provider auth choice, the default-model and
|
||||
allowlist pickers prefer that provider automatically. For paired providers such
|
||||
|
||||
@@ -308,6 +308,98 @@ describe("config cli", () => {
|
||||
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Dry run successful"));
|
||||
});
|
||||
|
||||
it("rejects protected model map replacement unless explicitly requested", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-5.4": { alias: "GPT" },
|
||||
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"agents.defaults.models",
|
||||
'{"openai/gpt-5.4":{}}',
|
||||
"--strict-json",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Refusing to replace agents.defaults.models"),
|
||||
);
|
||||
});
|
||||
|
||||
it("merges protected model map values with --merge", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-5.4": { alias: "GPT" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"agents.defaults.models",
|
||||
'{"anthropic/claude-sonnet-4-6":{"alias":"Sonnet"}}',
|
||||
"--strict-json",
|
||||
"--merge",
|
||||
]);
|
||||
|
||||
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
|
||||
const written = mockWriteConfigFile.mock.calls[0]?.[0];
|
||||
expect(written.agents?.defaults?.models).toEqual({
|
||||
"openai/gpt-5.4": { alias: "GPT" },
|
||||
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
});
|
||||
});
|
||||
|
||||
it("merges provider model arrays by id with --merge", async () => {
|
||||
const resolved = {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
api: "ollama",
|
||||
models: [
|
||||
{ id: "llama3.2", name: "Llama 3.2", contextWindow: 131072 },
|
||||
{ id: "qwen3", name: "Qwen 3" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"models.providers.ollama.models",
|
||||
'[{"id":"llama3.2","name":"Llama 3.2 latest"},{"id":"gemma4","name":"Gemma 4"}]',
|
||||
"--strict-json",
|
||||
"--merge",
|
||||
]);
|
||||
|
||||
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
|
||||
const written = mockWriteConfigFile.mock.calls[0]?.[0];
|
||||
expect(written.models?.providers?.ollama?.models).toEqual([
|
||||
{ id: "llama3.2", name: "Llama 3.2 latest", contextWindow: 131072 },
|
||||
{ id: "qwen3", name: "Qwen 3" },
|
||||
{ id: "gemma4", name: "Gemma 4" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes agents.defaults.llm.idleTimeoutSeconds without disturbing sibling defaults", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
agents: {
|
||||
@@ -1256,6 +1348,57 @@ describe("config cli", () => {
|
||||
expect(written.gateway?.auth).toEqual({ mode: "token" });
|
||||
});
|
||||
|
||||
it("batch-file nested leaf updates preserve agents defaults and list siblings", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-5.4": { alias: "GPT" },
|
||||
},
|
||||
model: { primary: "openai/gpt-5.4" },
|
||||
},
|
||||
list: [{ id: "main" }, { id: "ops" }],
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"github-copilot": { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
const pathname = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-config-memory-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
pathname,
|
||||
JSON.stringify([
|
||||
{ path: "agents.defaults.memorySearch.enabled", value: true },
|
||||
{ path: "agents.defaults.memorySearch.provider", value: "gemini" },
|
||||
{ path: "agents.defaults.memorySearch.sources", value: ["memory"] },
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
try {
|
||||
await runConfigCommand(["config", "set", "--batch-file", pathname]);
|
||||
} finally {
|
||||
fs.rmSync(pathname, { force: true });
|
||||
}
|
||||
|
||||
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
|
||||
const written = mockWriteConfigFile.mock.calls[0]?.[0];
|
||||
expect(written.agents?.defaults?.models).toEqual(resolved.agents?.defaults?.models);
|
||||
expect(written.agents?.defaults?.model).toEqual(resolved.agents?.defaults?.model);
|
||||
expect(written.agents?.defaults?.memorySearch).toEqual({
|
||||
enabled: true,
|
||||
provider: "gemini",
|
||||
sources: ["memory"],
|
||||
});
|
||||
expect(written.agents?.list).toEqual(resolved.agents?.list);
|
||||
expect(written.plugins).toEqual(resolved.plugins);
|
||||
});
|
||||
|
||||
it("rejects malformed batch-file payloads", async () => {
|
||||
const pathname = path.join(
|
||||
os.tmpdir(),
|
||||
|
||||
@@ -200,6 +200,10 @@ function hasOwnPathKey(value: Record<string, unknown>, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(value, key);
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function formatDoctorHint(message: string): string {
|
||||
return `Run \`${formatCliCommand("openclaw doctor")}\` ${message}`;
|
||||
}
|
||||
@@ -293,6 +297,149 @@ function setAtPath(root: Record<string, unknown>, path: PathSegment[], value: un
|
||||
(current as Record<string, unknown>)[last] = value;
|
||||
}
|
||||
|
||||
function modelArrayIds(value: unknown): Set<string> | null {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const entry of value) {
|
||||
if (!isPlainRecord(entry) || typeof entry.id !== "string" || !entry.id.trim()) {
|
||||
return null;
|
||||
}
|
||||
ids.add(entry.id.trim());
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function mergeModelArrays(existing: unknown[], patch: unknown[]): unknown[] {
|
||||
const merged = [...existing];
|
||||
const indexById = new Map<string, number>();
|
||||
for (const [index, entry] of merged.entries()) {
|
||||
if (isPlainRecord(entry) && typeof entry.id === "string" && entry.id.trim()) {
|
||||
indexById.set(entry.id.trim(), index);
|
||||
}
|
||||
}
|
||||
for (const entry of patch) {
|
||||
if (!isPlainRecord(entry) || typeof entry.id !== "string" || !entry.id.trim()) {
|
||||
merged.push(entry);
|
||||
continue;
|
||||
}
|
||||
const id = entry.id.trim();
|
||||
const existingIndex = indexById.get(id);
|
||||
if (existingIndex === undefined) {
|
||||
indexById.set(id, merged.length);
|
||||
merged.push(entry);
|
||||
continue;
|
||||
}
|
||||
const existingEntry = merged[existingIndex];
|
||||
merged[existingIndex] = isPlainRecord(existingEntry) ? { ...existingEntry, ...entry } : entry;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mergeConfigValue(existing: unknown, patch: unknown, path: PathSegment[]): unknown {
|
||||
if (isProviderModelListPath(path) && Array.isArray(existing) && Array.isArray(patch)) {
|
||||
return mergeModelArrays(existing, patch);
|
||||
}
|
||||
if (isPlainRecord(existing) && isPlainRecord(patch)) {
|
||||
const next: Record<string, unknown> = { ...existing };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
next[key] =
|
||||
hasOwnPathKey(next, key) && isPlainRecord(next[key]) && isPlainRecord(value)
|
||||
? mergeConfigValue(next[key], value, [...path, key])
|
||||
: value;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
throw new Error(`Cannot merge ${toDotPath(path)}; use --replace to replace intentionally.`);
|
||||
}
|
||||
|
||||
function mergeAtPath(root: Record<string, unknown>, path: PathSegment[], value: unknown): void {
|
||||
const existing = getAtPath(root, path);
|
||||
if (!existing.found) {
|
||||
setAtPath(root, path, value);
|
||||
return;
|
||||
}
|
||||
setAtPath(root, path, mergeConfigValue(existing.value, value, path));
|
||||
}
|
||||
|
||||
function isProviderModelListPath(path: PathSegment[]): boolean {
|
||||
return (
|
||||
path.length === 4 && path[0] === "models" && path[1] === "providers" && path[3] === "models"
|
||||
);
|
||||
}
|
||||
|
||||
function isProtectedMapReplacementPath(path: PathSegment[]): boolean {
|
||||
if (path.join(".") === "agents.defaults.models") {
|
||||
return true;
|
||||
}
|
||||
if (path.join(".") === "models.providers") {
|
||||
return true;
|
||||
}
|
||||
if (path.length === 3 && path[0] === "models" && path[1] === "providers") {
|
||||
return true;
|
||||
}
|
||||
if (path.join(".") === "plugins.entries") {
|
||||
return true;
|
||||
}
|
||||
if (path.join(".") === "auth.profiles") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isProtectedArrayReplacementPath(path: PathSegment[]): boolean {
|
||||
return isProviderModelListPath(path) || path.join(".") === "agents.list";
|
||||
}
|
||||
|
||||
function formatRemovedEntries(entries: string[]): string {
|
||||
const visible = entries.slice(0, 6);
|
||||
const suffix =
|
||||
entries.length > visible.length ? `, ... ${entries.length - visible.length} more` : "";
|
||||
return `${visible.join(", ")}${suffix}`;
|
||||
}
|
||||
|
||||
function assertNonDestructiveReplacement(params: {
|
||||
root: Record<string, unknown>;
|
||||
path: PathSegment[];
|
||||
value: unknown;
|
||||
allowReplace?: boolean;
|
||||
}): void {
|
||||
if (params.allowReplace) {
|
||||
return;
|
||||
}
|
||||
const existing = getAtPath(params.root, params.path);
|
||||
if (!existing.found) {
|
||||
return;
|
||||
}
|
||||
const pathLabel = toDotPath(params.path);
|
||||
if (isProtectedMapReplacementPath(params.path) && isPlainRecord(existing.value)) {
|
||||
if (!isPlainRecord(params.value)) {
|
||||
return;
|
||||
}
|
||||
const nextKeys = new Set(Object.keys(params.value));
|
||||
const removed = Object.keys(existing.value).filter((key) => !nextKeys.has(key));
|
||||
if (removed.length > 0) {
|
||||
throw new Error(
|
||||
`Refusing to replace ${pathLabel}; it would remove existing entries: ${formatRemovedEntries(removed)}. Use --merge to merge object values or --replace to replace intentionally.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isProtectedArrayReplacementPath(params.path)) {
|
||||
const existingIds = modelArrayIds(existing.value);
|
||||
const nextIds = modelArrayIds(params.value);
|
||||
if (!existingIds || !nextIds) {
|
||||
return;
|
||||
}
|
||||
const removed = [...existingIds].filter((id) => !nextIds.has(id));
|
||||
if (removed.length > 0) {
|
||||
throw new Error(
|
||||
`Refusing to replace ${pathLabel}; it would remove existing entries: ${formatRemovedEntries(removed)}. Use --merge to merge by id or --replace to replace intentionally.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unsetAtPath(root: Record<string, unknown>, path: PathSegment[]): boolean {
|
||||
let current: unknown = root;
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
@@ -1027,6 +1174,9 @@ export async function runConfigSet(opts: {
|
||||
if (opts.cliOptions.allowExec && !opts.cliOptions.dryRun) {
|
||||
throw modeError("--allow-exec requires --dry-run.");
|
||||
}
|
||||
if (opts.cliOptions.merge && opts.cliOptions.replace) {
|
||||
throw modeError("choose either --merge or --replace, not both.");
|
||||
}
|
||||
|
||||
const batchEntries = parseBatchSource(opts.cliOptions);
|
||||
if (batchEntries) {
|
||||
@@ -1047,7 +1197,17 @@ export async function runConfigSet(opts: {
|
||||
// This prevents runtime defaults from leaking into the written config file (issue #6070)
|
||||
const next = structuredClone(snapshot.resolved) as Record<string, unknown>;
|
||||
for (const operation of operations) {
|
||||
setAtPath(next, operation.setPath, operation.value);
|
||||
if (opts.cliOptions.merge) {
|
||||
mergeAtPath(next, operation.setPath, operation.value);
|
||||
} else {
|
||||
assertNonDestructiveReplacement({
|
||||
root: next,
|
||||
path: operation.setPath,
|
||||
value: operation.value,
|
||||
allowReplace: opts.cliOptions.replace,
|
||||
});
|
||||
setAtPath(next, operation.setPath, operation.value);
|
||||
}
|
||||
}
|
||||
const removedGatewayAuthPaths = pruneInactiveGatewayAuthCredentials({
|
||||
root: next,
|
||||
@@ -1389,6 +1549,12 @@ export function registerConfigCli(program: Command) {
|
||||
"Dry-run only: allow exec SecretRef resolvability checks (may execute provider commands)",
|
||||
false,
|
||||
)
|
||||
.option("--merge", "Merge object/map values instead of replacing the target path", false)
|
||||
.option(
|
||||
"--replace",
|
||||
"Allow full replacement of protected map/list paths such as agents.defaults.models",
|
||||
false,
|
||||
)
|
||||
.option("--ref-provider <alias>", "SecretRef builder: provider alias")
|
||||
.option("--ref-source <source>", "SecretRef builder: source (env|file|exec)")
|
||||
.option("--ref-id <id>", "SecretRef builder: ref id")
|
||||
|
||||
@@ -10,6 +10,8 @@ export type ConfigSetOptions = {
|
||||
json?: boolean;
|
||||
dryRun?: boolean;
|
||||
allowExec?: boolean;
|
||||
merge?: boolean;
|
||||
replace?: boolean;
|
||||
refProvider?: string;
|
||||
refSource?: string;
|
||||
refId?: string;
|
||||
|
||||
@@ -161,6 +161,43 @@ describe("promptAuthConfig", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves existing model entries outside provider-scoped allowlist updates", async () => {
|
||||
mocks.promptAuthChoiceGrouped.mockResolvedValue("token");
|
||||
mocks.applyAuthChoice.mockResolvedValue({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-5.4": { alias: "GPT" },
|
||||
"anthropic/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.promptModelAllowlist.mockResolvedValue({
|
||||
models: ["anthropic/claude-sonnet-4-6"],
|
||||
scopeKeys: ["anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6"],
|
||||
});
|
||||
mocks.resolveProviderPluginChoice.mockReturnValue({
|
||||
provider: { id: "anthropic", label: "Anthropic", auth: [] },
|
||||
method: { id: "setup-token", label: "setup-token", kind: "token" },
|
||||
wizard: {
|
||||
modelAllowlist: {
|
||||
allowedKeys: ["anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6"],
|
||||
initialSelections: ["anthropic/claude-sonnet-4-6"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await promptAuthConfig({}, makeRuntime(), noopPrompter);
|
||||
|
||||
expect(result.agents?.defaults?.models).toEqual({
|
||||
"openai/gpt-5.4": { alias: "GPT" },
|
||||
"anthropic/claude-sonnet-4-6": {},
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes the allowlist picker to the selected provider when available", async () => {
|
||||
mocks.promptAuthChoiceGrouped.mockResolvedValue("openai-api-key");
|
||||
mocks.resolvePreferredProviderForAuthChoice.mockResolvedValue("openai");
|
||||
|
||||
@@ -163,7 +163,9 @@ export async function promptAuthConfig(
|
||||
preferredProvider,
|
||||
});
|
||||
if (allowlistSelection.models) {
|
||||
next = applyModelAllowlist(next, allowlistSelection.models);
|
||||
next = applyModelAllowlist(next, allowlistSelection.models, {
|
||||
scopeKeys: allowlistSelection.scopeKeys,
|
||||
});
|
||||
next = applyModelFallbacksFromSelection(next, allowlistSelection.models);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ describe("promptModelAllowlist", () => {
|
||||
const prompter = makePrompter({ multiselect });
|
||||
const config = { agents: { defaults: {} } } as OpenClawConfig;
|
||||
|
||||
await promptModelAllowlist({
|
||||
const result = await promptModelAllowlist({
|
||||
config,
|
||||
prompter,
|
||||
allowedKeys: ["anthropic/claude-opus-4-6"],
|
||||
@@ -362,6 +362,7 @@ describe("promptModelAllowlist", () => {
|
||||
expect(options.map((opt: { value: string }) => opt.value)).toEqual([
|
||||
"anthropic/claude-opus-4-6",
|
||||
]);
|
||||
expect(result.scopeKeys).toEqual(["anthropic/claude-opus-4-6"]);
|
||||
});
|
||||
|
||||
it("scopes the initial allowlist picker to the preferred provider", async () => {
|
||||
@@ -452,6 +453,28 @@ describe("applyModelAllowlist", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves entries outside scoped allowlist updates", () => {
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-5.4": { alias: "gpt" },
|
||||
"anthropic/claude-opus-4-6": { alias: "opus" },
|
||||
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const next = applyModelAllowlist(config, ["anthropic/claude-sonnet-4-6"], {
|
||||
scopeKeys: ["anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6"],
|
||||
});
|
||||
expect(next.agents?.defaults?.models).toEqual({
|
||||
"openai/gpt-5.4": { alias: "gpt" },
|
||||
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the allowlist when no models remain", () => {
|
||||
const config = {
|
||||
agents: {
|
||||
|
||||
@@ -59,6 +59,41 @@ describe("onboard auth provider config merges", () => {
|
||||
expect(next.agents?.defaults?.models).toEqual(agentModels);
|
||||
});
|
||||
|
||||
it("preserves existing agent model entries when adding provider models", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-5.4": { alias: "GPT" },
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
custom: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://old.example.com/v1",
|
||||
models: [makeModel("model-a")],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const next = applyProviderConfigWithDefaultModels(cfg, {
|
||||
agentModels,
|
||||
providerId: "custom",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://new.example.com/v1",
|
||||
defaultModels: [makeModel("model-b")],
|
||||
defaultModelId: "model-b",
|
||||
});
|
||||
|
||||
expect(next.agents?.defaults?.models).toEqual({
|
||||
"openai/gpt-5.4": { alias: "GPT" },
|
||||
...agentModels,
|
||||
});
|
||||
});
|
||||
|
||||
it("merges model catalogs without duplicating existing model ids", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
|
||||
@@ -45,7 +45,7 @@ export type PromptDefaultModelParams = {
|
||||
};
|
||||
|
||||
export type PromptDefaultModelResult = { model?: string; config?: OpenClawConfig };
|
||||
export type PromptModelAllowlistResult = { models?: string[] };
|
||||
export type PromptModelAllowlistResult = { models?: string[]; scopeKeys?: string[] };
|
||||
|
||||
async function loadModelPickerRuntime() {
|
||||
return import("../commands/model-picker.runtime.js");
|
||||
@@ -658,6 +658,12 @@ export async function promptModelAllowlist(params: {
|
||||
? allowedCatalog.filter((entry) => matchesPreferredProvider?.(entry.provider))
|
||||
: allowedCatalog;
|
||||
|
||||
const scopeKeys = allowedKeySet
|
||||
? allowedKeys
|
||||
: preferredProvider
|
||||
? filteredCatalog.map((entry) => modelKey(entry.provider, entry.id))
|
||||
: undefined;
|
||||
|
||||
for (const entry of filteredCatalog) {
|
||||
addModelSelectOption({ entry, options, seen, aliasIndex, hasAuth });
|
||||
}
|
||||
@@ -686,7 +692,17 @@ export async function promptModelAllowlist(params: {
|
||||
});
|
||||
const selected = normalizeModelKeys(selection);
|
||||
if (selected.length > 0) {
|
||||
return { models: selected };
|
||||
return { models: selected, ...(scopeKeys ? { scopeKeys } : {}) };
|
||||
}
|
||||
if (scopeKeys) {
|
||||
const confirmScopedClear = await params.prompter.confirm({
|
||||
message: "Remove these provider models from the /model picker?",
|
||||
initialValue: false,
|
||||
});
|
||||
if (!confirmScopedClear) {
|
||||
return {};
|
||||
}
|
||||
return { models: [], scopeKeys };
|
||||
}
|
||||
if (existingKeys.length === 0) {
|
||||
return { models: [] };
|
||||
@@ -701,13 +717,34 @@ export async function promptModelAllowlist(params: {
|
||||
return { models: [] };
|
||||
}
|
||||
|
||||
export function applyModelAllowlist(cfg: OpenClawConfig, models: string[]): OpenClawConfig {
|
||||
export function applyModelAllowlist(
|
||||
cfg: OpenClawConfig,
|
||||
models: string[],
|
||||
opts: { scopeKeys?: string[] } = {},
|
||||
): OpenClawConfig {
|
||||
const defaults = cfg.agents?.defaults;
|
||||
const normalized = normalizeModelKeys(models);
|
||||
const scopeKeys = opts.scopeKeys ? normalizeModelKeys(opts.scopeKeys) : [];
|
||||
const scopeKeySet = scopeKeys.length > 0 ? new Set(scopeKeys) : null;
|
||||
if (normalized.length === 0) {
|
||||
if (!defaults?.models) {
|
||||
return cfg;
|
||||
}
|
||||
if (scopeKeySet) {
|
||||
const nextModels = { ...defaults.models };
|
||||
for (const key of scopeKeySet) {
|
||||
delete nextModels[key];
|
||||
}
|
||||
const { models: _ignored, ...restDefaults } = defaults;
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults:
|
||||
Object.keys(nextModels).length > 0 ? { ...defaults, models: nextModels } : restDefaults,
|
||||
},
|
||||
};
|
||||
}
|
||||
const { models: _ignored, ...restDefaults } = defaults;
|
||||
return {
|
||||
...cfg,
|
||||
@@ -719,6 +756,26 @@ export function applyModelAllowlist(cfg: OpenClawConfig, models: string[]): Open
|
||||
}
|
||||
|
||||
const existingModels = defaults?.models ?? {};
|
||||
if (scopeKeySet) {
|
||||
const nextModels = { ...existingModels };
|
||||
for (const key of scopeKeySet) {
|
||||
delete nextModels[key];
|
||||
}
|
||||
for (const key of normalized) {
|
||||
nextModels[key] = existingModels[key] ?? {};
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...defaults,
|
||||
models: nextModels,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const nextModels: Record<string, { alias?: string }> = {};
|
||||
for (const key of normalized) {
|
||||
nextModels[key] = existingModels[key] ?? {};
|
||||
|
||||
@@ -182,13 +182,17 @@ export function applyOnboardAuthAgentModelsAndProviders(
|
||||
providers: Record<string, ModelProviderConfig>;
|
||||
},
|
||||
): OpenClawConfig {
|
||||
const mergedAgentModels = {
|
||||
...cfg.agents?.defaults?.models,
|
||||
...params.agentModels,
|
||||
};
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models: params.agentModels,
|
||||
models: mergedAgentModels,
|
||||
},
|
||||
},
|
||||
models: {
|
||||
|
||||
Reference in New Issue
Block a user