fix(console): filter client tools for multi-agent models - #898
Open
Orustown wants to merge 1 commit into
Open
Conversation
Prevent Grok 4.20 multi-agent requests from forwarding beta-gated client tools while preserving hosted search and tool choice semantics.
Author
PR #898 验证:multi-agent 模型客户端工具支持
结论
测试矩阵
测试方法与运行命令测试脚本会对两个 Base URL 各发送 6 次请求:
将下方脚本保存为 chmod +x pr898_client_tools.py
python3 pr898_client_tools.py /path/to/api-key.txt ./pr898-test-results测试脚本 pr898_client_tools_test.py#!/usr/bin/env python3
import json
import os
import sys
import time
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
MODEL = "grok-4.20-multi-agent-0309-xhigh"
PROMPT = "马斯克收购Cursor了吗"
TIMEOUT = 120
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/131 Safari/537.36"
PROTOCOLS = ("messages", "responses", "chat")
def read_key(path):
key = Path(path).read_text(encoding="utf-8").strip()
if not key:
raise ValueError("API key file is empty")
return key
def tool_definition():
return [{
"name": "get_current_time",
"description": "Get the current time",
"input_schema": {
"type": "object",
"properties": {},
"required](streamdown:incomplete-link)
def request_payload(protocol, include_client_tool):
tools = tool_definition() if include_client_tool else None
if protocol == "messages":
payload = {
"model": MODEL,
"max_tokens": 512,
"messages": [{"role": "user", "content": PROMPT}],
}
if tools is not None:
payload["tools"] = tools
return "/v1/messages", payload
if protocol == "responses":
payload = {
"model": MODEL,
"input": PROMPT,
"max_output_tokens": 512,
}
if tools is not None:
payload["tools"] = [{
"type": "function",
"name": item["name"],
"description": item["description"],
"parameters": item["input_schema"],
} for item in tools]
return "/v1/responses", payload
payload = {
"model": MODEL,
"messages": [{"role": "user", "content": PROMPT}],
"max_tokens": 512,
}
if tools is not None:
payload["tools"] = [{
"type": "function",
"function": {
"name": item["name"],
"description": item["description"],
"parameters": item["input_schema"],
},
} for item in tools]
return "/v1/chat/completions", payload
def extract_text(protocol, body):
if not isinstance(body, dict):
return ""
if protocol == "messages":
content = body.get("content", [])
if isinstance(content, list):
return " ".join(
item.get("text", "")
for item in content
if isinstance(item, dict) and item.get("type") == "text"
).strip()
return str(content or "").strip()
if protocol == "responses":
if isinstance(body.get("output_text"), str):
return body["output_text"].strip()
output = body.get("output", [])
parts = []
if isinstance(output, list):
for item in output:
if not isinstance(item, dict):
continue
content = item.get("content", [])
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and isinstance(part.get("text"), str):
parts.append(part["text"])
return " ".join(parts).strip()
choices = body.get("choices", [])
if choices and isinstance(choices[0], dict):
message = choices[0].get("message", {})
if isinstance(message, dict):
content = message.get("content", "")
return content if isinstance(content, str) else ""
return ""
def extract_error(body):
if not isinstance(body, dict):
return ""
error = body.get("error", body)
if isinstance(error, dict):
for key in ("message", "detail", "code"):
value = error.get(key)
if value:
return str(value)
return str(error) if error else ""
def run_case(base_url, protocol, include_client_tool, api_key):
endpoint, payload = request_payload(protocol, include_client_tool)
url = base_url.rstrip("/") + endpoint
request = Request(
url,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
method="POST",
headers={
"Authorization": "Bearer " + api_key,
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
},
)
started = time.monotonic()
status = None
raw = ""
error = ""
try:
with urlopen(request, timeout=TIMEOUT) as response:
status = response.status
raw = response.read().decode("utf-8", errors="replace")
except HTTPError as exc:
status = exc.code
raw = exc.read().decode("utf-8", errors="replace")
except (URLError, TimeoutError, OSError) as exc:
error = str(exc)
try:
body = json.loads(raw) if raw else {}
except json.JSONDecodeError:
body = {"raw": raw[:2000]}
if not error:
error = extract_error(body)
return {
"protocol": protocol,
"include_client_tool": include_client_tool,
"status": status,
"seconds": round(time.monotonic() - started, 3),
"has_answer": bool(extract_text(protocol, body)),
"error": error,
"body": body,
}
def status_text(result):
status = result["status"]
if isinstance(status, int) and 200 <= status < 300:
return "成功,有文本回答" if result["has_answer"] else "成功,无文本回答"
return "失败:" + (result["error"] or "请求失败")[:160]
def write_report(results, output_path, started_at, finished_at):
names = {
"messages": "Messages",
"responses": "Responses",
"chat": "Chat Completions",
}
pairs = {}
for item in results:
label = "after" if item["phase"] == "after" else "before"
pairs[(item["protocol"], item["include_client_tool"], label)] = item
lines = [
"# PR #898 验证报告",
"",
"> 模型:`" + MODEL + "` ",
"> 提示词:`" + PROMPT + "` ",
"> 测试时间:" + started_at + " 至 " + finished_at,
"",
"## 结论",
"",
"- 本报告覆盖 Messages、Responses、Chat Completions 三种协议。",
"- 每种协议分别测试无客户端工具和带客户端工具两种请求。",
"- 结果中的 Base URL 和 API Key 不写入报告。",
"",
"## 测试矩阵",
"",
"| 协议 | 客户端工具 | 修复前 | 修复后 | 对比 |",
"|---|---:|---|---|---|",
]
tool_success_before = 0
tool_success_after = 0
for protocol in PROTOCOLS:
for with_tool in (False, True):
before = pairs[(protocol, with_tool, "before")]
after = pairs[(protocol, with_tool, "after")]
before_ok = isinstance(before["status"], int) and 200 <= before["status"] < 300
after_ok = isinstance(after["status"], int) and 200 <= after["status"] < 300
if with_tool:
tool_success_before += int(before_ok)
tool_success_after += int(after_ok)
if not before_ok and after_ok:
comparison = "**修复后成功**"
elif before_ok and not after_ok:
comparison = "**修复后退化**"
else:
comparison = "行为一致"
before_text = "HTTP %s,%.2fs,%s" % (before["status"], before["seconds"], status_text(before))
after_text = "HTTP %s,%.2fs,%s" % (after["status"], after["seconds"], status_text(after))
lines.append("| %s | %s | %s | %s | %s |" % (
names[protocol], "是" if with_tool else "否",
before_text, after_text, comparison,
))
lines += [
"",
"### 客户端工具汇总",
"",
"- 修复前:%d/3 成功。" % tool_success_before,
"- 修复后:%d/3 成功。" % tool_success_after,
"",
"<details>",
"<summary>原始结果文件</summary>",
"",
"详细 JSON 结果保存在同一输出目录的 `results.json` 中。该文件不包含 API Key,但可能包含服务返回内容,请在公开 PR 前检查并按需脱敏。",
"",
"</details>",
"",
]
output_path.write_text("\n".join(lines), encoding="utf-8")
def main():
if len(sys.argv) != 3:
print("用法:python3 pr898_client_tools_test.py API_KEY_FILE OUTPUT_DIR", file=sys.stderr)
return 2
before_url = os.environ.get("BEFORE_BASE_URL", "").strip()
after_url = os.environ.get("AFTER_BASE_URL", "").strip()
if not before_url or not after_url:
print("请先设置 BEFORE_BASE_URL 和 AFTER_BASE_URL", file=sys.stderr)
return 2
api_key = read_key(sys.argv[1])
output_dir = Path(sys.argv[2])
output_dir.mkdir(parents=True, exist_ok=True)
started_at = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
results = []
for phase, base_url in (("before", before_url), ("after", after_url)):
for protocol in PROTOCOLS:
for with_tool in (False, True):
print("%s %s client_tool=%s" % (phase, protocol, with_tool), flush=True)
result = run_case(base_url, protocol, with_tool, api_key)
result["phase"] = phase
results.append(result)
finished_at = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
(output_dir / "results.json").write_text(
json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8"
)
write_report(results, output_dir / "github-pr-report.md", started_at, finished_at)
print("报告:" + str(output_dir / "github-pr-report.md"))
print("结果:" + str(output_dir / "results.json"))
return 0
if __name__ == "__main__":
raise SystemExit(main()) |
realotz
pushed a commit
to realotz/grok2api
that referenced
this pull request
Aug 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
背景
Grok 上游对
grok-4.20-multi-agent-*设置了客户端工具的 Beta 功能权限门控。这里的 Beta 限制是上游模型能力权限限制:普通账号请求一旦携带function、custom、shell或 MCP 等客户端工具,就可能被拒绝并返回:grok-4.5不受同样的限制。修复内容
grok-4.20-multi-agent-0309不允许客户端工具。function、custom、shell、mcp和mcp_*工具。web_search/x_search服务端工具,并规范化搜索工具别名和重复项。none、auto、required的有效tool_choice语义。invalid_request_error,避免静默降级为普通文本响应。grok-4.5等其他 Console 模型的客户端工具行为。Responses、Chat Completions 和 Anthropic Messages 在转换后都会经过同一 Console 出站归一化边界,因此修复对三种公开 API 一致生效。
修复前后
相同请求携带可选客户端工具和
web_search:/v1/responses/v1/chat/completions/v1/messages验证
go test ./... -count=1通过go vet ./...通过git diff --check通过grok-4.5控制请求:HTTP 200真实账号凭据、客户端 Key 和本地测试配置未包含在本 PR 中。