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

@@ -100,6 +100,7 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
### 💬 インテリジェントチャットインターフェース
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。
### 📡 マルチチャネル管理
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。

View File

@@ -100,6 +100,7 @@ Complete the entire setup—from installation to your first AI interaction—thr
### 💬 Intelligent Chat Interface
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, rich content rendering with Markdown, and direct `@agent` routing in the main composer for multi-agent setups.
When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings.
Each agent can also override its own `provider/model` runtime setting; agents without overrides continue inheriting the global default model.
### 📡 Multi-Channel Management
Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks.

View File

@@ -101,6 +101,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
### 💬 智能聊天界面
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
当你使用 `@agent` 选择其他智能体时ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。
### 📡 多频道管理
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。

View File

@@ -7,10 +7,11 @@ import {
listAgentsSnapshot,
removeAgentWorkspaceDirectory,
resolveAccountIdForAgent,
updateAgentModel,
updateAgentName,
} from '../../utils/agent-config';
import { deleteChannelAccountConfig } from '../../utils/channel-config';
import { syncAllProviderAuthToRuntime } from '../../services/providers/provider-runtime-sync';
import { syncAgentModelOverrideToRuntime, syncAllProviderAuthToRuntime } from '../../services/providers/provider-runtime-sync';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
@@ -151,6 +152,26 @@ export async function handleAgentRoutes(
return true;
}
if (parts.length === 2 && parts[1] === 'model') {
try {
const body = await parseJsonBody<{ modelRef?: string | null }>(req);
const agentId = decodeURIComponent(parts[0]);
const snapshot = await updateAgentModel(agentId, body.modelRef ?? null);
try {
await syncAllProviderAuthToRuntime();
// Ensure this agent's runtime model registry reflects the new model override.
await syncAgentModelOverrideToRuntime(agentId);
} catch (syncError) {
console.warn('[agents] Failed to sync runtime after updating agent model:', syncError);
}
scheduleGatewayReload(ctx, 'update-agent-model');
sendJson(res, 200, { success: true, ...snapshot });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (parts.length === 3 && parts[1] === 'channels') {
try {
const agentId = decodeURIComponent(parts[0]);

View File

@@ -12,8 +12,10 @@ import {
setOpenClawDefaultModelWithOverride,
syncProviderConfigToOpenClaw,
updateAgentModelProvider,
updateSingleAgentModelProvider,
} from '../../utils/openclaw-auth';
import { logger } from '../../utils/logger';
import { listAgentsSnapshot } from '../../utils/agent-config';
const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`;
@@ -336,6 +338,116 @@ async function syncProviderToRuntime(
return context;
}
function parseModelRef(modelRef: string): { providerKey: string; modelId: string } | null {
const trimmed = modelRef.trim();
const separatorIndex = trimmed.indexOf('/');
if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) {
return null;
}
return {
providerKey: trimmed.slice(0, separatorIndex),
modelId: trimmed.slice(separatorIndex + 1),
};
}
async function buildRuntimeProviderConfigMap(): Promise<Map<string, ProviderConfig>> {
const configs = await getAllProviders();
const runtimeMap = new Map<string, ProviderConfig>();
for (const config of configs) {
const runtimeKey = await resolveRuntimeProviderKey(config);
runtimeMap.set(runtimeKey, config);
}
return runtimeMap;
}
async function buildAgentModelProviderEntry(
config: ProviderConfig,
modelId: string,
): Promise<{
baseUrl?: string;
api?: string;
models?: Array<{ id: string; name: string }>;
apiKey?: string;
authHeader?: boolean;
} | null> {
const meta = getProviderConfig(config.type);
const api = config.apiProtocol || (config.type === 'custom' ? 'openai-completions' : meta?.api);
const baseUrl = normalizeProviderBaseUrl(config, config.baseUrl || meta?.baseUrl, api);
if (!api || !baseUrl) {
return null;
}
let apiKey: string | undefined;
let authHeader: boolean | undefined;
if (config.type === 'custom') {
apiKey = (await getApiKey(config.id)) || undefined;
} else if (config.type === 'minimax-portal' || config.type === 'minimax-portal-cn') {
const accountApiKey = await getApiKey(config.id);
if (accountApiKey) {
apiKey = accountApiKey;
} else {
authHeader = true;
apiKey = 'minimax-oauth';
}
} else if (config.type === 'qwen-portal') {
const accountApiKey = await getApiKey(config.id);
if (accountApiKey) {
apiKey = accountApiKey;
} else {
apiKey = 'qwen-oauth';
}
}
return {
baseUrl,
api,
models: [{ id: modelId, name: modelId }],
apiKey,
authHeader,
};
}
async function syncAgentModelsToRuntime(agentIds?: Set<string>): Promise<void> {
const snapshot = await listAgentsSnapshot();
const runtimeProviderConfigs = await buildRuntimeProviderConfigMap();
const targets = snapshot.agents.filter((agent) => {
if (!agent.modelRef) return false;
if (!agentIds) return true;
return agentIds.has(agent.id);
});
for (const agent of targets) {
const parsed = parseModelRef(agent.modelRef || '');
if (!parsed) {
continue;
}
const providerConfig = runtimeProviderConfigs.get(parsed.providerKey);
if (!providerConfig) {
logger.warn(
`[provider-runtime] No provider account mapped to runtime key "${parsed.providerKey}" for agent "${agent.id}"`,
);
continue;
}
const entry = await buildAgentModelProviderEntry(providerConfig, parsed.modelId);
if (!entry) {
continue;
}
await updateSingleAgentModelProvider(agent.id, parsed.providerKey, entry);
}
}
export async function syncAgentModelOverrideToRuntime(agentId: string): Promise<void> {
await syncAgentModelsToRuntime(new Set([agentId]));
}
export async function syncSavedProviderToRuntime(
config: ProviderConfig,
apiKey: string | undefined,
@@ -346,6 +458,12 @@ export async function syncSavedProviderToRuntime(
return;
}
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider save:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway reload after saving provider "${context.runtimeProviderKey}" config`,
@@ -388,6 +506,12 @@ export async function syncUpdatedProviderToRuntime(
}
}
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after provider update:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway reload after updating provider "${ock}" config`,
@@ -496,6 +620,11 @@ export async function syncDefaultProviderToRuntime(
await setOpenClawDefaultModel(browserOAuthRuntimeProvider, modelOverride, fallbackModels);
logger.info(`Configured openclaw.json for browser OAuth provider "${provider.id}"`);
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after browser OAuth switch:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway reload after provider switch to "${browserOAuthRuntimeProvider}"`,
@@ -557,6 +686,12 @@ export async function syncDefaultProviderToRuntime(
});
}
try {
await syncAgentModelsToRuntime();
} catch (err) {
logger.warn('[provider-runtime] Failed to sync per-agent model registries after default provider switch:', err);
}
scheduleGatewayRefresh(
gatewayManager,
`Scheduling Gateway reload after provider switch to "${ock}"`,

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.
*

View File

@@ -30,6 +30,17 @@
"nameLabel": "Agent Name",
"agentIdLabel": "Agent ID",
"modelLabel": "Model",
"modelOverrideLabel": "Model Override",
"modelProviderLabel": "Provider",
"modelProviderPlaceholder": "Select a provider",
"modelProviderEmpty": "No provider accounts configured. Add one in Settings → AI Providers.",
"modelIdLabel": "Model ID",
"modelIdPlaceholder": "model-id",
"modelPreview": "Preview",
"modelOverridePlaceholder": "provider/model (for example: openrouter/openai/gpt-5.4)",
"modelOverrideDescription": "Select provider and model ID for this agent.",
"saveModelOverride": "Save model",
"useDefaultModel": "Use default model",
"channelsTitle": "Channels",
"channelsDescription": "This list is read-only. Manage channel accounts and bindings in the Channels page.",
"mainAccount": "Main account",
@@ -48,6 +59,13 @@
"agentDeleteFailed": "Failed to delete agent: {{error}}",
"agentUpdated": "Agent updated",
"agentUpdateFailed": "Failed to update agent: {{error}}",
"agentModelInvalid": "Model must be in provider/model format",
"agentModelProviderRequired": "Select a provider first",
"agentModelIdRequired": "Model ID is required",
"agentModelUpdated": "Agent model updated",
"agentModelUpdateFailed": "Failed to update agent model: {{error}}",
"agentModelReset": "Agent model reset to default",
"agentModelResetFailed": "Failed to reset agent model: {{error}}",
"channelAssigned": "{{channel}} assigned to agent",
"channelAssignFailed": "Failed to assign channel: {{error}}",
"channelRemoved": "{{channel}} removed",

View File

@@ -30,6 +30,17 @@
"nameLabel": "Agent 名",
"agentIdLabel": "Agent ID",
"modelLabel": "Model",
"modelOverrideLabel": "モデル上書き",
"modelProviderLabel": "Provider",
"modelProviderPlaceholder": "Provider を選択",
"modelProviderEmpty": "Provider アカウントが未設定です。設定 → AI Providers で追加してください。",
"modelIdLabel": "モデル ID",
"modelIdPlaceholder": "model-id",
"modelPreview": "プレビュー",
"modelOverridePlaceholder": "provider/model例: openrouter/openai/gpt-5.4",
"modelOverrideDescription": "この Agent の Provider とモデル ID を選択します。",
"saveModelOverride": "モデルを保存",
"useDefaultModel": "デフォルトモデルを使用",
"channelsTitle": "Channels",
"channelsDescription": "この一覧は読み取り専用です。チャンネルアカウントと紐付けは Channels ページで管理してください。",
"mainAccount": "メインアカウント",
@@ -48,6 +59,13 @@
"agentDeleteFailed": "Agent の削除に失敗しました: {{error}}",
"agentUpdated": "Agent を更新しました",
"agentUpdateFailed": "Agent の更新に失敗しました: {{error}}",
"agentModelInvalid": "モデル形式は provider/model で指定してください",
"agentModelProviderRequired": "先に Provider を選択してください",
"agentModelIdRequired": "モデル ID は必須です",
"agentModelUpdated": "Agent のモデルを更新しました",
"agentModelUpdateFailed": "Agent モデルの更新に失敗しました: {{error}}",
"agentModelReset": "Agent モデルをデフォルトに戻しました",
"agentModelResetFailed": "Agent モデルのリセットに失敗しました: {{error}}",
"channelAssigned": "{{channel}} を Agent に割り当てました",
"channelAssignFailed": "Channel の割り当てに失敗しました: {{error}}",
"channelRemoved": "{{channel}} を削除しました",

View File

@@ -30,6 +30,17 @@
"nameLabel": "Agent 名称",
"agentIdLabel": "Agent ID",
"modelLabel": "Model",
"modelOverrideLabel": "模型覆盖",
"modelProviderLabel": "Provider",
"modelProviderPlaceholder": "选择 Provider",
"modelProviderEmpty": "尚未配置 Provider 账号。请先前往 设置 → AI Providers 添加。",
"modelIdLabel": "模型 ID",
"modelIdPlaceholder": "model-id",
"modelPreview": "预览",
"modelOverridePlaceholder": "provider/model例如openrouter/openai/gpt-5.4",
"modelOverrideDescription": "为该 Agent 选择 Provider 和模型 ID。",
"saveModelOverride": "保存模型",
"useDefaultModel": "使用默认模型",
"channelsTitle": "频道",
"channelsDescription": "该列表为只读。频道账号与绑定关系请在 Channels 页面管理。",
"mainAccount": "主账号",
@@ -48,6 +59,13 @@
"agentDeleteFailed": "删除 Agent 失败:{{error}}",
"agentUpdated": "Agent 已更新",
"agentUpdateFailed": "更新 Agent 失败:{{error}}",
"agentModelInvalid": "模型格式必须为 provider/model",
"agentModelProviderRequired": "请先选择 Provider",
"agentModelIdRequired": "模型 ID 不能为空",
"agentModelUpdated": "Agent 模型已更新",
"agentModelUpdateFailed": "更新 Agent 模型失败:{{error}}",
"agentModelReset": "Agent 模型已恢复为默认",
"agentModelResetFailed": "恢复 Agent 默认模型失败:{{error}}",
"channelAssigned": "{{channel}} 已分配给 Agent",
"channelAssignFailed": "分配频道失败:{{error}}",
"channelRemoved": "{{channel}} 已移除",

View File

@@ -10,10 +10,12 @@ import { Switch } from '@/components/ui/switch';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { useAgentsStore } from '@/stores/agents';
import { useGatewayStore } from '@/stores/gateway';
import { useProviderStore } from '@/stores/providers';
import { hostApiFetch } from '@/lib/host-api';
import { subscribeHostEvent } from '@/lib/host-events';
import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType } from '@/types/channel';
import type { AgentSummary } from '@/types/agent';
import type { ProviderAccount, ProviderVendorInfo, ProviderWithKeyInfo } from '@/lib/providers';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -43,9 +45,57 @@ interface ChannelGroupItem {
accounts: ChannelAccountItem[];
}
interface RuntimeProviderOption {
runtimeProviderKey: string;
accountId: string;
label: string;
modelIdPlaceholder?: string;
configuredModelId?: string;
}
function resolveRuntimeProviderKey(account: ProviderAccount): string {
if (account.authMode === 'oauth_browser') {
if (account.vendorId === 'google') return 'google-gemini-cli';
if (account.vendorId === 'openai') return 'openai-codex';
}
if (account.vendorId === 'custom' || account.vendorId === 'ollama') {
const suffix = account.id.replace(/-/g, '').slice(0, 8);
return `${account.vendorId}-${suffix}`;
}
if (account.vendorId === 'minimax-portal-cn') {
return 'minimax-portal';
}
return account.vendorId;
}
function splitModelRef(modelRef: string | null | undefined): { providerKey: string; modelId: string } | null {
const value = (modelRef || '').trim();
if (!value) return null;
const separatorIndex = value.indexOf('/');
if (separatorIndex <= 0 || separatorIndex >= value.length - 1) return null;
return {
providerKey: value.slice(0, separatorIndex),
modelId: value.slice(separatorIndex + 1),
};
}
function hasConfiguredProviderCredentials(
account: ProviderAccount,
statusById: Map<string, ProviderWithKeyInfo>,
): boolean {
if (account.authMode === 'oauth_device' || account.authMode === 'oauth_browser' || account.authMode === 'local') {
return true;
}
return statusById.get(account.id)?.hasKey ?? false;
}
export function Agents() {
const { t } = useTranslation('agents');
const gatewayStatus = useGatewayStore((state) => state.status);
const refreshProviderSnapshot = useProviderStore((state) => state.refreshProviderSnapshot);
const lastGatewayStateRef = useRef(gatewayStatus.state);
const {
agents,
@@ -72,8 +122,8 @@ export function Agents() {
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void Promise.all([fetchAgents(), fetchChannelAccounts()]);
}, [fetchAgents, fetchChannelAccounts]);
void Promise.all([fetchAgents(), fetchChannelAccounts(), refreshProviderSnapshot()]);
}, [fetchAgents, fetchChannelAccounts, refreshProviderSnapshot]);
useEffect(() => {
const unsubscribe = subscribeHostEvent('gateway:channel-status', () => {
@@ -316,6 +366,7 @@ function AgentCard({
}
const inputClasses = 'h-[44px] rounded-xl font-mono text-[13px] bg-[#eeece3] dark:bg-muted border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40';
const selectClasses = 'h-[44px] w-full rounded-xl font-mono text-[13px] bg-[#eeece3] dark:bg-muted border border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground px-3';
const labelClasses = 'text-[14px] text-foreground/80 font-bold';
function ChannelLogo({ type }: { type: ChannelType }) {
@@ -438,9 +489,10 @@ function AgentSettingsModal({
onClose: () => void;
}) {
const { t } = useTranslation('agents');
const { updateAgent } = useAgentsStore();
const { updateAgent, defaultModelRef } = useAgentsStore();
const [name, setName] = useState(agent.name);
const [savingName, setSavingName] = useState(false);
const [showModelModal, setShowModelModal] = useState(false);
useEffect(() => {
setName(agent.name);
@@ -530,7 +582,11 @@ function AgentSettingsModal({
</p>
<p className="font-mono text-[13px] text-foreground">{agent.id}</p>
</div>
<div className="space-y-1 rounded-2xl bg-black/5 dark:bg-white/5 border border-transparent p-4">
<button
type="button"
onClick={() => setShowModelModal(true)}
className="space-y-1 rounded-2xl bg-black/5 dark:bg-white/5 border border-transparent p-4 text-left hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
>
<p className="text-[11px] uppercase tracking-[0.08em] text-muted-foreground/80 font-medium">
{t('settingsDialog.modelLabel')}
</p>
@@ -538,7 +594,10 @@ function AgentSettingsModal({
{agent.modelDisplay}
{agent.inheritedModel ? ` (${t('inherited')})` : ''}
</p>
</div>
<p className="font-mono text-[12px] text-foreground/70 break-all">
{agent.modelRef || defaultModelRef || '-'}
</p>
</button>
</div>
</div>
@@ -587,6 +646,233 @@ function AgentSettingsModal({
</div>
</CardContent>
</Card>
{showModelModal && (
<AgentModelModal
agent={agent}
onClose={() => setShowModelModal(false)}
/>
)}
</div>
);
}
function AgentModelModal({
agent,
onClose,
}: {
agent: AgentSummary;
onClose: () => void;
}) {
const { t } = useTranslation('agents');
const providerAccounts = useProviderStore((state) => state.accounts);
const providerStatuses = useProviderStore((state) => state.statuses);
const providerVendors = useProviderStore((state) => state.vendors);
const providerDefaultAccountId = useProviderStore((state) => state.defaultAccountId);
const { updateAgentModel, defaultModelRef } = useAgentsStore();
const [selectedRuntimeProviderKey, setSelectedRuntimeProviderKey] = useState('');
const [modelIdInput, setModelIdInput] = useState('');
const [savingModel, setSavingModel] = useState(false);
const runtimeProviderOptions = useMemo<RuntimeProviderOption[]>(() => {
const vendorMap = new Map<string, ProviderVendorInfo>(providerVendors.map((vendor) => [vendor.id, vendor]));
const statusById = new Map<string, ProviderWithKeyInfo>(providerStatuses.map((status) => [status.id, status]));
const entries = providerAccounts
.filter((account) => account.enabled && hasConfiguredProviderCredentials(account, statusById))
.sort((left, right) => {
if (left.id === providerDefaultAccountId) return -1;
if (right.id === providerDefaultAccountId) return 1;
return right.updatedAt.localeCompare(left.updatedAt);
});
const deduped = new Map<string, RuntimeProviderOption>();
for (const account of entries) {
const runtimeProviderKey = resolveRuntimeProviderKey(account);
if (!runtimeProviderKey || deduped.has(runtimeProviderKey)) continue;
const vendor = vendorMap.get(account.vendorId);
const label = `${account.label} (${vendor?.name || account.vendorId})`;
const configuredModelId = account.model
? (account.model.startsWith(`${runtimeProviderKey}/`)
? account.model.slice(runtimeProviderKey.length + 1)
: account.model)
: undefined;
deduped.set(runtimeProviderKey, {
runtimeProviderKey,
accountId: account.id,
label,
modelIdPlaceholder: vendor?.modelIdPlaceholder,
configuredModelId,
});
}
return [...deduped.values()];
}, [providerAccounts, providerDefaultAccountId, providerStatuses, providerVendors]);
useEffect(() => {
const override = splitModelRef(agent.overrideModelRef);
if (override) {
setSelectedRuntimeProviderKey(override.providerKey);
setModelIdInput(override.modelId);
return;
}
const effective = splitModelRef(agent.modelRef || defaultModelRef);
if (effective) {
setSelectedRuntimeProviderKey(effective.providerKey);
setModelIdInput(effective.modelId);
return;
}
setSelectedRuntimeProviderKey(runtimeProviderOptions[0]?.runtimeProviderKey || '');
setModelIdInput('');
}, [agent.modelRef, agent.overrideModelRef, defaultModelRef, runtimeProviderOptions]);
const selectedProvider = runtimeProviderOptions.find((option) => option.runtimeProviderKey === selectedRuntimeProviderKey) || null;
const trimmedModelId = modelIdInput.trim();
const nextModelRef = selectedRuntimeProviderKey && trimmedModelId
? `${selectedRuntimeProviderKey}/${trimmedModelId}`
: '';
const normalizedDefaultModelRef = (defaultModelRef || '').trim();
const isUsingDefaultModelInForm = Boolean(normalizedDefaultModelRef) && nextModelRef === normalizedDefaultModelRef;
const currentOverrideModelRef = (agent.overrideModelRef || '').trim();
const desiredOverrideModelRef = nextModelRef && nextModelRef !== normalizedDefaultModelRef
? nextModelRef
: null;
const modelChanged = (desiredOverrideModelRef || '') !== currentOverrideModelRef;
const handleSaveModel = async () => {
if (!selectedRuntimeProviderKey) {
toast.error(t('toast.agentModelProviderRequired'));
return;
}
if (!trimmedModelId) {
toast.error(t('toast.agentModelIdRequired'));
return;
}
if (!modelChanged) return;
if (!nextModelRef.includes('/')) {
toast.error(t('toast.agentModelInvalid'));
return;
}
setSavingModel(true);
try {
await updateAgentModel(agent.id, desiredOverrideModelRef);
toast.success(desiredOverrideModelRef ? t('toast.agentModelUpdated') : t('toast.agentModelReset'));
onClose();
} catch (error) {
toast.error(t('toast.agentModelUpdateFailed', { error: String(error) }));
} finally {
setSavingModel(false);
}
};
const handleUseDefaultModel = () => {
const parsedDefault = splitModelRef(normalizedDefaultModelRef);
if (!parsedDefault) {
setSelectedRuntimeProviderKey('');
setModelIdInput('');
return;
}
setSelectedRuntimeProviderKey(parsedDefault.providerKey);
setModelIdInput(parsedDefault.modelId);
};
return (
<div className="fixed inset-0 z-[60] bg-black/50 flex items-center justify-center p-4">
<Card className="w-full max-w-xl rounded-3xl border-0 shadow-2xl bg-[#f3f1e9] dark:bg-card overflow-hidden">
<CardHeader className="flex flex-row items-start justify-between pb-2">
<div>
<CardTitle className="text-2xl font-serif font-normal tracking-tight">
{t('settingsDialog.modelLabel')}
</CardTitle>
<CardDescription className="text-[15px] mt-1 text-foreground/70">
{t('settingsDialog.modelOverrideDescription', { defaultModel: defaultModelRef || '-' })}
</CardDescription>
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="rounded-full h-8 w-8 -mr-2 -mt-2 text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-4 p-6 pt-4">
<div className="space-y-2">
<Label htmlFor="agent-model-provider" className="text-[12px] text-foreground/70">{t('settingsDialog.modelProviderLabel')}</Label>
<select
id="agent-model-provider"
value={selectedRuntimeProviderKey}
onChange={(event) => {
const nextProvider = event.target.value;
setSelectedRuntimeProviderKey(nextProvider);
if (!modelIdInput.trim()) {
const option = runtimeProviderOptions.find((candidate) => candidate.runtimeProviderKey === nextProvider);
setModelIdInput(option?.configuredModelId || '');
}
}}
className={selectClasses}
>
<option value="">{t('settingsDialog.modelProviderPlaceholder')}</option>
{runtimeProviderOptions.map((option) => (
<option key={option.runtimeProviderKey} value={option.runtimeProviderKey}>
{option.label}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="agent-model-id" className="text-[12px] text-foreground/70">{t('settingsDialog.modelIdLabel')}</Label>
<Input
id="agent-model-id"
value={modelIdInput}
onChange={(event) => setModelIdInput(event.target.value)}
placeholder={selectedProvider?.modelIdPlaceholder || selectedProvider?.configuredModelId || t('settingsDialog.modelIdPlaceholder')}
className={inputClasses}
/>
</div>
{!!nextModelRef && (
<p className="text-[12px] font-mono text-foreground/70 break-all">
{t('settingsDialog.modelPreview')}: {nextModelRef}
</p>
)}
{runtimeProviderOptions.length === 0 && (
<p className="text-[12px] text-amber-600 dark:text-amber-400">
{t('settingsDialog.modelProviderEmpty')}
</p>
)}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="outline"
onClick={handleUseDefaultModel}
disabled={savingModel || !normalizedDefaultModelRef || isUsingDefaultModelInForm}
className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground"
>
{t('settingsDialog.useDefaultModel')}
</Button>
<Button
variant="outline"
onClick={onClose}
className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground"
>
{t('common:actions.cancel')}
</Button>
<Button
onClick={() => void handleSaveModel()}
disabled={savingModel || !selectedRuntimeProviderKey || !trimmedModelId || !modelChanged}
className="h-9 text-[13px] font-medium rounded-full px-4 shadow-none"
>
{savingModel ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
t('common:actions.save')
)}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import type { AgentSummary, AgentsSnapshot } from '@/types/agent';
interface AgentsState {
agents: AgentSummary[];
defaultAgentId: string;
defaultModelRef: string | null;
configuredChannelTypes: string[];
channelOwners: Record<string, string>;
channelAccountOwners: Record<string, string>;
@@ -14,6 +15,7 @@ interface AgentsState {
fetchAgents: () => Promise<void>;
createAgent: (name: string, options?: { inheritWorkspace?: boolean }) => Promise<void>;
updateAgent: (agentId: string, name: string) => Promise<void>;
updateAgentModel: (agentId: string, modelRef: string | null) => Promise<void>;
deleteAgent: (agentId: string) => Promise<void>;
assignChannel: (agentId: string, channelType: ChannelType) => Promise<void>;
removeChannel: (agentId: string, channelType: ChannelType) => Promise<void>;
@@ -24,6 +26,7 @@ function applySnapshot(snapshot: AgentsSnapshot | undefined) {
return snapshot ? {
agents: snapshot.agents ?? [],
defaultAgentId: snapshot.defaultAgentId ?? 'main',
defaultModelRef: snapshot.defaultModelRef ?? null,
configuredChannelTypes: snapshot.configuredChannelTypes ?? [],
channelOwners: snapshot.channelOwners ?? {},
channelAccountOwners: snapshot.channelAccountOwners ?? {},
@@ -33,6 +36,7 @@ function applySnapshot(snapshot: AgentsSnapshot | undefined) {
export const useAgentsStore = create<AgentsState>((set) => ({
agents: [],
defaultAgentId: 'main',
defaultModelRef: null,
configuredChannelTypes: [],
channelOwners: {},
channelAccountOwners: {},
@@ -83,6 +87,23 @@ export const useAgentsStore = create<AgentsState>((set) => ({
}
},
updateAgentModel: async (agentId: string, modelRef: string | null) => {
set({ error: null });
try {
const snapshot = await hostApiFetch<AgentsSnapshot & { success?: boolean }>(
`/api/agents/${encodeURIComponent(agentId)}/model`,
{
method: 'PUT',
body: JSON.stringify({ modelRef }),
}
);
set(applySnapshot(snapshot));
} catch (error) {
set({ error: String(error) });
throw error;
}
},
deleteAgent: async (agentId: string) => {
set({ error: null });
try {

View File

@@ -3,6 +3,8 @@ export interface AgentSummary {
name: string;
isDefault: boolean;
modelDisplay: string;
modelRef?: string | null;
overrideModelRef?: string | null;
inheritedModel: boolean;
workspace: string;
agentDir: string;
@@ -13,6 +15,7 @@ export interface AgentSummary {
export interface AgentsSnapshot {
agents: AgentSummary[];
defaultAgentId: string;
defaultModelRef?: string | null;
configuredChannelTypes: string[];
channelOwners: Record<string, string>;
channelAccountOwners: Record<string, string>;

View File

@@ -102,6 +102,101 @@ describe('agent config lifecycle', () => {
);
});
it('exposes effective and override model refs in the snapshot', async () => {
await writeOpenClawJson({
agents: {
defaults: {
model: {
primary: 'moonshot/kimi-k2.5',
},
},
list: [
{ id: 'main', name: 'Main', default: true },
{ id: 'coder', name: 'Coder', model: { primary: 'ark/ark-code-latest' } },
],
},
});
const { listAgentsSnapshot } = await import('@electron/utils/agent-config');
const snapshot = await listAgentsSnapshot();
const main = snapshot.agents.find((agent) => agent.id === 'main');
const coder = snapshot.agents.find((agent) => agent.id === 'coder');
expect(snapshot.defaultModelRef).toBe('moonshot/kimi-k2.5');
expect(main).toMatchObject({
modelRef: 'moonshot/kimi-k2.5',
overrideModelRef: null,
inheritedModel: true,
modelDisplay: 'kimi-k2.5',
});
expect(coder).toMatchObject({
modelRef: 'ark/ark-code-latest',
overrideModelRef: 'ark/ark-code-latest',
inheritedModel: false,
modelDisplay: 'ark-code-latest',
});
});
it('updates and clears per-agent model overrides', async () => {
await writeOpenClawJson({
agents: {
defaults: {
model: {
primary: 'moonshot/kimi-k2.5',
},
},
list: [
{ id: 'main', name: 'Main', default: true },
{ id: 'coder', name: 'Coder' },
],
},
});
const { listAgentsSnapshot, updateAgentModel } = await import('@electron/utils/agent-config');
await updateAgentModel('coder', 'ark/ark-code-latest');
let config = await readOpenClawJson();
let coder = ((config.agents as { list: Array<{ id: string; model?: { primary?: string } }> }).list)
.find((agent) => agent.id === 'coder');
expect(coder?.model?.primary).toBe('ark/ark-code-latest');
let snapshot = await listAgentsSnapshot();
let snapshotCoder = snapshot.agents.find((agent) => agent.id === 'coder');
expect(snapshotCoder).toMatchObject({
modelRef: 'ark/ark-code-latest',
overrideModelRef: 'ark/ark-code-latest',
inheritedModel: false,
});
await updateAgentModel('coder', null);
config = await readOpenClawJson();
coder = ((config.agents as { list: Array<{ id: string; model?: unknown }> }).list)
.find((agent) => agent.id === 'coder');
expect(coder?.model).toBeUndefined();
snapshot = await listAgentsSnapshot();
snapshotCoder = snapshot.agents.find((agent) => agent.id === 'coder');
expect(snapshotCoder).toMatchObject({
modelRef: 'moonshot/kimi-k2.5',
overrideModelRef: null,
inheritedModel: true,
});
});
it('rejects invalid model ref formats when updating agent model', async () => {
await writeOpenClawJson({
agents: {
list: [{ id: 'main', name: 'Main', default: true }],
},
});
const { updateAgentModel } = await import('@electron/utils/agent-config');
await expect(updateAgentModel('main', 'invalid-model-ref')).rejects.toThrow(
'modelRef must be in "provider/model" format',
);
});
it('deletes the config entry, bindings, runtime directory, and managed workspace for a removed agent', async () => {
await writeOpenClawJson({
agents: {

View File

@@ -1,21 +1,31 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { Agents } from '../../src/pages/Agents/index';
const hostApiFetchMock = vi.fn();
const subscribeHostEventMock = vi.fn();
const fetchAgentsMock = vi.fn();
const updateAgentMock = vi.fn();
const updateAgentModelMock = vi.fn();
const refreshProviderSnapshotMock = vi.fn();
const { gatewayState, agentsState } = vi.hoisted(() => ({
const { gatewayState, agentsState, providersState } = vi.hoisted(() => ({
gatewayState: {
status: { state: 'running', port: 18789 },
},
agentsState: {
agents: [] as Array<Record<string, unknown>>,
defaultModelRef: null as string | null,
loading: false,
error: null as string | null,
},
providersState: {
accounts: [] as Array<Record<string, unknown>>,
statuses: [] as Array<Record<string, unknown>>,
vendors: [] as Array<Record<string, unknown>>,
defaultAccountId: '' as string,
},
}));
vi.mock('@/stores/gateway', () => ({
@@ -25,12 +35,16 @@ vi.mock('@/stores/gateway', () => ({
vi.mock('@/stores/agents', () => ({
useAgentsStore: (selector?: (state: typeof agentsState & {
fetchAgents: typeof fetchAgentsMock;
updateAgent: typeof updateAgentMock;
updateAgentModel: typeof updateAgentModelMock;
createAgent: ReturnType<typeof vi.fn>;
deleteAgent: ReturnType<typeof vi.fn>;
}) => unknown) => {
const state = {
...agentsState,
fetchAgents: fetchAgentsMock,
updateAgent: updateAgentMock,
updateAgentModel: updateAgentModelMock,
createAgent: vi.fn(),
deleteAgent: vi.fn(),
};
@@ -38,6 +52,18 @@ vi.mock('@/stores/agents', () => ({
},
}));
vi.mock('@/stores/providers', () => ({
useProviderStore: (selector: (state: typeof providersState & {
refreshProviderSnapshot: typeof refreshProviderSnapshotMock;
}) => unknown) => {
const state = {
...providersState,
refreshProviderSnapshot: refreshProviderSnapshotMock,
};
return selector(state);
},
}));
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
@@ -64,7 +90,16 @@ describe('Agents page status refresh', () => {
beforeEach(() => {
vi.clearAllMocks();
gatewayState.status = { state: 'running', port: 18789 };
agentsState.agents = [];
agentsState.defaultModelRef = null;
providersState.accounts = [];
providersState.statuses = [];
providersState.vendors = [];
providersState.defaultAccountId = '';
fetchAgentsMock.mockResolvedValue(undefined);
updateAgentMock.mockResolvedValue(undefined);
updateAgentModelMock.mockResolvedValue(undefined);
refreshProviderSnapshotMock.mockResolvedValue(undefined);
hostApiFetchMock.mockResolvedValue({
success: true,
channels: [],
@@ -118,4 +153,65 @@ describe('Agents page status refresh', () => {
expect(channelFetchCalls).toHaveLength(2);
});
});
it('uses "Use default model" as form fill only and disables it when already default', async () => {
agentsState.agents = [
{
id: 'main',
name: 'Main',
isDefault: true,
modelDisplay: 'claude-opus-4.6',
modelRef: 'openrouter/anthropic/claude-opus-4.6',
overrideModelRef: null,
inheritedModel: true,
workspace: '~/.openclaw/workspace',
agentDir: '~/.openclaw/agents/main/agent',
mainSessionKey: 'agent:main:desk',
channelTypes: [],
},
];
agentsState.defaultModelRef = 'openrouter/anthropic/claude-opus-4.6';
providersState.accounts = [
{
id: 'openrouter-default',
label: 'OpenRouter',
vendorId: 'openrouter',
authMode: 'api_key',
model: 'openrouter/anthropic/claude-opus-4.6',
enabled: true,
createdAt: '2026-03-24T00:00:00.000Z',
updatedAt: '2026-03-24T00:00:00.000Z',
},
];
providersState.statuses = [{ id: 'openrouter-default', hasKey: true }];
providersState.vendors = [
{ id: 'openrouter', name: 'OpenRouter', modelIdPlaceholder: 'anthropic/claude-opus-4.6' },
];
providersState.defaultAccountId = 'openrouter-default';
render(<Agents />);
await waitFor(() => {
expect(fetchAgentsMock).toHaveBeenCalledTimes(1);
});
fireEvent.click(screen.getByTitle('settings'));
fireEvent.click(screen.getByText('settingsDialog.modelLabel').closest('button') as HTMLButtonElement);
const useDefaultButton = await screen.findByRole('button', { name: 'settingsDialog.useDefaultModel' });
const modelIdInput = screen.getByLabelText('settingsDialog.modelIdLabel');
const saveButton = screen.getByRole('button', { name: 'common:actions.save' });
expect(useDefaultButton).toBeDisabled();
fireEvent.change(modelIdInput, { target: { value: 'anthropic/claude-sonnet-4.5' } });
expect(useDefaultButton).toBeEnabled();
expect(saveButton).toBeEnabled();
fireEvent.click(useDefaultButton);
expect(updateAgentModelMock).not.toHaveBeenCalled();
expect((modelIdInput as HTMLInputElement).value).toBe('anthropic/claude-opus-4.6');
expect(useDefaultButton).toBeDisabled();
});
});

View File

@@ -19,6 +19,8 @@ const mocks = vi.hoisted(() => ({
setOpenClawDefaultModelWithOverride: vi.fn(),
syncProviderConfigToOpenClaw: vi.fn(),
updateAgentModelProvider: vi.fn(),
updateSingleAgentModelProvider: vi.fn(),
listAgentsSnapshot: vi.fn(),
}));
vi.mock('@electron/services/providers/provider-store', () => ({
@@ -50,6 +52,11 @@ vi.mock('@electron/utils/openclaw-auth', () => ({
setOpenClawDefaultModelWithOverride: mocks.setOpenClawDefaultModelWithOverride,
syncProviderConfigToOpenClaw: mocks.syncProviderConfigToOpenClaw,
updateAgentModelProvider: mocks.updateAgentModelProvider,
updateSingleAgentModelProvider: mocks.updateSingleAgentModelProvider,
}));
vi.mock('@electron/utils/agent-config', () => ({
listAgentsSnapshot: mocks.listAgentsSnapshot,
}));
vi.mock('@electron/utils/logger', () => ({
@@ -62,6 +69,7 @@ vi.mock('@electron/utils/logger', () => ({
}));
import {
syncAgentModelOverrideToRuntime,
syncDefaultProviderToRuntime,
syncDeletedProviderToRuntime,
syncSavedProviderToRuntime,
@@ -109,6 +117,8 @@ describe('provider-runtime-sync refresh strategy', () => {
mocks.saveProviderKeyToOpenClaw.mockResolvedValue(undefined);
mocks.removeProviderFromOpenClaw.mockResolvedValue(undefined);
mocks.updateAgentModelProvider.mockResolvedValue(undefined);
mocks.updateSingleAgentModelProvider.mockResolvedValue(undefined);
mocks.listAgentsSnapshot.mockResolvedValue({ agents: [] });
});
it('uses debouncedReload after saving provider config', async () => {
@@ -142,4 +152,48 @@ describe('provider-runtime-sync refresh strategy', () => {
expect(gateway.debouncedReload).not.toHaveBeenCalled();
expect(gateway.debouncedRestart).not.toHaveBeenCalled();
});
it('syncs a targeted agent model override to runtime provider registry', async () => {
mocks.getAllProviders.mockResolvedValue([
createProvider({
id: 'ark',
type: 'ark',
model: 'doubao-pro',
}),
]);
mocks.getProviderConfig.mockImplementation((providerType: string) => {
if (providerType === 'ark') {
return {
api: 'openai-completions',
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
apiKeyEnv: 'ARK_API_KEY',
};
}
return {
api: 'openai-completions',
baseUrl: 'https://api.moonshot.cn/v1',
apiKeyEnv: 'MOONSHOT_API_KEY',
};
});
mocks.listAgentsSnapshot.mockResolvedValue({
agents: [
{
id: 'coder',
modelRef: 'ark/ark-code-latest',
},
],
});
await syncAgentModelOverrideToRuntime('coder');
expect(mocks.updateSingleAgentModelProvider).toHaveBeenCalledWith(
'coder',
'ark',
expect.objectContaining({
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
api: 'openai-completions',
models: [{ id: 'ark-code-latest', name: 'ark-code-latest' }],
}),
);
});
});