Skip to content

Commit 94e07b6

Browse files
committed
feat(console): add per-cron-job model override picker
The backend already honors request.model_slot_override when a cron job fires (runtime builder reads it off the request and model_factory's _resolve_model_slot_override resolves a ModelSlotConfig dict or a "<provider_id>:<model>" string, taking precedence over the agent's active model). The console had no way to set it. - JobDrawer: model select for agent-type tasks, options built from the /models provider list in "<provider_id>:<model>" form; clearable, empty means follow the agent's active model; a previously saved override stays selectable even if the model is no longer listed. - Submit/edit mapping normalizes string|dict <-> form value and drops the key when cleared. - Document model_slot_override on CronJobRequest. - i18n: en/zh/ja/ru/pt-BR. - Unit tests for the option builder and value normalization.
1 parent 59f2849 commit 94e07b6

10 files changed

Lines changed: 306 additions & 0 deletions

File tree

console/src/api/types/cronjob.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,23 @@ export interface CronJobRuntime {
3737
tool_safety?: boolean;
3838
}
3939

40+
export interface CronJobModelSlotOverride {
41+
provider_id: string;
42+
model: string;
43+
}
44+
4045
export interface CronJobRequest {
4146
input: unknown;
4247
session_id?: string | null;
4348
user_id?: string | null;
49+
/**
50+
* Per-job model override, consumed by the backend as
51+
* `request.model_slot_override` (takes precedence over the agent's
52+
* active model). Accepts a "<provider_id>:<model>" string or a dict;
53+
* the UI always submits the string form. Omit to follow the agent's
54+
* active model.
55+
*/
56+
model_slot_override?: string | CronJobModelSlotOverride | null;
4457
[key: string]: unknown;
4558
}
4659

console/src/locales/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1409,6 +1409,9 @@
14091409
"textTooltip": "Simple message task: this is the actual message body. Required when task type is 'text'.",
14101410
"requestInputTooltip": "Message content in JSON format. This is what the agent will receive and process. Required when task type is 'agent'.",
14111411
"requestInputExample": "Format: [{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Your message here\"}]}]",
1412+
"modelOverride": "Model Override",
1413+
"modelOverrideTooltip": "Run this job with a different model. Leave empty to use the agent's active model. Submitted as request.model_slot_override in '<provider_id>:<model>' form.",
1414+
"modelOverrideDefault": "Follow the agent's active model",
14121415
"requestSessionIdTooltip": "Session ID for the request context. Use 'default' if unsure.",
14131416
"requestUserIdTooltip": "User ID that initiates the request. Use 'system' for automated tasks.",
14141417
"dispatchChannelTooltip": "Target channel where the response will be sent (e.g., 'console', 'discord', 'imessage').",

console/src/locales/ja.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -957,6 +957,9 @@
957957
"textTooltip": "シンプルなメッセージタスク:実際に送る本文です。タスクタイプが「text」のとき必須です。",
958958
"requestInputTooltip": "JSON形式のメッセージ内容。エージェントが受け取って処理する内容です。タスクタイプが「agent」のとき必須です。",
959959
"requestInputExample": "フォーマット: [{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"ここにメッセージを入力\"}]}]",
960+
"modelOverride": "モデル上書き",
961+
"modelOverrideTooltip": "この cron ジョブを別のモデルで実行します。空の場合はエージェントのアクティブモデルを使用します。request.model_slot_override として「<provider_id>:<model>」形式で送信されます。",
962+
"modelOverrideDefault": "エージェントのアクティブモデルに従う",
960963
"requestSessionIdTooltip": "リクエストコンテキストのセッションID。不明な場合は「default」を使用してください。",
961964
"requestUserIdTooltip": "リクエストを開始するユーザーID。自動タスクには「system」を使用してください。",
962965
"dispatchChannelTooltip": "レスポンスを送信する対象チャンネル(例: 'console', 'discord', 'imessage')。",

console/src/locales/pt-BR.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,6 +1179,9 @@
11791179
"textTooltip": "Tarefa de mensagem simples: este é o corpo da mensagem. Obrigatório quando o tipo é 'text'.",
11801180
"requestInputTooltip": "Conteúdo da mensagem em JSON. É o que o agente receberá e processará. Obrigatório quando o tipo é 'agent'.",
11811181
"requestInputExample": "Formato: [{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Sua mensagem aqui\"}]}]",
1182+
"modelOverride": "Substituição de modelo",
1183+
"modelOverrideTooltip": "Execute esta tarefa com um modelo diferente. Deixe vazio para usar o modelo ativo do agente. Enviado como request.model_slot_override no formato \"<provider_id>:<model>\".",
1184+
"modelOverrideDefault": "Seguir o modelo ativo do agente",
11821185
"requestSessionIdTooltip": "ID da sessão para o contexto da requisição. Use 'default' se não tiver certeza.",
11831186
"requestUserIdTooltip": "ID do usuário que inicia a requisição. Use 'system' para tarefas automatizadas.",
11841187
"dispatchChannelTooltip": "Canal de destino onde a resposta será enviada (ex.: 'console', 'discord', 'imessage').",

