mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:00:42 +00:00
fix(doctor): preserve active auth profile metadata
This commit is contained in:
@@ -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.
|
||||
|
||||
210
src/commands/doctor-auth-profile-config.ts
Normal file
210
src/commands/doctor-auth-profile-config.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
77
src/config/model-refs.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user