fix(doctor): preserve active auth profile metadata

This commit is contained in:
Peter Steinberger
2026-05-04 23:15:10 +01:00
parent a07d8cbf8a
commit be6543caf8
9 changed files with 653 additions and 162 deletions

View File

@@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai
- Agents/OpenAI: default direct OpenAI Responses models to the SSE transport instead of WebSocket auto-selection, preventing pi runtime chat turns from hanging on servers where the WebSocket path stalls while the OpenAI HTTP stream works. Thanks @vincentkoc.
- Docker: prune package-excluded plugin dist directories from runtime images unless the build explicitly opts that plugin in, so official external plugins such as Feishu stay install-on-demand instead of shipping partial metadata without compiled runtime output. Fixes #77424. Thanks @vincentkoc.
- Model switching: include the exact additive allowlist repair command when `/model ... --runtime ...` targets a blocked model, and make Telegram's model picker say that it changes only the session model while leaving the runtime unchanged. Thanks @vincentkoc.
- Doctor/config: keep active `auth.profiles` metadata intact when `doctor --fix` strips stale secret fields from configs, repairing legacy `<provider>:default` API-key profile metadata when model fallbacks or explicit `model@profile` refs still depend on it. Fixes #77400.
- CLI/update: disable and skip plugins that fail package-update plugin sync, so a broken npm/ClawHub/git/marketplace plugin cannot turn a successful OpenClaw package update into a failed update result. Thanks @vincentkoc.
- CLI/update: use an absolute POSIX npm script shell during package-manager updates, so restricted PATH environments can still run dependency lifecycle scripts while updating from `--tag main`. Fixes #77530. Thanks @PeterTremonti.
- Diagnostics: grant the internal diagnostics event bus to official installed diagnostics exporter plugins, so npm-installed `@openclaw/diagnostics-prometheus` can emit metrics without broadening the capability to arbitrary global plugins. Fixes #76628. Thanks @RayWoo.

View File

