返回博客

阿里云百炼接口没有 CORS,Tauri 桌面应用要走 tauri-plugin-http

我在一个 Tauri 2 桌面 AI 应用里接入了阿里云百炼(Bailian)的 LLM 接口。用的是它的 OpenAI 兼容端点,写法和调 DeepSeek 一模一样——全局 fetchAuthorization: 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,排除 CSP connect-src 的封锁。
  • 换 DeepSeek / OpenAI 端点,同样的 fetch 写法就能用,不报错。

根因

问题在百炼网关,不在 Tauri 配置。

发一次普通的 POST 时,AuthorizationContent-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 必败。

坑:百炼端点的 CORS 与 DeepSeek 完全相反

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 全部照常可用。

正解:用 tauri-plugin-http 的 fetch 替换全局 fetch

四步完成:加 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 行为相反。