console/src/locales/ru.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -957,6 +957,9 @@
957957
"textTooltip": "Простая текстовая задача: здесь указывается фактический текст сообщения. Обязательно при типе задачи «text».",
958958
"requestInputTooltip": "Содержимое сообщения в формате JSON. Именно это агент получит и обработает. Обязательно при типе задачи «agent».",
959959
"requestInputExample": "Формат: [{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Ваше сообщение\"}]}]",
960+
"modelOverride": "Переопределение модели",
961+
"modelOverrideTooltip": "Запускать задачу с другой моделью. Если пусто — используется активная модель агента. Отправляется как request.model_slot_override в формате «<provider_id>:<model>».",
962+
"modelOverrideDefault": "Использовать активную модель агента",
960963
"requestSessionIdTooltip": "ID сессии для контекста запроса. Если не уверены, используйте 'default'.",
961964
"requestUserIdTooltip": "ID пользователя, который инициирует запрос. Для автоматических задач используйте 'system'.",
962965
"dispatchChannelTooltip": "Целевой канал, куда будет отправлен ответ (например, 'console', 'discord', 'imessage').",

console/src/locales/zh.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1248,6 +1248,9 @@
12481248
"textTooltip": "简单消息任务:此处为实际的消息正文,任务类型为'text'时必填。",
12491249
"requestInputTooltip": "JSON 格式的消息内容。这是智能体将接收和处理的内容,任务类型为'agent'时必填。",
12501250
"requestInputExample": "格式:[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"您的消息内容\"}]}]",
1251+
"modelOverride": "模型覆盖",
1252+
"modelOverrideTooltip": "为该定时任务指定不同的模型,留空则使用 Agent 当前生效的模型。以 request.model_slot_override 字段提交,格式为 <provider_id>:<model>。",
1253+
"modelOverrideDefault": "跟随 Agent 当前模型",
12511254
"requestSessionIdTooltip": "请求上下文的会话ID。不确定时使用 'default'。",
12521255
"requestUserIdTooltip": "发起请求的用户ID。自动化任务使用 'system'。",
12531256
"dispatchChannelTooltip": "响应将发送到的目标频道(例如:'console'、'discord'、'imessage')。",

console/src/pages/Control/CronJobs/components/JobDrawer.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ import type {
1616
CronDispatchTargetItem,
1717
CronJobSpecOutput,
1818
} from "../../../../api/types";
19+
import api from "../../../../api";
1920
import { DEFAULT_FORM_VALUES } from "./constants";
21+
import {
22+
buildModelOverrideOptions,
23+
modelOverrideToFormValue,
24+
type ModelOverrideOption,
25+
} from "./modelOverride";
2026
import { useTimezoneOptions } from "../../../../hooks/useTimezoneOptions";
2127
import styles from "../index.module.less";
2228

