From 4a7f494ba523a42e10466ab31a63601bc1175e16 Mon Sep 17 00:00:00 2001 From: wuwuzhijing Date: Sat, 16 May 2026 12:03:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(delegate):=20per-task=20model=20routing=20?= =?UTF-8?q?=E2=80=94=20model/provider/base=5Furl/api=5Fkey=20overrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delegate_task 顶层和 per-task 均支持 model/provider/base_url/api_key 参数 - 每个子任务可按 provider 名称解析完整凭证(base_url、api_key、api_mode) - 顶层参数作为所有子任务的默认值,per-task 参数覆盖顶层 - 新增 DELEGATE_TASK_SCHEMA 字段定义 - 新增 3 个单元测试覆盖 batch mode / single-task / provider-only resolution - 在 scripts/release.py AUTHOR_MAP 中注册 wuyefeima9@gmail.com --- scripts/release.py | 2 + tests/tools/test_delegate.py | 203 +++++++++++++++++++++++++++++++++++ tools/delegate_tool.py | 100 +++++++++++++++-- 3 files changed, 297 insertions(+), 8 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index 6084e0754c01e..d2a1e366e7ae6 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -66,6 +66,8 @@ "jeremy@geocaching.com": "outdoorsea", "leone.parise@gmail.com": "leoneparise", "mr@shu.io": "mrshu", + "chuang.guo@hopechart.com": "郭闯", + "wuyefeima9@gmail.com": "wuwuzhijing", "adam.manning@gmail.com": "am423", "buraysandro9@gmail.com": "ygd58", "108427749+buntingszn@users.noreply.github.com": "buntingszn", diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 468fbdaf942ff..0bdc920baa3fd 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -69,6 +69,12 @@ def test_schema_valid(self): self.assertIn("tasks", props) self.assertIn("context", props) self.assertIn("toolsets", props) + # Per-call model routing lets a single delegate_task invocation assign + # different providers/models to different children without changing the + # global delegation config. + for key in ("model", "provider", "base_url", "api_key"): + self.assertIn(key, props) + self.assertIn(key, props["tasks"]["items"]["properties"]) # max_iterations is intentionally NOT exposed to the model — it's # config-authoritative via delegation.max_iterations so users get # predictable budgets. @@ -1109,6 +1115,203 @@ def test_direct_endpoint_credentials_reach_child_agent(self, mock_creds, mock_cf self.assertEqual(kwargs["api_key"], "local-key") self.assertEqual(kwargs["api_mode"], "chat_completions") + @patch("tools.delegate_tool._load_config") + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_per_task_model_routing_overrides_global_config(self, mock_creds, mock_cfg): + """Each batch task can override model/provider/base_url/api_key independently.""" + mock_cfg.return_value = { + "max_iterations": 45, + "model": "global-model", + "provider": "global-provider", + "base_url": "https://global.example/v1", + "api_key": "global-key", + } + mock_creds.side_effect = [ + { + "model": "global-model", + "provider": "global-provider", + "base_url": "https://global.example/v1", + "api_key": "global-key", + "api_mode": "chat_completions", + }, + { + "model": "deepseek-chat", + "provider": "deepseek", + "base_url": "https://api.deepseek.com/v1", + "api_key": "deepseek-key", + "api_mode": "chat_completions", + }, + { + "model": "glm-4.5", + "provider": "zai", + "base_url": "https://open.bigmodel.cn/api/paas/v4", + "api_key": "glm-key", + "api_mode": "chat_completions", + }, + ] + parent = _make_mock_parent(depth=0) + + with patch("tools.delegate_tool._build_child_agent") as mock_build, \ + patch("tools.delegate_tool._run_single_child") as mock_run: + mock_child_a = MagicMock() + mock_child_b = MagicMock() + mock_build.side_effect = [mock_child_a, mock_child_b] + mock_run.side_effect = [ + { + "task_index": 0, + "status": "completed", + "summary": "A", + "api_calls": 1, + "duration_seconds": 1.0, + }, + { + "task_index": 1, + "status": "completed", + "summary": "B", + "api_calls": 1, + "duration_seconds": 1.0, + }, + ] + + tasks = [ + { + "goal": "DeepSeek review", + "model": "deepseek-chat", + "provider": "deepseek", + "base_url": "https://api.deepseek.com/v1", + "api_key": "deepseek-key", + }, + { + "goal": "GLM review", + "model": "glm-4.5", + "provider": "zai", + "base_url": "https://open.bigmodel.cn/api/paas/v4", + "api_key": "glm-key", + }, + ] + delegate_task(tasks=tasks, parent_agent=parent) + + self.assertEqual(mock_build.call_count, 2) + first = mock_build.call_args_list[0].kwargs + second = mock_build.call_args_list[1].kwargs + self.assertEqual(first["model"], "deepseek-chat") + self.assertEqual(first["override_provider"], "deepseek") + self.assertEqual(first["override_base_url"], "https://api.deepseek.com/v1") + self.assertEqual(first["override_api_key"], "deepseek-key") + self.assertEqual(second["model"], "glm-4.5") + self.assertEqual(second["override_provider"], "zai") + self.assertEqual(second["override_base_url"], "https://open.bigmodel.cn/api/paas/v4") + self.assertEqual(second["override_api_key"], "glm-key") + + @patch("tools.delegate_tool._load_config") + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_toplevel_model_routing_overrides_global_config(self, mock_creds, mock_cfg): + """Top-level model routing applies to single-task mode.""" + mock_cfg.return_value = { + "max_iterations": 45, + "model": "global-model", + "provider": "global-provider", + "base_url": "https://global.example/v1", + "api_key": "global-key", + } + mock_creds.side_effect = [ + { + "model": "global-model", + "provider": "global-provider", + "base_url": "https://global.example/v1", + "api_key": "global-key", + "api_mode": "chat_completions", + }, + { + "model": "kimi-k2-0905-preview", + "provider": "moonshot", + "base_url": "https://api.moonshot.cn/v1", + "api_key": "kimi-key", + "api_mode": "chat_completions", + }, + ] + parent = _make_mock_parent(depth=0) + + with patch("tools.delegate_tool._build_child_agent") as mock_build, \ + patch("tools.delegate_tool._run_single_child") as mock_run: + mock_build.return_value = MagicMock() + mock_run.return_value = { + "task_index": 0, + "status": "completed", + "summary": "Done", + "api_calls": 1, + "duration_seconds": 1.0, + } + + delegate_task( + goal="KIMI efficiency review", + model="kimi-k2-0905-preview", + provider="moonshot", + base_url="https://api.moonshot.cn/v1", + api_key="kimi-key", + parent_agent=parent, + ) + + _, kwargs = mock_build.call_args + self.assertEqual(kwargs["model"], "kimi-k2-0905-preview") + self.assertEqual(kwargs["override_provider"], "moonshot") + self.assertEqual(kwargs["override_base_url"], "https://api.moonshot.cn/v1") + self.assertEqual(kwargs["override_api_key"], "kimi-key") + + @patch("tools.delegate_tool._load_config") + @patch("tools.delegate_tool._resolve_delegation_credentials") + def test_per_task_provider_resolved_when_only_provider_and_model_given(self, mock_creds, mock_cfg): + """Per-task provider/model must use provider resolution, not raw parent credentials.""" + mock_cfg.return_value = {"max_iterations": 45} + mock_creds.side_effect = [ + { + "model": None, + "provider": None, + "base_url": None, + "api_key": None, + "api_mode": None, + }, + { + "model": "glm-4.5", + "provider": "zai", + "base_url": "https://open.bigmodel.cn/api/paas/v4", + "api_key": "resolved-glm-key", + "api_mode": "chat_completions", + "command": "glm-acp", + "args": ["--stdio"], + }, + ] + parent = _make_mock_parent(depth=0) + + with patch("tools.delegate_tool._build_child_agent") as mock_build, \ + patch("tools.delegate_tool._run_single_child") as mock_run: + mock_build.return_value = MagicMock() + mock_run.return_value = { + "task_index": 0, + "status": "completed", + "summary": "Done", + "api_calls": 1, + "duration_seconds": 1.0, + } + + delegate_task( + tasks=[{"goal": "GLM review", "model": "glm-4.5", "provider": "zai"}], + parent_agent=parent, + ) + + self.assertEqual(mock_creds.call_count, 2) + resolved_cfg = mock_creds.call_args_list[1].args[0] + self.assertEqual(resolved_cfg["model"], "glm-4.5") + self.assertEqual(resolved_cfg["provider"], "zai") + _, kwargs = mock_build.call_args + self.assertEqual(kwargs["model"], "glm-4.5") + self.assertEqual(kwargs["override_provider"], "zai") + self.assertEqual(kwargs["override_base_url"], "https://open.bigmodel.cn/api/paas/v4") + self.assertEqual(kwargs["override_api_key"], "resolved-glm-key") + self.assertEqual(kwargs["override_api_mode"], "chat_completions") + self.assertEqual(kwargs["override_acp_command"], "glm-acp") + self.assertEqual(kwargs["override_acp_args"], ["--stdio"]) + @patch("tools.delegate_tool._load_config") @patch("tools.delegate_tool._resolve_delegation_credentials") def test_empty_config_inherits_parent(self, mock_creds, mock_cfg): diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index f3a037c4341ad..f3abc6c4ea4a3 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -1918,6 +1918,10 @@ def delegate_task( max_iterations: Optional[int] = None, acp_command: Optional[str] = None, acp_args: Optional[List[str]] = None, + model: Optional[str] = None, + provider: Optional[str] = None, + base_url: Optional[str] = None, + api_key: Optional[str] = None, role: Optional[str] = None, parent_agent=None, ) -> str: @@ -1993,6 +1997,10 @@ def delegate_task( return tool_error(str(exc)) # Normalize to task list + per_call_model = str(model or "").strip() or None + per_call_provider = str(provider or "").strip() or None + per_call_base_url = str(base_url or "").strip() or None + per_call_api_key = str(api_key or "").strip() or None max_children = _get_max_concurrent_children() recovered_tasks, tasks_error = _recover_tasks_from_json_string(tasks) if tasks_error: @@ -2012,7 +2020,16 @@ def delegate_task( task_list = tasks elif goal and isinstance(goal, str) and goal.strip(): task_list = [ - {"goal": goal, "context": context, "toolsets": toolsets, "role": top_role} + { + "goal": goal, + "context": context, + "toolsets": toolsets, + "role": top_role, + "model": per_call_model, + "provider": per_call_provider, + "base_url": per_call_base_url, + "api_key": per_call_api_key, + } ] else: return tool_error("Provide either 'goal' (single task) or 'tasks' (batch).") @@ -2053,26 +2070,57 @@ def delegate_task( # Per-task role beats top-level; normalise again so unknown # per-task values warn and degrade to leaf uniformly. effective_role = _normalize_role(t.get("role") or top_role) + task_model_override = str(t.get("model") or "").strip() or per_call_model + task_provider_override = str(t.get("provider") or "").strip() or per_call_provider + task_base_url_override = str(t.get("base_url") or "").strip() or per_call_base_url + task_api_key_override = str(t.get("api_key") or "").strip() or per_call_api_key + if any( + ( + task_model_override, + task_provider_override, + task_base_url_override, + task_api_key_override, + ) + ): + task_cfg = dict(cfg) + if task_model_override: + task_cfg["model"] = task_model_override + if task_provider_override: + task_cfg["provider"] = task_provider_override + if task_base_url_override: + task_cfg["base_url"] = task_base_url_override + if task_api_key_override: + task_cfg["api_key"] = task_api_key_override + try: + task_creds = _resolve_delegation_credentials(task_cfg, parent_agent) + except ValueError as exc: + return tool_error(f"Task {i} credential resolution failed: {exc}") + else: + task_creds = creds + task_model = task_model_override or task_creds["model"] + task_provider = task_creds["provider"] + task_base_url = task_base_url_override or task_creds["base_url"] + task_api_key = task_api_key_override or task_creds["api_key"] child = _build_child_agent( task_index=i, goal=t["goal"], context=t.get("context"), toolsets=t.get("toolsets") or toolsets, - model=creds["model"], + model=task_model, max_iterations=effective_max_iter, task_count=n_tasks, parent_agent=parent_agent, - override_provider=creds["provider"], - override_base_url=creds["base_url"], - override_api_key=creds["api_key"], - override_api_mode=creds["api_mode"], + override_provider=task_provider, + override_base_url=task_base_url, + override_api_key=task_api_key, + override_api_mode=task_creds["api_mode"], override_acp_command=t.get("acp_command") or acp_command - or creds.get("command"), + or task_creds.get("command"), override_acp_args=( task_acp_args if task_acp_args is not None - else (acp_args if acp_args is not None else creds.get("args")) + else (acp_args if acp_args is not None else task_creds.get("args")) ), role=effective_role, ) @@ -2699,6 +2747,22 @@ def _build_dynamic_schema_overrides() -> dict: "items": {"type": "string"}, "description": f"Toolsets for this specific task. Available: {_TOOLSET_LIST_STR}. Use 'web' for network access, 'terminal' for shell, 'browser' for web interaction.", }, + "model": { + "type": "string", + "description": "Per-task model override. Overrides top-level and delegation config model for this task only.", + }, + "provider": { + "type": "string", + "description": "Per-task provider override (e.g. openrouter, anthropic, zai, moonshot, custom:name). Overrides delegation config provider for this task only.", + }, + "base_url": { + "type": "string", + "description": "Per-task direct OpenAI-compatible base URL override. Use with api_key/model when the provider is not configured globally.", + }, + "api_key": { + "type": "string", + "description": "Per-task API key override. Only use when the user explicitly provided or configured a key for this task; never invent keys.", + }, "acp_command": { "type": "string", "description": ( @@ -2730,6 +2794,22 @@ def _build_dynamic_schema_overrides() -> dict: "enum": ["leaf", "orchestrator"], "description": "(rebuilt at get_definitions() time)", }, + "model": { + "type": "string", + "description": "Model override for this delegate_task call. In batch mode, per-task model fields override this value.", + }, + "provider": { + "type": "string", + "description": "Provider override for this delegate_task call (e.g. openrouter, anthropic, zai, moonshot, custom:name). In batch mode, per-task provider fields override this value.", + }, + "base_url": { + "type": "string", + "description": "Direct OpenAI-compatible base URL override for this delegate_task call. In batch mode, per-task base_url fields override this value.", + }, + "api_key": { + "type": "string", + "description": "API key override for this delegate_task call. Only use keys explicitly provided/configured by the user; per-task api_key fields override this value.", + }, "acp_command": { "type": "string", "description": ( @@ -2773,6 +2853,10 @@ def _build_dynamic_schema_overrides() -> dict: max_iterations=args.get("max_iterations"), acp_command=args.get("acp_command"), acp_args=args.get("acp_args"), + model=args.get("model"), + provider=args.get("provider"), + base_url=args.get("base_url"), + api_key=args.get("api_key"), role=args.get("role"), parent_agent=kw.get("parent_agent"), ),