@@ -0,0 +1,210 @@
import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js";
import { collectConfiguredModelRefs } from "../config/model-refs.js";
import type { AuthProfileConfig } from "../config/types.auth.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { isRecord } from "../utils.js";
const AUTH_PROFILE_MODES = new Set<AuthProfileConfig["mode"]>(["api_key", "oauth", "token"]);
export type AuthProfileConfigProtectionResult = {
config: OpenClawConfig;
repairs: string[];
warnings: string[];
};
function normalizeProviderId(value: unknown): string {
return normalizeLowercaseStringOrEmpty(value);
}
function normalizeProfileId(value: unknown): string | null {
return normalizeOptionalString(value) ?? null;
}
function normalizeMode(value: unknown): AuthProfileConfig["mode"] | null {
return typeof value === "string" && AUTH_PROFILE_MODES.has(value as AuthProfileConfig["mode"])
? (value as AuthProfileConfig["mode"])
: null;
}
function extractProviderFromModelRef(value: string): string | null {
const { model } = splitTrailingAuthProfile(value);
const slash = model.indexOf("/");
if (slash <= 0) {
return null;
}
return normalizeProviderId(model.slice(0, slash)) || null;
}
function extractProviderFromProfileId(profileId: string): string | null {
const colon = profileId.indexOf(":");
if (colon <= 0) {
return null;
}
return normalizeProviderId(profileId.slice(0, colon)) || null;
}
function collectActiveAuthHints(config: OpenClawConfig): {
activeProviders: Set<string>;
explicitProfileIds: Set<string>;
explicitProfileProviders: Map<string, Set<string>>;
} {
const activeProviders = new Set<string>();
const explicitProfileIds = new Set<string>();
const explicitProfileProviders = new Map<string, Set<string>>();
const models = isRecord(config.models) ? config.models : {};
const providers = isRecord(models.providers) ? models.providers : {};
for (const providerId of Object.keys(providers)) {
const normalized = normalizeProviderId(providerId);
if (normalized) {
activeProviders.add(normalized);
}
}
for (const { value } of collectConfiguredModelRefs(config)) {
const { profile } = splitTrailingAuthProfile(value);
const provider = extractProviderFromModelRef(value);
if (profile) {
explicitProfileIds.add(profile);
if (provider) {
const providers = explicitProfileProviders.get(profile) ?? new Set<string>();
providers.add(provider);
explicitProfileProviders.set(profile, providers);
}
}
if (provider) {
activeProviders.add(provider);
}
}
const auth = isRecord(config.auth) ? config.auth : {};
const order = isRecord(auth.order) ? auth.order : {};
for (const [providerId, profileIds] of Object.entries(order)) {
const provider = normalizeProviderId(providerId);
if (!provider || !activeProviders.has(provider) || !Array.isArray(profileIds)) {
continue;
}
for (const profileId of profileIds) {
const normalized = normalizeProfileId(profileId);
if (normalized) {
explicitProfileIds.add(normalized);
}
}
}
return { activeProviders, explicitProfileIds, explicitProfileProviders };
}
function isValidProfileMetadata(value: unknown): value is AuthProfileConfig {
if (!isRecord(value)) {
return false;
}
return normalizeProviderId(value.provider) !== "" && normalizeMode(value.mode) !== null;
}
function buildProfileMetadata(params: {
profileId: string;
before: unknown;
after: unknown;
providerHint?: string;
}): AuthProfileConfig | null {
const before = isRecord(params.before) ? params.before : {};
const after = isRecord(params.after) ? params.after : {};
const provider =
normalizeProviderId(after.provider) ||
normalizeProviderId(before.provider) ||
extractProviderFromProfileId(params.profileId) ||
normalizeProviderId(params.providerHint);
if (!provider) {
return null;
}
const mode = normalizeMode(after.mode) ?? normalizeMode(before.mode) ?? "api_key";
const repaired: AuthProfileConfig = { provider, mode };
const email = normalizeOptionalString(after.email) ?? normalizeOptionalString(before.email);
const displayName =
normalizeOptionalString(after.displayName) ?? normalizeOptionalString(before.displayName);
if (email) {
repaired.email = email;
}
if (displayName) {
repaired.displayName = displayName;
}
return repaired;
}
function ensureAuthProfiles(config: OpenClawConfig): Record<string, AuthProfileConfig> {
const root = config as Record<string, unknown>;
const auth: Record<string, unknown> = isRecord(root.auth) ? root.auth : {};
if (root.auth !== auth) {
root.auth = auth;
}
if (!isRecord(auth.profiles)) {
auth.profiles = {};
}
return auth.profiles as Record<string, AuthProfileConfig>;
}
export function protectActiveAuthProfileConfig(params: {
before: OpenClawConfig;
after: OpenClawConfig;
}): AuthProfileConfigProtectionResult {
const { activeProviders, explicitProfileIds, explicitProfileProviders } = collectActiveAuthHints(
params.before,
);
const beforeAuth = isRecord(params.before.auth) ? params.before.auth : {};
const beforeProfiles = isRecord(beforeAuth.profiles) ? beforeAuth.profiles : {};
if (Object.keys(beforeProfiles).length === 0) {
return { config: params.after, repairs: [], warnings: [] };
}
const config = structuredClone(params.after);
const afterAuth = isRecord(config.auth) ? config.auth : {};
const afterProfiles = isRecord(afterAuth.profiles) ? afterAuth.profiles : {};
const repairs: string[] = [];
const warnings: string[] = [];
for (const [profileId, beforeProfile] of Object.entries(beforeProfiles)) {
const afterProfile = afterProfiles[profileId];
const afterProfileRecord = isRecord(afterProfile) ? afterProfile : null;
const beforeProfileRecord = isRecord(beforeProfile) ? beforeProfile : null;
if (isValidProfileMetadata(afterProfile)) {
continue;
}
const provider =
normalizeProviderId(afterProfileRecord?.provider) ||
normalizeProviderId(beforeProfileRecord?.provider) ||
extractProviderFromProfileId(profileId);
const protectsActiveProvider = !!provider && activeProviders.has(provider);
const protectsExplicitProfile = explicitProfileIds.has(profileId);
if (!protectsActiveProvider && !protectsExplicitProfile) {
continue;
}
const repaired = buildProfileMetadata({
profileId,
before: beforeProfile,
after: afterProfile,
providerHint:
explicitProfileProviders.get(profileId)?.size === 1
? [...(explicitProfileProviders.get(profileId) ?? [])][0]
: undefined,
});
if (!repaired) {
warnings.push(
`auth.profiles.${profileId}: active auth profile metadata could not be inferred; repair manually before running doctor --fix.`,
);
continue;
}
const profiles = ensureAuthProfiles(config);
profiles[profileId] = repaired;
repairs.push(
`Repaired auth.profiles.${profileId} metadata for active ${repaired.provider} auth.`,
);
}
return { config, repairs, warnings };
}

View File

@@ -257,10 +257,16 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
doctorFixCommand,
});
({ cfg, candidate, pendingChanges, fixHints } = unknownStep.state);
if (unknownStep.removed.length > 0) {
const lines = unknownStep.removed.map((path) => `- ${path}`).join("\n");
if (unknownStep.removed.length > 0 || unknownStep.repairs.length > 0) {
const lines = [
...unknownStep.removed.map((path) => `- ${path}`),
...unknownStep.repairs.map((change) => `- ${change}`),
].join("\n");
note(lines, shouldRepair ? "Doctor changes" : "Unknown config keys");
}
if (unknownStep.warnings.length > 0) {
note(unknownStep.warnings.join("\n"), "Doctor warnings");
}
const finalized = await finalizeDoctorConfigFlow({
cfg,

View File

@@ -160,4 +160,332 @@ describe("doctor config flow steps", () => {
expect(result.state.candidate).toEqual({});
expect(result.state.fixHints).toContain('Run "openclaw doctor --fix" to remove these keys.');
});
it("repairs active malformed auth profile metadata after unknown-key cleanup", () => {
stripUnknownConfigKeysMock.mockReturnValueOnce({
config: {
auth: {
profiles: {
"openai:default": {},
},
},
models: {
providers: {
openai: { apiKey: "${OPENAI_API_KEY}" },
},
},
agents: {
defaults: {
model: {
primary: "anthropic/claude-opus-4-6",
fallbacks: ["openai/gpt-5.5"],
},
},
},
},
removed: ["auth.profiles.openai:default.key"],
});
const result = applyUnknownConfigKeyStep({
state: {
cfg: {},
candidate: {
auth: {
profiles: {
"openai:default": { key: "sk-test" },
},
},
models: {
providers: {
openai: { apiKey: "${OPENAI_API_KEY}" },
},
},
agents: {
defaults: {
model: {
primary: "anthropic/claude-opus-4-6",
fallbacks: ["openai/gpt-5.5"],
},
},
},
} as unknown as OpenClawConfig,
pendingChanges: false,
fixHints: [],
},
shouldRepair: true,
doctorFixCommand: "openclaw doctor --fix",
});
expect(result.repairs).toEqual([
"Repaired auth.profiles.openai:default metadata for active openai auth.",
]);
expect(result.state.cfg.auth?.profiles?.["openai:default"]).toEqual({
provider: "openai",
mode: "api_key",
});
});
it("keeps valid active auth profile metadata while stripping stale secret fields", () => {
stripUnknownConfigKeysMock.mockReturnValueOnce({
config: {
auth: {
profiles: {
"openai:default": { provider: "openai", mode: "api_key" },
},
},
models: {
providers: {
openai: { apiKey: "${OPENAI_API_KEY}" },
},
},
agents: {
defaults: {
model: {
fallbacks: ["openai/gpt-5.5"],
},
},
},
},
removed: ["auth.profiles.openai:default.key"],
});
const result = applyUnknownConfigKeyStep({
state: {
cfg: {},
candidate: {
auth: {
profiles: {
"openai:default": {
provider: "openai",
mode: "api_key",
key: "sk-test",
},
},
},
models: {
providers: {
openai: { apiKey: "${OPENAI_API_KEY}" },
},
},
agents: {
defaults: {
model: {
fallbacks: ["openai/gpt-5.5"],
},
},
},
} as unknown as OpenClawConfig,
pendingChanges: false,
fixHints: [],
},
shouldRepair: true,
doctorFixCommand: "openclaw doctor --fix",
});
expect(result.repairs).toEqual([]);
expect(result.state.cfg.auth?.profiles?.["openai:default"]).toEqual({
provider: "openai",
mode: "api_key",
});
});
it("repairs non-default auth profiles for active providers", () => {
stripUnknownConfigKeysMock.mockReturnValueOnce({
config: {
auth: {
profiles: {
"openai:work": {},
},
},
agents: {
defaults: {
model: {
fallbacks: ["openai/gpt-5.5"],
},
},
},
},
removed: ["auth.profiles.openai:work.key"],
});
const result = applyUnknownConfigKeyStep({
state: {
cfg: {},
candidate: {
auth: {
profiles: {
"openai:work": { key: "sk-test" },
},
},
agents: {
defaults: {
model: {
fallbacks: ["openai/gpt-5.5"],
},
},
},
} as unknown as OpenClawConfig,
pendingChanges: false,
fixHints: [],
},
shouldRepair: true,
doctorFixCommand: "openclaw doctor --fix",
});
expect(result.repairs).toEqual([
"Repaired auth.profiles.openai:work metadata for active openai auth.",
]);
expect(result.state.cfg.auth?.profiles?.["openai:work"]).toEqual({
provider: "openai",
mode: "api_key",
});
});
it("preserves explicit model auth profile refs during unknown-key cleanup", () => {
stripUnknownConfigKeysMock.mockReturnValueOnce({
config: {
auth: {
profiles: {
"openai:default": {},
},
},
agents: {
defaults: {
model: {
primary: "openai/gpt-5.5@openai:default",
},
},
},
},
removed: ["auth.profiles.openai:default.key"],
});
const result = applyUnknownConfigKeyStep({
state: {
cfg: {},
candidate: {
auth: {
profiles: {
"openai:default": { key: "sk-test" },
},
},
agents: {
defaults: {
model: {
primary: "openai/gpt-5.5@openai:default",
},
},
},
} as unknown as OpenClawConfig,
pendingChanges: false,
fixHints: [],
},
shouldRepair: true,
doctorFixCommand: "openclaw doctor --fix",
});
expect(result.state.cfg.auth?.profiles?.["openai:default"]).toEqual({
provider: "openai",
mode: "api_key",
});
});
it("infers providers for bare auth profile suffixes", () => {
stripUnknownConfigKeysMock.mockReturnValueOnce({
config: {
auth: {
profiles: {
work: {},
},
},
agents: {
defaults: {
model: {
primary: "openai/gpt-5.5@work",
},
},
},
},
removed: ["auth.profiles.work.key"],
});
const result = applyUnknownConfigKeyStep({
state: {
cfg: {},
candidate: {
auth: {
profiles: {
work: { key: "sk-test" },
},
},
agents: {
defaults: {
model: {
primary: "openai/gpt-5.5@work",
},
},
},
} as unknown as OpenClawConfig,
pendingChanges: false,
fixHints: [],
},
shouldRepair: true,
doctorFixCommand: "openclaw doctor --fix",
});
expect(result.warnings).toEqual([]);
expect(result.state.cfg.auth?.profiles?.work).toEqual({
provider: "openai",
mode: "api_key",
});
});
it("protects auth profiles referenced only by channel model overrides", () => {
stripUnknownConfigKeysMock.mockReturnValueOnce({
config: {
auth: {
profiles: {
"openai:default": {},
},
},
channels: {
modelByChannel: {
slack: {
C123: "openai/gpt-5.5@openai:default",
},
},
},
},
removed: ["auth.profiles.openai:default.key"],
});
const result = applyUnknownConfigKeyStep({
state: {
cfg: {},
candidate: {
auth: {
profiles: {
"openai:default": { key: "sk-test" },
},
},
channels: {
modelByChannel: {
slack: {
C123: "openai/gpt-5.5@openai:default",
},
},
},
} as unknown as OpenClawConfig,
pendingChanges: false,
fixHints: [],
},
shouldRepair: true,
doctorFixCommand: "openclaw doctor --fix",
});
expect(result.state.cfg.auth?.profiles?.["openai:default"]).toEqual({
provider: "openai",
mode: "api_key",
});
});
});

View File

@@ -1,4 +1,5 @@
import { formatConfigIssueLines } from "../../../config/issue-format.js";
import { protectActiveAuthProfileConfig } from "../../doctor-auth-profile-config.js";
import { stripUnknownConfigKeys } from "../../doctor-config-analysis.js";
import type { DoctorConfigPreflightResult } from "../../doctor-config-preflight.js";
import type { DoctorConfigMutationState } from "./config-mutation-state.js";
@@ -75,21 +76,29 @@ export function applyUnknownConfigKeyStep(params: {
}): {
state: DoctorConfigMutationState;
removed: string[];
repairs: string[];
warnings: string[];
} {
const unknown = stripUnknownConfigKeys(params.state.candidate);
if (unknown.removed.length === 0) {
return { state: params.state, removed: [] };
return { state: params.state, removed: [], repairs: [], warnings: [] };
}
const protectedAuth = protectActiveAuthProfileConfig({
before: params.state.candidate,
after: unknown.config,
});
return {
state: {
cfg: params.shouldRepair ? unknown.config : params.state.cfg,
candidate: unknown.config,
cfg: params.shouldRepair ? protectedAuth.config : params.state.cfg,
candidate: protectedAuth.config,
pendingChanges: true,
fixHints: params.shouldRepair
? params.state.fixHints
: [...params.state.fixHints, `Run "${params.doctorFixCommand}" to remove these keys.`],
},
removed: unknown.removed,
repairs: protectedAuth.repairs,
warnings: protectedAuth.warnings,
};
}

View File

@@ -2,6 +2,7 @@ import { collectConfiguredAgentHarnessRuntimes } from "../../../agents/harness-r
import { listPotentialConfiguredChannelPresenceSignals } from "../../../channels/config-presence.js";
import { normalizeChatChannelId } from "../../../channels/registry.js";
import { isChannelConfigured } from "../../../config/channel-configured.js";
import { collectConfiguredModelRefs } from "../../../config/model-refs.js";
import { detectPluginAutoEnableCandidates } from "../../../config/plugin-auto-enable.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { compareOpenClawVersions } from "../../../config/version.js";
@@ -151,49 +152,13 @@ function collectConfiguredProviderIds(cfg: OpenClawConfig): Set<string> {
for (const providerId of Object.keys(asObjectRecord(cfg.models?.providers) ?? {})) {
add(providerId);
}
const collectModelRef = (value: unknown) => {
const ref = normalizeId(value);
const slash = ref?.indexOf("/") ?? -1;
if (ref && slash > 0) {
add(ref.slice(0, slash));
for (const { value } of collectConfiguredModelRefs(cfg, {
includeChannelModelOverrides: false,
})) {
const slash = value.indexOf("/");
if (slash > 0) {
add(value.slice(0, slash));
}
};
const collectModelConfig = (value: unknown) => {
if (typeof value === "string") {
collectModelRef(value);
return;
}
const record = asObjectRecord(value);
if (!record) {
return;
}
collectModelRef(record.primary);
if (Array.isArray(record.fallbacks)) {
for (const fallback of record.fallbacks) {
collectModelRef(fallback);
}
}
};
const collectAgent = (agent: unknown) => {
const record = asObjectRecord(agent);
if (!record) {
return;
}
for (const key of [
"model",
"imageGenerationModel",
"videoGenerationModel",
"musicGenerationModel",
]) {
collectModelConfig(record[key]);
}
for (const modelRef of Object.keys(asObjectRecord(record.models) ?? {})) {
collectModelRef(modelRef);
}
};
collectAgent(cfg.agents?.defaults);
for (const agent of Array.isArray(cfg.agents?.list) ? cfg.agents.list : []) {
collectAgent(agent);
}
return ids;
}

77
src/config/model-refs.ts Normal file
View File

@@ -0,0 +1,77 @@
import { isRecord } from "../utils.js";
export type ConfiguredModelRef = {
path: string;
value: string;
};
export const AGENT_MODEL_CONFIG_KEYS = [
"model",
"imageModel",
"imageGenerationModel",
"videoGenerationModel",
"musicGenerationModel",
"pdfModel",
] as const;
export function collectConfiguredModelRefs(
config: unknown,
options: { includeChannelModelOverrides?: boolean } = {},
): ConfiguredModelRef[] {
const refs: ConfiguredModelRef[] = [];
const pushModelRef = (path: string, value: unknown) => {
if (typeof value === "string" && value.trim()) {
refs.push({ path, value: value.trim() });
}
};
const collectModelConfig = (path: string, value: unknown) => {
if (typeof value === "string") {
pushModelRef(path, value);
return;
}
if (!isRecord(value)) {
return;
}
pushModelRef(`${path}.primary`, value.primary);
if (Array.isArray(value.fallbacks)) {
for (const [index, entry] of value.fallbacks.entries()) {
pushModelRef(`${path}.fallbacks.${index}`, entry);
}
}
};
const collectFromAgent = (path: string, agent: unknown) => {
if (!isRecord(agent)) {
return;
}
for (const key of AGENT_MODEL_CONFIG_KEYS) {
collectModelConfig(`${path}.${key}`, agent[key]);
}
if (isRecord(agent.models)) {
for (const modelRef of Object.keys(agent.models)) {
pushModelRef(`${path}.models.${modelRef}`, modelRef);
}
}
};
const root = isRecord(config) ? config : {};
const agents = isRecord(root.agents) ? root.agents : {};
collectFromAgent("agents.defaults", agents.defaults);
if (Array.isArray(agents.list)) {
for (const [index, entry] of agents.list.entries()) {
collectFromAgent(`agents.list.${index}`, entry);
}
}
if (options.includeChannelModelOverrides !== false) {
const channels = isRecord(root.channels) ? root.channels : {};
const modelByChannel = isRecord(channels.modelByChannel) ? channels.modelByChannel : {};
for (const [channelId, channelMap] of Object.entries(modelByChannel)) {
if (!isRecord(channelMap)) {
continue;
}
for (const [targetId, modelRef] of Object.entries(channelMap)) {
pushModelRef(`channels.modelByChannel.${channelId}.${targetId}`, modelRef);
}
}
}
return refs;
}

View File

@@ -15,6 +15,7 @@ import { resolvePluginSetupAutoEnableReasons } from "../plugins/setup-registry.j
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { isRecord } from "../utils.js";
import { isChannelConfigured } from "./channel-configured.js";
import { collectConfiguredModelRefs } from "./model-refs.js";
import { shouldSkipPreferredPluginAutoEnable } from "./plugin-auto-enable.prefer-over.js";
import type {
PluginAutoEnableCandidate,
@@ -47,61 +48,6 @@ function resolveAutoEnableProviderPluginIds(
return Object.fromEntries(entries);
}
function collectModelRefs(cfg: OpenClawConfig): string[] {
const refs: string[] = [];
const pushModelRef = (value: unknown) => {
if (typeof value === "string" && value.trim()) {
refs.push(value.trim());
}
};
const collectModelConfig = (value: unknown) => {
if (typeof value === "string") {
pushModelRef(value);
return;
}
if (!isRecord(value)) {
return;
}
pushModelRef(value.primary);
const fallbacks = value.fallbacks;
if (Array.isArray(fallbacks)) {
for (const entry of fallbacks) {
pushModelRef(entry);
}
}
};
const collectFromAgent = (agent: Record<string, unknown> | null | undefined) => {
if (!agent) {
return;
}
for (const key of [
"model",
"imageGenerationModel",
"videoGenerationModel",
"musicGenerationModel",
]) {
collectModelConfig(agent[key]);
}
const models = agent.models;
if (isRecord(models)) {
for (const key of Object.keys(models)) {
pushModelRef(key);
}
}
};
collectFromAgent(cfg.agents?.defaults as Record<string, unknown> | undefined);
const list = cfg.agents?.list;
if (Array.isArray(list)) {
for (const entry of list) {
if (isRecord(entry)) {
collectFromAgent(entry);
}
}
}
return refs;
}
function extractProviderFromModelRef(value: string): string | null {
const trimmed = value.trim();
const slash = trimmed.indexOf("/");
@@ -157,7 +103,9 @@ function isProviderConfigured(cfg: OpenClawConfig, providerId: string): boolean
}
}
for (const ref of collectModelRefs(cfg)) {
for (const { value: ref } of collectConfiguredModelRefs(cfg, {
includeChannelModelOverrides: false,
})) {
const provider = extractProviderFromModelRef(ref);
if (provider && provider === normalized) {
return true;
@@ -493,7 +441,7 @@ function hasConfiguredProviderModelOrHarness(cfg: OpenClawConfig, env: NodeJS.Pr
if (cfg.models?.providers && Object.keys(cfg.models.providers).length > 0) {
return true;
}
if (collectModelRefs(cfg).length > 0) {
if (collectConfiguredModelRefs(cfg, { includeChannelModelOverrides: false }).length > 0) {
return true;
}
return hasConfiguredEmbeddedHarnessRuntime(cfg, env);
@@ -618,7 +566,9 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: {
}
}
for (const modelRef of collectModelRefs(params.config)) {
for (const { value: modelRef } of collectConfiguredModelRefs(params.config, {
includeChannelModelOverrides: false,
})) {
const owningPluginIds = resolveOwningPluginIdsForModelRef({
model: modelRef,
config: params.config,

View File

@@ -35,6 +35,7 @@ import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-value
import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "./bundled-channel-config-metadata.generated.js";
import { collectChannelSchemaMetadata } from "./channel-config-metadata.js";
import { materializeRuntimeConfig } from "./materialize.js";
import { collectConfiguredModelRefs } from "./model-refs.js";
import type { OpenClawConfig, ConfigValidationIssue } from "./types.js";
import { coerceSecretRef } from "./types.secrets.js";
import { OpenClawSchema } from "./zod-schema.js";
@@ -50,10 +51,6 @@ type AllowedValuesCollection = {
hasValues: boolean;
};
type JsonSchemaLike = Record<string, unknown>;
type ConfiguredModelRef = {
path: string;
value: string;
};
function stripDeprecatedValidationKeys(raw: unknown): unknown {
if (!isRecord(raw) || !isRecord(raw.commands) || !Object.hasOwn(raw.commands, "modelsWrite")) {
@@ -1110,58 +1107,6 @@ function validateConfigObjectWithPluginsBase(
issues.push(issue);
};
const collectConfiguredModelRefs = (): ConfiguredModelRef[] => {
const refs: ConfiguredModelRef[] = [];
const pushModelRef = (path: string, value: unknown) => {
if (typeof value === "string" && value.trim()) {
refs.push({ path, value: value.trim() });
}
};
const collectModelConfig = (path: string, value: unknown) => {
if (typeof value === "string") {
pushModelRef(path, value);
return;
}
if (!isRecord(value)) {
return;
}
pushModelRef(`${path}.primary`, value.primary);
if (Array.isArray(value.fallbacks)) {
for (const [index, entry] of value.fallbacks.entries()) {
pushModelRef(`${path}.fallbacks.${index}`, entry);
}
}
};
const collectFromAgent = (path: string, agent: unknown) => {
if (!isRecord(agent)) {
return;
}
for (const key of [
"model",
"imageModel",
"imageGenerationModel",
"videoGenerationModel",
"musicGenerationModel",
"pdfModel",
]) {
collectModelConfig(`${path}.${key}`, agent[key]);
}
if (isRecord(agent.models)) {
for (const modelRef of Object.keys(agent.models)) {
pushModelRef(`${path}.models.${modelRef}`, modelRef);
}
}
};
collectFromAgent("agents.defaults", config.agents?.defaults);
if (Array.isArray(config.agents?.list)) {
for (const [index, entry] of config.agents.list.entries()) {
collectFromAgent(`agents.list.${index}`, entry);
}
}
return refs;
};
const parseProviderModelRef = (value: string): { provider: string; model: string } | null => {
const slashIndex = value.indexOf("/");
if (slashIndex <= 0 || slashIndex >= value.length - 1) {
@@ -1173,7 +1118,7 @@ function validateConfigObjectWithPluginsBase(
};
const validateConfiguredModelRefs = () => {
const configuredRefs = collectConfiguredModelRefs();
const configuredRefs = collectConfiguredModelRefs(config);
if (configuredRefs.length === 0) {
return;
}