Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 或更高版本:
Expand All @@ -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` 项目绑定。

## 运行时限制
Expand All @@ -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
Expand Down
17 changes: 16 additions & 1 deletion edge-functions/v1/[[default]].js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -3268,6 +3282,7 @@ export {
isAnthropicFormat,
isThinkingDisabled,
isThinkingEnabled,
isZenPrefixedPath,
loadResponseState,
mapRequestBody,
normalizeFinishReason,
Expand Down
9 changes: 9 additions & 0 deletions edge-functions/zen/v1/[[default]].js
Original file line number Diff line number Diff line change
@@ -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;
178 changes: 178 additions & 0 deletions test/v1-proxy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import onRequest, {
isAnthropicFormat,
isThinkingDisabled,
isThinkingEnabled,
isZenPrefixedPath,
loadResponseState,
mapRequestBody,
normalizeFinishReason,
Expand Down Expand Up @@ -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",
Expand Down