数据截至 (上游 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 | 启停命令,给前端 invoke | src-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::bind 到 host: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_hosts | Host / 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_hosts 是 Vec<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-910。bearer_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_key 和 api_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 != true | OpenAI 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 做四件事:
system从顶层字段变成 messages 数组里的第一条role:"system"(在convert_messages,proxy.rs:395-414)。- 消息内容块逐条转换(下表)。
tools[].input_schema换名成tools[].function.parameters(proxy.rs:241-269)。- 采样参数按名单直接抄,
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) | assistant | tool_calls[] 项,input 被 to_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_reason 到 stop_reason 的对应表(proxy.rs:684-689):
OpenAI finish_reason | Anthropic stop_reason |
|---|---|
stop | end_turn |
length | max_tokens |
tool_calls | tool_use |
| 其它 | 原样透传 |
流式路径是另一套代码:transform_and_forward_stream(proxy.rs:3226-3513)要把 OpenAI 的
SSE delta 流重编成 Anthropic 的事件流(message_start → content_block_start →
content_block_delta → content_block_stop → message_delta → message_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。