摘要:在中国大陆销售的国行 Mac 上,Apple Intelligence 的某些在线 AI 功能通常受限或不可用。Xcode 26 引入了对外部大型模型(LLM)接入的能力,官方对 Claude 的支持较好,但 Claude 成本高昂,许多开发者希望接入性价比更优的 DeepSeek。实际接入过程中会遇到两类典型错误:
Invalid 'tools': empty array. Expected an array with minimum length 1, but got an empty array instead.
------ 原因是请求或响应中出现空的tools: []
数组;
Failed to deserialize the JSON body into the target type: messages[2]: invalid type: sequence, expected a string ...
------ 原因是 Xcode(或 IDE)发出的messages[].content
字段是数组结构,但 DeepSeek 期望它是字符串。本文解释这些错误的成因,并提供一个安全、可控 的本地 mitmproxy 方案来修正请求/响应(仅针对
api.deepseek.com
),包含脚本、安装与调试步骤。
背景:为什么要把 Xcode 接入 DeepSeek?
- 国行 macOS 与 Apple Intelligence:Apple 在不同地区对在线 AI 功能有不同的政策与接口可用性。国行 Mac 的 Apple Intelligence(或 Apple 的一些云 AI 功能)常常受限或被下线,导致 IDE(例如 Xcode)的智能补全、智能重构、代码搜索等 AI 能力无法或受限地工作。
- Xcode 26 的扩展性:Xcode 26 支持把外部模型接入到 IDE 的 Intelligence 流程里,理论上可以用任何兼容 provider 填补 Apple Intelligence 的功能空缺。
- Claude vs DeepSeek:Xcode 对 Claude(Anthropic)的支持很好,但 Claude 的定价较高。DeepSeek(或其它性价比更高的 LLM 提供商)成为很多开发者的首选 --- 代价更低、响应灵活。但 DeepSeek 与 Xcode 的集成并非"开箱即用",需要做些兼容工作。
两个常见错误:原因与本质
错误一(格式校验)------ Invalid 'tools': empty array...
报错:
php
Invalid 'tools': empty array. Expected an array with minimum length 1, but got an empty array instead.
原因 :
Xcode 26 的 Intelligence 模块在与 LLM 交互时,遵循某种扩展的 Chat Completions/Function-calling 风格(类似 OpenAI 的 function
/ tools
概念)。当响应或请求中包含 tools
字段时,Xcode 期望 tools
为非空数组 (至少 1 个元素),用于表示可被调用的"工具"(例如 editCode 等)。但 DeepSeek 在某些情况会返回 tools: []
(空数组),或 Xcode 发出的请求里包含 tools: []
。空数组就违反了 Xcode 的校验规则,于是抛出该错误并中止处理。
解决思路:
- 最直接的做法是在代理层将空的
tools
字段移除(或替换成包含至少一个"占位"工具的数组),使 Xcode 接收到符合期望的 JSON。 - 我们推荐删除空
tools
字段(比填入伪造 tool 更安全、风险更小),仅在必要时补入占位工具。
错误二(类型不匹配)------ messages[2]: invalid type: sequence, expected a string ...
报错:
go
Failed to deserialize the JSON body into the target type: messages[2]: invalid type: sequence, expected a string at line 1 column 6733
原因 :
此错误发生在 Xcode 自动向 DeepSeek 发起的后续请求中。DeepSeek(或 Xcode 的后端)期待 messages
数组里每个 message 的 content
字段是一个 字符串 (plain text)。但 Xcode(或 IDE 的内部生成逻辑)在某些场景会把 message 的 content
以数组/结构化内容 的形式发送(例如分段的对象数组 { "content": [ { "text": "..." }, {...} ] }
)。服务端在反序列化时期待字符串,见到数组(sequence),于是报错。
解决思路:
- 在转发请求前检测
messages
内的content
:若是数组/结构化数据,就按规则把它"扁平化"为字符串(例如把content
数组中的每个子项的text
或content
字段抽取并按顺序拼接为一个字符串,必要时用换行或空格分隔)。 - 同时仍保留对
tools
的处理(删除空tools
),从而确保 Xcode 与 DeepSeek 间的请求/响应都符合双方期望的 schema。
推荐方案:使用 mitmproxy 在本机拦截并修正请求/响应(只针对 api.deepseek.com
)
为什么选择 mitmproxy?
- mitmproxy 支持 HTTPS 拦截(需安装根证书),可以在请求/响应层面透明修改 JSON。
- 比起修改 Xcode/SDK,本地代理方式对环境侵入更小、可控、易回滚。
- 通过只对
api.deepseek.com
生效,可以把影响范围降到最低、保证安全。
重要安全说明:mitmproxy 会解密 HTTPS 流量,请在受信任环境中使用(本地开发机)。避免在公共网络或生产环境长期运行 mitmproxy。修改请求/响应时注意不要泄露 API 密钥或敏感数据到日志。
可直接使用的脚本(只对 api.deepseek.com
生效;删除空 tools
;扁平化 messages[].content)
将下列脚本保存为 modify_tools_flatten_content_api_only.py
(推荐路径:~/mitm-scripts/modify_tools_flatten_content_api_only.py
):
python
# modify_tools_flatten_content_api_only.py
# mitmproxy addon:
# 1) only operate on flows targeting api.deepseek.com
# 2) remove empty "tools": [] from request/response JSON (root or nested)
# 3) flatten message.content arrays in requests into a single string (to satisfy DeepSeek expecting string)
#
# Usage:
# mitmproxy -p 8080 -s modify_tools_flatten_content_api_only.py
from mitmproxy import http, ctx
import json
from typing import Any, Optional
TARGET_HOST = "api.deepseek.com"
def is_target_flow(flow: http.HTTPFlow) -> bool:
"""
Return True if this flow is for the target host (api.deepseek.com).
Checks multiple places for robustness.
"""
try:
host_header = flow.request.headers.get("host", "")
except Exception:
host_header = ""
# flow.request.host or pretty_host may be available depending on mitmproxy version
flow_host = getattr(flow.request, "host", "") or getattr(flow.request, "pretty_host", "")
flow_host = flow_host or host_header
# Normalize (lowercase) and compare
return str(flow_host).lower() == TARGET_HOST
def remove_empty_tools_in_obj(obj: Any) -> bool:
"""
Recursively remove empty `tools: []` in dict/list structures.
Returns True if any modification was made.
"""
modified = False
if isinstance(obj, dict):
if "tools" in obj and isinstance(obj["tools"], list) and len(obj["tools"]) == 0:
del obj["tools"]
modified = True
# handle choices[].message.tools and choice.tools
if "choices" in obj and isinstance(obj["choices"], list):
for choice in obj["choices"]:
if isinstance(choice, dict):
msg = choice.get("message")
if isinstance(msg, dict):
if "tools" in msg and isinstance(msg["tools"], list) and len(msg["tools"]) == 0:
del msg["tools"]
choice["message"] = msg
modified = True
if "tools" in choice and isinstance(choice["tools"], list) and len(choice["tools"]) == 0:
del choice["tools"]
modified = True
# recurse
for k, v in list(obj.items()):
if isinstance(v, (dict, list)):
if remove_empty_tools_in_obj(v):
modified = True
elif isinstance(obj, list):
for item in obj:
if isinstance(item, (dict, list)):
if remove_empty_tools_in_obj(item):
modified = True
return modified
def flatten_message_content_in_messages(obj: Any) -> bool:
"""
If obj has a 'messages' key that is a list, and any message has 'content' that is a list,
flatten that content array into a single string (prefer item['text'] if exists).
Returns True if modified.
"""
modified = False
if not isinstance(obj, dict):
return False
messages = obj.get("messages")
if not isinstance(messages, list):
return False
for i, msg in enumerate(messages):
if not isinstance(msg, dict):
continue
content = msg.get("content")
# if content is list/array -> flatten
if isinstance(content, list) and len(content) > 0:
parts = []
for part in content:
# if the part is dict with 'text' field
if isinstance(part, dict):
txt = None
if "text" in part and isinstance(part["text"], str):
txt = part["text"]
elif "content" in part and isinstance(part["content"], str):
txt = part["content"]
elif "value" in part and isinstance(part["value"], str):
txt = part["value"]
else:
try:
txt = json.dumps(part, ensure_ascii=False)
except Exception:
txt = str(part)
parts.append(txt)
elif isinstance(part, str):
parts.append(part)
else:
try:
parts.append(json.dumps(part, ensure_ascii=False))
except Exception:
parts.append(str(part))
new_content = "\n".join(p for p in parts if p is not None)
messages[i]["content"] = new_content
modified = True
# also handle case where content is dict with nested arrays (parts/items)
elif isinstance(content, dict):
for key in ("parts", "items", "segments"):
if key in content and isinstance(content[key], list):
parts = []
for part in content[key]:
if isinstance(part, dict) and "text" in part and isinstance(part["text"], str):
parts.append(part["text"])
elif isinstance(part, str):
parts.append(part)
else:
try:
parts.append(json.dumps(part, ensure_ascii=False))
except Exception:
parts.append(str(part))
if parts:
messages[i]["content"] = "\n".join(parts)
modified = True
break
if modified:
obj["messages"] = messages
return modified
class ToolAndContentFixer:
def request(self, flow: http.HTTPFlow) -> None:
"""
Inspect JSON request bodies for target host:
- remove empty tools arrays
- flatten messages[].content arrays into a single string
"""
if not is_target_flow(flow):
return
# if no body, nothing to do
if not flow.request.content:
return
# check likely JSON (content-type or starting char)
ctype = flow.request.headers.get("content-type", "")
looks_like_json = ("json" in ctype.lower()) or flow.request.content.strip().startswith(b"{") or flow.request.content.strip().startswith(b"[")
if not looks_like_json:
return
try:
text = flow.request.get_text(strict=False)
except Exception as e:
ctx.log.warn(f"modify_tools: failed to read request body text: {e}")
return
if not text:
return
try:
data = json.loads(text)
except Exception:
# not valid JSON
return
modified1 = remove_empty_tools_in_obj(data)
modified2 = flatten_message_content_in_messages(data)
if modified1 or modified2:
new_text = json.dumps(data, ensure_ascii=False)
flow.request.set_text(new_text)
ctx.log.info(
f"modify_tools: patched request {flow.request.method} {flow.request.pretty_url} "
f"(removed tools: {modified1}, flattened content: {modified2})"
)
def response(self, flow: http.HTTPFlow) -> None:
"""
Inspect JSON responses for target host and remove empty tools arrays.
"""
if not is_target_flow(flow):
return
if not flow.response.content:
return
try:
text = flow.response.get_text(strict=False)
except Exception:
return
if not text:
return
if not (text.lstrip().startswith("{") or text.lstrip().startswith("[")):
return
try:
data = json.loads(text)
except Exception:
return
modified = remove_empty_tools_in_obj(data)
if modified:
new_text = json.dumps(data, ensure_ascii=False)
flow.response.set_text(new_text)
ctx.log.info(f"modify_tools: removed empty 'tools' from response for {flow.request.method} {flow.request.pretty_url}")
addons = [
ToolAndContentFixer()
]
mitmproxy 在 macOS 上的安装与使用教程(简化版,结合本脚本)
下面是从安装 mitmproxy、导入根证书,到加载脚本和在 Xcode 中验证的可执行步骤(摘取并精简自之前详尽教程):
1)安装 mitmproxy(Homebrew 推荐)
bash
brew install mitmproxy
或用 pip(虚拟环境)安装:
bash
python3 -m venv ~/.venv/mitm
source ~/.venv/mitm/bin/activate
pip install mitmproxy
2)把脚本保存好
例如:
bash
mkdir -p ~/mitm-scripts
# 把上面脚本保存为
~/mitm-scripts/modify_tools_flatten_content_api_only.py
3)启动 mitmproxy 并加载脚本(默认 8080 端口)
bash
mitmproxy -p 8080 -s ~/mitm-scripts/modify_tools_flatten_content_api_only.py
或用图形界面:
bash
mitmweb -p 8080 -s ~/mitm-scripts/modify_tools_flatten_content_api_only.py
4)安装并信任 mitmproxy 根证书(必须,以便拦截 HTTPS)
推荐一键安装(会安装到当前用户登录钥匙串):
bash
mitmproxy --install-cert
如果你需要让系统级应用(如 Xcode)也信任证书,请把证书导入 System 钥匙串并设为 Always Trust (需要管理员权限)。也可以通过 Keychain Access 手动导入 ~/.mitmproxy/mitmproxy-ca-cert.pem
。
5)让 macOS 走代理(让 Xcode 的请求被拦截)
- 打开 系统设置 → 网络 → 所用网络接口 → 高级 → 代理 ,勾选 HTTP/HTTPS 代理,填写
127.0.0.1
端口8080
。保存后系统级流量(以及多数应用)会走 mitmproxy。 - 注意 :部分内部进程或直接使用 system APIs 的请求可能会绕过系统代理。如果 Xcode 仍然没有走代理,可以考虑使用
pfctl
做透明重定向(高级、需谨慎)。
6)测试
- 用
curl
通过代理测试(在终端):
bash
curl -x http://127.0.0.1:8080 -v https://api.deepseek.com/v1/models
- 在 Xcode 里触发 Intelligence 请求(或按你原来的流程操作),检查 mitmproxy 控制台,脚本应打印类似:
sql
modify_tools: patched request POST https://api.deepseek.com/v1/chat/completions (removed tools: True, flattened content: True)
modify_tools: removed empty 'tools' from response for GET https://api.deepseek.com/v1/models
这说明脚本生效并已对请求/响应做了必要修改。
常见问题与排错建议(精简版)
-
没有拦截到请求
- 确认 mitmproxy 正在运行并监听预期端口;
- 确认系统代理已设置为
127.0.0.1:8080
; - 对 curl 测试通过
-x
指定代理,验证 mitmproxy 是否能拦截; - 若应用绕过系统代理(部分系统级流程),可用
pfctl
做透明重定向(需谨慎)。
-
证书/HTTPS 问题
- 证书未被信任会导致 TLS 错误。把 mitmproxy 根证书导入 System keychain 并设置 Always Trust。重启 Xcode 以确保信任生效。
- 开发中可用
curl -k
暂时忽略 TLS,但目标是把证书安装并信任。
-
仍然返回 403/鉴权错误
- 代理不要删除或修改重要 headers(例如 Cookie/Authorization)。我们的脚本只改 body(删除空
tools
和扁平化messages
),不会触碰 headers。若你自己在其它代理/脚本中改了 header 需要恢复。 - Cloudflare / WAF 规则可能会基于 IP、UA 等做额外检查,尽量保持请求的原生特征(User-Agent、Host 等)。
- 代理不要删除或修改重要 headers(例如 Cookie/Authorization)。我们的脚本只改 body(删除空
-
日志或数据敏感性
- 脚本会记录被修改的请求到 mitmproxy 控制台,避免在日志中输出敏感信息(可按需注释掉 log 行或将日志写入只读受限的本地文件)。
总结与建议
- 对于国行 macOS 用户,Apple Intelligence 的局限确实影响 IDE 智能体验;通过把 Xcode 的 Intelligence 接入 DeepSeek(或其它第三方模型)可以在一定程度上弥补这一缺失。
- Xcode 26 自带的模型接入能力强,但不同模型/提供商之间在 API 格式上仍存在细微差别(例如
tools
、messages[].content
的类型)。这些差异会导致校验/反序列化错误(如本文开头展示的两类典型错误)。 - 最稳妥的短期方案是本地代理修正 :使用
mitmproxy
拦截并修改流量,只对api.deepseek.com
生效,删除空tools
、把数组content
扁平化为字符串,从而让 Xcode 与 DeepSeek 相互兼容。 - 长期来看,最好让模型提供方(DeepSeek)在其 API 层面增强与 Xcode/OpenAI 风格 schema 的兼容性,或让 Xcode 能更灵活地处理
tools
与messages
的不同表示方式。