Skip to content

fix(console): filter client tools for multi-agent models - #898

Open
Orustown wants to merge 1 commit into
chenyme:mainfrom
Orustown:fix/grok-4.20-multi-agent-tools
Open

fix(console): filter client tools for multi-agent models#898
Orustown wants to merge 1 commit into
chenyme:mainfrom
Orustown:fix/grok-4.20-multi-agent-tools

Conversation

@Orustown

Copy link
Copy Markdown

背景

Grok 上游对 grok-4.20-multi-agent-* 设置了客户端工具的 Beta 功能权限门控。这里的 Beta 限制是上游模型能力权限限制:普通账号请求一旦携带 functioncustomshell 或 MCP 等客户端工具,就可能被拒绝并返回:

Client-side tools for multi-agent models require beta access

grok-4.5 不受同样的限制。

修复内容

  • 在 Console 模型能力声明中标记 grok-4.20-multi-agent-0309 不允许客户端工具。
  • 在 Console 出站请求归一化阶段,仅对该模型过滤 functioncustomshellmcpmcp_* 工具。
  • 保留调用方明确声明的 web_search / x_search 服务端工具,并规范化搜索工具别名和重复项。
  • 保留 noneautorequired 的有效 tool_choice 语义。
  • 对明确指定已被该模型禁止的客户端工具返回本地 invalid_request_error,避免静默降级为普通文本响应。
  • 不修改 Build、Web Provider,也不改变 grok-4.5 等其他 Console 模型的客户端工具行为。

Responses、Chat Completions 和 Anthropic Messages 在转换后都会经过同一 Console 出站归一化边界,因此修复对三种公开 API 一致生效。

修复前后

相同请求携带可选客户端工具和 web_search

API 修复前 修复后
/v1/responses HTTP 400,Beta 权限错误 HTTP 200
/v1/chat/completions HTTP 400,Beta 权限错误 HTTP 200
/v1/messages HTTP 400,Beta 权限错误 HTTP 200

验证

  • go test ./... -count=1 通过
  • go vet ./... 通过
  • git diff --check 通过
  • 使用 5 个真实 Grok Console 账号通过项目原生 Console 出口代理完成三协议验证:
    • grok-4.5 控制请求:HTTP 200
    • Responses:HTTP 200
    • Chat Completions:HTTP 200
    • Anthropic Messages:HTTP 200
  • 修复前通过确定性上游契约模拟,三种协议均可复现上述 Beta 权限错误;修复后均通过。

真实账号凭据、客户端 Key 和本地测试配置未包含在本 PR 中。

Prevent Grok 4.20 multi-agent requests from forwarding beta-gated client tools while preserving hosted search and tool choice semantics.
@Orustown

Copy link
Copy Markdown
Author

PR #898 验证:multi-agent 模型客户端工具支持

测试模型:grok-4.20-multi-agent-0309-xhigh
测试提示词:马斯克收购Cursor了吗
测试范围:Messages、Responses、Chat 三种协议;每种协议分别测试不携带客户端工具和携带客户端工具。

结论

  • 修复前: 携带客户端工具时,Messages、Responses、Chat 三种协议均失败,全部返回 HTTP 400,错误为 Client-side tools for multi-agent models require beta access
  • 修复后: 携带客户端工具时,三种协议全部成功,均返回 HTTP 200 并得到模型回答。
  • 无客户端工具: 三种协议在修复前后均返回 HTTP 200,未观察到回归。
  • 客户端工具场景由 0/3 成功提升至 3/3 成功

测试矩阵

协议 客户端工具 修复前 修复后 对比
Messages ✅ HTTP 200,23.88s ✅ HTTP 200,23.08s 无回归
Messages ❌ HTTP 400,0.27s ✅ HTTP 200,22.11s 修复后成功
Responses ✅ HTTP 200,24.34s ✅ HTTP 200,21.89s 无回归
Responses ❌ HTTP 400,0.31s ✅ HTTP 200,17.38s 修复后成功
Chat Completions ✅ HTTP 200,17.24s ✅ HTTP 200,11.81s 无回归
Chat Completions ❌ HTTP 400,0.27s ✅ HTTP 200,20.80s 修复后成功
测试方法与运行命令

测试脚本会对两个 Base URL 各发送 6 次请求:

  • 3 种协议:Messages、Responses、Chat Completions
  • 每种协议 2 种请求:不带客户端工具、带一个 get_current_time 客户端工具
  • 统一使用 120 秒超时和 Chrome User-Agent
  • API Key 从本地文件读取,不写入结果文件或 Markdown

将下方脚本保存为 pr898_client_tools_test.py,然后运行:

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant