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:
Peter Steinberger
2026-07-20 03:31:03 -07:00
committed by GitHub
parent fab745b82d
commit 8bec61bab1
7 changed files with 2212 additions and 8 deletions
+4 -1
View File
@@ -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.
+215
View File
@@ -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
View File
@@ -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) {
+367
View File
@@ -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,
},
});
});
});
+948
View File
@@ -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,
},
});
});
});
+635
View File
@@ -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 };
}
+1 -1
View File
@@ -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;
};