From e385d5d714fa76adecabdfd135ef8ea04a26260e Mon Sep 17 00:00:00 2001 From: Formerly 3Kmfi6HP <179412085+6Kmfi6HP@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:11:43 +0700 Subject: [PATCH] Add /zen/v1 passthrough for the upstream Zen API Expose the raw OpenCode Zen API surface under /zen/v1/*, relayed verbatim to https://opencode.ai/zen/v1/*: no free-model filtering, no -free suffix mapping, and no protocol conversion (no reasoning backfill either). - upstreamUrl is now prefix-aware so an already-/zen path is not prefixed twice; /v1/* routing is unchanged - new edge-functions/zen/v1/[[default]].js registers the route and delegates to the main handler - 8 new tests cover prefix detection, URL mapping, and end-to-end verbatim relay for chat/messages/models incl. SSE and non-2xx passthrough - README documents the route and the deploy step to refresh routes.json --- README.md | 14 ++- edge-functions/v1/[[default]].js | 17 ++- edge-functions/zen/v1/[[default]].js | 9 ++ test/v1-proxy.test.js | 178 +++++++++++++++++++++++++++ 4 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 edge-functions/zen/v1/[[default]].js diff --git a/README.md b/README.md index 6767fa0..cde1482 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,16 @@ curl https://oc2api-edgeone.edgeone.dev/v1/chat/completions \ | `/v1/responses` | `/zen/v1/responses` | | `/v1/chat/completions` | `/zen/v1/chat/completions` | | `/v1/messages` | `/zen/v1/messages` | +| `/zen/v1/*` | `/zen/v1/*`(原样透传) | 查询参数、端到端请求头和上游状态码会保留。逐跳头、`Host`、`Content-Length` 和客户端鉴权头会被移除,由运行时重新生成必要字段。 +### `/zen/v1/*` 原样透传 + +除上述四条会做协议转换的路由外,网关注册了 `/zen/v1/*` 路由,把请求**原样转发**到上游 OpenCode Zen 的同一路径:请求体、查询参数与响应(含状态码、响应头、SSE 流)都不做改写——不过滤免费模型、不追加 `-free` 后缀、不进行协议转换,也没有 `reasoning_content` 兜底。客户端需按上游原生协议调用(模型 ID 需自行使用 `-free` 后缀)。鉴权头处理与其它路由一致:客户端传入的 `Authorization` / `x-api-key` 被移除,统一注入 `Bearer public`。 + +注:上游在推理层对 `Bearer public` 强制模型门槛——付费模型(如 `claude-opus-5`、`gpt-5.6-sol`)会返回 `401 AuthError`,只有免费模型可被调用。因此即使透传原样转发 `model`,也无法借 public 令牌消耗付费额度(模型列表可见 ≠ 可调用)。 + ## 本地开发 需要 Node.js 22 或更高版本: @@ -100,6 +107,8 @@ EdgeOne CLI 默认监听 `http://localhost:8088`。本地调试环境不能通 npm exec -- edgeone makers deploy -a overseas -e production ``` +新增或变更 `edge-functions/` 下的路由文件后,部署前需刷新平台级路由文件:`npx edgeone makers generate-routes`(注意:该命令在 `routes.json` 已存在时会跳过重新生成,需先删除 `.edgeone/routes.json` 再执行)。否则新路径(如 `/zen/v1/*`)虽然进了边端函数包,却不会触发,请求会落到默认静态页。 + 项目锁定使用 `edgeone@1.6.19`,仓库不包含账号 Token、`.env` 或本地 `.edgeone` 项目绑定。 ## 运行时限制 @@ -115,8 +124,9 @@ npm exec -- edgeone makers deploy -a overseas -e production ## 项目结构 ```text -edge-functions/v1/[[default]].js EdgeOne 路由与代理实现 -test/v1-proxy.test.js Node.js 单元测试 +edge-functions/v1/[[default]].js EdgeOne 路由与代理实现 +edge-functions/zen/v1/[[default]].js /zen/v1/* 原样透传路由(委托给主实现) +test/v1-proxy.test.js Node.js 单元测试 ``` ## License diff --git a/edge-functions/v1/[[default]].js b/edge-functions/v1/[[default]].js index 7889e4e..55f2078 100644 --- a/edge-functions/v1/[[default]].js +++ b/edge-functions/v1/[[default]].js @@ -153,9 +153,23 @@ function sseResponse(stream) { // ======================== 模型 ID / 转发(原有) ======================== +/** + * True when the request path already carries the upstream `/zen` prefix, so a + * pass-through request (`/zen/v1/...`) must not have the prefix appended twice. + */ +function isZenPrefixedPath(pathname) { + return ( + pathname === UPSTREAM_PREFIX + || pathname.startsWith(`${UPSTREAM_PREFIX}/`) + ); +} + function upstreamUrl(requestUrl) { const incomingUrl = new URL(requestUrl); - return `${UPSTREAM_ORIGIN}${UPSTREAM_PREFIX}${incomingUrl.pathname}${incomingUrl.search}`; + const pathname = isZenPrefixedPath(incomingUrl.pathname) + ? incomingUrl.pathname + : `${UPSTREAM_PREFIX}${incomingUrl.pathname}`; + return `${UPSTREAM_ORIGIN}${pathname}${incomingUrl.search}`; } function chatUpstreamUrl(requestUrl) { @@ -3268,6 +3282,7 @@ export { isAnthropicFormat, isThinkingDisabled, isThinkingEnabled, + isZenPrefixedPath, loadResponseState, mapRequestBody, normalizeFinishReason, diff --git a/edge-functions/zen/v1/[[default]].js b/edge-functions/zen/v1/[[default]].js new file mode 100644 index 0000000..9a74207 --- /dev/null +++ b/edge-functions/zen/v1/[[default]].js @@ -0,0 +1,9 @@ +// Raw pass-through of the upstream OpenCode Zen API surface, exposed under +// /zen/v1/*. Unlike the client-facing /v1/* routes, requests here are relayed +// to https://opencode.ai/zen/v1/* verbatim: no free-model filtering, no model +// suffix mapping, and no protocol conversion. The main handler already routes +// these paths through its generic pass-through branch (upstreamUrl is +// prefix-aware), so this file only needs to register the route and delegate. +import onRequest from "../../v1/[[default]].js"; + +export default onRequest; \ No newline at end of file diff --git a/test/v1-proxy.test.js b/test/v1-proxy.test.js index 92df09c..2dfcf8e 100644 --- a/test/v1-proxy.test.js +++ b/test/v1-proxy.test.js @@ -31,6 +31,7 @@ import onRequest, { isAnthropicFormat, isThinkingDisabled, isThinkingEnabled, + isZenPrefixedPath, loadResponseState, mapRequestBody, normalizeFinishReason, @@ -124,6 +125,183 @@ test("maps supported Zen paths and query strings without duplicating /v1", async } }); +// ==================== /zen/v1 原样透传 ==================== + +test("isZenPrefixedPath only matches an actual /zen prefix", () => { + assert.equal(isZenPrefixedPath("/zen"), true); + assert.equal(isZenPrefixedPath("/zen/v1/models"), true); + assert.equal(isZenPrefixedPath("/zen/v1/chat/completions"), true); + assert.equal(isZenPrefixedPath("/v1/models"), false); + assert.equal(isZenPrefixedPath("/zenith/foo"), false); +}); + +test("upstreamUrl relays already-prefixed /zen paths without duplication", () => { + assert.equal( + upstreamUrl("https://proxy.example/zen/v1/models?stream=true"), + "https://opencode.ai/zen/v1/models?stream=true", + ); + assert.equal( + upstreamUrl("https://proxy.example/zen/v1/chat/completions"), + "https://opencode.ai/zen/v1/chat/completions", + ); + assert.equal(upstreamUrl("https://proxy.example/zen"), "https://opencode.ai/zen"); +}); + +test("/zen/v1 chat requests pass through verbatim: URL, body, auth, response", async () => { + const requestBody = { + model: "deepseek-v4-flash-free", + messages: [{ role: "user", content: "hello" }], + // A nameless function tool that /v1 would strip; /zen/v1 must not. + tools: [{ type: "function", function: {} }], + }; + const upstreamBody = JSON.stringify({ + id: "chatcmpl-zen", + object: "chat.completion", + model: "deepseek-v4-flash-free", + choices: [{ message: { role: "assistant", content: "hi" }, finish_reason: "stop", index: 0 }], + }); + + let capturedUrl; + let capturedInit; + const response = await withMockFetch(async (url, init) => { + capturedUrl = url; + capturedInit = init; + return new Response(upstreamBody, { + status: 200, + headers: { "content-type": "application/json", "x-upstream": "kept" }, + }); + }, async () => onRequest(contextFor("/zen/v1/chat/completions?raw=1", { + method: "POST", + headers: { + authorization: "Bearer client", + "x-api-key": "client-value", + "content-type": "application/json", + }, + body: JSON.stringify(requestBody), + }))); + + assert.equal(capturedUrl, "https://opencode.ai/zen/v1/chat/completions?raw=1"); + assert.equal(capturedInit.headers.get("authorization"), PUBLIC_AUTHORIZATION); + assert.equal(capturedInit.headers.has("x-api-key"), false); + // The raw upstream model ID and the nameless tool are forwarded untouched. + assert.deepEqual(JSON.parse(await new Response(capturedInit.body).text()), requestBody); + + assert.equal(await response.text(), upstreamBody); + assert.equal(response.status, 200); + assert.equal(response.headers.get("x-upstream"), "kept"); +}); + +test("/zen/v1/messages is relayed as-is and is not converted to Chat", async () => { + const requestBody = { + model: "deepseek-v4-flash-free", + max_tokens: 64, + stream: false, + messages: [{ role: "user", content: "hello" }], + }; + const upstreamBody = JSON.stringify({ + type: "message", + role: "assistant", + content: [{ type: "text", text: "hi" }], + }); + + let capturedUrl; + let capturedInit; + const response = await withMockFetch(async (url, init) => { + capturedUrl = url; + capturedInit = init; + return new Response(upstreamBody, { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, async () => onRequest(contextFor("/zen/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(requestBody), + }))); + + assert.equal(capturedUrl, "https://opencode.ai/zen/v1/messages"); + assert.deepEqual(JSON.parse(await new Response(capturedInit.body).text()), requestBody); + assert.equal(await response.text(), upstreamBody); +}); + +test("/zen/v1/models returns the raw upstream list without free-model filtering", async () => { + const upstreamBody = JSON.stringify({ + object: "list", + data: [ + { id: "deepseek-v4-flash-free", object: "model" }, + { id: "gpt-5.6-sol", object: "model" }, + ], + }); + + let capturedUrl; + const response = await withMockFetch(async (url) => { + capturedUrl = url; + return new Response(upstreamBody, { + status: 200, + headers: { "content-type": "application/json", "x-raw": "1" }, + }); + }, async () => onRequest(contextFor("/zen/v1/models"))); + + assert.equal(capturedUrl, "https://opencode.ai/zen/v1/models"); + // Unlike GET /v1/models, the pass-through keeps non-free models and the + // -free suffix, and preserves upstream headers verbatim. + assert.deepEqual(await response.json(), { + object: "list", + data: [ + { id: "deepseek-v4-flash-free", object: "model" }, + { id: "gpt-5.6-sol", object: "model" }, + ], + }); + assert.equal(response.headers.get("x-raw"), "1"); +}); + +test("/zen/v1 never appends the -free suffix to a client model", async () => { + let capturedInit; + await withMockFetch(async (_url, init) => { + capturedInit = init; + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + }, async () => onRequest(contextFor("/zen/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "deepseek-v4-flash", messages: [{ role: "user", content: "hi" }] }), + }))); + + // A non -free ID is forwarded unmapped: /zen/v1 does not add the suffix. + const forwarded = JSON.parse(await new Response(capturedInit.body).text()); + assert.equal(forwarded.model, "deepseek-v4-flash"); +}); + +test("/zen/v1 streams pass through unchanged", async () => { + const sseBody = 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n'; + const response = await withMockFetch(async () => new Response(sseBody, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), async () => onRequest(contextFor("/zen/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "deepseek-v4-flash-free", stream: true, messages: [{ role: "user", content: "hi" }] }), + }))); + + assert.equal(response.headers.get("content-type"), "text/event-stream"); + assert.equal(await response.text(), sseBody); +}); + +test("/zen/v1 error responses pass through with the upstream status", async () => { + const upstreamBody = JSON.stringify({ error: { message: "nope" } }); + const response = await withMockFetch(async () => new Response(upstreamBody, { + status: 429, + headers: { "content-type": "application/json", "retry-after": "5" }, + }), async () => onRequest(contextFor("/zen/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "deepseek-v4-flash", messages: [{ role: "user", content: "x" }] }), + }))); + + assert.equal(response.status, 429); + assert.equal(response.headers.get("retry-after"), "5"); + assert.deepEqual(await response.json(), { error: { message: "nope" } }); +}); + test("exposes only free models and strips the -free suffix", async () => { const upstreamResponse = new Response(JSON.stringify({ object: "list",