feat(agent-model): add per-agent model override with default-reset UX and runtime sync (#651)

This commit is contained in:
Felix
2026-03-25 10:13:11 +08:00
committed by GitHub
Unverified
parent 9d40e1fa05
commit ab8fe760ef
16 changed files with 871 additions and 26 deletions

View File

@@ -81,6 +81,8 @@ export interface AgentSummary {
name: string;
isDefault: boolean;
modelDisplay: string;
modelRef: string | null;
overrideModelRef: string | null;
inheritedModel: boolean;
workspace: string;
agentDir: string;
@@ -91,29 +93,38 @@ export interface AgentSummary {
export interface AgentsSnapshot {
agents: AgentSummary[];
defaultAgentId: string;
defaultModelRef: string | null;
configuredChannelTypes: string[];
channelOwners: Record<string, string>;
channelAccountOwners: Record<string, string>;
}
function formatModelLabel(model: unknown): string | null {
function resolveModelRef(model: unknown): string | null {
if (typeof model === 'string' && model.trim()) {
const trimmed = model.trim();
const parts = trimmed.split('/');
return parts[parts.length - 1] || trimmed;
return model.trim();
}
if (model && typeof model === 'object') {
const primary = (model as AgentModelConfig).primary;
if (typeof primary === 'string' && primary.trim()) {
const parts = primary.trim().split('/');
return parts[parts.length - 1] || primary.trim();
return primary.trim();
}
}
return null;
}
function formatModelLabel(model: unknown): string | null {
const modelRef = resolveModelRef(model);
if (modelRef) {
const trimmed = modelRef;
const parts = trimmed.split('/');
return parts[parts.length - 1] || trimmed;
}
return null;
}
function normalizeAgentName(name: string): string {
return name.trim() || 'Agent';
}
@@ -487,10 +498,13 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
channelOwners[channelType] = primaryOwner;
}
const defaultModelLabel = formatModelLabel((config.agents as AgentsConfig | undefined)?.defaults?.model);
const defaultModelConfig = (config.agents as AgentsConfig | undefined)?.defaults?.model;
const defaultModelLabel = formatModelLabel(defaultModelConfig);
const defaultModelRef = resolveModelRef(defaultModelConfig);
const agents: AgentSummary[] = entries.map((entry) => {
const explicitModelRef = resolveModelRef(entry.model);
const modelLabel = formatModelLabel(entry.model) || defaultModelLabel || 'Not configured';
const inheritedModel = !formatModelLabel(entry.model) && Boolean(defaultModelLabel);
const inheritedModel = !explicitModelRef && Boolean(defaultModelLabel);
const entryIdNorm = normalizeAgentIdForBinding(entry.id);
const ownedChannels = agentChannelSets.get(entryIdNorm) ?? new Set<string>();
return {
@@ -498,6 +512,8 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
name: entry.name || (entry.id === MAIN_AGENT_ID ? MAIN_AGENT_NAME : entry.id),
isDefault: entry.id === defaultAgentId,
modelDisplay: modelLabel,
modelRef: explicitModelRef || defaultModelRef || null,
overrideModelRef: explicitModelRef,
inheritedModel,
workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`),
agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id),
@@ -511,6 +527,7 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
return {
agents,
defaultAgentId,
defaultModelRef,
configuredChannelTypes: configuredChannels.map((channelType) => toUiChannelType(channelType)),
channelOwners,
channelAccountOwners,
@@ -598,6 +615,44 @@ export async function updateAgentName(agentId: string, name: string): Promise<Ag
});
}
function isValidModelRef(modelRef: string): boolean {
const firstSlash = modelRef.indexOf('/');
return firstSlash > 0 && firstSlash < modelRef.length - 1;
}
export async function updateAgentModel(agentId: string, modelRef: string | null): Promise<AgentsSnapshot> {
return withConfigLock(async () => {
const config = await readOpenClawConfig() as AgentConfigDocument;
const { agentsConfig, entries } = normalizeAgentsConfig(config);
const index = entries.findIndex((entry) => entry.id === agentId);
if (index === -1) {
throw new Error(`Agent "${agentId}" not found`);
}
const normalizedModelRef = typeof modelRef === 'string' ? modelRef.trim() : '';
const nextEntry: AgentListEntry = { ...entries[index] };
if (!normalizedModelRef) {
delete nextEntry.model;
} else {
if (!isValidModelRef(normalizedModelRef)) {
throw new Error('modelRef must be in "provider/model" format');
}
nextEntry.model = { primary: normalizedModelRef };
}
entries[index] = nextEntry;
config.agents = {
...agentsConfig,
list: entries,
};
await writeOpenClawConfig(config);
logger.info('Updated agent model', { agentId, modelRef: normalizedModelRef || null });
return buildSnapshotFromConfig(config);
});
}
export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: AgentsSnapshot; removedEntry: AgentListEntry }> {
return withConfigLock(async () => {
if (agentId === MAIN_AGENT_ID) {

View File

@@ -918,18 +918,20 @@ export async function syncSessionIdleMinutesToOpenClaw(): Promise<void> {
/**
* Update a provider entry in every discovered agent's models.json.
*/
export async function updateAgentModelProvider(
type AgentModelProviderEntry = {
baseUrl?: string;
api?: string;
models?: Array<{ id: string; name: string }>;
apiKey?: string;
/** When true, pi-ai sends Authorization: Bearer instead of x-api-key */
authHeader?: boolean;
};
async function updateModelsJsonProviderEntriesForAgents(
agentIds: string[],
providerType: string,
entry: {
baseUrl?: string;
api?: string;
models?: Array<{ id: string; name: string }>;
apiKey?: string;
/** When true, pi-ai sends Authorization: Bearer instead of x-api-key */
authHeader?: boolean;
}
entry: AgentModelProviderEntry,
): Promise<void> {
const agentIds = await discoverAgentIds();
for (const agentId of agentIds) {
const modelsPath = join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'models.json');
let data: Record<string, unknown> = {};
@@ -975,6 +977,26 @@ export async function updateAgentModelProvider(
}
}
export async function updateAgentModelProvider(
providerType: string,
entry: AgentModelProviderEntry,
): Promise<void> {
const agentIds = await discoverAgentIds();
await updateModelsJsonProviderEntriesForAgents(agentIds, providerType, entry);
}
export async function updateSingleAgentModelProvider(
agentId: string,
providerType: string,
entry: AgentModelProviderEntry,
): Promise<void> {
const normalizedAgentId = agentId.trim();
if (!normalizedAgentId) {
throw new Error('agentId is required');
}
await updateModelsJsonProviderEntriesForAgents([normalizedAgentId], providerType, entry);
}
/**
* Sanitize ~/.openclaw/openclaw.json before Gateway start.
*