diff --git a/README.ja-JP.md b/README.ja-JP.md index c9e4cd538..673927dc7 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -100,6 +100,7 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています ### 💬 インテリジェントチャットインターフェース モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリングに加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。 `@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。 +各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。 ### 📡 マルチチャネル管理 複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。 diff --git a/README.md b/README.md index 0a51bdcc6..322d31381 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 8be813ede..264010877 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -101,6 +101,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们 ### 💬 智能聊天界面 通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染,以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。 当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。 +每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。 ### 📡 多频道管理 同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。 diff --git a/electron/api/routes/agents.ts b/electron/api/routes/agents.ts index dd0973959..be2b9dfad 100644 --- a/electron/api/routes/agents.ts +++ b/electron/api/routes/agents.ts @@ -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]); diff --git a/electron/services/providers/provider-runtime-sync.ts b/electron/services/providers/provider-runtime-sync.ts index 6d6234e71..a0af1d359 100644 --- a/electron/services/providers/provider-runtime-sync.ts +++ b/electron/services/providers/provider-runtime-sync.ts @@ -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> { + const configs = await getAllProviders(); + const runtimeMap = new Map(); + + 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): Promise { + 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 { + 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}"`, diff --git a/electron/utils/agent-config.ts b/electron/utils/agent-config.ts index a6c38c9b0..386735e18 100644 --- a/electron/utils/agent-config.ts +++ b/electron/utils/agent-config.ts @@ -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; channelAccountOwners: Record; } -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 { + 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(); return { @@ -498,6 +512,8 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise toUiChannelType(channelType)), channelOwners, channelAccountOwners, @@ -598,6 +615,44 @@ export async function updateAgentName(agentId: string, name: string): Promise 0 && firstSlash < modelRef.length - 1; +} + +export async function updateAgentModel(agentId: string, modelRef: string | null): Promise { + 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) { diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index a6b298a8d..4267b514a 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -918,18 +918,20 @@ export async function syncSessionIdleMinutesToOpenClaw(): Promise { /** * 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 { - const agentIds = await discoverAgentIds(); for (const agentId of agentIds) { const modelsPath = join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'models.json'); let data: Record = {}; @@ -975,6 +977,26 @@ export async function updateAgentModelProvider( } } +export async function updateAgentModelProvider( + providerType: string, + entry: AgentModelProviderEntry, +): Promise { + const agentIds = await discoverAgentIds(); + await updateModelsJsonProviderEntriesForAgents(agentIds, providerType, entry); +} + +export async function updateSingleAgentModelProvider( + agentId: string, + providerType: string, + entry: AgentModelProviderEntry, +): Promise { + const normalizedAgentId = agentId.trim(); + if (!normalizedAgentId) { + throw new Error('agentId is required'); + } + await updateModelsJsonProviderEntriesForAgents([normalizedAgentId], providerType, entry); +} + /** * Sanitize ~/.openclaw/openclaw.json before Gateway start. * diff --git a/src/i18n/locales/en/agents.json b/src/i18n/locales/en/agents.json index ba9be5360..7de598c97 100644 --- a/src/i18n/locales/en/agents.json +++ b/src/i18n/locales/en/agents.json @@ -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", diff --git a/src/i18n/locales/ja/agents.json b/src/i18n/locales/ja/agents.json index cbfcc1df0..ade06789b 100644 --- a/src/i18n/locales/ja/agents.json +++ b/src/i18n/locales/ja/agents.json @@ -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}} を削除しました", diff --git a/src/i18n/locales/zh/agents.json b/src/i18n/locales/zh/agents.json index d1347aa92..511758ae5 100644 --- a/src/i18n/locales/zh/agents.json +++ b/src/i18n/locales/zh/agents.json @@ -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}} 已移除", diff --git a/src/pages/Agents/index.tsx b/src/pages/Agents/index.tsx index 52f718877..87588fff5 100644 --- a/src/pages/Agents/index.tsx +++ b/src/pages/Agents/index.tsx @@ -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, +): 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({

