跳到主要内容

数据截至 (上游 commit 3dcf4cad0124)

反向暴露:本地兼容 API 与服务端工具循环

30 秒导读: 前面五章讲的都是"Jan 作为客户端"——它去调模型、调工具。本章讲它的第二副面孔: Jan 里内置了一台 HTTP 服务器,对外假装成 OpenAI 和 Anthropic 的官方 API。别的程序(Claude Code、 你自己的 Python 脚本、任何 OpenAI SDK)把 base URL 一改就能用上你机器里跑着的模型。 更进一步——打开一个开关,这台服务器还能自己跑完整的"想 → 调工具 → 看结果 → 再想"循环, 请求方只需要发一次、收一个最终答案。


1. 这是什么(零基础也能懂)

一句话定义: Jan 桌面应用里藏着一台本地 HTTP 服务器,它把"OpenAI / Anthropic 云端 API"这套协议 搬到了 127.0.0.1 上,后面接的是你自己机器上跑的模型。

解决什么问题: 你已经在 Jan 里下好了一个本地模型,也配好了 MCP 工具。现在你想在别的地方用它—— 比如让 Claude Code 这个命令行编码助手不去连 Anthropic 官网,而是连你的笔记本。没有这台服务器, 模型就被锁死在 Jan 的聊天窗口里。

