mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
fix(cli): reject unknown models in config set (#111571)
* fix(cli): validate configured model references * fix(cli): cover dependent model references * fix(cli): reject malformed model references * test(cli): satisfy model validation gates * fix(cli): harden model validation drafts * fix(cli): preserve model runtime ownership * fix(cli): validate expanded model references * fix(cli): close model validation gaps * fix(cli): preserve model alias ownership * fix(cli): secure model validation context * fix(cli): redact expanded model references * fix(cli): validate inherited agent catalogs * fix(cli): align canonical model resolution * fix(cli): scope inherited catalog validation * fix(cli): compare resolved model snapshots * fix(cli): cover model dependency transitions * fix(cli): isolate model env validation * fix(cli): redact dependent model checks * fix(cli): compare agent model identity * fix(cli): validate expanded model removal * fix(cli): defer unresolved fallback validation * fix(cli): preserve authored model changes
This commit is contained in:
+4
-1
@@ -117,6 +117,8 @@ openclaw config set channels.whatsapp.groups '["*"]' --strict-json
|
||||
|
||||
`config get <path> --json` prints the raw value as JSON instead of terminal-formatted text.
|
||||
|
||||
When a write changes `agents.defaults.model` or a per-agent `agents.list[].model`, OpenClaw resolves each changed primary or fallback through the configured provider catalogs before writing. Unknown model references are rejected without changing the active config; run `openclaw models list` to see available models.
|
||||
|
||||
<Note>
|
||||
Object assignment replaces the target path by default. Protected paths that commonly hold user-added entries refuse replacements that would remove existing entries unless you pass `--replace`: `agents.defaults.models`, `agents.list`, `models.providers`, `models.providers.<id>`, `models.providers.<id>.models`, `plugins.entries`, and `auth.profiles`.
|
||||
</Note>
|
||||
@@ -365,7 +367,7 @@ openclaw config set channels.discord.token \
|
||||
skippedExecRefs: number,
|
||||
errors?: [
|
||||
{
|
||||
kind: "missing-path" | "schema" | "resolvability",
|
||||
kind: "missing-path" | "schema" | "resolvability" | "model",
|
||||
message: string,
|
||||
ref?: string, // present for resolvability errors
|
||||
},
|
||||
@@ -422,6 +424,7 @@ openclaw config set channels.discord.token \
|
||||
- `config schema validation failed`: your post-change config shape is invalid; fix the path/value or provider/ref object shape.
|
||||
- `Config policy validation failed: unsupported SecretRef usage`: move that credential back to plaintext/string input; keep SecretRefs on supported surfaces only.
|
||||
- `SecretRef assignment(s) could not be resolved`: the referenced provider/ref cannot currently resolve (missing env var, invalid file pointer, exec provider failure, or provider/source mismatch).
|
||||
- `model reference validation failed`: a changed text-model primary or fallback is unknown; run `openclaw models list` and choose an available model.
|
||||
- `Dry run note: skipped <n> exec SecretRef resolvability check(s)`: rerun with `--allow-exec` if you need exec resolvability validation.
|
||||
- For batch mode, fix failing entries and rerun `--dry-run` before writing.
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js";
|
||||
import type { PluginManifestRecord, PluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
|
||||
import type { ConfigSetDryRunResult } from "./config-set-dryrun.js";
|
||||
import { applyCliProfileEnv } from "./profile.js";
|
||||
import { createCliRuntimeCapture, mockRuntimeModule } from "./test-runtime-capture.js";
|
||||
|
||||
@@ -28,6 +29,7 @@ const mockWriteConfigFile = vi.fn<
|
||||
) => Promise<void>
|
||||
>(async () => {});
|
||||
const mockResolveSecretRefValue = vi.fn();
|
||||
const mockCheckTouchedTextModelRefs = vi.fn();
|
||||
const mockReadBestEffortRuntimeConfigSchema = vi.fn();
|
||||
const mockLoadPluginMetadataSnapshot = vi.fn((_configForTest: unknown) =>
|
||||
createPluginMetadataSnapshot(),
|
||||
@@ -61,6 +63,10 @@ vi.mock("../config/runtime-schema.js", () => ({
|
||||
readBestEffortRuntimeConfigSchema: () => mockReadBestEffortRuntimeConfigSchema(),
|
||||
}));
|
||||
|
||||
vi.mock("./config-model-validation.js", () => ({
|
||||
checkTouchedTextModelRefs: (...args: unknown[]) => mockCheckTouchedTextModelRefs(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/config-reload-plan.js", () => ({
|
||||
buildGatewayReloadPlan: (changedPaths: string[]) => {
|
||||
const restartReasons = changedPaths.filter(
|
||||
@@ -497,6 +503,7 @@ describe("config cli", () => {
|
||||
throw new ExitError(code, errorMessages || undefined);
|
||||
});
|
||||
mockResolveSecretRefValue.mockResolvedValue("resolved-secret");
|
||||
mockCheckTouchedTextModelRefs.mockResolvedValue({ refsChecked: 0, refsTotal: 0, errors: [] });
|
||||
});
|
||||
|
||||
describe("config set - issue #6070", () => {
|
||||
@@ -678,6 +685,133 @@ describe("config cli", () => {
|
||||
expect(written.agents?.defaults?.models).toEqual({
|
||||
"google/gemini-3.1-pro-preview": { alias: "gemini" },
|
||||
});
|
||||
expect(mockCheckTouchedTextModelRefs).toHaveBeenCalledWith({
|
||||
config: written,
|
||||
previousConfig: expect.any(Object),
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
redactDependencyValues: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unresolved primary model before writing config", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
agents: { defaults: { model: { primary: "openai/gpt-5.4-mini" } } },
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
mockCheckTouchedTextModelRefs.mockResolvedValueOnce({
|
||||
refsChecked: 1,
|
||||
refsTotal: 1,
|
||||
errors: [
|
||||
'Cannot set model reference "missing/nope" at agents.defaults.model.primary: Unknown model: missing/nope. Run openclaw models list to list available models.',
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runConfigCommand(["config", "set", "agents.defaults.model.primary", "missing/nope"]),
|
||||
).rejects.toThrow(ExitError);
|
||||
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
expectErrorIncludes('Cannot set model reference "missing/nope"');
|
||||
expectErrorIncludes("openclaw models list");
|
||||
});
|
||||
|
||||
it("preserves an authored env placeholder after model validation", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
agents: { defaults: { model: { primary: "openai/gpt-5.4-mini" } } },
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
mockCheckTouchedTextModelRefs.mockResolvedValueOnce({
|
||||
refsChecked: 1,
|
||||
refsTotal: 1,
|
||||
errors: [],
|
||||
});
|
||||
|
||||
await runConfigCommand(["config", "set", "agents.defaults.model.primary", "${MODEL_REF}"]);
|
||||
|
||||
expect(firstWrittenConfig().agents?.defaults?.model).toEqual({
|
||||
primary: "${MODEL_REF}",
|
||||
});
|
||||
expect(mockCheckTouchedTextModelRefs).toHaveBeenCalledWith({
|
||||
config: expect.objectContaining({
|
||||
agents: expect.objectContaining({
|
||||
defaults: expect.objectContaining({ model: { primary: "${MODEL_REF}" } }),
|
||||
}),
|
||||
}),
|
||||
previousConfig: resolved,
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
redactDependencyValues: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an unresolved primary model in dry-run JSON without writing config", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
agents: { defaults: { model: { primary: "openai/gpt-5.4-mini" } } },
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
mockCheckTouchedTextModelRefs.mockResolvedValueOnce({
|
||||
refsChecked: 1,
|
||||
refsTotal: 1,
|
||||
errors: [
|
||||
'Cannot set model reference "missing/nope" at agents.defaults.model.primary: Unknown model: missing/nope. Run openclaw models list to list available models.',
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"agents.defaults.model.primary",
|
||||
'"missing/nope"',
|
||||
"--dry-run",
|
||||
"--json",
|
||||
]),
|
||||
).rejects.toThrow(ExitError);
|
||||
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
const payload = parseLastLogPayload() as ConfigSetDryRunResult;
|
||||
expect(payload).toMatchObject({
|
||||
ok: false,
|
||||
checks: { resolvability: true, resolvabilityComplete: true },
|
||||
refsChecked: 1,
|
||||
errors: [
|
||||
{
|
||||
kind: "model",
|
||||
message: expect.stringContaining('Cannot set model reference "missing/nope"'),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports model resolver setup failures as incomplete dry-run JSON", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
agents: { defaults: { model: { primary: "openai/gpt-5.4-mini" } } },
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
mockCheckTouchedTextModelRefs.mockResolvedValueOnce({
|
||||
refsChecked: 0,
|
||||
refsTotal: 1,
|
||||
errors: ["Unable to validate changed model references before writing: catalog unavailable"],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"agents.defaults.model.primary",
|
||||
'"openai/gpt-5.4-mini"',
|
||||
"--dry-run",
|
||||
"--json",
|
||||
]),
|
||||
).rejects.toThrow(ExitError);
|
||||
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
const payload = parseLastLogPayload() as ConfigSetDryRunResult;
|
||||
expect(payload).toMatchObject({
|
||||
ok: false,
|
||||
checks: { resolvability: true, resolvabilityComplete: false },
|
||||
refsChecked: 0,
|
||||
errors: [{ kind: "model", message: expect.stringContaining("catalog unavailable") }],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes explicit model-map paths before writing config mutations", async () => {
|
||||
@@ -3457,6 +3591,87 @@ describe("config cli", () => {
|
||||
expect(mockReadConfigFileSnapshot).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects an unset that makes a dependent model reference unresolved", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "provider-a/main",
|
||||
fallbacks: ["backup"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
mockCheckTouchedTextModelRefs.mockResolvedValueOnce({
|
||||
refsChecked: 1,
|
||||
refsTotal: 1,
|
||||
errors: [
|
||||
'Cannot set model reference "backup" at agents.defaults.model.fallbacks.0: Unknown model: openai/backup. Run openclaw models list to list available models.',
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runConfigCommand(["config", "unset", "agents.defaults.model.primary"]),
|
||||
).rejects.toThrow(ExitError);
|
||||
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
expect(mockCheckTouchedTextModelRefs).toHaveBeenCalledWith({
|
||||
config: {
|
||||
agents: { defaults: { model: { fallbacks: ["backup"] } } },
|
||||
},
|
||||
previousConfig: resolved,
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
redactDependencyValues: true,
|
||||
});
|
||||
expectErrorIncludes('Cannot set model reference "backup"');
|
||||
});
|
||||
|
||||
it("reports an unset model failure through dry-run JSON", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "provider-a/main",
|
||||
fallbacks: ["backup"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
setSnapshot(resolved, resolved);
|
||||
mockCheckTouchedTextModelRefs.mockResolvedValueOnce({
|
||||
refsChecked: 1,
|
||||
refsTotal: 1,
|
||||
errors: [
|
||||
'Cannot set model reference "backup" at agents.defaults.model.fallbacks.0: Unknown model: openai/backup. Run openclaw models list to list available models.',
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"unset",
|
||||
"agents.defaults.model.primary",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
]),
|
||||
).rejects.toThrow(ExitError);
|
||||
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
expect(parseLastLogPayload()).toMatchObject({
|
||||
ok: false,
|
||||
checks: { resolvability: true, resolvabilityComplete: true },
|
||||
refsChecked: 1,
|
||||
errors: [
|
||||
{
|
||||
kind: "model",
|
||||
message: expect.stringContaining('Cannot set model reference "backup"'),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("prints JSON for config unset dry-run", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
agents: { list: [{ id: "main" }] },
|
||||
|
||||
+42
-6
@@ -73,6 +73,7 @@ import {
|
||||
import { parseConfigPathArrayIndex } from "../shared/path-array-index.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
import { checkTouchedTextModelRefs } from "./config-model-validation.js";
|
||||
import { formatPluginPackagingRuntimeOutputRecoveryHint } from "./config-recovery-hints.js";
|
||||
import type {
|
||||
ConfigSetDryRunError,
|
||||
@@ -2106,6 +2107,7 @@ function formatDryRunFailureMessage(params: {
|
||||
const missingPathErrors = errors.filter((error) => error.kind === "missing-path");
|
||||
const schemaErrors = errors.filter((error) => error.kind === "schema");
|
||||
const resolveErrors = errors.filter((error) => error.kind === "resolvability");
|
||||
const modelErrors = errors.filter((error) => error.kind === "model");
|
||||
const lines: string[] = [];
|
||||
if (missingPathErrors.length > 0) {
|
||||
lines.push(...missingPathErrors.map((error) => error.message));
|
||||
@@ -2127,6 +2129,10 @@ function formatDryRunFailureMessage(params: {
|
||||
lines.push(`- ... ${resolveErrors.length - 5} more`);
|
||||
}
|
||||
}
|
||||
if (modelErrors.length > 0) {
|
||||
lines.push("Dry run failed: model reference validation failed.");
|
||||
lines.push(...modelErrors.map((error) => `- ${error.message}`));
|
||||
}
|
||||
if (skippedExecRefs > 0) {
|
||||
lines.push(
|
||||
`Dry run note: skipped ${skippedExecRefs} exec SecretRef resolvability check(s). Re-run with --allow-exec to execute exec providers during dry-run.`,
|
||||
@@ -2227,6 +2233,13 @@ async function runConfigOperations(params: {
|
||||
allowExecInDryRun: Boolean(options.allowExec),
|
||||
});
|
||||
const errors: ConfigSetDryRunError[] = [];
|
||||
const modelRefCheck = await checkTouchedTextModelRefs({
|
||||
config: nextConfig,
|
||||
previousConfig: currentConfigForApplyHint,
|
||||
touchedPaths: operations.map((operation) => operation.setPath),
|
||||
redactDependencyValues: true,
|
||||
});
|
||||
errors.push(...modelRefCheck.errors.map((message) => ({ kind: "model" as const, message })));
|
||||
if ((!hasJsonMode || !requiresFullSchemaValidation) && policyIssueLines.length > 0) {
|
||||
errors.push(
|
||||
...policyIssueLines.map((message) => ({
|
||||
@@ -2268,12 +2281,13 @@ async function runConfigOperations(params: {
|
||||
requiresFullSchemaValidation ||
|
||||
policyIssueLines.length > 0 ||
|
||||
pluginIntegrationProviderErrors.length > 0,
|
||||
resolvability: hasJsonMode || hasBuilderMode || hasUnsetMode,
|
||||
resolvability: hasJsonMode || hasBuilderMode || hasUnsetMode || modelRefCheck.refsTotal > 0,
|
||||
resolvabilityComplete:
|
||||
(hasJsonMode || hasBuilderMode || hasUnsetMode) &&
|
||||
selectedDryRunRefs.skippedExecRefs.length === 0,
|
||||
(hasJsonMode || hasBuilderMode || hasUnsetMode || modelRefCheck.refsTotal > 0) &&
|
||||
selectedDryRunRefs.skippedExecRefs.length === 0 &&
|
||||
modelRefCheck.refsChecked === modelRefCheck.refsTotal,
|
||||
},
|
||||
refsChecked: selectedDryRunRefs.refsToResolve.length,
|
||||
refsChecked: selectedDryRunRefs.refsToResolve.length + modelRefCheck.refsChecked,
|
||||
skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length,
|
||||
...(dedupedErrors.length > 0 ? { errors: dedupedErrors } : {}),
|
||||
};
|
||||
@@ -2325,6 +2339,17 @@ async function runConfigOperations(params: {
|
||||
);
|
||||
}
|
||||
|
||||
const modelRefCheck = await checkTouchedTextModelRefs({
|
||||
config: nextConfig,
|
||||
previousConfig: currentConfigForApplyHint,
|
||||
touchedPaths: operations.map((operation) => operation.setPath),
|
||||
redactDependencyValues: true,
|
||||
});
|
||||
const firstModelError = modelRefCheck.errors[0];
|
||||
if (firstModelError) {
|
||||
throw new Error(firstModelError);
|
||||
}
|
||||
|
||||
await replaceConfigFile({
|
||||
nextConfig,
|
||||
...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}),
|
||||
@@ -2574,8 +2599,19 @@ export async function runConfigUnset(opts: {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const nextConfig = normalizeConfigMutationModelRefs(structuredClone(next) as OpenClawConfig);
|
||||
const modelRefCheck = await checkTouchedTextModelRefs({
|
||||
config: nextConfig,
|
||||
previousConfig: currentConfigForApplyHint,
|
||||
touchedPaths: [parsedPath],
|
||||
redactDependencyValues: true,
|
||||
});
|
||||
const firstModelError = modelRefCheck.errors[0];
|
||||
if (firstModelError) {
|
||||
throw new Error(firstModelError);
|
||||
}
|
||||
await replaceConfigFile({
|
||||
nextConfig: next,
|
||||
nextConfig,
|
||||
...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}),
|
||||
...(unsetResult.leafContainer === "array"
|
||||
? { writeOptions: { auditOrigin: "cli" } }
|
||||
@@ -2584,7 +2620,7 @@ export async function runConfigUnset(opts: {
|
||||
const hint = configApplyHintForOperations(
|
||||
[buildUnsetOperation(parsedPath)],
|
||||
currentConfigForApplyHint,
|
||||
normalizeConfigMutationModelRefs(structuredClone(next) as OpenClawConfig),
|
||||
nextConfig,
|
||||
);
|
||||
runtime.log(info(`Removed ${opts.path}. ${hint}`));
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { checkTouchedTextModelRefs } from "./config-model-validation.js";
|
||||
|
||||
type ResolverInput = {
|
||||
config: OpenClawConfig;
|
||||
ref: {
|
||||
path: string;
|
||||
value: string;
|
||||
agentIndex?: number;
|
||||
agentId?: string;
|
||||
fallback: boolean;
|
||||
authProfileId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
describe("config model validation env handling", () => {
|
||||
it("validates an expanded ref while preserving the authored config", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
const config: OpenClawConfig = {
|
||||
agents: { defaults: { model: { primary: "${MODEL_REF}" } } },
|
||||
};
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config,
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
env: { MODEL_REF: "openai/gpt-5.4-mini" },
|
||||
resolveModelRef,
|
||||
});
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledWith({
|
||||
config: { agents: { defaults: { model: { primary: "openai/gpt-5.4-mini" } } } },
|
||||
ref: {
|
||||
path: "agents.defaults.model.primary",
|
||||
value: "openai/gpt-5.4-mini",
|
||||
fallback: false,
|
||||
},
|
||||
});
|
||||
expect(config.agents?.defaults?.model).toEqual({ primary: "${MODEL_REF}" });
|
||||
});
|
||||
|
||||
it("reports an authored placeholder without exposing its expanded value", async () => {
|
||||
const resolveModelRef = vi.fn(async () => "Unknown model: private-provider/private-model");
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: { agents: { defaults: { model: { primary: "${MODEL_REF}" } } } },
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
env: { MODEL_REF: "private-provider/private-model@work" },
|
||||
resolveModelRef,
|
||||
});
|
||||
expect(result.errors).toEqual([expect.stringContaining('model reference "${MODEL_REF}"')]);
|
||||
expect(result.errors).toEqual([
|
||||
expect.stringContaining("Unable to resolve authored model reference"),
|
||||
]);
|
||||
expect(result.errors.join("\n")).not.toContain("private-provider");
|
||||
});
|
||||
|
||||
it("redacts a fallback selected after provider expansion", async () => {
|
||||
const resolveModelRef = vi.fn(async ({ ref }: ResolverInput) =>
|
||||
ref.fallback ? "Unknown model: private-fallback" : undefined,
|
||||
);
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "${PRIMARY_REF}", fallbacks: ["${FALLBACK_REF}"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
previousConfig: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/current", fallbacks: ["${FALLBACK_REF}"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
env: { PRIMARY_REF: "provider-b/next", FALLBACK_REF: "private-fallback" },
|
||||
resolveModelRef,
|
||||
});
|
||||
expect(result.errors).toEqual([expect.stringContaining('model reference "${FALLBACK_REF}"')]);
|
||||
expect(result.errors.join("\n")).not.toContain("private-fallback");
|
||||
});
|
||||
|
||||
it("does not revalidate an unchanged expanded primary in a model replacement", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "${MODEL_REF}", fallbacks: ["provider-b/next"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
previousConfig: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "${MODEL_REF}", fallbacks: ["provider-b/current"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model"]],
|
||||
env: { MODEL_REF: "provider-a/main" },
|
||||
resolveModelRef,
|
||||
});
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledWith({
|
||||
config: expect.any(Object),
|
||||
ref: {
|
||||
path: "agents.defaults.model.fallbacks.0",
|
||||
value: "provider-b/next",
|
||||
fallback: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("validates authored changes even when expansion matches the previous values", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "${PRIMARY_REF}", fallbacks: ["${FALLBACK_REF}"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
previousConfig: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "provider-a/main", fallbacks: ["provider-b/backup"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model"]],
|
||||
env: { PRIMARY_REF: "provider-a/main", FALLBACK_REF: "provider-b/backup" },
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 2, refsTotal: 2, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("replaces a stale previous placeholder without requiring its env var", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: { agents: { defaults: { model: { primary: "provider-a/next" } } } },
|
||||
previousConfig: { agents: { defaults: { model: { primary: "${OLD_MODEL}" } } } },
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
env: {},
|
||||
resolveModelRef,
|
||||
});
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("ignores a missing placeholder outside model validation inputs", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: { defaults: { model: { primary: "provider-a/next" } } },
|
||||
channels: { discord: { token: "${DISCORD_TOKEN}" } },
|
||||
},
|
||||
previousConfig: {
|
||||
agents: { defaults: { model: { primary: "provider-a/current" } } },
|
||||
channels: { discord: { token: "${DISCORD_TOKEN}" } },
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
env: {},
|
||||
resolveModelRef,
|
||||
});
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("redacts provider details inherited indirectly from an expanded primary", async () => {
|
||||
const resolveModelRef = vi.fn(async ({ ref }: ResolverInput) =>
|
||||
ref.fallback ? "Unknown model: private-provider/backup" : undefined,
|
||||
);
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { model: { primary: "${PRIMARY_REF}", fallbacks: ["backup"] } },
|
||||
},
|
||||
},
|
||||
previousConfig: {
|
||||
agents: {
|
||||
defaults: { model: { primary: "provider-a/current", fallbacks: ["backup"] } },
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
env: { PRIMARY_REF: "private-provider/main" },
|
||||
resolveModelRef,
|
||||
});
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toContain('model reference "backup"');
|
||||
expect(result.errors[0]).not.toContain("private-provider");
|
||||
});
|
||||
|
||||
it("redacts dependency values when authored spelling is unavailable", async () => {
|
||||
const resolveModelRef = vi.fn(async ({ ref }: ResolverInput) =>
|
||||
ref.fallback ? "Unknown model: provider-b/backup" : undefined,
|
||||
);
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { model: { primary: "provider-b/main", fallbacks: ["backup"] } },
|
||||
},
|
||||
},
|
||||
previousConfig: {
|
||||
agents: {
|
||||
defaults: { model: { primary: "provider-a/main", fallbacks: ["backup"] } },
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
redactDependencyValues: true,
|
||||
resolveModelRef,
|
||||
});
|
||||
expect(result.errors).toEqual([
|
||||
expect.stringContaining('model reference "<configured model reference>"'),
|
||||
]);
|
||||
expect(result.errors[0]).not.toContain("provider-b");
|
||||
expect(result.errors[0]).not.toContain('reference "backup"');
|
||||
});
|
||||
|
||||
it("leaves a bare fallback unchecked when its primary provider is env-unresolved", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { model: { primary: "${MODEL_REF}", fallbacks: ["backup"] } },
|
||||
},
|
||||
},
|
||||
previousConfig: {
|
||||
agents: {
|
||||
defaults: { model: { primary: "${MODEL_REF}", fallbacks: ["previous"] } },
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "fallbacks", "0"]],
|
||||
env: {},
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 0, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates a bare fallback when its primary provider resolves from env", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { model: { primary: "${MODEL_REF}", fallbacks: ["backup"] } },
|
||||
},
|
||||
},
|
||||
previousConfig: {
|
||||
agents: {
|
||||
defaults: { model: { primary: "${MODEL_REF}", fallbacks: ["previous"] } },
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "fallbacks", "0"]],
|
||||
env: { MODEL_REF: "provider-a/main" },
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("validates an explicit fallback when its primary provider is env-unresolved", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "${MODEL_REF}", fallbacks: ["provider-a/backup"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
previousConfig: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "${MODEL_REF}", fallbacks: ["provider-a/previous"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "fallbacks", "0"]],
|
||||
env: {},
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects an invalid bare fallback when its primary provider is env-unresolved", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { model: { primary: "${MODEL_REF}", fallbacks: [" "] } },
|
||||
},
|
||||
},
|
||||
previousConfig: {
|
||||
agents: {
|
||||
defaults: { model: { primary: "${MODEL_REF}", fallbacks: ["previous"] } },
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "fallbacks", "0"]],
|
||||
env: {},
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
refsChecked: 1,
|
||||
refsTotal: 1,
|
||||
errors: [expect.stringContaining("Model reference is empty")],
|
||||
});
|
||||
expect(resolveModelRef).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates a bare fallback when only the primary model is env-unresolved", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { model: { primary: "provider-a/${MODEL_ID}", fallbacks: ["backup"] } },
|
||||
},
|
||||
},
|
||||
previousConfig: {
|
||||
agents: {
|
||||
defaults: { model: { primary: "provider-a/current", fallbacks: ["previous"] } },
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "fallbacks", "0"]],
|
||||
env: {},
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("revalidates a fallback when an expanded primary is removed", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: { defaults: { model: { fallbacks: ["backup"] } } },
|
||||
},
|
||||
previousConfig: {
|
||||
agents: {
|
||||
defaults: { model: { primary: "${MODEL_REF}", fallbacks: ["backup"] } },
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
env: { MODEL_REF: "provider-a/main" },
|
||||
resolveModelRef,
|
||||
});
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledWith({
|
||||
config: expect.any(Object),
|
||||
ref: {
|
||||
path: "agents.defaults.model.fallbacks.0",
|
||||
value: "backup",
|
||||
fallback: true,
|
||||
dependency: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,948 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { checkTouchedTextModelRefs } from "./config-model-validation.js";
|
||||
|
||||
type ResolverInput = {
|
||||
config: OpenClawConfig;
|
||||
ref: {
|
||||
path: string;
|
||||
value: string;
|
||||
agentIndex?: number;
|
||||
agentId?: string;
|
||||
fallback: boolean;
|
||||
authProfileId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
describe("config model validation", () => {
|
||||
it("rejects an unresolved default primary with an actionable error", async () => {
|
||||
const resolveModelRef = vi.fn(async () => "Unknown model: missing/nope");
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: { defaults: { model: { primary: "missing/nope" } } },
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
refsChecked: 1,
|
||||
refsTotal: 1,
|
||||
errors: [
|
||||
'Cannot set model reference "missing/nope" at agents.defaults.model.primary: Unknown model: missing/nope. Run openclaw models list to list available models.',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a resolved default primary", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: { defaults: { model: { primary: "openai/gpt-5.4-mini" } } },
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(resolveModelRef).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
});
|
||||
|
||||
it("accepts a primary that resembles the old validation sentinel", async () => {
|
||||
const resolveModelRef = vi.fn(async () => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "provider/__openclaw_config_validation_unresolved_model__" },
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("validates default refs in every inheriting agent catalog", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-5.4-mini",
|
||||
fallbacks: ["anthropic/claude-sonnet-4-6"],
|
||||
},
|
||||
},
|
||||
list: [{ id: "main", default: true }, { id: "ops" }],
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 4, refsTotal: 4, errors: [] });
|
||||
expect(
|
||||
resolveModelRef.mock.calls.map(([call]) => ({
|
||||
path: call.ref.path,
|
||||
agentId: call.ref.agentId,
|
||||
})),
|
||||
).toEqual([
|
||||
{ path: "agents.defaults.model.primary", agentId: undefined },
|
||||
{ path: "agents.defaults.model.primary", agentId: "ops" },
|
||||
{ path: "agents.defaults.model.fallbacks.0", agentId: undefined },
|
||||
{ path: "agents.defaults.model.fallbacks.0", agentId: "ops" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips the default-agent catalog when that agent overrides the default", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { model: { primary: "provider-a/default" } },
|
||||
list: [{ id: "main", default: true, model: "provider-b/override" }, { id: "ops" }],
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledWith({
|
||||
config: expect.any(Object),
|
||||
ref: {
|
||||
path: "agents.defaults.model.primary",
|
||||
value: "provider-a/default",
|
||||
agentIndex: 1,
|
||||
agentId: "ops",
|
||||
fallback: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("carries a primary auth profile into runtime resolution", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: { defaults: { model: { primary: "openai/gpt-5.4-mini@work" } } },
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledWith({
|
||||
config: {
|
||||
agents: { defaults: { model: { primary: "openai/gpt-5.4-mini@work" } } },
|
||||
},
|
||||
ref: {
|
||||
path: "agents.defaults.model.primary",
|
||||
value: "openai/gpt-5.4-mini@work",
|
||||
fallback: false,
|
||||
authProfileId: "work",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing/", "Invalid model reference"],
|
||||
["provider/@work", "Invalid model reference"],
|
||||
["", "Model reference is empty"],
|
||||
])("rejects the malformed primary %j before runtime resolution", async (primary, detail) => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: { defaults: { model: { primary } } },
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
refsChecked: 1,
|
||||
refsTotal: 1,
|
||||
errors: [expect.stringContaining(detail)],
|
||||
});
|
||||
expect(resolveModelRef).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a configured alias with slash-edge syntax", async () => {
|
||||
const resolveModelRef = vi.fn(async () => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "legacy/" },
|
||||
models: { "openai/gpt-5.4-mini": { alias: "legacy/" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("allows an auth-qualified configured alias with slash-edge syntax", async () => {
|
||||
const resolveModelRef = vi.fn(async () => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "legacy/@work" },
|
||||
models: { "openai/gpt-5.4-mini": { alias: "legacy/" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledWith({
|
||||
config: expect.any(Object),
|
||||
ref: {
|
||||
path: "agents.defaults.model.primary",
|
||||
value: "legacy/@work",
|
||||
fallback: false,
|
||||
authProfileId: "work",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves exact alias precedence for an auth-shaped alias", async () => {
|
||||
const resolveModelRef = vi.fn(async () => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "legacy/@work" },
|
||||
models: { "openai/gpt-5.4-mini": { alias: "legacy/@work" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledWith({
|
||||
config: expect.any(Object),
|
||||
ref: {
|
||||
path: "agents.defaults.model.primary",
|
||||
value: "legacy/@work",
|
||||
fallback: false,
|
||||
authProfileId: "work",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a configured alias whose target is malformed", async () => {
|
||||
const resolveModelRef = vi.fn(async () => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "legacy/" },
|
||||
models: { "broken/": { alias: "legacy/" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result.errors).toEqual([expect.stringContaining("Invalid model reference")]);
|
||||
expect(resolveModelRef).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a configured alias whose target is a valid bare model", async () => {
|
||||
const resolveModelRef = vi.fn(async () => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "fast" },
|
||||
models: { "gpt-5": { alias: "fast" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("uses the canonical valid target when duplicate aliases exist", async () => {
|
||||
const resolveModelRef = vi.fn(async () => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "fast" },
|
||||
models: {
|
||||
"broken/": { alias: "fast" },
|
||||
"provider/good": { alias: "fast" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("matches runtime fallback behavior by not selecting an auth profile", async () => {
|
||||
const resolveModelRef = vi.fn(async () => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-5.4-mini",
|
||||
fallbacks: ["anthropic/claude-sonnet-4-6@work"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "fallbacks", "0"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledWith({
|
||||
config: expect.any(Object),
|
||||
ref: {
|
||||
path: "agents.defaults.model.fallbacks.0",
|
||||
value: "anthropic/claude-sonnet-4-6@work",
|
||||
fallback: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a configured CLI backend model without an embedded catalog row", async () => {
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "acme-cli/foo" },
|
||||
cliBackends: { "acme-cli": { command: "acme" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
});
|
||||
|
||||
it("infers a configured provider for a bare primary model", async () => {
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "foo" },
|
||||
models: { "acme-cli/foo": {} },
|
||||
cliBackends: { "acme-cli": { command: "acme" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
});
|
||||
|
||||
it("keeps an explicit qualified primary ahead of a same-named bare alias", async () => {
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "acme-cli/foo" },
|
||||
models: { bar: { alias: "acme-cli/foo" } },
|
||||
cliBackends: { "acme-cli": { command: "acme" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
});
|
||||
|
||||
it("reports resolver setup failures without claiming refs were checked", async () => {
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: { defaults: { model: { primary: "openai/gpt-5.4-mini" } } },
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
createModelRefResolver: async () => {
|
||||
throw new Error("catalog unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
refsChecked: 0,
|
||||
refsTotal: 1,
|
||||
errors: ["Unable to validate changed model references before writing: catalog unavailable"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not count a thrown resolver call as checked", async () => {
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: { defaults: { model: { primary: "openai/gpt-5.4-mini" } } },
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
resolveModelRef: async () => {
|
||||
throw new Error("catalog unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
refsChecked: 0,
|
||||
refsTotal: 1,
|
||||
errors: [expect.stringContaining("Unable to validate model reference: catalog unavailable")],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([{ agents: { list: {} } }, { agents: { list: [null] } }])(
|
||||
"ignores schema-invalid agent-list draft values",
|
||||
async (config) => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: config as unknown as OpenClawConfig,
|
||||
touchedPaths: [["agents", "list"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 0, refsTotal: 0, errors: [] });
|
||||
expect(resolveModelRef).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an unresolved default fallback", async () => {
|
||||
const resolveModelRef = vi.fn(async () => "Unknown model: missing/fallback");
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-5.4-mini",
|
||||
fallbacks: ["missing/fallback"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "fallbacks", "0"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result.errors).toEqual([
|
||||
expect.stringContaining(
|
||||
'Cannot set model reference "missing/fallback" at agents.defaults.model.fallbacks.0',
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
it("collects every unresolved ref in a multi-reference update", async () => {
|
||||
const resolveModelRef = vi.fn(
|
||||
async ({ ref }: { ref: { value: string } }) => `Unknown model: ${ref.value}`,
|
||||
);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "missing/primary",
|
||||
fallbacks: ["missing/fallback"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result.refsChecked).toBe(2);
|
||||
expect(result.errors).toHaveLength(2);
|
||||
expect(resolveModelRef).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("revalidates default and per-agent fallbacks when the default provider changes", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "provider-b/main",
|
||||
fallbacks: ["backup", "provider-a/qualified-backup"],
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
{
|
||||
id: "ops",
|
||||
model: {
|
||||
primary: "provider-c/main",
|
||||
fallbacks: ["agent-backup", "provider-c/qualified-agent-backup"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config,
|
||||
previousConfig: {
|
||||
...config,
|
||||
agents: {
|
||||
...config.agents,
|
||||
defaults: {
|
||||
...config.agents?.defaults,
|
||||
model: {
|
||||
primary: "provider-a/main",
|
||||
fallbacks: ["backup", "provider-a/qualified-backup"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result.refsChecked).toBe(3);
|
||||
expect(resolveModelRef.mock.calls.map(([call]) => call.ref.path)).toEqual([
|
||||
"agents.defaults.model.primary",
|
||||
"agents.defaults.model.fallbacks.0",
|
||||
"agents.list.1.model.fallbacks.0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("revalidates a slash-shaped alias whose bare target changes provider", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "provider-b/main",
|
||||
fallbacks: ["legacy/"],
|
||||
},
|
||||
models: { "gpt-5": { alias: "legacy/" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config,
|
||||
previousConfig: {
|
||||
...config,
|
||||
agents: {
|
||||
...config.agents,
|
||||
defaults: {
|
||||
...config.agents?.defaults,
|
||||
model: {
|
||||
primary: "provider-a/main",
|
||||
fallbacks: ["legacy/"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 2, refsTotal: 2, errors: [] });
|
||||
expect(resolveModelRef.mock.calls.map(([call]) => call.ref.path)).toEqual([
|
||||
"agents.defaults.model.primary",
|
||||
"agents.defaults.model.fallbacks.0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not revalidate bare fallbacks when only the default model changes", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "provider-a/next",
|
||||
fallbacks: ["backup"],
|
||||
},
|
||||
},
|
||||
list: [{ id: "ops", model: { fallbacks: ["agent-backup"] } }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config,
|
||||
previousConfig: {
|
||||
...config,
|
||||
agents: {
|
||||
...config.agents,
|
||||
defaults: {
|
||||
...config.agents?.defaults,
|
||||
model: {
|
||||
primary: "provider-a/current",
|
||||
fallbacks: ["backup"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "model", "primary"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef.mock.calls.map(([call]) => call.ref.path)).toEqual([
|
||||
"agents.defaults.model.primary",
|
||||
]);
|
||||
});
|
||||
|
||||
it("validates touched fallback and per-agent model refs", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-5.4-mini",
|
||||
fallbacks: ["anthropic/claude-sonnet-4-6"],
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
{ id: "ops", model: { primary: "google/gemini-3.1-pro-preview" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config,
|
||||
touchedPaths: [
|
||||
["agents", "defaults", "model", "fallbacks"],
|
||||
["agents", "list", "1", "model", "primary"],
|
||||
],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 2, refsTotal: 2, errors: [] });
|
||||
expect(resolveModelRef.mock.calls.map(([call]) => call.ref)).toEqual([
|
||||
{
|
||||
path: "agents.defaults.model.fallbacks.0",
|
||||
value: "anthropic/claude-sonnet-4-6",
|
||||
fallback: true,
|
||||
},
|
||||
{
|
||||
path: "agents.list.1.model.primary",
|
||||
value: "google/gemini-3.1-pro-preview",
|
||||
agentIndex: 1,
|
||||
agentId: "ops",
|
||||
fallback: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not validate unrelated or media model keys", async () => {
|
||||
const resolveModelRef = vi.fn(async () => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.4-mini" },
|
||||
videoGenerationModel: { primary: "qwen/wan2.6-t2v" },
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "defaults", "videoGenerationModel", "primary"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 0, refsTotal: 0, errors: [] });
|
||||
expect(resolveModelRef).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not revalidate unchanged refs under an ancestor merge", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.4-mini" },
|
||||
workspace: "/tmp/next-workspace",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config,
|
||||
previousConfig: {
|
||||
agents: { defaults: { model: { primary: "openai/gpt-5.4-mini" } } },
|
||||
},
|
||||
touchedPaths: [["agents", "defaults"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 0, refsTotal: 0, errors: [] });
|
||||
expect(resolveModelRef).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revalidates per-agent refs when list ownership changes", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "beta", model: "provider-a/model" },
|
||||
{ id: "alpha", model: "provider-b/model" },
|
||||
],
|
||||
},
|
||||
},
|
||||
previousConfig: {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "alpha", model: "provider-a/model" },
|
||||
{ id: "beta", model: "provider-b/model" },
|
||||
],
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "list"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 2, refsTotal: 2, errors: [] });
|
||||
expect(resolveModelRef.mock.calls.map(([call]) => call.ref.agentId)).toEqual(["beta", "alpha"]);
|
||||
});
|
||||
|
||||
it("does not revalidate a retained agent model after an earlier entry is removed", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: { list: [{ id: "beta", model: "provider-b/model" }] },
|
||||
},
|
||||
previousConfig: {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "alpha", model: "provider-a/model" },
|
||||
{ id: "beta", model: "provider-b/model" },
|
||||
],
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "list", "0"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 0, refsTotal: 0, errors: [] });
|
||||
expect(resolveModelRef).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revalidates a per-agent model when its agent id changes directly", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: { list: [{ id: "next", model: "provider-a/model" }] },
|
||||
},
|
||||
previousConfig: {
|
||||
agents: { list: [{ id: "current", model: "provider-a/model" }] },
|
||||
},
|
||||
touchedPaths: [["agents", "list", "0", "id"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledWith({
|
||||
config: expect.any(Object),
|
||||
ref: {
|
||||
path: "agents.list.0.model",
|
||||
value: "provider-a/model",
|
||||
agentIndex: 0,
|
||||
agentId: "next",
|
||||
fallback: false,
|
||||
dependency: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("validates defaults newly inherited after removing an agent model override", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "provider-a/default",
|
||||
fallbacks: ["provider-a/backup"],
|
||||
},
|
||||
},
|
||||
list: [{ id: "ops" }],
|
||||
},
|
||||
},
|
||||
previousConfig: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "provider-a/default",
|
||||
fallbacks: ["provider-a/backup"],
|
||||
},
|
||||
},
|
||||
list: [{ id: "ops", model: "provider-b/override" }],
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "list", "0", "model"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 2, refsTotal: 2, errors: [] });
|
||||
expect(resolveModelRef.mock.calls.map(([call]) => call.ref)).toEqual([
|
||||
{
|
||||
path: "agents.defaults.model.primary",
|
||||
value: "provider-a/default",
|
||||
agentIndex: 0,
|
||||
agentId: "ops",
|
||||
fallback: false,
|
||||
dependency: true,
|
||||
},
|
||||
{
|
||||
path: "agents.defaults.model.fallbacks.0",
|
||||
value: "provider-a/backup",
|
||||
agentIndex: 0,
|
||||
agentId: "ops",
|
||||
fallback: true,
|
||||
dependency: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("validates inherited defaults when an agent is created through its id path", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "provider-a/default",
|
||||
fallbacks: ["provider-a/backup"],
|
||||
},
|
||||
},
|
||||
list: [{ id: "ops" }],
|
||||
},
|
||||
},
|
||||
previousConfig: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "provider-a/default",
|
||||
fallbacks: ["provider-a/backup"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "list", "0", "id"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 2, refsTotal: 2, errors: [] });
|
||||
expect(resolveModelRef.mock.calls.map(([call]) => call.ref.agentId)).toEqual(["ops", "ops"]);
|
||||
});
|
||||
|
||||
it("validates defaults activated by removing the last configured agent", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "provider-a/default",
|
||||
fallbacks: ["provider-a/backup"],
|
||||
},
|
||||
},
|
||||
list: [],
|
||||
},
|
||||
},
|
||||
previousConfig: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "provider-a/default",
|
||||
fallbacks: ["provider-a/backup"],
|
||||
},
|
||||
},
|
||||
list: [{ id: "ops", default: true, model: "provider-b/override" }],
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "list", "0"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 2, refsTotal: 2, errors: [] });
|
||||
expect(resolveModelRef.mock.calls.map(([call]) => call.ref.path)).toEqual([
|
||||
"agents.defaults.model.primary",
|
||||
"agents.defaults.model.fallbacks.0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not revalidate a default primary that was already inherited", async () => {
|
||||
const resolveModelRef = vi.fn(async (_params: ResolverInput) => undefined);
|
||||
|
||||
const result = await checkTouchedTextModelRefs({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: { model: { primary: "provider-a/default" } },
|
||||
list: [{ id: "ops", model: { fallbacks: ["provider-b/next"] } }],
|
||||
},
|
||||
},
|
||||
previousConfig: {
|
||||
agents: {
|
||||
defaults: { model: { primary: "provider-a/default" } },
|
||||
list: [{ id: "ops", model: { fallbacks: ["provider-b/current"] } }],
|
||||
},
|
||||
},
|
||||
touchedPaths: [["agents", "list", "0", "model", "fallbacks"]],
|
||||
resolveModelRef,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ refsChecked: 1, refsTotal: 1, errors: [] });
|
||||
expect(resolveModelRef).toHaveBeenCalledWith({
|
||||
config: expect.any(Object),
|
||||
ref: {
|
||||
path: "agents.list.0.model.fallbacks.0",
|
||||
value: "provider-b/next",
|
||||
agentIndex: 0,
|
||||
agentId: "ops",
|
||||
fallback: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,635 @@
|
||||
import {
|
||||
resolveAgentExplicitModelPrimary,
|
||||
resolveAgentModelFallbacksOverride,
|
||||
resolveDefaultAgentId,
|
||||
} from "../agents/agent-scope.js";
|
||||
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js";
|
||||
import { resolveDefaultModelForAgent } from "../agents/model-selection-config.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "../agents/model-selection-shared.js";
|
||||
import type { loadPreparedModelCatalogOwnerSnapshot } from "../agents/prepared-model-catalog.js";
|
||||
import { containsEnvVarReference, resolveConfigEnvVars } from "../config/env-substitution.js";
|
||||
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
|
||||
type TouchedModelRef = {
|
||||
path: string;
|
||||
value: string;
|
||||
agentIndex?: number;
|
||||
agentId?: string;
|
||||
fallback: boolean;
|
||||
authProfileId?: string;
|
||||
dependency?: boolean;
|
||||
};
|
||||
|
||||
type ConfigModelRefResolver = (params: {
|
||||
config: OpenClawConfig;
|
||||
ref: TouchedModelRef;
|
||||
}) => Promise<string | undefined>;
|
||||
|
||||
type ConfigModelRefCheckResult = {
|
||||
refsChecked: number;
|
||||
refsTotal: number;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
function isPathPrefix(prefix: readonly string[], path: readonly string[]): boolean {
|
||||
return prefix.length <= path.length && prefix.every((segment, index) => path[index] === segment);
|
||||
}
|
||||
|
||||
function collectTextModelConfigRefs(params: {
|
||||
model: unknown;
|
||||
path: string;
|
||||
agentIndex?: number;
|
||||
agentId?: string;
|
||||
}): TouchedModelRef[] {
|
||||
if (typeof params.model === "string") {
|
||||
const value = params.model.trim();
|
||||
return [
|
||||
{
|
||||
path: params.path,
|
||||
value,
|
||||
...(params.agentIndex === undefined ? {} : { agentIndex: params.agentIndex }),
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
fallback: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (!params.model || typeof params.model !== "object" || Array.isArray(params.model)) {
|
||||
return [];
|
||||
}
|
||||
const model = params.model as { primary?: unknown; fallbacks?: unknown };
|
||||
const refs: TouchedModelRef[] = [];
|
||||
if (typeof model.primary === "string") {
|
||||
const value = model.primary.trim();
|
||||
refs.push({
|
||||
path: `${params.path}.primary`,
|
||||
value,
|
||||
...(params.agentIndex === undefined ? {} : { agentIndex: params.agentIndex }),
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
fallback: false,
|
||||
});
|
||||
}
|
||||
if (Array.isArray(model.fallbacks)) {
|
||||
for (const [index, fallback] of model.fallbacks.entries()) {
|
||||
if (typeof fallback !== "string") {
|
||||
continue;
|
||||
}
|
||||
refs.push({
|
||||
path: `${params.path}.fallbacks.${index}`,
|
||||
value: fallback.trim(),
|
||||
...(params.agentIndex === undefined ? {} : { agentIndex: params.agentIndex }),
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
fallback: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function collectTextModelRefs(config: OpenClawConfig): TouchedModelRef[] {
|
||||
const refs = collectTextModelConfigRefs({
|
||||
model: config.agents?.defaults?.model,
|
||||
path: "agents.defaults.model",
|
||||
});
|
||||
const agentList = config.agents?.list;
|
||||
if (Array.isArray(agentList)) {
|
||||
for (const [agentIndex, agent] of agentList.entries()) {
|
||||
if (!agent || typeof agent !== "object" || Array.isArray(agent)) {
|
||||
continue;
|
||||
}
|
||||
refs.push(
|
||||
...collectTextModelConfigRefs({
|
||||
model: (agent as { model?: unknown }).model,
|
||||
path: `agents.list.${agentIndex}.model`,
|
||||
agentIndex,
|
||||
...(typeof agent.id === "string" ? { agentId: agent.id } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const ref of refs) {
|
||||
// Runtime preserves an auth-profile suffix only for configured primaries. Fallback
|
||||
// candidates carry provider/model pairs, so validation must mirror that behavior.
|
||||
if (ref.fallback) {
|
||||
continue;
|
||||
}
|
||||
const authProfileId = splitTrailingAuthProfile(ref.value).profile;
|
||||
if (authProfileId) {
|
||||
ref.authProfileId = authProfileId;
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function modelRefComparisonKey(ref: TouchedModelRef): string {
|
||||
if (ref.agentId && ref.agentIndex !== undefined) {
|
||||
const prefix = `agents.list.${ref.agentIndex}.`;
|
||||
const relativePath = ref.path.startsWith(prefix) ? ref.path.slice(prefix.length) : ref.path;
|
||||
return `agent:${normalizeAgentId(ref.agentId)}:${relativePath}`;
|
||||
}
|
||||
return `path:${ref.path}`;
|
||||
}
|
||||
|
||||
function collectTouchedTextModelRefs(params: {
|
||||
config: OpenClawConfig;
|
||||
previousConfig?: OpenClawConfig;
|
||||
touchedPaths: readonly (readonly string[])[];
|
||||
}): TouchedModelRef[] {
|
||||
const defaultPrimaryPath = ["agents", "defaults", "model", "primary"];
|
||||
const defaultPrimaryTouched = params.touchedPaths.some(
|
||||
(touchedPath) =>
|
||||
isPathPrefix(touchedPath, defaultPrimaryPath) ||
|
||||
isPathPrefix(defaultPrimaryPath, touchedPath),
|
||||
);
|
||||
const refs = collectTextModelRefs(params.config);
|
||||
const previousRefs = params.previousConfig
|
||||
? collectTextModelRefs(params.previousConfig)
|
||||
: undefined;
|
||||
const previousRefsByIdentity = previousRefs
|
||||
? new Map(previousRefs.map((ref) => [modelRefComparisonKey(ref), ref]))
|
||||
: undefined;
|
||||
const defaultPrimaryProviderChanged =
|
||||
defaultPrimaryTouched &&
|
||||
(!previousRefs ||
|
||||
resolveDefaultModelForAgent({ cfg: params.config }).provider !==
|
||||
resolveDefaultModelForAgent({ cfg: params.previousConfig ?? {} }).provider);
|
||||
const touchedRefs = refs.filter((ref) => {
|
||||
if (ref.fallback && defaultPrimaryProviderChanged) {
|
||||
const previousRef = previousRefsByIdentity?.get(modelRefComparisonKey(ref));
|
||||
const nextResolved = resolveCanonicalFallbackRef(params.config, ref.value);
|
||||
const previousResolved =
|
||||
params.previousConfig && previousRef
|
||||
? resolveCanonicalFallbackRef(params.previousConfig, previousRef.value)
|
||||
: undefined;
|
||||
if (
|
||||
!nextResolved ||
|
||||
!previousResolved ||
|
||||
nextResolved.provider !== previousResolved.provider ||
|
||||
nextResolved.model !== previousResolved.model
|
||||
) {
|
||||
ref.dependency = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const refPath = ref.path.split(".");
|
||||
const agentIdPath =
|
||||
ref.agentIndex === undefined ? undefined : ["agents", "list", String(ref.agentIndex), "id"];
|
||||
if (
|
||||
agentIdPath &&
|
||||
params.touchedPaths.some((touchedPath) => isPathPrefix(agentIdPath, touchedPath))
|
||||
) {
|
||||
ref.dependency = true;
|
||||
return true;
|
||||
}
|
||||
const touched = params.touchedPaths.some(
|
||||
(touchedPath) => isPathPrefix(touchedPath, refPath) || isPathPrefix(refPath, touchedPath),
|
||||
);
|
||||
if (!touched || !previousRefsByIdentity) {
|
||||
return touched;
|
||||
}
|
||||
const previousRef = previousRefsByIdentity.get(modelRefComparisonKey(ref));
|
||||
const ownerChanged = previousRef?.agentId !== ref.agentId;
|
||||
if (ownerChanged) {
|
||||
ref.dependency = true;
|
||||
}
|
||||
return previousRef?.value !== ref.value || ownerChanged;
|
||||
});
|
||||
const defaultRefs = refs.filter((ref) => ref.agentIndex === undefined);
|
||||
const agentList = params.config.agents?.list;
|
||||
if (defaultRefs.length === 0) {
|
||||
return touchedRefs;
|
||||
}
|
||||
if (!Array.isArray(agentList) || agentList.length === 0) {
|
||||
const listPath = ["agents", "list"];
|
||||
const listTouched = params.touchedPaths.some(
|
||||
(touchedPath) => isPathPrefix(touchedPath, listPath) || isPathPrefix(listPath, touchedPath),
|
||||
);
|
||||
const previousList = params.previousConfig?.agents?.list;
|
||||
if (!listTouched || !Array.isArray(previousList) || previousList.length === 0) {
|
||||
return touchedRefs;
|
||||
}
|
||||
const previousDefaultAgentId = resolveDefaultAgentId(params.previousConfig ?? {});
|
||||
for (const defaultRef of defaultRefs) {
|
||||
const sameOwner = normalizeAgentId(previousDefaultAgentId) === DEFAULT_AGENT_ID;
|
||||
const previouslyInherited =
|
||||
sameOwner && params.previousConfig
|
||||
? defaultRef.fallback
|
||||
? resolveAgentModelFallbacksOverride(params.previousConfig, previousDefaultAgentId) ===
|
||||
undefined
|
||||
: resolveAgentExplicitModelPrimary(params.previousConfig, previousDefaultAgentId) ===
|
||||
undefined
|
||||
: false;
|
||||
const alreadySelected = touchedRefs.some(
|
||||
(ref) => ref.agentIndex === undefined && ref.path === defaultRef.path,
|
||||
);
|
||||
if (!previouslyInherited && !alreadySelected) {
|
||||
touchedRefs.push({ ...defaultRef, dependency: true });
|
||||
}
|
||||
}
|
||||
return touchedRefs;
|
||||
}
|
||||
for (const [agentIndex, agent] of agentList.entries()) {
|
||||
const agentId = typeof agent?.id === "string" ? agent.id : "";
|
||||
if (!agentId) {
|
||||
continue;
|
||||
}
|
||||
const agentEntryPath = ["agents", "list", String(agentIndex)];
|
||||
const agentIdPath = [...agentEntryPath, "id"];
|
||||
const agentModelPath = [...agentEntryPath, "model"];
|
||||
const ownershipTouched = params.touchedPaths.some(
|
||||
(touchedPath) =>
|
||||
isPathPrefix(touchedPath, agentEntryPath) ||
|
||||
isPathPrefix(touchedPath, agentIdPath) ||
|
||||
isPathPrefix(agentIdPath, touchedPath) ||
|
||||
isPathPrefix(touchedPath, agentModelPath) ||
|
||||
isPathPrefix(agentModelPath, touchedPath),
|
||||
);
|
||||
if (!ownershipTouched) {
|
||||
continue;
|
||||
}
|
||||
for (const defaultRef of defaultRefs) {
|
||||
const inherits = defaultRef.fallback
|
||||
? resolveAgentModelFallbacksOverride(params.config, agentId) === undefined
|
||||
: resolveAgentExplicitModelPrimary(params.config, agentId) === undefined;
|
||||
const previousAgentExists = Boolean(
|
||||
params.previousConfig?.agents?.list?.some(
|
||||
(entry) => normalizeAgentId(entry?.id) === normalizeAgentId(agentId),
|
||||
),
|
||||
);
|
||||
const previouslyInherited =
|
||||
previousAgentExists && params.previousConfig
|
||||
? defaultRef.fallback
|
||||
? resolveAgentModelFallbacksOverride(params.previousConfig, agentId) === undefined
|
||||
: resolveAgentExplicitModelPrimary(params.previousConfig, agentId) === undefined
|
||||
: false;
|
||||
if (inherits && !previouslyInherited) {
|
||||
touchedRefs.push({ ...defaultRef, agentIndex, agentId, dependency: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
return touchedRefs;
|
||||
}
|
||||
|
||||
function resolveCanonicalPrimaryRef(
|
||||
config: OpenClawConfig,
|
||||
value: string,
|
||||
): { provider: string; model: string } | undefined {
|
||||
const validationConfig: OpenClawConfig = {
|
||||
...config,
|
||||
agents: {
|
||||
...config.agents,
|
||||
defaults: {
|
||||
...config.agents?.defaults,
|
||||
model: value,
|
||||
},
|
||||
},
|
||||
};
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg: validationConfig,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: "",
|
||||
allowPluginNormalization: true,
|
||||
});
|
||||
return resolved.model ? resolved : undefined;
|
||||
}
|
||||
|
||||
function resolveFallbackRef(config: OpenClawConfig, value: string) {
|
||||
const defaultProvider = resolveDefaultModelForAgent({ cfg: config }).provider;
|
||||
return resolveModelRefFromString({
|
||||
cfg: config,
|
||||
raw: value,
|
||||
defaultProvider,
|
||||
aliasIndex: buildModelAliasIndex({
|
||||
cfg: config,
|
||||
defaultProvider,
|
||||
allowPluginNormalization: true,
|
||||
}),
|
||||
allowPluginNormalization: true,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCanonicalFallbackRef(
|
||||
config: OpenClawConfig,
|
||||
value: string,
|
||||
): { provider: string; model: string } | undefined {
|
||||
return resolveFallbackRef(config, value)?.ref;
|
||||
}
|
||||
|
||||
function hasUnresolvedInheritedFallbackProvider(
|
||||
config: OpenClawConfig,
|
||||
ref: TouchedModelRef,
|
||||
): boolean {
|
||||
if (!ref.fallback || ref.value.includes("/")) {
|
||||
return false;
|
||||
}
|
||||
const primary = resolveAgentModelPrimaryValue(config.agents?.defaults?.model);
|
||||
if (!primary) {
|
||||
return false;
|
||||
}
|
||||
const primaryModel = splitTrailingAuthProfile(primary).model;
|
||||
const slash = primaryModel.indexOf("/");
|
||||
const provider = slash > 0 ? primaryModel.slice(0, slash) : primaryModel;
|
||||
const fallback = resolveFallbackRef(config, ref.value);
|
||||
return Boolean(fallback && !fallback.alias && containsEnvVarReference(provider));
|
||||
}
|
||||
|
||||
function expandInheritedDefaultRefs(
|
||||
config: OpenClawConfig,
|
||||
refs: TouchedModelRef[],
|
||||
): TouchedModelRef[] {
|
||||
const agentList = config.agents?.list;
|
||||
if (!Array.isArray(agentList)) {
|
||||
return refs;
|
||||
}
|
||||
const defaultAgentId = resolveDefaultAgentId(config);
|
||||
const expanded: TouchedModelRef[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (ref: TouchedModelRef) => {
|
||||
const key = `${ref.path}\u0000${ref.agentId ?? ""}\u0000${ref.agentIndex ?? ""}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
expanded.push(ref);
|
||||
}
|
||||
};
|
||||
for (const ref of refs) {
|
||||
if (ref.agentIndex !== undefined) {
|
||||
push(ref);
|
||||
continue;
|
||||
}
|
||||
const defaultAgentConfigured = agentList.some(
|
||||
(agent) => normalizeAgentId(agent?.id) === normalizeAgentId(defaultAgentId),
|
||||
);
|
||||
const defaultAgentInherits =
|
||||
!defaultAgentConfigured ||
|
||||
(ref.fallback
|
||||
? resolveAgentModelFallbacksOverride(config, defaultAgentId) === undefined
|
||||
: resolveAgentExplicitModelPrimary(config, defaultAgentId) === undefined);
|
||||
if (defaultAgentInherits) {
|
||||
push(ref);
|
||||
}
|
||||
for (const [agentIndex, agent] of agentList.entries()) {
|
||||
const agentId = typeof agent?.id === "string" ? agent.id : "";
|
||||
if (!agentId || normalizeAgentId(agentId) === normalizeAgentId(defaultAgentId)) {
|
||||
continue;
|
||||
}
|
||||
const inherits = ref.fallback
|
||||
? resolveAgentModelFallbacksOverride(config, agentId) === undefined
|
||||
: resolveAgentExplicitModelPrimary(config, agentId) === undefined;
|
||||
if (inherits) {
|
||||
push({ ...ref, agentIndex, agentId });
|
||||
}
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
function validateModelRefSyntax(config: OpenClawConfig, ref: TouchedModelRef): string | undefined {
|
||||
if (!ref.value) {
|
||||
return "Model reference is empty";
|
||||
}
|
||||
if (containsEnvVarReference(ref.value)) {
|
||||
return "Model reference contains an unresolved environment variable";
|
||||
}
|
||||
const resolved = ref.fallback
|
||||
? resolveCanonicalFallbackRef(config, ref.value)
|
||||
: resolveCanonicalPrimaryRef(config, ref.value);
|
||||
return resolved ? undefined : "Invalid model reference or configured model alias target";
|
||||
}
|
||||
|
||||
async function createRuntimeModelRefResolver(): Promise<ConfigModelRefResolver> {
|
||||
const [agentScope, modelSelection] = await Promise.all([
|
||||
import("../agents/agent-scope.js"),
|
||||
import("../agents/model-selection.js"),
|
||||
]);
|
||||
const preparedByAgent = new Map<
|
||||
string,
|
||||
Awaited<ReturnType<typeof loadPreparedModelCatalogOwnerSnapshot>>
|
||||
>();
|
||||
let modelModules:
|
||||
| Promise<
|
||||
[
|
||||
typeof import("../agents/embedded-agent-runner/model.js"),
|
||||
typeof import("../agents/prepared-model-catalog.js"),
|
||||
]
|
||||
>
|
||||
| undefined;
|
||||
const loadModelModules = () =>
|
||||
(modelModules ??= Promise.all([
|
||||
import("../agents/embedded-agent-runner/model.js"),
|
||||
import("../agents/prepared-model-catalog.js"),
|
||||
]));
|
||||
|
||||
return async ({ config, ref }) => {
|
||||
const configuredAgent =
|
||||
ref.agentIndex === undefined ? undefined : config.agents?.list?.[ref.agentIndex];
|
||||
const targetAgentId =
|
||||
typeof configuredAgent?.id === "string"
|
||||
? configuredAgent.id
|
||||
: agentScope.resolveDefaultAgentId(config);
|
||||
const agentDir = agentScope.resolveAgentDir(config, targetAgentId);
|
||||
const workspaceDir = agentScope.resolveAgentWorkspaceDir(config, targetAgentId);
|
||||
const resolvedRef = ref.fallback
|
||||
? resolveCanonicalFallbackRef(config, ref.value)
|
||||
: resolveCanonicalPrimaryRef(config, ref.value);
|
||||
if (!resolvedRef) {
|
||||
return `Unknown model: ${ref.value}`;
|
||||
}
|
||||
// CLI backends own model validation; their model ids do not need embedded catalog rows.
|
||||
if (modelSelection.isCliProvider(resolvedRef.provider, config)) {
|
||||
return undefined;
|
||||
}
|
||||
const [modelRuntime, preparedCatalog] = await loadModelModules();
|
||||
|
||||
let prepared = preparedByAgent.get(targetAgentId);
|
||||
if (!prepared) {
|
||||
prepared = await preparedCatalog.loadPreparedModelCatalogOwnerSnapshot({
|
||||
agentId: targetAgentId,
|
||||
agentDir,
|
||||
config,
|
||||
readOnly: true,
|
||||
workspaceDir,
|
||||
});
|
||||
preparedByAgent.set(targetAgentId, prepared);
|
||||
}
|
||||
const stores = prepared.createStores();
|
||||
const resolution = await modelRuntime.resolveModelAsync(
|
||||
resolvedRef.provider,
|
||||
resolvedRef.model,
|
||||
agentDir,
|
||||
config,
|
||||
{
|
||||
agentId: targetAgentId,
|
||||
allowBundledStaticCatalogFallback: true,
|
||||
authStorage: stores.authStorage,
|
||||
...(ref.authProfileId ? { authProfileId: ref.authProfileId } : {}),
|
||||
modelRegistry: stores.modelRegistry,
|
||||
workspaceDir,
|
||||
},
|
||||
);
|
||||
return resolution.model
|
||||
? undefined
|
||||
: (resolution.error ?? `Unknown model: ${resolvedRef.provider}/${resolvedRef.model}`);
|
||||
};
|
||||
}
|
||||
|
||||
function formatModelRefError(
|
||||
ref: TouchedModelRef,
|
||||
error: string,
|
||||
authoredValue = ref.value,
|
||||
options?: { suppressDetail?: boolean },
|
||||
): string {
|
||||
const safeError =
|
||||
options?.suppressDetail || authoredValue !== ref.value
|
||||
? "Unable to resolve authored model reference"
|
||||
: error;
|
||||
const detail = safeError.endsWith(".") ? safeError : `${safeError}.`;
|
||||
return `Cannot set model reference "${authoredValue}" at ${ref.path}: ${detail} Run ${formatCliCommand("openclaw models list")} to list available models.`;
|
||||
}
|
||||
|
||||
export async function checkTouchedTextModelRefs(params: {
|
||||
config: OpenClawConfig;
|
||||
previousConfig?: OpenClawConfig;
|
||||
touchedPaths: readonly (readonly string[])[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
resolveModelRef?: ConfigModelRefResolver;
|
||||
createModelRefResolver?: () => Promise<ConfigModelRefResolver>;
|
||||
redactDependencyValues?: boolean;
|
||||
}): Promise<ConfigModelRefCheckResult> {
|
||||
const authoredRefs = collectTouchedTextModelRefs(params);
|
||||
const authoredValuesByPath = new Map(
|
||||
collectTextModelRefs(params.config).map((ref) => [ref.path, ref.value]),
|
||||
);
|
||||
const previousAuthoredValuesByPath = new Map(
|
||||
collectTextModelRefs(params.previousConfig ?? {}).map((ref) => [ref.path, ref.value]),
|
||||
);
|
||||
let validationConfig: OpenClawConfig;
|
||||
let validationPreviousConfig: OpenClawConfig | undefined;
|
||||
try {
|
||||
const env = params.env ?? process.env;
|
||||
validationConfig = resolveConfigEnvVars(params.config, env, {
|
||||
onMissing: () => {},
|
||||
}) as OpenClawConfig;
|
||||
validationPreviousConfig = params.previousConfig
|
||||
? (resolveConfigEnvVars(params.previousConfig, env, {
|
||||
onMissing: () => {},
|
||||
}) as OpenClawConfig)
|
||||
: undefined;
|
||||
} catch (cause) {
|
||||
const detail = cause instanceof Error ? cause.message : String(cause);
|
||||
return {
|
||||
refsChecked: 0,
|
||||
refsTotal: authoredRefs.length,
|
||||
errors: [`Unable to validate changed model references before writing: ${detail}`],
|
||||
};
|
||||
}
|
||||
const validationValuesByPath = new Map(
|
||||
collectTextModelRefs(validationConfig).map((ref) => [ref.path, ref.value]),
|
||||
);
|
||||
const modelEnvWasExpanded = [...authoredValuesByPath].some(
|
||||
([path, value]) => validationValuesByPath.get(path) !== value,
|
||||
);
|
||||
const formatError = (ref: TouchedModelRef, error: string) => {
|
||||
const redactDependency = Boolean(params.redactDependencyValues && ref.dependency);
|
||||
return formatModelRefError(
|
||||
ref,
|
||||
error,
|
||||
redactDependency ? "<configured model reference>" : authoredValuesByPath.get(ref.path),
|
||||
{ suppressDetail: modelEnvWasExpanded || redactDependency },
|
||||
);
|
||||
};
|
||||
const validationRefsByPath = new Map(
|
||||
collectTextModelRefs(validationConfig).map((ref) => [ref.path, ref]),
|
||||
);
|
||||
const refsByKey = new Map(
|
||||
collectTouchedTextModelRefs({
|
||||
config: validationConfig,
|
||||
previousConfig: validationPreviousConfig,
|
||||
touchedPaths: params.touchedPaths,
|
||||
}).map((ref) => [modelRefComparisonKey(ref), ref]),
|
||||
);
|
||||
for (const authoredRef of authoredRefs) {
|
||||
if (
|
||||
authoredRef.dependency &&
|
||||
previousAuthoredValuesByPath.get(authoredRef.path) === authoredRef.value
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const validationRef = validationRefsByPath.get(authoredRef.path);
|
||||
if (!validationRef) {
|
||||
continue;
|
||||
}
|
||||
const key = modelRefComparisonKey(validationRef);
|
||||
const expandedRef = refsByKey.get(key);
|
||||
refsByKey.set(key, {
|
||||
...validationRef,
|
||||
...(authoredRef.dependency || expandedRef?.dependency ? { dependency: true } : {}),
|
||||
});
|
||||
}
|
||||
const refs = expandInheritedDefaultRefs(validationConfig, [...refsByKey.values()]);
|
||||
if (refs.length === 0) {
|
||||
return { refsChecked: 0, refsTotal: 0, errors: [] };
|
||||
}
|
||||
// A bare fallback cannot be accepted while its inherited provider is env-unresolved;
|
||||
// leave it unchecked until runtime can determine that provider.
|
||||
const refsToValidate = refs.filter(
|
||||
(ref) => !hasUnresolvedInheritedFallbackProvider(validationConfig, ref),
|
||||
);
|
||||
const validatedRefs = refsToValidate.map((ref) => ({
|
||||
ref,
|
||||
error: validateModelRefSyntax(validationConfig, ref),
|
||||
}));
|
||||
const syntaxFailures = validatedRefs.filter(
|
||||
(entry): entry is { ref: TouchedModelRef; error: string } => Boolean(entry.error),
|
||||
);
|
||||
const refsToResolve = validatedRefs.filter((entry) => !entry.error).map((entry) => entry.ref);
|
||||
const errors = syntaxFailures.map(({ ref, error }) => formatError(ref, error));
|
||||
if (refsToResolve.length === 0) {
|
||||
return { refsChecked: syntaxFailures.length, refsTotal: refs.length, errors };
|
||||
}
|
||||
let resolveModelRef = params.resolveModelRef;
|
||||
if (!resolveModelRef) {
|
||||
try {
|
||||
resolveModelRef = await (params.createModelRefResolver ?? createRuntimeModelRefResolver)();
|
||||
} catch (cause) {
|
||||
const detail =
|
||||
modelEnvWasExpanded ||
|
||||
Boolean(params.redactDependencyValues && refs.some((ref) => ref.dependency))
|
||||
? "model resolver setup failed"
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: String(cause);
|
||||
return {
|
||||
refsChecked: syntaxFailures.length,
|
||||
refsTotal: refs.length,
|
||||
errors: [
|
||||
...errors,
|
||||
`Unable to validate changed model references before writing: ${detail}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
let refsChecked = syntaxFailures.length;
|
||||
for (const ref of refsToResolve) {
|
||||
let error: string | undefined;
|
||||
try {
|
||||
error = await resolveModelRef({ config: validationConfig, ref });
|
||||
refsChecked += 1;
|
||||
} catch (cause) {
|
||||
const detail = cause instanceof Error ? cause.message : String(cause);
|
||||
errors.push(formatError(ref, `Unable to validate model reference: ${detail}`));
|
||||
continue;
|
||||
}
|
||||
if (!error) {
|
||||
continue;
|
||||
}
|
||||
errors.push(formatError(ref, error));
|
||||
}
|
||||
return { refsChecked, refsTotal: refs.length, errors };
|
||||
}
|
||||
@@ -4,7 +4,7 @@ export type ConfigSetDryRunInputMode = "value" | "json" | "builder" | "unset";
|
||||
|
||||
/** One validation error found during config-set dry-run processing. */
|
||||
export type ConfigSetDryRunError = {
|
||||
kind: "missing-path" | "schema" | "resolvability";
|
||||
kind: "missing-path" | "schema" | "resolvability" | "model";
|
||||
message: string;
|
||||
ref?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user