阿里云百炼接口没有 CORS,Tauri 桌面应用要走 tauri-plugin-http
我在一个 Tauri 2 桌面 AI 应用里接入了阿里云百炼(Bailian)的 LLM 接口。用的是它的 OpenAI 兼容端点,写法和调 DeepSeek 一模一样——全局 fetch,Authorization: Bearer sk-sp-...,Content-Type: application/json,发给 *.aliyuncs.com。
在终端用 curl 试,秒回一段对话内容。切回桌面应用点「测试连接」,弹出:Load failed。没有 HTTP 状态码,没有任何响应体,就像请求从来没出门过。
现象
- 同样的 URL、同样的 key,
curl/ Python / Node 都能拿到正常的 JSON completion。 - Tauri 应用(macOS WKWebView)调用后立刻报
Load failed;Windows WebView2 报TypeError: Failed to fetch。 - 已经在
tauri.conf.json里设了"csp": null,排除 CSPconnect-src的封锁。 - 换 DeepSeek / OpenAI 端点,同样的
fetch写法就能用,不报错。
根因
问题在百炼网关,不在 Tauri 配置。
发一次普通的 POST 时,Authorization 和 Content-Type 这两个头会触发浏览器的 CORS preflight——先发一个 OPTIONS 请求询问「我能不能跨域发?」。百炼的网关(istio-envoy)不回答这个问题:OPTIONS 直接返回 401,响应里没有任何 access-control-allow-* 头。WKWebView 拿到这个结果,判定「服务端不允许跨域」,把真正的 POST 拦在本地,就是你看到的「Load failed」。
用一条 curl 就能确认:
curl -s -i -X OPTIONS \
"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/chat/completions" \
-H "Origin: http://tauri.localhost" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type" \
| grep -i "access-control"
# → (空) + HTTP/1.1 401输出一行都没有,CORS preflight 必败。
DeepSeek 的端点会「反射」请求来源的 Origin,发回正确的 access-control-allow-origin,直接在 webview 里 fetch 完全没问题。百炼不做这件事——两者都是 OpenAI 兼容接口,行为却截然不同。不能用 DeepSeek 调通了就假设所有 LLM 端点的 CORS 行为一致。
正解
让请求绕开浏览器 CORS 机制,走 Rust 原生层发出去。@tauri-apps/plugin-http 提供的 fetch 底层是 Rust reqwest,不经过 WKWebView 的网络栈,自然没有 CORS 限制,而且对外的 API 形状与 Web fetch 完全一致,改动量极小。
第一步:Cargo 依赖
# src-tauri/Cargo.toml
[dependencies]
tauri-plugin-http = "2"第二步:注册插件
// src-tauri/src/lib.rs
.plugin(tauri_plugin_http::init())第三步:开放 capability(必须显式允许目标 URL,否则请求会被 Tauri 安全层拦截)
// src-tauri/capabilities/default.json
{
"identifier": "http:default",
"allow": [
{ "url": "https://token-plan.cn-beijing.maas.aliyuncs.com/*" },
{ "url": "https://*.aliyuncs.com/*" }
]
}第四步:换一行 import,其余代码不动
// 之前:import 全局 fetch,隐式使用 window.fetch
// 现在:
import { fetch } from '@tauri-apps/plugin-http' // 只改这一行
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${key}`,
},
body: JSON.stringify({ model, messages, stream: false }),
signal,
}).ok、.status、.json()、AbortSignal 全部照常可用。
四步完成:加 Cargo 依赖 → 注册插件 → capability 开放目标 URL → import 从 @tauri-apps/plugin-http 引入 fetch。调用方不用感知,但请求已经从 Rust 层发出,CORS 不再是障碍。
验证
改完之后「测试连接」会收到一个真实的模型回复,而不是「Load failed」。如果 key 填错,会拿到一个 401 JSON 响应体(No API-key provided),这本身就证明请求已经到达服务器——之前连到服务器的机会都没有。
一句话外卖
接入新 LLM 接口前,先
curl -X OPTIONS … | grep access-control确认 CORS 头是否存在——OpenAI 兼容不等于 CORS 兼容,百炼和 DeepSeek 行为相反。