@@ -54,6 +60,14 @@ export function JobDrawer({
5460
const [channelSearch, setChannelSearch] = useState("");
5561
const [userSearch, setUserSearch] = useState("");
5662
const [sessionSearch, setSessionSearch] = useState("");
63+
const [providerModelOptions, setProviderModelOptions] = useState<
64+
ModelOverrideOption[]
65+
>([]);
66+
const [modelsLoading, setModelsLoading] = useState(false);
67+
const selectedModelOverride = Form.useWatch(
68+
["request", "model_slot_override"],
69+
form,
70+
);
5771
const selectedChannel = Form.useWatch(["dispatch", "channel"], form);
5872
const selectedTaskType = Form.useWatch("task_type", form);
5973
const selectedTargetUserId = Form.useWatch(
@@ -72,9 +86,30 @@ export function JobDrawer({
7286
onReloadTargets().catch((error) =>
7387
console.error("Failed to reload cron dispatch targets", error),
7488
);
89+
setModelsLoading(true);
90+
api
91+
.listProviders()
92+
.then((providers) =>
93+
setProviderModelOptions(buildModelOverrideOptions(providers)),
94+
)
95+
.catch((error) => {
96+
console.error("Failed to load providers for model override", error);
97+
setProviderModelOptions([]);
98+
})
99+
.finally(() => setModelsLoading(false));
75100
}
76101
}, [open, editingJob?.id, onReloadTargets]);
77102

103+
const modelOverrideOptions = useMemo(() => {
104+
const selected = modelOverrideToFormValue(selectedModelOverride);
105+
if (selected && !providerModelOptions.some((o) => o.value === selected)) {
106+
// Keep a previously saved override selectable even when the model is
107+
// no longer listed by its provider.
108+
return [...providerModelOptions, { value: selected, label: selected }];
109+
}
110+
return providerModelOptions;
111+
}, [providerModelOptions, selectedModelOverride]);
112+
78113
useEffect(() => {
79114
if (selectedTaskType === "text") {
80115
form.setFieldValue(["dispatch", "silent"], false);
@@ -610,6 +645,27 @@ export function JobDrawer({
610645
style={{ fontFamily: "monospace", fontSize: 12 }}
611646
/>
612647
</Form.Item>
648+
649+
{agentRequired && (
650+
<Form.Item
651+
name={["request", "model_slot_override"]}
652+
label={t("cronJobs.modelOverride")}
653+
tooltip={t("cronJobs.modelOverrideTooltip")}
654+
>
655+
<Select
656+
showSearch
657+
allowClear
658+
loading={modelsLoading}
659+
placeholder={t("cronJobs.modelOverrideDefault")}
660+
options={modelOverrideOptions}
661+
filterOption={(input, option) =>
662+
(option?.label?.toString() || "")
663+
.toLowerCase()
664+
.includes(input.toLowerCase())
665+
}
666+
/>
667+
</Form.Item>
668+
)}
613669
</>
614670
);
615671
}}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import type { ProviderInfo } from "../../../../api/types";
4+
import {
5+
applyModelOverrideToRequest,
6+
buildModelOverrideOptions,
7+
modelOverrideToFormValue,
8+
} from "./modelOverride";
9+
10+
function makeProvider(overrides: Record<string, unknown> = {}): ProviderInfo {
11+
return {
12+
id: "dashscope",
13+
name: "DashScope",
14+
models: [],
15+
extra_models: [],
16+
...overrides,
17+
} as unknown as ProviderInfo;
18+
}
19+
20+
describe("buildModelOverrideOptions", () => {
21+
it("flattens provider models into provider_id:model options", () => {
22+
const providers = [
23+
makeProvider({
24+
id: "dashscope",
25+
name: "DashScope",
26+
models: [
27+
{ id: "qwen3-max", name: "Qwen3 Max" },
28+
{ id: "qwen3-plus", name: "" },
29+
],
30+
extra_models: [{ id: "qwen-custom", name: "Custom" }],
31+
}),
32+
makeProvider({
33+
id: "openai",
34+
name: "OpenAI",
35+
models: [{ id: "gpt-5", name: "GPT-5" }],
36+
}),
37+
] as unknown as ProviderInfo[];
38+
39+
const options = buildModelOverrideOptions(providers);
40+
41+
expect(options).toHaveLength(4);
42+
expect(options.map((o) => o.value).sort()).toEqual([
43+
"dashscope:qwen-custom",
44+
"dashscope:qwen3-max",
45+
"dashscope:qwen3-plus",
46+
"openai:gpt-5",
47+
]);
48+
// Falls back to model id when display name is empty.
49+
const plus = options.find((o) => o.value === "dashscope:qwen3-plus");
50+
expect(plus?.label).toBe("DashScope / qwen3-plus");
51+
});
52+
53+
it("dedupes models repeated across models/extra_models", () => {
54+
const providers = [
55+
makeProvider({
56+
models: [{ id: "m1", name: "M1" }],
57+
extra_models: [{ id: "m1", name: "M1 dup" }],
58+
}),
59+
] as unknown as ProviderInfo[];
60+
61+
expect(buildModelOverrideOptions(providers)).toHaveLength(1);
62+
});
63+
64+
it("handles null/empty input", () => {
65+
expect(buildModelOverrideOptions(null)).toEqual([]);
66+
expect(buildModelOverrideOptions([])).toEqual([]);
67+
});
68+
});
69+
70+
describe("modelOverrideToFormValue", () => {
71+
it("passes through a trimmed string value", () => {
72+
expect(modelOverrideToFormValue(" dashscope:qwen3-max ")).toBe(
73+
"dashscope:qwen3-max",
74+
);
75+
});
76+
77+
it("converts a dict value to the string form", () => {
78+
expect(
79+
modelOverrideToFormValue({ provider_id: "openai", model: "gpt-5" }),
80+
).toBe("openai:gpt-5");
81+
});
82+
83+
it("keeps colons inside the model name", () => {
84+
expect(
85+
modelOverrideToFormValue({
86+
provider_id: "openrouter",
87+
model: "anthropic/claude:beta",
88+
}),
89+
).toBe("openrouter:anthropic/claude:beta");
90+
});
91+
92+
it("returns undefined for empty or unusable values", () => {
93+
expect(modelOverrideToFormValue("")).toBeUndefined();
94+
expect(modelOverrideToFormValue(" ")).toBeUndefined();
95+
expect(modelOverrideToFormValue(undefined)).toBeUndefined();
96+
expect(modelOverrideToFormValue(null)).toBeUndefined();
97+
expect(modelOverrideToFormValue({ provider_id: "openai" })).toBeUndefined();
98+
expect(modelOverrideToFormValue({ model: "gpt-5" })).toBeUndefined();
99+
});
100+
});
101+
102+
describe("applyModelOverrideToRequest", () => {
103+
it("writes the normalized string into request.model_slot_override", () => {
104+
const request: Record<string, unknown> = { input: [] };
105+
applyModelOverrideToRequest(request, " dashscope:qwen3-max ");
106+
expect(request.model_slot_override).toBe("dashscope:qwen3-max");
107+
});
108+
109+
it("removes the key when the value is cleared", () => {
110+
const request: Record<string, unknown> = {
111+
input: [],
112+
model_slot_override: "dashscope:qwen3-max",
113+
};
114+
applyModelOverrideToRequest(request, undefined);
115+
expect("model_slot_override" in request).toBe(false);
116+
});
117+
118+
it("removes the key for blank strings", () => {
119+
const request: Record<string, unknown> = {
120+
model_slot_override: "dashscope:qwen3-max",
121+
};
122+
applyModelOverrideToRequest(request, " ");
123+
expect("model_slot_override" in request).toBe(false);
124+
});
125+
126+
it("is a no-op when request is undefined", () => {
127+
expect(() => applyModelOverrideToRequest(undefined, "a:b")).not.toThrow();
128+
});
129+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { ProviderInfo } from "../../../../api/types";
2+
3+
export type ModelOverrideOption = { value: string; label: string };
4+
5+
/**
6+
* The backend accepts `request.model_slot_override` on cron jobs either as a
7+
* `{provider_id, model}` dict or as a "<provider_id>:<model>" string. The
8+
* model name itself may contain ":" (only the first one separates), so the
9+
* string form is unambiguous and is what this UI uses.
10+
*/
11+
export function buildModelOverrideOptions(
12+
providers: ProviderInfo[] | null | undefined,
13+
): ModelOverrideOption[] {
14+
const options: ModelOverrideOption[] = [];
15+
for (const provider of providers || []) {
16+
if (!provider?.id) continue;
17+
const seen = new Set<string>();
18+
const models = [
19+
...(provider.models || []),
20+
...(provider.extra_models || []),
21+
];
22+
for (const model of models) {
23+
if (!model?.id || seen.has(model.id)) continue;
24+
seen.add(model.id);
25+
options.push({
26+
value: `${provider.id}:${model.id}`,
27+
label: `${provider.name || provider.id} / ${model.name || model.id}`,
28+
});
29+
}
30+
}
31+
return options.sort((a, b) => a.label.localeCompare(b.label));
32+
}
33+
34+
/**
35+
* Normalize the form value of `request.model_slot_override` before submit.
36+
* Mutates `request` in place: writes a trimmed "<provider_id>:<model>"
37+
* string, or removes the key entirely when empty (meaning: follow the
38+
* agent's active model).
39+
*/
40+
export function applyModelOverrideToRequest(
41+
request: Record<string, unknown> | undefined,
42+
value: unknown,
43+
): void {
44+
if (!request) return;
45+
const normalized = modelOverrideToFormValue(value);
46+
if (normalized) {
47+
request.model_slot_override = normalized;
48+
} else {
49+
delete request.model_slot_override;
50+
}
51+
}
52+
53+
/**
54+
* Normalize a persisted `model_slot_override` (string or dict) into the
55+
* "<provider_id>:<model>" string used by the form select. Returns undefined
56+
* when there is no usable override.
57+
*/
58+
export function modelOverrideToFormValue(value: unknown): string | undefined {
59+
if (typeof value === "string" && value.trim()) {
60+
return value.trim();
61+
}
62+
if (value && typeof value === "object") {
63+
const dict = value as { provider_id?: unknown; model?: unknown };
64+
if (
65+
typeof dict.provider_id === "string" &&
66+
dict.provider_id.trim() &&
67+
typeof dict.model === "string" &&
68+
dict.model.trim()
69+
) {
70+
return `${dict.provider_id.trim()}:${dict.model.trim()}`;
71+
}
72+
}
73+
return undefined;
74+
}

0 commit comments

Comments
 (0)