它能做什么:

  • 对外提供 /v1/chat/completions/v1/completions/v1/embeddings/v1/models(OpenAI 方言)。
  • 对外提供 /v1/messages/v1/messages/count_tokens(Anthropic 方言),Claude Code 直接能连。
  • 提供一个 Jan 自己的扩展端点 /v1/orchestrations:一次请求,服务端替你跑完整个工具循环。
  • 自带 Swagger 文档页(根路径 //openapi.json)。

用起来什么样: 在 Jan 的 Settings → Local API Server 里点启动,然后:

# 示意,非源码。默认监听 127.0.0.1:1337,前缀 /v1
curl http://127.0.0.1:1337/v1/chat/completions \
-H "Authorization: Bearer <你在设置里填的 api key>" \
-H "Content-Type: application/json" \
-d '{"model":"qwen3:4b","messages":[{"role":"user","content":"你好"}]}'

一句话直觉: 把它当成一台装在本机的 API 网关——门口有保安(Host 白名单 + API key), 里面有个调度台(按 model 字段决定这单谁接),后厨则是 llama.cpp、MLX 或某个远程厂商。 特别之处在于,这台网关还可以自己动手:它能替调用方去调 MCP 工具,而不是把工具调用原样吐回去。

本节到此不涉及代码。下面开始拆。


2. 顶层全景(它大概怎么转)

2.1 谁在跟谁说话

┌────────────────┐ ┌─────────────────────┐
│ Claude Code │ │ llama.cpp router │
│ OpenAI SDK │──── HTTP ──────▶ ┌──│ MLX 本地会话 │
│ 任意脚本 │ localhost:1337 │ │ 远程 provider │
└────────────────┘ │ └─────────────────────┘

Jan 本地 API 服务器
(hyper, src-tauri 进程内)

└──▶ MCP 工具服务器(仅开关打开时)

怎么读:左边是任何能发 HTTP 的程序;中间那台服务器跑在 Jan 的 Rust 进程里; 右边三个是它可能把请求转过去的上游;下面那条虚线是本章的重点——服务端自己去调工具。

2.2 一个请求在服务器内部的流水线

请求

├─▶ ① 门卫 CORS 预检 / Host 白名单 / proxy_api_key

├─▶ ② 剥前缀 /v1/messages ──去掉 prefix──▶ /messages

├─▶ ③ 分流 (method, 剥完的 path) 匹配到某个处理分支

├─▶ ④ 决策 开关打开且非流式? ──是──▶ 服务端 agent 循环(§5)
│ └──否──▶ 上游解析(§4)+ 转发

└─▶ ⑤ 回程 Anthropic 请求的响应要翻译回 Anthropic 格式(§6)

2.3 部件一句话职责

部件干什么在哪
ProxyConfig服务器的全部旋钮:前缀、密钥、可信主机、监听地址、工具开关src-tauri/src/core/server/proxy.rs:708
proxy_request唯一的请求入口函数,门卫 + 路由 + 转发全在里面src-tauri/src/core/server/proxy.rs:1286
resolve_upstream_for_model拿一个 model id,判断该找谁算src-tauri/src/core/server/proxy.rs:889
run_server_side_openai_orchestration服务端 agent 循环的本体src-tauri/src/core/server/proxy.rs:1136
transform_anthropic_to_openai / transform_openai_response_to_anthropic两种方言的双向翻译src-tauri/src/core/server/proxy.rs:223 / :626
start_server / stop_server启停命令,给前端 invokesrc-tauri/src/core/server/commands.rs:23 / :77
useLocalApiServer前端那份持久化配置(端口、前缀、可信主机、开关)web-app/src/hooks/useLocalApiServer.ts:48

3. 骨架:服务器怎么起、怎么守门

3.1 启动路径:从一个按钮到一个 TCP 监听

前端把配置打包成一个对象丢给 Tauri 命令 start_server,后者读出 StartServerConfig (src-tauri/src/core/server/commands.rs:11-20),拼上运行时状态(llama 状态、MLX 会话表、 provider 配置表、MCP 服务器表),调进 proxy::start_server

有一个细节值得单独点出——服务端工具执行的默认值在这里被钉死为关:

// src-tauri/src/core/server/commands.rs:69
enable_server_tool_execution.unwrap_or(false),

字段类型是 Option<bool>,前端不传就是 false。这不是随手写的默认值,而是本章最后一节 (§9 边界与风险)的第一道防线。

真正干活的是 start_server_internal(proxy.rs:3014):它建 reqwest::Client(超时来自 proxy_timeout)、TcpListener::bindhost:port,然后每来一条连接就 spawn 一个 service_fn 包住 proxy_request(proxy.rs:3107-3126)。句柄存进 AppState.server_handle; stop_server 就是把这个 JoinHandle 直接 abort()(proxy.rs:3143-3145)。

3.2 ProxyConfig:六个旋钮

字段含义默认来源
prefix对外 URL 前缀,要被剥掉的那截前端 apiPrefix,默认 /v1(useLocalApiServer.ts:63)
proxy_api_key调用方必须出示的密钥;空字符串=不校验前端 apiKey,默认空(useLocalApiServer.ts:84)
trusted_hostsHost / Origin 白名单(二维数组)前端 trustedHosts,默认空数组(useLocalApiServer.ts:69)
host / port监听地址127.0.0.1 / 1337(useLocalApiServer.ts:57,60)
enable_server_tool_execution是否允许服务端自己执行 MCP 工具默认 false(useLocalApiServer.ts:81)

定义见 ProxyConfig(proxy.rs:708-715)。注意 trusted_hostsVec<Vec<String>> —— 前端传 一个扁平数组,命令层用 vec![trusted_hosts] 包一层再交进来(commands.rs:62)。

一个曾经踩过、后来改掉的坑: 旧版在用户选 0.0.0.0 时会把可信主机整个换成通配符 *; 现在的做法相反——start_server_internal 明确不再丢弃用户的 Trusted Hosts 白名单 (proxy.rs:3038-3045 的注释):is_valid_host 改为直接放行环回和私网 IP 字面量 (局域网客户端的 Host 就是 192.168.x.x 这类字面量,而 DNS rebinding 攻击发来的是 主机名不是 IP,所以这么做对 rebinding 仍是安全的),主机名仍需显式加白名单。 作为补偿,新增了 is_insecure_public_bind(proxy.rs:2934-2938:非环回地址 + 空 API key) 的大声告警(proxy.rs:3078-3085):不拒绝启动,但明确告诉运维"本机 API 正在无鉴权暴露, 去 Settings 设一个 API key"。

3.3 剥前缀:get_destination_path

整个路由表都是按"剥完前缀"的路径写的,所以第一步永远是:

// src-tauri/src/core/server/proxy.rs:717
pub fn get_destination_path(original_path: &str, prefix: &str) -> String {
remove_prefix(original_path, prefix)
}

remove_prefix(src-tauri/utils/src/path.rs:72-86)只做三件事:前缀不匹配就原样返回; 剥完为空就补成 /;剥完不以 / 开头就补一个。于是 /v1/messages/messages, /v1/chat/completions/chat/completions

这解释了 Claude Code 那条链路为什么能通:前端把 ANTHROPIC_BASE_URL 设成 http://host:port(不含前缀,web-app/src/routes/settings/claude-code.tsx:71), Anthropic 客户端自己会在后面接 /v1/messages,正好被默认前缀 /v1 吃掉 (inferred: 客户端拼接 /v1 的行为不在本仓库代码里)。

3.4 门卫的三道关

proxy_request 开头是一大段守卫逻辑,顺序是固定的:

OPTIONS 预检 ──▶ 方法白名单 ──▶ Host 白名单 ──▶ 请求头白名单 ──▶ 反射 Origin
(仅当 Origin 可信)

其它方法 ──▶ Host 白名单 ──▶ proxy_api_key ──▶ /configs 屏蔽 ──▶ 路由分支

第一关:CORS 预检(proxy.rs:1298-1445)。只放行六个方法(proxy.rs:1324), 只放行一份写死的请求头清单(proxy.rs:1372-1399,里面专门收了 OpenAI SDK 会带的 x-stainless-* 系列和 x-api-key)。只有 Origin 通过 is_valid_host 校验时才回显 Access-Control-Allow-Origin(proxy.rs:1432-1441),不可信的 Origin 拿不到跨域许可。

第二关:Host 白名单(proxy.rs:1479-1507)。缺 Host 头 → 400;Host 不在白名单 → 403。 is_valid_host(src-tauri/utils/src/http.rs:19-131)的规则值得记:白名单里含 * 直接放行; 否则先剥端口(兼容 IPv6 的 [::1]:1337 写法),再和四个内置值比对—— localhost / 127.0.0.1 / 0.0.0.0 / host.docker.internal这四个是硬编码永远可信的, 所以"白名单为空"不等于"谁都进不来",而等于"只有本机能进来"。

第三关:API key(proxy.rs:1509-1541)。Authorization: Bearer <key>X-Api-Key 二选一,任一匹配即通过。proxy_api_key 为空串时整段跳过——默认配置下本地 API 服务器是 无鉴权的。

三关之外还有两个豁免和一个屏蔽:

  • 豁免: 文档相关路径(//openapi.json/favicon.ico、三个 swagger 静态资源) 跳过 Host 校验和鉴权(proxy.rs:1469-1477:1538-1540)。
  • 屏蔽: 任何含 /configs 的路径一律 404(proxy.rs:1543-1552),防止通过代理读到配置类端点。

4. 上游解析:一个 model id 怎么找到目的地

4.1 要解决的小问题

请求体里只有一个字符串 "model": "qwen3:4b"。服务器必须凭它决定:是转给本机的 llama.cpp、 转给本机的 MLX 进程,还是转给某个云厂商——而且要连带把对应的鉴权密钥找出来。

4.2 三个候选,按固定优先级

model id

├─① 远程 provider? 三种匹配法都试:
│ a. 某个 provider 的 models 列表里有它
│ b. id 形如 "anthropic/claude-x",前半段是已注册 provider 名
│ c. id 本身就是 provider 名
│ 命中 → base_url + "/chat/completions",密钥用 bearer_key_chain()

├─② MLX 会话? 会话表里有 model_id 相同的 → http://127.0.0.1:<port>/v1/...

└─③ llama.cpp router? → http://127.0.0.1:<router port>/v1/...
都不中 → Err("No upstream session found for model ...")

代码在 resolve_upstream_for_model(proxy.rs:889-940);三种 provider 匹配法在 proxy.rs:898-910bearer_key_chain()(src-tauri/src/core/state.rs:40-45)返回一串有序密钥: 有 api_keys 就用整串,否则退化成单个 api_key

这串密钥不是摆设。 call_openai_chat_completions逐个试:上游返回 401 / 403 / 429 时 换下一把钥匙重来(proxy.rs:1121-1126),判定函数是 http_status_indicates_api_key_retry (proxy.rs:215-220)。普通转发路径上也有同一套重试(proxy.rs:2683-2690)。

provider 配置从哪来?前端通过 Tauri 命令 register_provider_config (src-tauri/src/core/server/remote_provider_commands.rs:54)注册进 AppState.provider_configs, merge_register_api_keys(:26-44)负责把 api_keyapi_keys 去重合并成有序密钥链。

4.3 router 的三个小工具

函数干什么位置
router_upstream拿到 llama.cpp router 的 URL + 密钥proxy.rs:832-843
router_list_models去 router 的 /v1/models 抓一份模型 id 列表,失败返回空proxy.rs:845-883
router_first_model上面那个列表取第一个,用作"没指定 model 时的兜底"proxy.rs:885-887

GET /v1/models 这个端点(proxy.rs:2342-2427)就是把三路来源拼起来:router 的标 llama.cpp、 MLX 会话标 mlx、所有 provider 的模型标 remote。对调用方来说,一次 /v1/models 就能看到 Jan 手上全部可用模型——这正是"网关"该有的样子。


5. 服务端 agent 循环(本章核心)

5.1 要解决的小问题

标准的 OpenAI 工具调用是乒乓球:模型说"我要调 read_file",客户端负责真去读文件, 再把结果发回去,如此往复。这要求调用方自己实现循环、自己接 MCP。

Jan 的想法是:既然 Jan 进程里已经连着一堆 MCP 服务器(见 03 章), 那就让服务器把这套乒乓球在内部打完,调用方只发一次、只收一个最终答案。

5.2 循环长什么样

进入循环(最多 max_turns 轮)

├─▶ 组请求体:model / messages / stream=false / tool_choice="auto" / tools

├─▶ call_openai_chat_completions ──▶ 上游模型

├─▶ extract_tool_calls
│ │
│ ├─ 空 ──▶ 直接把这条 completion 当最终答案返回 ✅
│ │
│ └─ 非空 ──▶ 把 assistant(含 tool_calls)压回 messages
│ execute_mcp_tool_calls 逐个执行(带超时)
│ 每个结果压成一条 role:"tool" 消息
│ 回到循环顶部 ↻

└─▶ 轮次用完 ──▶ 报错,附上最后一次 last_response ❌

主体是 run_server_side_openai_orchestration(proxy.rs:1136-1282)。逐段对照:

轮次上限。 默认 8,并且被强制夹在 1..20:

// src-tauri/src/core/server/proxy.rs:1198-1202
let max_turns = json_body.get("max_turns").and_then(|v| v.as_u64())
.unwrap_or(8).clamp(1, 20) as usize;

调用方能调这个数,但改不到 20 以上——防止一个请求把服务器和 MCP 工具无限期占住。

工具清单。 collect_mcp_openai_tools(proxy.rs:959-1013)遍历所有已连的 MCP 服务器, 对每台调 list_all_tools()(带超时,超时或报错就跳过这台而不是整体失败,proxy.rs:971-985), 把工具转成 OpenAI 的 {type:"function", function:{...}} 结构。同时建一张 tool_name → server_name 的反查表——后面执行时靠它找回该找哪台服务器。

执行。 execute_mcp_tool_calls(proxy.rs:1015-1080)串行跑每个 tool call: arguments 是字符串,解析失败就退化成空对象(proxy.rs:1041-1042);查不到对应服务器直接整体报错; 真正的 call_tool 包在 tokio::time::timeout 里(proxy.rs:1063-1069),超时被转成一条 ERROR: ... 文本喂回给模型,而不是让请求崩掉。超时时长来自 MCP 设置的 tool_call_timeout_duration()(src-tauri/src/core/mcp/models.rs:89-91)。

结果回灌。 MCP 的返回是结构化的 CallToolResult,mcp_call_result_to_string (proxy.rs:813-830)只抽出其中的文本块拼接;is_error == Some(true) 时加 ERROR: 前缀 (proxy.rs:821-827)——错误也当正常内容喂回模型,让模型自己决定重试还是换路子。

用完轮次。 不是静默返回,而是 Err(...),并把最后一次响应序列化进错误消息 (proxy.rs:1277-1281)。走 /orchestrations 端点时这会变成 HTTP 422 + 一个带 last_response 的 JSON(proxy.rs:2086-2096)。

5.3 三个入口都能进这个循环

入口条件出口格式
POST /chat/completions开关开 stream != trueOpenAI completion 原样(proxy.rs:2139-2184)
POST /messages开关开 stream != true;先转成 OpenAI 体再翻译回 Anthropic(proxy.rs:1606-1671)
POST /orchestrations无条件(不看开关)OpenAI completion(proxy.rs:1770-2098)

注意第三行:/orchestrations 是 Jan 自己的扩展端点,它内联了一份和 run_server_side_openai_orchestration 几乎逐行相同的循环(proxy.rs:1975-2081), 区别只在于错误直接变成 HTTP 响应而不是 Result::Err。它显式拒绝 stream=true (proxy.rs:1817-1828),并且不检查 enable_server_tool_execution——只要服务器起着、 过了鉴权,这个端点就会执行工具。

/orchestrations 还多支持一个 assistant_id 字段:据此加载 Jan 里配置的助手,把它的 instructions 塞成 system prompt(见 §8)。

5.4 和前端循环的关键差别

Jan 里其实有两套 agent 循环。前端那套见 02 章03 章;本章这套跑在 Rust 侧。差别如下:

维度前端循环(02 / 03 章)服务端循环(本章)
谁发起Jan UI 里的用户任何能访问该端口的程序
工具审批有闸门,useToolApproval 管人工放行完全没有,拿到 tool_calls 直接执行
是否默认可用MCP 连上就能用默认关,要显式打开 enable_server_tool_execution
流式支持不支持,循环只走 stream:false(proxy.rs:1214)
轮次上限没有显式上限,靠 abort 闸门 + 模型自己不再要工具收敛($threadId.tsx:158-169 followUpMessage)max_turns,默认 8,夹在 1..20
工具 schema前端 normalizeToolInputSchema 修补Rust normalize_openai_tool_parameters_schema 修补(§7)

轮次上限那一格值得多说一句:前端不数轮数,它的续跑谓词只判两件事——有没有一个活着且未 abort 的 AbortController,以及 AI SDK 的 lastAssistantMessageIsCompleteWithToolCalls。所以前端循环 真正的刹车是用户点"停止"和模型自己停手;服务端因为没有人盯着,才必须补一个硬计数。

审批闸门那一列是本章最重要的一句话:服务端循环的安全性完全不靠"问用户",而靠 "这个端口谁能碰"。所以 §3.4 的三道门卫和默认关闭的开关,不是外围配置,是这条链路的主要防线

前端 hook 在 web-app/src/hooks/useToolApproval.ts(含 allowAllMCPPermissions 之类的开关); 服务端这条路径上没有任何对应物。


6. 协议翻译:Anthropic ↔ OpenAI

6.1 要解决的小问题

Jan 的所有上游最终都说 OpenAI 方言(/chat/completions)。但 Claude Code 说的是 Anthropic 方言 (/messages)。中间必须有个翻译官,而且是双向的:请求进来时 Anthropic→OpenAI, 响应出去时 OpenAI→Anthropic。

6.2 请求方向:transform_anthropic_to_openai

proxy.rs:223-289 做四件事:

  1. system 从顶层字段变成 messages 数组里的第一条 role:"system"(在 convert_messages,proxy.rs:395-414)。
  2. 消息内容块逐条转换(下表)。
  3. tools[].input_schema 换名成 tools[].function.parameters(proxy.rs:241-269)。
  4. 采样参数按名单直接抄,stop_sequences 改名 stop(proxy.rs:272-286)。 max_tokens 被特意注释掉了(proxy.rs:273),不往下传。

内容块的映射表(实现在 convert_messages,proxy.rs:387-557):

Anthropic 块出现在变成 OpenAI 的
text任意角色{type:"text", text} 部件
image(base64 + media_type)user / assistant{type:"image_url", image_url:{url:"data:<mime>;base64,<data>"}}
tool_use(id/name/input)assistanttool_calls[] 项,inputto_string() 成字符串
tool_result(tool_use_id + content)user一条独立的 role:"tool" 消息

两个不显然的处理:

  • 纯文本会被折叠回字符串。 text_parts_to_content(proxy.rs:560-574)在只有一个 text 部件时 返回裸字符串而非单元素数组——很多 OpenAI 兼容服务端对数组形式的兼容性更差。
  • tool_result 必须排在用户文本前面。 proxy.rs:524-539 先 push 完所有 role:"tool" 消息, 再 push 剩下的 user 文本。OpenAI 协议要求 tool 消息紧跟在带 tool_calls 的 assistant 消息之后。
  • extract_tool_result_content(proxy.rs:605-624)把 tool_result 的内容压成纯文本: 字符串直接用,数组只挑 type=="text" 的块拼接,其它情况退化成 to_string()
  • convert_media_block(proxy.rs:577-602)是"非 text 非 tool 块"的兜底出口。

6.3 响应方向:transform_openai_response_to_anthropic

proxy.rs:627-704,反着来:message.content → 一个 text 块;message.tool_calls[] → 若干 tool_use 块(arguments 字符串解析回 JSON 对象,解析失败退化成 {},proxy.rs:667-668)。

finish_reasonstop_reason 的对应表(proxy.rs:684-689):

OpenAI finish_reasonAnthropic stop_reason
stopend_turn
lengthmax_tokens
tool_callstool_use
其它原样透传

流式路径是另一套代码:transform_and_forward_stream(proxy.rs:3226-3513)要把 OpenAI 的 SSE delta 流重编成 Anthropic 的事件流(message_startcontent_block_startcontent_block_deltacontent_block_stopmessage_deltamessage_stop), 自己维护"OpenAI tool index → Anthropic block index"的映射表(proxy.rs:3238)。 同一张 stop_reason 对照表在这里又出现一次(proxy.rs:3478-3483)。 sse_event(proxy.rs:3155-3161)负责格式化成 event: <type>\ndata: <json>\n\n

6.4 /messages 的双重保险

Anthropic 请求走的是"先原样试,失败再翻译"的策略:

POST /v1/messages

├─▶ 原样转发到上游的 /messages ── 成功 ──▶ 流式透传,不翻译
│ │
│ └─ 任何非 2xx
│ │
└────────────────────────────────────┴─▶ 转成 OpenAI 体,打 <base>/chat/completions
成功 ──▶ 按流式/非流式翻译回 Anthropic
失败 ──▶ 把 fallback 的错误返给调用方

代码在 proxy.rs:2692-2824。这么设计是因为有些上游(比如某些远程 provider)原生支持 /messages, 那就没必要绕一圈翻译;只有原生不支持时才降级。降级用的是一个全新的 reqwest::Client (proxy.rs:2726-2728),注释说是为了避开连接池问题。

6.5 Claude Code 特判:剥掉计费头

Claude Code 会在 system prompt / 第一条消息的开头塞一行 x-anthropic-billing-header: cc_version=...;。 Jan 在转发前把它剥掉:

// src-tauri/src/core/server/proxy.rs:296
pub(crate) fn strip_anthropic_billing_header(text: &str) -> &str {

函数头的注释把动机写得很清楚:这行的值(cc_versioncch…)是动态的,留在内容里会 (a) 打爆上游的 prompt 缓存,(b) 把 CLI 的元信息泄露给模型。实现要处理两种观察到的形态—— 元数据在头一行(inline)和元数据在续行(wrapped),分别在 proxy.rs:309-311:312-318

strip_billing_header_in_body(proxy.rs:355-368)只扫两个位置:顶层 system 字段和 第一条消息的 content——因为 Claude Code 只往这两处注入。这个清洗在 /messages (proxy.rs:1601)和 /chat/completions/messages/count_tokens(proxy.rs:2134)两条分支上都会跑。

前端的配合在 web-app/src/hooks/useClaudeCodeModel.ts:61(useClaudeCodeModel): 它把用户为 opus / sonnet / haiku 三档挑的本地模型 id、自定义环境变量和自定义 CLI 命令存进 localStorage(STORAGE_KEY = 'claude-code-helper-models',:18)。启动时 web-app/src/routes/settings/claude-code.tsx:181 调 Tauri 命令 launch_claude_code_with_config,后者把这些写成 shell 环境变量 (src-tauri/src/core/system/commands.rs:409-425):

环境变量
ANTHROPIC_BASE_URLhttp://<serverHost>:<serverPort>
ANTHROPIC_AUTH_TOKEN用户填的 api key,没填就是字面量 jan
ANTHROPIC_DEFAULT_OPUS_MODEL / ..._SONNET_MODEL / ..._HAIKU_MODEL用户挑的本地模型 id

于是 Claude Code 以为自己在连 Anthropic,实际上每一次 /v1/messages 都落进了本机的这台代理。


7. schema 兼容修补:为什么要改写 MCP 工具的 JSON Schema

7.1 要解决的小问题

MCP 工具自带 JSON Schema。这些 schema 是给 MCP 客户端看的,写得比较随意;但它们会被原样塞进 tools[].function.parameters 发给上游模型服务端。而 llama.cpp 要把 schema 编译成 GBNF 语法 来约束模型输出——它的 json-schema-to-grammar 转换器很严格,遇到不认识的写法会失败, 失败的后果是工具调用的 JSON 约束被静默关掉,而不是报一个明确的错。

7.2 四类修补

normalize_openai_tool_parameters_schema(proxy.rs:101-188)递归遍历整棵 schema,做四件事:

症状修法代码
{"type":"object"} 但没有 properties补一个空 properties: {}proxy.rs:114-119
叶子节点只有 description、没有 typetype: "string"proxy.rs:122-127
formatdate / time / date-time删掉 formatproxy.rs:129-137
pattern 里含 PCRE 简写(\d \w \s 及大写版)删掉 patternproxy.rs:138-145

前两条修的是"schema 写得不完整";后两条修的是"llama.cpp 生成的语法自己会崩"——常量 LLAMACPP_BROKEN_STRING_FORMATS(proxy.rs:61)上面那行注释直说了: llama.cpp 的转换器会为这几个 format 生成 PCRE 的 \d,而 GBNF 不认,语法一失败就静默关掉 工具调用的 JSON 约束。判定函数是 pattern_has_pcre_shorthand(proxy.rs:63-73), 一个手写的两字节扫描。

还有第五类:裸类型名简写。有些生成器会写 {"properties": {"foo": "string"}} 而不是 {"properties": {"foo": {"type": "string"}}}coerce_schema_node(proxy.rs:79-91)负责把 这种裸字符串展开成 {"type": <name>}。它只在子节点确实是 schema 节点的容器里被调用—— properties / patternProperties / definitions / $defs / anyOf / oneOf / allOf / prefixItems / items(proxy.rs:149-178)。这个区分很重要:直接对所有字符串做展开的话, 一个描述文本恰好等于 "string" 就会被误改。

7.3 两处调用点,一处镜像

什么时候修调用点
服务端循环汇总 MCP 工具时collect_mcp_openai_tools 内,proxy.rs:993-994
调用方自己带了 tools 打到 /chat/completionsnormalize_openai_tools_in_chat_body,proxy.rs:190-213,调用点 proxy.rs:2130

前端有一份行为等价的实现:normalizeToolInputSchema (web-app/src/lib/custom-chat-transport.ts:613-617),内部是 normalizeToolInputSchemaValue (:136)和 coerceSchemaNode(:128)。Rust 侧的注释明确要求两边保持一致 (proxy.rs:100:"Keep this behavior aligned with normalizeToolInputSchema in the frontend")。 之所以要两份,是因为两条链路各自把工具交给模型:前端走 AI SDK,服务端走这个代理, 中间没有共享代码——这是一处需要人工同步的重复实现


8. 助手注入与采样默认

8.1 助手:让服务端循环带上人格

/orchestrations 和服务端循环都支持一个 assistant_id。给了它,服务器就去磁盘上读:

// src-tauri/src/core/server/proxy.rs:725
fn assistant_json_path(jan_data_folder: &str, assistant_id: &str) -> PathBuf
// → <jan 数据目录>/assistants/<assistant_id>/assistant.json

load_assistant_config(proxy.rs:733-755)从中取两样:instructions(系统提示)和 model(模型建议)。前者交给 set_system_prompt(proxy.rs:782-791)——注意它的做法是 先删掉消息里所有已有的 system 消息,再把新的插到第 0 位,即助手的 instructions 会覆盖调用方自己传的 system。

模型 id 的兜底顺序(proxy.rs:1167-1186):

请求体 model → 助手的 model 提示(排除空串和 "*") → router 第一个模型 → MLX 第一个会话
都没有 → 报错

请求消息的解析走 parse_openai_messages(proxy.rs:757-780),它只接受 content 是字符串 的消息——多模态内容块在这条路径上是不支持的,给数组会直接 400。

8.2 采样参数:两个不同的机制

机制一:调用方参数透传。 copy_optional_chat_params(proxy.rs:942-957)从原始请求体里 按名单抄八个键到每一轮的上游请求:temperaturetop_ptop_kmax_tokensstop_sequencesstopfrequency_penaltypresence_penalty

机制二:MLX 专属的默认值注入。 inject_sampling_defaults(proxy.rs:375-384)—— 只填调用方没给的键,给过的绝不覆盖(proxy.rs:380)。

为什么只对 MLX?函数上的注释解释了:llama.cpp router 有 preset 机制携带服务端默认值, 远程 provider 则应该保持原样不动,只有 MLX 目标两头不靠。所以注入点是转发前的一个 if let Some(mid) = &mlx_model_id(proxy.rs:2618-2631),默认值从 AppState.model_param_defaults 里按模型 id 取,而这张表由前端通过 Tauri 命令 set_model_param_defaults(remote_provider_commands.rs:110-117)整张覆盖写入。


9. 边界与风险

这台服务器默认是安全的,但每一层默认值都可以被一个复选框推翻。 逐条列清楚:

默认打开/放宽之后
监听地址127.0.0.1,只有本机能连0.0.0.0 后局域网可达;Host 白名单不再被替换成 *(私网 IP 字面量直接放行、主机名仍需白名单,proxy.rs:3038-3045),但空 API key 时启动会大声告警(proxy.rs:3078-3085)
proxy_api_key空串 → 完全不校验(proxy.rs:1509)填了才有 Bearer / X-Api-Key 校验
可信主机空数组,但 localhost/127.0.0.1/0.0.0.0/host.docker.internal 硬编码永远可信(utils/src/http.rs:36)加条目只是放宽,不能收紧内置四项
enable_server_tool_executionfalse(commands.rs:69useLocalApiServer.ts:81)打开后 /chat/completions/messages 都会在服务端执行 MCP 工具
/orchestrations不看上面那个开关只要服务器起着且过了鉴权,就会执行工具

把这些合起来看,最需要警惕的组合是:打开服务端工具执行,等于把你所有 MCP 工具的执行权限, 交给任何能访问这个端口的程序。没有审批弹窗、没有逐工具白名单、没有调用来源的区分—— 本机上任何一个进程(包括你在浏览器里打开的网页,如果它的 Origin 恰好在可信列表里) 都能让模型去调你的文件系统工具、shell 工具或任何已连的 MCP 服务器。

其它已知边界:

  • 服务端循环不支持流式。 三个入口都要求 stream != true;/orchestrations 直接 400 (proxy.rs:1817-1828)。原因很直观:循环中途要暂停去执行工具,没法边算边吐。
  • parse_openai_messages 只吃字符串 content。 走服务端循环时,多模态请求会被 400 拒掉 (proxy.rs:765-771)——虽然普通转发路径支持图片(§6.2)。
  • 工具串行执行。 execute_mcp_tool_calls 是个顺序 for 循环(proxy.rs:1026), 同一轮里的多个 tool call 不会并发。
  • 循环代码有两份。 run_server_side_openai_orchestration(proxy.rs:1207-1275)和 /orchestrations 内联的那份(proxy.rs:1975-2081)逻辑几乎逐行相同,改一处要记得改两处。
  • schema 修补也有两份。 Rust 和前端各一套(§7.3),靠注释约定同步。
  • 流式输出的 token 计数是估算。 output_tokens 直接用空白分词计数 (proxy.rs:3276:3344),不是真实 token 数。
  • max_tokens 在 Anthropic→OpenAI 翻译里被丢弃(proxy.rs:273 是注释掉的), 调用方设的上限不会传到上游。

10. 横向对比

Jan 属于书架里的"桌面客户端(本地优先)"一支,同支的还有 cherry-studio、chatbox、big-agi 等; 这一支的共同套路是"桌面壳 + 多 provider + MCP",分类依据见总库 guide/agent-products.md。在这个共同底盘上,Jan 多做了两件事:

① 多说一种方言。 本地端点除 OpenAI 那套外还翻译了 Anthropic 的 /messages,而且是 "先原样试、失败再降级翻译"的双保险(§6.4)。目标非常具体——让 Claude Code 把 base URL 指到本机 就能跑(§6.5)。

② 把 agent 循环塞进了转发层。 一般的本地 API 服务器是纯转发;Jan 在里面内置了完整的工具循环 (§5),外部程序发一次请求就能拿到跑完工具的最终答案。这是它和普通"本地 OpenAI 兼容层"的分水岭。

代价是前端那套审批模型(见 03 章)在这条路径上整个消失。 同书架里同样把工具循环放在服务端的两个项目,在"谁来批"这件事上给了不同答案:

项目循环跑在哪人工审批轮次上限
Jan(本章)本机 Rust 代理内没有,只有开关 + 端口访问控制(§9)max_turns,默认 8,夹在 1..20
AnythingLLM服务端 agent 内核有,requestToolApproval 是 WebSocket 协议的一等公民,2 分钟不回默认拒绝
Open WebUI服务端请求中间件CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS,默认 256

后两行的依据是同书架的子库 doc,本章未核对这两个项目的上游源码: anything-llm/04-streaming-approval-citations.mdopen-webui/04-agentic-loop.md。 对照下来 Jan 的位置很清楚:审批做得最重的那套留在了前端,而唯一能被外部程序驱动的那条链路上一道都没有。

本库其余章节:01 骨架:三层进程与可插拔扩展系统讲三层怎么分工; 04 本地推理运行时:llama.cpp router 与后端分发讲本章反复提到的 router 是怎么回事;05 记性:线程持久化、分支、上下文压缩与 RAG 讲客户端侧的线程持久化——注意本章这条链路完全不落库,服务端循环的 messages 只活在一次请求的内存里。


11. 代码地图(导航索引)

主题文件路径符号名
服务器配置结构src-tauri/src/core/server/proxy.rsProxyConfig
请求总入口(门卫 + 路由 + 转发)src-tauri/src/core/server/proxy.rsproxy_request
启停与监听循环src-tauri/src/core/server/proxy.rsstart_server / start_server_internal / stop_server
前缀剥离src-tauri/src/core/server/proxy.rs · src-tauri/utils/src/path.rsget_destination_path · remove_prefix
Host / Origin 白名单src-tauri/utils/src/http.rsis_valid_host / extract_host_from_origin
CORS 响应头src-tauri/src/core/server/proxy.rsadd_cors_headers_with_host_and_origin
上游解析src-tauri/src/core/server/proxy.rsresolve_upstream_for_model / router_upstream / router_list_models / router_first_model
多密钥重试src-tauri/src/core/server/proxy.rs · src-tauri/src/core/state.rshttp_status_indicates_api_key_retry · ProviderConfig::bearer_key_chain
服务端 agent 循环src-tauri/src/core/server/proxy.rsrun_server_side_openai_orchestration
工具汇总 / 执行src-tauri/src/core/server/proxy.rscollect_mcp_openai_tools / execute_mcp_tool_calls / mcp_call_result_to_string
单轮上游调用src-tauri/src/core/server/proxy.rscall_openai_chat_completions / extract_tool_calls / extract_choice_message
Anthropic → OpenAIsrc-tauri/src/core/server/proxy.rstransform_anthropic_to_openai / convert_messages / convert_media_block / extract_tool_result_content / text_parts_to_content
OpenAI → Anthropicsrc-tauri/src/core/server/proxy.rstransform_openai_response_to_anthropic / transform_and_forward_stream / forward_non_streaming / sse_event
Claude Code 计费头特判src-tauri/src/core/server/proxy.rsstrip_anthropic_billing_header / strip_billing_header_in_body
Claude Code 环境变量注入src-tauri/src/core/system/commands.rslaunch_claude_code_with_config
schema 修补(Rust)src-tauri/src/core/server/proxy.rsnormalize_openai_tool_parameters_schema / coerce_schema_node / pattern_has_pcre_shorthand / normalize_openai_tools_in_chat_body
schema 修补(前端镜像)web-app/src/lib/custom-chat-transport.tsnormalizeToolInputSchema / coerceSchemaNode
助手注入src-tauri/src/core/server/proxy.rsload_assistant_config / assistant_json_path / set_system_prompt / parse_openai_messages
采样参数src-tauri/src/core/server/proxy.rsinject_sampling_defaults / copy_optional_chat_params
Tauri 启停命令src-tauri/src/core/server/commands.rsStartServerConfig / start_server / stop_server / get_server_status
provider 注册与默认值src-tauri/src/core/server/remote_provider_commands.rsregister_provider_config / merge_register_api_keys / set_model_param_defaults
前端配置存储web-app/src/hooks/useLocalApiServer.tsuseLocalApiServer
前端 Claude Code 配置web-app/src/hooks/useClaudeCodeModel.ts · web-app/src/routes/settings/claude-code.tsxuseClaudeCodeModel
路由/鉴权单测src-tauri/src/core/server/tests.rstest_get_destination_path_* / test_auth_*