{agent.id}

-
+
+

+ {agent.modelRef || defaultModelRef || '-'} +

+ @@ -587,6 +646,233 @@ function AgentSettingsModal({ + {showModelModal && ( + setShowModelModal(false)} + /> + )} + + ); +} + +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(() => { + const vendorMap = new Map(providerVendors.map((vendor) => [vendor.id, vendor])); + const statusById = new Map(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(); + 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 ( +
+ + +
+ + {t('settingsDialog.modelLabel')} + + + {t('settingsDialog.modelOverrideDescription', { defaultModel: defaultModelRef || '-' })} + +
+ +
+ +
+ + +
+
+ + setModelIdInput(event.target.value)} + placeholder={selectedProvider?.modelIdPlaceholder || selectedProvider?.configuredModelId || t('settingsDialog.modelIdPlaceholder')} + className={inputClasses} + /> +
+ {!!nextModelRef && ( +

+ {t('settingsDialog.modelPreview')}: {nextModelRef} +

+ )} + {runtimeProviderOptions.length === 0 && ( +

+ {t('settingsDialog.modelProviderEmpty')} +

+ )} +
+ + + +
+
+
); } diff --git a/src/stores/agents.ts b/src/stores/agents.ts index 22b01d323..7aacf02df 100644 --- a/src/stores/agents.ts +++ b/src/stores/agents.ts @@ -6,6 +6,7 @@ import type { AgentSummary, AgentsSnapshot } from '@/types/agent'; interface AgentsState { agents: AgentSummary[]; defaultAgentId: string; + defaultModelRef: string | null; configuredChannelTypes: string[]; channelOwners: Record; channelAccountOwners: Record; @@ -14,6 +15,7 @@ interface AgentsState { fetchAgents: () => Promise; createAgent: (name: string, options?: { inheritWorkspace?: boolean }) => Promise; updateAgent: (agentId: string, name: string) => Promise; + updateAgentModel: (agentId: string, modelRef: string | null) => Promise; deleteAgent: (agentId: string) => Promise; assignChannel: (agentId: string, channelType: ChannelType) => Promise; removeChannel: (agentId: string, channelType: ChannelType) => Promise; @@ -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((set) => ({ agents: [], defaultAgentId: 'main', + defaultModelRef: null, configuredChannelTypes: [], channelOwners: {}, channelAccountOwners: {}, @@ -83,6 +87,23 @@ export const useAgentsStore = create((set) => ({ } }, + updateAgentModel: async (agentId: string, modelRef: string | null) => { + set({ error: null }); + try { + const snapshot = await hostApiFetch( + `/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 { diff --git a/src/types/agent.ts b/src/types/agent.ts index b154833f3..b286c7999 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -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; channelAccountOwners: Record; diff --git a/tests/unit/agent-config.test.ts b/tests/unit/agent-config.test.ts index 3304717eb..77256acaa 100644 --- a/tests/unit/agent-config.test.ts +++ b/tests/unit/agent-config.test.ts @@ -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: { diff --git a/tests/unit/agents-page.test.tsx b/tests/unit/agents-page.test.tsx index 46adec1e0..4be182bc9 100644 --- a/tests/unit/agents-page.test.tsx +++ b/tests/unit/agents-page.test.tsx @@ -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>, + defaultModelRef: null as string | null, loading: false, error: null as string | null, }, + providersState: { + accounts: [] as Array>, + statuses: [] as Array>, + vendors: [] as Array>, + 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; deleteAgent: ReturnType; }) => 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(); + + 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(); + }); }); diff --git a/tests/unit/provider-runtime-sync.test.ts b/tests/unit/provider-runtime-sync.test.ts index 1e6fa4094..772b3bcae 100644 --- a/tests/unit/provider-runtime-sync.test.ts +++ b/tests/unit/provider-runtime-sync.test.ts @@ -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' }], + }), + ); + }); });