Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bots.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
"larkAppSecret": "your_secret_1",
"cliId": "claude-code",
"model": "sonnet",
"groupDefaultModels": {
"oc_xxx_team": { "claude-code": { "model": "opus", "reasoningEffort": "high" } }
},
"disableCliBypass": true,
"allowedUsers": ["alice@company.com"],
"allowedChatGroups": ["oc_xxx_team"],
Expand Down
9 changes: 8 additions & 1 deletion docs-site/docs/en/bots-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ There are many fields, listed below grouped by purpose. The vast majority are **
|------|------|
| `name` | Process name suffix, e.g. `claude-main` → `botmux-claude-main`; leave empty to default to `botmux-<index>` |
| `cliId` | CLI adapter, defaults to `claude-code`. See [Multi-CLI adapters](/en/adapters) |
| `model` | Model name used to launch the CLI (e.g. `claude --model opus`); leave empty to use the CLI default. Multiple bots with the same `cliId` can run different models. Each adapter's `modelChoices` are the candidates offered in `botmux setup`. **Resolved from the current config on every CLI launch**, resume included: a change (dashboard or this file) also applies to **existing sessions**, from their next launch/resume onward. Unlike `cliId` / `cliRuntime` / `wrapperCli`, which are frozen when the session is created so a live conversation never has its runtime swapped underneath it |
| `model` | Model name used to launch the CLI (e.g. `claude --model opus`); leave empty to use the CLI default. Multiple bots with the same `cliId` can run different models. Each adapter's `modelChoices` are the candidates offered in `botmux setup`. **Resolved from the current config on every CLI launch**, resume included: a change (dashboard or this file) also applies to **existing sessions without a captured group override**, from their next launch/resume onward. Unlike `cliId` / `cliRuntime` / `wrapperCli`, which are frozen when the session is created so a live conversation never has its runtime swapped underneath it |
| `groupDefaultModels` | Per-chat defaults for new topics, e.g. `{ "oc_team": { "codex": { "model": "your-codex-model", "reasoningEffort": "high" } } }`. Supports Codex and Claude; configure each bot from Dashboard group management |
| `reasoningEffort` | Default reasoning effort for new sessions. Only applies to CLIs with structured reasoning controls (`codex` / `codex-app` / `traex` / `grok`); values are validated against the selected CLI/model, and unsupported or undeclared combinations are rejected or ignored |
| `nativeSubagentRuntime` | Trae-only native subagent runtime policy. Configure `model` and `reasoningEffort` independently as `{ "mode": "custom", "value": "..." }`; an absent dimension passes through the value from the subagent request. Remove the whole field when both dimensions pass through. `inherit` is not a supported mode |
| `cliRuntime` | Structured runtime descriptor for a Codex-compatible distribution: `{ id, displayName?, executable, update? }`. It reuses the `codex` adapter while retaining its own version, update source, and session identity. See [Codex-compatible distributions](/en/adapters#codex-compatible-distributions) |
Expand All @@ -62,6 +63,12 @@ There are many fields, listed below grouped by purpose. The vast majority are **

`nativeSubagentRuntime` rewrites only new subagents created through Trae's native `spawn_agent`; it does not alter the parent agent itself. An absent dimension passes through the subagent request, while `custom` replaces it with a fixed value. When both a custom model and custom effort are configured, BotMux validates that Trae supports the combination. Switching the bot to another CLI removes this field automatically. In the Dashboard, “Pass through request” corresponds to an absent dimension. This policy is behavior configuration and is copied when cloning a bot, but it is intentionally excluded from portable Agent presets. Legacy `mode: "inherit"` values are invalid and are not applied.

### Per-group defaults for new topics

Each bot owns its own `groupDefaultModels`, keyed by chat ID and CLI. The Dashboard follows the bot’s Agent CLI and shows only its model and reasoning-effort dropdowns, reusing Agent model discovery and effort validation. Both fields can inherit the Agent defaults. Custom models and legacy string-valued entries remain supported. A new topic captures these settings when its session is created; later edits or clearing the group configuration do not alter that topic on restart or resume. Selecting a CLI uses only its matching entry. Topics without a group override retain the existing live bot-model fallback. Direct messages, chat-scoped group sessions, and adopted external sessions do not use the snapshot.

Precedence: explicit trigger model > captured group model > matching bot model > existing cross-CLI fallback. Reasoning effort is also captured for new topics; explicit trigger settings can override it. The CLI and runtime remain unchanged. Dashboard saves apply without restarting the daemon. Choosing inheritance removes the corresponding override for future topics while retaining historical settings for other CLIs; manual file edits follow the existing configuration-loading procedure.

### Automatic CLI quota handoff

`quotaFallbackBot` lets the daemon post one fixed, real `@` to a backup Bot at the original session landing point once the current CLI is confirmed quota-limited. It does not call the exhausted primary model, and the existing limit card and owner notification remain unchanged.
Expand Down
13 changes: 12 additions & 1 deletion docs-site/docs/zh/bots-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
|------|------|
| `name` | 进程名后缀,如 `claude-main` → `botmux-claude-main`;留空默认 `botmux-<序号>` |
| `cliId` | CLI 适配器,默认 `claude-code`。见 [多 CLI 适配器](/adapters) |
| `model` | 启动 CLI 用的模型名(如 `claude --model opus`);留空走 CLI 默认。同一 `cliId` 的多个 bot 可跑不同模型。各适配器的 `modelChoices` 是 `botmux setup` 里给出的候选。**每次启动 CLI 时都按当前配置解析**(含 resume):改完(dashboard 或本文件)对**存量会话**也生效,在它下一次启动/恢复时应用;与 `cliId` / `cliRuntime` / `wrapperCli` 不同——那几个在会话创建时冻结,避免中途换掉底层运行时 |
| `model` | 启动 CLI 用的模型名(如 `claude --model opus`);留空走 CLI 默认。同一 `cliId` 的多个 bot 可跑不同模型。各适配器的 `modelChoices` 是 `botmux setup` 里给出的候选。**每次启动 CLI 时都按当前配置解析**(含 resume):改完(dashboard 或本文件)对**未设置群级模型的存量会话**也生效,在它下一次启动/恢复时应用;与 `cliId` / `cliRuntime` / `wrapperCli` 不同——那几个在会话创建时冻结,避免中途换掉底层运行时 |
| `groupDefaultModels` | 按群 ID 配置新话题默认模型,例如 `{ "oc_team": { "codex": { "model": "your-codex-model", "reasoningEffort": "high" } } }`;目前仅支持 Codex 和 Claude。可在 Dashboard「群管理 → 新话题默认模型」按 Bot 配置 |
| `reasoningEffort` | 新会话默认思考强度。仅对 `codex` / `codex-app` / `traex` / `grok` 这类有结构化思考强度控制的 CLI 生效;按 CLI 与模型能力校验,不支持或未声明支持的组合会被拒绝或忽略 |
| `nativeSubagentRuntime` | 仅 `traex` 生效的原生子代理运行策略。`model` 与 `reasoningEffort` 可独立省略以透传子代理请求,或设为 `{ "mode": "custom", "value": "..." }` 以指定固定值;两个维度都透传时应删除整个字段。`inherit` 不是受支持的模式 |
| `cliRuntime` | Codex 兼容发行版的结构化运行时描述:`{ id, displayName?, executable, update? }`。它复用 `codex` 适配器,但版本、更新源和会话身份都属于该发行版。见 [Codex 兼容发行版](/adapters#codex-兼容发行版) |
Expand All @@ -62,6 +63,16 @@

`nativeSubagentRuntime` 只改写 Trae 原生 `spawn_agent` 创建的新子代理,不改变父代理自身配置。缺少某一维时透传子代理请求中的原值;`custom` 使用固定值。自定义模型和自定义思考强度同时设置时,BotMux 会校验该组合是否受 Trae 支持。切换到其它 CLI 会自动删除此字段。Dashboard 中“透传子代理请求”对应字段缺失;该策略属于 Bot 行为配置,克隆 Bot 时会复制,但不会进入可移植 Agent preset。旧版 `mode: "inherit"` 配置无效且不会生效。

### 群级新话题默认模型

每个 Bot 的 `groupDefaultModels` 独立配置;不同群、不同 Bot 的模型互不影响。Dashboard 中的 CLI 跟随 Bot 的 Agent 配置,只显示当前 CLI 的模型和思考强度。下拉列表复用 Agent 配置的静态候选、实时模型探测及强度校验,支持继承默认值和自定义模型名称。旧版模型字符串配置仍兼容。

新话题创建时保存该群的模型快照。后续修改或清空群配置只影响新话题,已有话题在重启、恢复时仍使用创建时的群模型。话题首次选择另一种 CLI 时只使用该 CLI 对应的快照,不会把 Claude 模型传给 Codex。未配置群模型的话题继续使用原有 Bot 默认模型规则;没有 Bot 模型时由 CLI 自行选择。私聊、普通群的 chat-scope 会话和外部接管会话不使用此快照。

优先级:显式触发模型 > 新话题保存的群模型 > 同 CLI 的 Bot 模型 > 原有 CLI 不匹配回退。思考强度也在新话题创建时保存,显式触发参数仍可覆盖。此配置不改变 CLI 类型或运行环境。

Dashboard 保存后无需重启 daemon。模型、思考强度分别选择“继承 Agent”可取消相应覆盖;两项都继承时删除当前 CLI 的覆盖,保留其它 CLI 的历史配置。手动编辑 `bots.json` 则沿用原有配置加载方式。

### CLI 限额自动交接

`quotaFallbackBot` 让 daemon 在当前 CLI 确认进入额度限制状态时,用固定文案在原会话落点真实 `@` 一个备用 Bot。它不调用已耗尽额度的主模型,也不会改变原有的限额卡片或 owner 通知。
Expand Down
Binary file added docs/assets/group-default-models.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { logger } from './utils/logger.js';
import { isLocale, setBotLookup, type Locale } from './i18n/index.js';
import type { VoiceConfig } from './services/voice/types.js';
import { normalizeGroupDefaultModels, type GroupDefaultModels } from './core/group-default-models.js';
import type { PricingOverrides } from './services/model-pricing.js';
import type { BudgetConfig } from './services/budget-tracker.js';
import { normalizePricingOverrides } from './services/model-pricing.js';
Expand Down Expand Up @@ -1449,6 +1450,8 @@ export interface BotConfig {
* `modelChoices` for the curated candidates surfaced in `botmux setup`.
*/
model?: string;
/** Per-chat defaults captured only by newly created topics. */
groupDefaultModels?: Record<string, GroupDefaultModels>;
/** Optional TraeX backend variant. Missing inherits TraeX global config. */
modelBackendVariant?: 'standard' | 'max';
/**
Expand Down Expand Up @@ -3540,6 +3543,7 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] {
model: typeof entry.model === 'string' && entry.model.trim()
? entry.model.trim()
: undefined,
groupDefaultModels: normalizeGroupDefaultModels(entry.groupDefaultModels),
modelBackendVariant: entryCliId === 'traex'
&& (entry.modelBackendVariant === 'standard' || entry.modelBackendVariant === 'max')
? entry.modelBackendVariant
Expand Down
18 changes: 18 additions & 0 deletions src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ import * as oncallStore from '../services/oncall-store.js';
import * as brandStore from '../services/brand-store.js';
import * as sandboxStore from '../services/sandbox-store.js';
import * as backendTypeStore from '../services/backend-type-store.js';
import { setGroupDefaultModels } from '../services/group-default-models-store.js';
import { parseGroupDefaultModels } from './group-default-models.js';
import { setChatStreamingCardPin } from '../services/pin-streaming-card-mode-store.js';
import { isValidRiffBaseUrl, isValidRiffSandboxCluster } from '../adapters/backend/riff-backend.js';
import { ensureBackendAvailable } from '../services/backend-availability.js';
Expand Down Expand Up @@ -4295,14 +4297,28 @@ ipcRoute('POST', '/api/grants/chat', async (req, res) => {

// ─── Groups (Phase B) ──────────────────────────────────────────────────────

ipcRoute('PUT', '/api/group-default-models/:chatId', async (req, res, p) => {
if (!cachedLarkAppId) return jsonRes(res, 503, { ok: false, error: 'larkAppId_not_set' });
if (!/^oc_[a-zA-Z0-9_-]+$/.test(p.chatId)) return jsonRes(res, 400, { ok: false, error: 'invalid_chat_id' });
let models;
try { models = parseGroupDefaultModels(await readJsonBody(req)); }
catch (e) { return jsonRes(res, 400, { ok: false, error: e instanceof Error ? e.message : 'bad_json' }); }
const result = await setGroupDefaultModels(cachedLarkAppId, p.chatId, models);
return jsonRes(res, result.ok ? 200 : result.reason === 'unsupported_reasoning_effort' ? 400 : 500, result);
});

ipcRoute('GET', '/api/groups', async (_req, res) => {
if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' });
try {
const chats = await groupsStore.listChats(cachedLarkAppId);
let agentDefaults: { agentCliId?: string; agentModel?: string; agentReasoningEffort?: string } = {};
let groupDefaultModels: Record<string, import('./group-default-models.js').GroupDefaultModels> = {};
let pinStreamingCardMasterEnabled = false;
let noPinStreamingCardChats = new Set<string>();
try {
const botConfig = getBot(cachedLarkAppId).config;
agentDefaults = { agentCliId: botConfig.cliId, agentModel: botConfig.model, agentReasoningEffort: botConfig.reasoningEffort };
groupDefaultModels = botConfig.groupDefaultModels ?? {};
pinStreamingCardMasterEnabled = botConfig.pinStreamingCard === true;
noPinStreamingCardChats = new Set(botConfig.noPinStreamingCardChats ?? []);
} catch {
Expand All @@ -4328,6 +4344,8 @@ ipcRoute('GET', '/api/groups', async (_req, res) => {
return {
...c,
oncallChat: oncall ?? null,
...agentDefaults,
...(groupDefaultModels[c.chatId] ? { defaultModels: groupDefaultModels[c.chatId] } : {}),
firstSeenAt: seenMap.get(c.chatId) ?? null,
hasRole,
hasMessageListener,
Expand Down
44 changes: 44 additions & 0 deletions src/core/group-default-models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { isCodexReasoningEffort, type CodexReasoningEffort } from '../services/codex-reasoning-effort.js';

export interface GroupModelSettings { model?: string; reasoningEffort?: CodexReasoningEffort }
export function groupModelSettings(value: string | GroupModelSettings | undefined): GroupModelSettings {
return typeof value === 'string' ? { model: value } : value ?? {};
}

/** New-topic defaults, keyed by CLI so model names never cross providers. */
export type GroupDefaultModels = Partial<Record<'codex' | 'claude-code', string | GroupModelSettings>>;

export function parseGroupDefaultModels(raw: unknown): GroupDefaultModels {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error('default_models_must_be_object');
}
const models: GroupDefaultModels = {};
for (const [cli, value] of Object.entries(raw)) {
if (cli !== 'codex' && cli !== 'claude-code') throw new Error('unsupported_model_cli');
const settings = typeof value === 'string' ? { model: value } : value;
if (!settings || typeof settings !== 'object' || Array.isArray(settings)
|| Object.keys(settings).some(key => key !== 'model' && key !== 'reasoningEffort')) throw new Error('invalid_model');
const { model, reasoningEffort } = settings as Record<string, unknown>;
if (model !== undefined && (typeof model !== 'string' || model.length > 200 || /[\x00-\x1f\x7f]/.test(model))) throw new Error('invalid_model');
if (reasoningEffort !== undefined && reasoningEffort !== '' && !isCodexReasoningEffort(reasoningEffort)) throw new Error('invalid_reasoning_effort');
const next: GroupModelSettings = {};
if (typeof model === 'string' && model.trim()) next.model = model.trim();
if (isCodexReasoningEffort(reasoningEffort)) next.reasoningEffort = reasoningEffort;
if (Object.keys(next).length) models[cli] = typeof value === 'string' ? next.model! : next;
}
return models;
}

/** Ignore invalid hand-edited entries without dropping other groups. */
export function normalizeGroupDefaultModels(raw: unknown): Record<string, GroupDefaultModels> {
const groups: Record<string, GroupDefaultModels> = {};
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return groups;
for (const [chatId, value] of Object.entries(raw)) {
if (!/^oc_[a-zA-Z0-9_-]+$/.test(chatId)) continue;
try {
const models = parseGroupDefaultModels(value);
if (Object.keys(models).length) groups[chatId] = models;
} catch { /* Invalid config is not a launch argument. */ }
}
return groups;
}
Loading