diff --git a/catalog.json b/catalog.json index 79dfc14..c1da018 100644 --- a/catalog.json +++ b/catalog.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "providerId": "official", "name": "PI-Desktop Official Plugins", - "updatedAt": "2026-09-20T17:49:14Z", + "updatedAt": "2026-10-02T17:42:29Z", "homepage": "https://github.com/vastsa/pi-desktop-plugins", "plugins": [ { @@ -319,6 +319,50 @@ } ] }, + { + "id": "io.github.yahooyuan666.zcode2pi", + "name": "ZCode Free Quota", + "description": "Use the ZCode / BigModel Coding Plan free quota inside PI-Desktop: declares a model provider in the manifest (provider.register) and runs a loopback relay (background.service) that forwards requests to BigModel's OpenAI-compatible Coding Plan endpoint. Both ends speak Chat Completions, so no protocol translation happens and streaming is passed through untouched. Your API key stays in PI-Desktop's own secret store \u2014 the relay never sees it.", + "i18n": { + "en": { + "name": "ZCode Free Quota", + "description": "Use the ZCode / BigModel Coding Plan free quota inside PI-Desktop: declares a model provider in the manifest (provider.register) and runs a loopback relay (background.service) that forwards requests to BigModel's OpenAI-compatible Coding Plan endpoint. Both ends speak Chat Completions, so no protocol translation happens and streaming is passed through untouched. Your API key stays in PI-Desktop's own secret store \u2014 the relay never sees it.", + "safetyNotes": "The relay binds 127.0.0.1 only and rejects any request whose Host header is not loopback. Its one and only network destination is open.bigmodel.cn. It never reads, stores or logs your API key: PI-Desktop attaches the key from its own secret store to the outgoing request and the relay forwards request headers unchanged. It does not read or write any file, touch the clipboard, run a shell, or load remote code. On an upstream error it logs one line describing the request's field names and counts only \u2014 never message content, tool arguments or credentials. It rewrites nothing in the request body, so what the model sees is exactly what PI-Desktop sent." + }, + "zh-CN": { + "name": "ZCode\u514d\u8d39\u989d\u5ea6", + "description": "\u628a ZCode / BigModel Coding Plan \u7684\u514d\u8d39\u989d\u5ea6\u63a5\u8fdb PI-Desktop\uff1amanifest \u9759\u6001\u58f0\u660e\u6a21\u578b provider\uff08provider.register\uff09\uff0c\u540e\u53f0\u5e38\u9a7b\u4e00\u4e2a\u56de\u73af\u4e2d\u7ee7\uff08background.service\uff09\uff0c\u628a\u8bf7\u6c42\u8f6c\u53d1\u5230 BigModel \u7684 OpenAI \u517c\u5bb9 Coding Plan \u7aef\u70b9\u3002\u4e24\u7aef\u90fd\u662f Chat Completions \u65b9\u8a00\uff0c\u4e0d\u505a\u4efb\u4f55\u534f\u8bae\u8f6c\u6362\uff0c\u6d41\u5f0f\u8f93\u51fa\u9010\u5757\u900f\u4f20\u3002API Key \u59cb\u7ec8\u7559\u5728 PI-Desktop \u81ea\u5df1\u7684\u5bc6\u94a5\u5e93\u91cc\uff0c\u4e2d\u7ee7\u63a5\u89e6\u4e0d\u5230\u5b83\u3002", + "safetyNotes": "\u4e2d\u7ee7\u53ea\u7ed1\u5b9a 127.0.0.1\uff0c\u51e1 Host \u5934\u4e0d\u662f\u56de\u73af\u5730\u5740\u7684\u8bf7\u6c42\u4e00\u5f8b\u62d2\u7edd\u3002\u5b83\u552f\u4e00\u7684\u5bf9\u5916\u8fde\u63a5\u76ee\u6807\u662f open.bigmodel.cn\u3002\u5b83\u4e0d\u8bfb\u53d6\u3001\u4e0d\u4fdd\u5b58\u3001\u4e0d\u8bb0\u5f55\u4f60\u7684 API Key\uff1aKey \u7531 PI-Desktop \u4ece\u81ea\u5df1\u7684\u5bc6\u94a5\u5e93\u9644\u5230\u51fa\u7ad9\u8bf7\u6c42\u4e0a\uff0c\u4e2d\u7ee7\u539f\u6837\u900f\u4f20\u8bf7\u6c42\u5934\u3002\u5b83\u4e0d\u8bfb\u5199\u4efb\u4f55\u6587\u4ef6\u3001\u4e0d\u78b0\u526a\u8d34\u677f\u3001\u4e0d\u8d77 shell\u3001\u4e0d\u52a0\u8f7d\u8fdc\u7a0b\u4ee3\u7801\u3002\u4e0a\u6e38\u62a5\u9519\u65f6\u5b83\u53ea\u6253\u4e00\u884c\u300c\u8bf7\u6c42\u91cc\u6709\u54ea\u4e9b\u5b57\u6bb5\u3001\u5404\u591a\u5c11\u4e2a\u300d\u2014\u2014\u7edd\u4e0d\u6253\u6d88\u606f\u6b63\u6587\u3001\u5de5\u5177\u53c2\u6570\u6216\u51ed\u636e\u3002\u5b83\u4e0d\u6539\u5199\u8bf7\u6c42\u4f53\uff0c\u6a21\u578b\u770b\u5230\u7684\u5c31\u662f PI-Desktop \u53d1\u51fa\u7684\u539f\u6837\u3002" + } + }, + "author": "YahooYuan666", + "categories": [ + "developer-tools", + "community" + ], + "verified": true, + "downloads": 0, + "homepage": "https://github.com/vastsa/pi-desktop-plugins/tree/main/plugins/io.github.yahooyuan666.zcode2pi", + "repository": "https://github.com/vastsa/pi-desktop-plugins", + "readmeMarkdown": "# ZCode Free Quota\n\nUse the free quota that comes with your **ZCode / BigModel Coding Plan** as a model provider inside PI-Desktop.\n\nModel: `GLM-5.3-Flash` (1M context, vision).\n\n\u628a **ZCode / BigModel Coding Plan** \u81ea\u5e26\u7684\u514d\u8d39\u989d\u5ea6\uff0c\u4f5c\u4e3a\u6a21\u578b provider \u63a5\u8fdb PI-Desktop\u3002\n\n\u6a21\u578b\uff1a`GLM-5.3-Flash`\uff081M \u4e0a\u4e0b\u6587\uff0c\u652f\u6301\u8bfb\u56fe\uff09\u3002\n\n---\n\n## How it works\n\n```\nPI-Desktop model picker\n \u2514\u2500 \"ZCode Free Quota\" group (declared in manifest, provider.register)\n \u2514\u2500 baseUrl http://127.0.0.1:41940/v1\n \u2514\u2500 resident service zcode-proxy (background.service, 127.0.0.1 only)\n \u2514\u2500 https://open.bigmodel.cn/api/coding/paas/v4/chat/completions\n```\n\nBoth ends speak OpenAI Chat Completions, so the relay **translates nothing** and\nstreams SSE through chunk by chunk.\n\n\u4e24\u7aef\u90fd\u662f OpenAI Chat Completions \u65b9\u8a00\uff0c\u6240\u4ee5\u4e2d\u7ee7**\u4e0d\u505a\u4efb\u4f55\u8f6c\u6362**\uff0cSSE \u9010\u5757\u900f\u4f20\u3002\n\nThat endpoint is not an arbitrary choice: PI-Desktop's own first-class support path\nfor Zhipu Coding Plan is `apiStyle: \"chat_completions\"` against\n`https://open.bigmodel.cn/api/coding/paas/v4`\n(see [ADR 0155](https://github.com/vastsa/PI-Desktop/blob/main/docs/adr/0155-zhipu-endpoint-presets.md)).\nPointing a `chat_completions` provider at BigModel's *Anthropic* endpoint instead\nfails immediately \u2014 the host sends OpenAI-shaped tools (`{type, function}`) and that\nendpoint rejects them with `body.tools.0.name: Field required`.\n\n\u8fd9\u4e2a\u7aef\u70b9\u4e0d\u662f\u968f\u4fbf\u9009\u7684\uff1aPI-Desktop \u5bf9\u667a\u8c31 Coding Plan \u7684\u4e00\u7b49\u652f\u6301\u8def\u5f84\u5c31\u662f\n`apiStyle: \"chat_completions\"` \u914d `https://open.bigmodel.cn/api/coding/paas/v4`\n\uff08\u89c1 [ADR 0155](https://github.com/vastsa/PI-Desktop/blob/main/docs/adr/0155-zhipu-endpoint-presets.md)\uff09\u3002\n\u53cd\u8fc7\u6765\u628a `chat_completions` \u7684 provider \u6307\u5230 BigModel \u7684 *Anthropic* \u7aef\u70b9\u4f1a\u76f4\u63a5\u5931\u8d25\u2014\u2014\n\u5bbf\u4e3b\u53d1\u7684\u662f OpenAI \u5f62\u72b6\u7684\u5de5\u5177\uff08`{type, function}`\uff09\uff0c\u90a3\u4e2a\u7aef\u70b9\u4f1a\u4ee5\n`body.tools.0.name: Field required` \u62d2\u7edd\u3002\n\n## Setup\n\n1. **Get an API key** from .\n It is the same key your ZCode client uses to sign in to the Coding Plan.\n2. **Install the plugin**, then grant `provider.register` and `background.service`.\n3. **Enter the key** on the provider row in Settings.\n4. Pick `GLM-5.3-Flash` under \"ZCode Free Quota\" in the chat model picker.\n\n1. \u5230 \u7533\u8bf7\u4e00\u628a API Key,\n \u548c ZCode \u5ba2\u6237\u7aef\u767b\u5f55 Coding Plan \u7528\u7684\u662f\u540c\u4e00\u628a\u3002\n2. \u88c5\u597d\u63d2\u4ef6\u540e\uff0c\u6388\u6743 `provider.register` \u4e0e `background.service`\u3002\n3. \u5728\u8bbe\u7f6e\u91cc\u7684\u670d\u52a1\u5546\u884c\u586b\u5165 Key\u3002\n4. \u804a\u5929\u7a97\u6a21\u578b\u9009\u62e9\u5668\u91cc\u9009\u300cZCode\u514d\u8d39\u989d\u5ea6\u300d\u4e0b\u7684 `GLM-5.3-Flash`\u3002\n\n## Your API key never reaches this plugin\n\nThe provider is declared with `authKind: \"api_key\"`, so PI-Desktop keeps the key in\nits own secret store and attaches it to the outgoing request. The relay forwards\nrequest headers unchanged and has no code path that reads, stores or logs a\ncredential. There is no `.env`, no hard-coded key, and nothing to revoke here.\n\nprovider \u58f0\u660e\u7528\u7684\u662f `authKind: \"api_key\"`\uff0cKey \u7531 PI-Desktop \u5b58\u5728\u81ea\u5df1\u7684\u5bc6\u94a5\u5e93\u91cc\u3001\n\u968f\u51fa\u7ad9\u8bf7\u6c42\u9644\u4e0a\u3002\u4e2d\u7ee7\u539f\u6837\u900f\u4f20\u8bf7\u6c42\u5934\uff0c\u4ee3\u7801\u91cc\u6ca1\u6709\u4efb\u4f55\u8bfb\u53d6\u3001\u4fdd\u5b58\u6216\u8bb0\u5f55\u51ed\u636e\u7684\u8def\u5f84\uff0c\n\u6ca1\u6709 `.env`\uff0c\u6ca1\u6709\u5199\u6b7b\u7684 Key\uff0c\u8fd9\u91cc\u4e5f\u6ca1\u6709\u9700\u8981\u540a\u9500\u7684\u4e1c\u897f\u3002\n\n## What the relay does not do\n\n- **It does not force `stream`.** A caller that sends `stream: false` gets a complete\n JSON object back; a caller that sends `stream: true` gets SSE. This is deliberate:\n `JasonYu0822/opencode2pi-desktop` had to force `stream: true` upstream to pass\n OpenCode's anonymous free-lane gate, and that hack is what made non-streaming\n consumers receive unparseable SSE fragments\n ([issue #3](https://github.com/JasonYu0822/opencode2pi-desktop/issues/3)). BigModel's\n Coding Plan has no such gate, so this relay simply forwards what it was given.\n- **It does not inject placeholder tools.** The free-lane gate that\n [issue #1](https://github.com/JasonYu0822/opencode2pi-desktop/issues/1) describes\n (`bash` + `read` stubs) is an OpenCode anonymous-channel requirement, not a\n BigModel one.\n- **It does not rewrite the request body at all.** What the model sees is exactly what\n PI-Desktop sent. `passthrough.test.mjs` asserts this against a local echo upstream.\n\n- **\u4e0d\u5f3a\u5236 `stream`\u3002** \u53d1 `stream: false` \u7684\u8c03\u7528\u65b9\u62ff\u56de\u5b8c\u6574 JSON \u5bf9\u8c61\uff0c\u53d1 `stream: true`\n \u7684\u62ff\u56de SSE\u3002\u8fd9\u662f\u523b\u610f\u7684\uff1a`JasonYu0822/opencode2pi-desktop` \u4e3a\u4e86\u8fc7 OpenCode \u533f\u540d\u901a\u9053\u7684\u95e8\u7981\n \u88ab\u8feb\u5bf9\u4e0a\u6e38\u5f3a\u5236 `stream: true`\uff0c\u800c\u90a3\u4e2a hack \u6b63\u662f\u8ba9\u975e\u6d41\u5f0f\u6d88\u8d39\u8005\u6536\u5230\u65e0\u6cd5\u89e3\u6790\u7684 SSE \u788e\u7247\u7684\u539f\u56e0\n \uff08[issue #3](https://github.com/JasonYu0822/opencode2pi-desktop/issues/3)\uff09\u3002\n BigModel Coding Plan \u6ca1\u6709\u8fd9\u9053\u95e8\uff0c\u6240\u4ee5\u8fd9\u4e2a\u4e2d\u7ee7\u539f\u6837\u8f6c\u53d1\u3002\n- **\u4e0d\u6ce8\u5165\u5360\u4f4d\u5de5\u5177\u3002** [issue #1](https://github.com/JasonYu0822/opencode2pi-desktop/issues/1)\n \u91cc\u7684 `bash` + `read` \u5360\u4f4d\u6869\u662f OpenCode \u533f\u540d\u901a\u9053\u7684\u8981\u6c42\uff0c\u4e0d\u662f BigModel \u7684\u3002\n- **\u5b8c\u5168\u4e0d\u6539\u5199\u8bf7\u6c42\u4f53**\uff0c\u6a21\u578b\u770b\u5230\u7684\u5c31\u662f PI-Desktop \u53d1\u51fa\u7684\u539f\u6837\u3002\n `passthrough.test.mjs` \u7528\u672c\u5730\u56de\u663e\u4e0a\u6e38\u5bf9\u8fd9\u4e00\u70b9\u505a\u4e86\u65ad\u8a00\u3002\n\n## Ports\n\nThe relay prefers `41940` and falls back to `41941`, `41942` if those are taken, so a\nstale process from a previous session cannot silently hijack the provider.\n\n\u4e2d\u7ee7\u4f18\u5148\u7528 `41940`\uff0c\u88ab\u5360\u7528\u5219\u987a\u5ef6\u5230 `41941`/`41942`\uff0c\u907f\u514d\u4e0a\u4e2a\u4f1a\u8bdd\u7684\u6b8b\u7559\u8fdb\u7a0b\u6084\u6084\u5360\u4f4f\u3002\n\n## Health check\n\n```bash\ncurl http://127.0.0.1:41940/healthz\n```\n\nReturns the bound port, the upstream URL, request/failure counters and the last error.\nOn an upstream 4xx/5xx the relay also logs one line describing the request's field\nnames and counts \u2014 never content, never credentials.\n\n\u8fd4\u56de\u7ed1\u5b9a\u7aef\u53e3\u3001\u4e0a\u6e38\u5730\u5740\u3001\u8bf7\u6c42\u4e0e\u5931\u8d25\u8ba1\u6570\u3001\u6700\u8fd1\u4e00\u6b21\u9519\u8bef\u3002\u4e0a\u6e38\u62a5 4xx/5xx \u65f6\u8fd8\u4f1a\u6253\u4e00\u884c\n\u8bf7\u6c42\u91cc\u6709\u54ea\u4e9b\u5b57\u6bb5\u3001\u5404\u591a\u5c11\u4e2a\u2014\u2014\u7edd\u4e0d\u6253\u5185\u5bb9\u6216\u51ed\u636e\u3002\n\n## Permissions\n\n| Permission | Why |\n|---|---|\n| `provider.register` | Materialize the declared provider row in the model picker |\n| `background.service` | Run the 127.0.0.1 relay the provider's `baseUrl` points at |\n\nNo network, filesystem, clipboard or shell permission is requested. The relay opens no\nlistening socket on anything but loopback, and its only outbound destination is\n`open.bigmodel.cn`.\n\n## Tests\n\n```bash\nnode passthrough.test.mjs\n```\n\nPoints the relay at a local echo upstream and asserts request/response fidelity \u2014\n12 checks covering byte-identical request bodies, unforced `stream`, non-streaming\ncallers receiving JSON instead of SSE, SSE chunk order, and the absence of injected\ntools or `tool_choice`.\n\n## Credits\n\nThe provider-declaration + loopback-relay shape follows\n[`JasonYu0822/opencode2pi-desktop`](https://github.com/JasonYu0822/opencode2pi-desktop).\nThe `buildStoreZip` packaging format was reverse-engineered from PI-Desktop's own\n`packages/plugin-devkit/src/pack.ts`.\n\n## License\n\nMIT", + "safetyNotes": "The relay binds 127.0.0.1 only and rejects any request whose Host header is not loopback. Its one and only network destination is open.bigmodel.cn. It never reads, stores or logs your API key: PI-Desktop attaches the key from its own secret store to the outgoing request and the relay forwards request headers unchanged. It does not read or write any file, touch the clipboard, run a shell, or load remote code. On an upstream error it logs one line describing the request's field names and counts only \u2014 never message content, tool arguments or credentials. It rewrites nothing in the request body, so what the model sees is exactly what PI-Desktop sent.", + "versions": [ + { + "version": "0.1.0", + "publishedAt": "2026-10-02T17:42:29Z", + "changelog": "Release 0.1.0: first release. Declares GLM-5.3-Flash over BigModel's OpenAI-compatible Coding Plan endpoint (the wire style PI-Desktop uses natively for Zhipu Coding Plan, see PI-Desktop ADR 0155), so no protocol translation is involved and SSE is forwarded chunk by chunk. Request bodies are passed through byte for byte: `stream` is never forced, placeholder tools are never injected, and a non-streaming caller gets a complete JSON object back rather than SSE fragments. The relay binds loopback only, rejects non-loopback Host headers, and never touches the API key. 0.1.0\uff1a\u9996\u4e2a\u7248\u672c\u3002\u7ecf BigModel \u7684 OpenAI \u517c\u5bb9 Coding Plan \u7aef\u70b9\u63a5\u5165 GLM-5.3-Flash\uff08\u8fd9\u662f PI-Desktop \u5bf9\u667a\u8c31 Coding Plan \u539f\u751f\u4f7f\u7528\u7684\u7ebf\u8def\uff0c\u89c1 ADR 0155\uff09\uff0c\u56e0\u6b64\u4e0d\u6d89\u53ca\u534f\u8bae\u8f6c\u6362\uff0cSSE \u9010\u5757\u900f\u4f20\u3002\u8bf7\u6c42\u4f53\u9010\u5b57\u8282\u900f\u4f20\uff1a\u4e0d\u5f3a\u5236 stream\u3001\u4e0d\u6ce8\u5165\u5360\u4f4d\u5de5\u5177\uff0c\u975e\u6d41\u5f0f\u8c03\u7528\u65b9\u62ff\u5230\u7684\u662f\u5b8c\u6574 JSON \u5bf9\u8c61\u800c\u4e0d\u662f SSE \u788e\u7247\u3002\u4e2d\u7ee7\u53ea\u7ed1\u56de\u73af\u3001\u62d2\u7edd\u975e\u56de\u73af Host\u3001\u4e0d\u63a5\u89e6 API Key\u3002", + "minPiDesktop": ">=0.9.0", + "shasum": "a779a30bd19f09014914c4d556802f791ad0753526dae7f41f774925a879f6a7", + "url": "packages/io.github.yahooyuan666.zcode2pi-0.1.0.piplug", + "sizeBytes": 28465, + "permissions": [ + "provider.register", + "background.service" + ], + "fs": {} + } + ] + }, { "id": "local.pi-desktop-install-guard", "name": "\u4e00\u6b21\u88c5\u5230\u4f4d", diff --git a/packages/io.github.yahooyuan666.zcode2pi-0.1.0.piplug b/packages/io.github.yahooyuan666.zcode2pi-0.1.0.piplug new file mode 100644 index 0000000..585198d Binary files /dev/null and b/packages/io.github.yahooyuan666.zcode2pi-0.1.0.piplug differ diff --git a/plugins/io.github.yahooyuan666.zcode2pi/README.md b/plugins/io.github.yahooyuan666.zcode2pi/README.md new file mode 100644 index 0000000..99f1f2f --- /dev/null +++ b/plugins/io.github.yahooyuan666.zcode2pi/README.md @@ -0,0 +1,145 @@ +# ZCode Free Quota + +Use the free quota that comes with your **ZCode / BigModel Coding Plan** as a model provider inside PI-Desktop. + +Model: `GLM-5.3-Flash` (1M context, vision). + +把 **ZCode / BigModel Coding Plan** 自带的免费额度,作为模型 provider 接进 PI-Desktop。 + +模型:`GLM-5.3-Flash`(1M 上下文,支持读图)。 + +--- + +## How it works + +``` +PI-Desktop model picker + └─ "ZCode Free Quota" group (declared in manifest, provider.register) + └─ baseUrl http://127.0.0.1:41940/v1 + └─ resident service zcode-proxy (background.service, 127.0.0.1 only) + └─ https://open.bigmodel.cn/api/coding/paas/v4/chat/completions +``` + +Both ends speak OpenAI Chat Completions, so the relay **translates nothing** and +streams SSE through chunk by chunk. + +两端都是 OpenAI Chat Completions 方言,所以中继**不做任何转换**,SSE 逐块透传。 + +That endpoint is not an arbitrary choice: PI-Desktop's own first-class support path +for Zhipu Coding Plan is `apiStyle: "chat_completions"` against +`https://open.bigmodel.cn/api/coding/paas/v4` +(see [ADR 0155](https://github.com/vastsa/PI-Desktop/blob/main/docs/adr/0155-zhipu-endpoint-presets.md)). +Pointing a `chat_completions` provider at BigModel's *Anthropic* endpoint instead +fails immediately — the host sends OpenAI-shaped tools (`{type, function}`) and that +endpoint rejects them with `body.tools.0.name: Field required`. + +这个端点不是随便选的:PI-Desktop 对智谱 Coding Plan 的一等支持路径就是 +`apiStyle: "chat_completions"` 配 `https://open.bigmodel.cn/api/coding/paas/v4` +(见 [ADR 0155](https://github.com/vastsa/PI-Desktop/blob/main/docs/adr/0155-zhipu-endpoint-presets.md))。 +反过来把 `chat_completions` 的 provider 指到 BigModel 的 *Anthropic* 端点会直接失败—— +宿主发的是 OpenAI 形状的工具(`{type, function}`),那个端点会以 +`body.tools.0.name: Field required` 拒绝。 + +## Setup + +1. **Get an API key** from . + It is the same key your ZCode client uses to sign in to the Coding Plan. +2. **Install the plugin**, then grant `provider.register` and `background.service`. +3. **Enter the key** on the provider row in Settings. +4. Pick `GLM-5.3-Flash` under "ZCode Free Quota" in the chat model picker. + +1. 到 申请一把 API Key, + 和 ZCode 客户端登录 Coding Plan 用的是同一把。 +2. 装好插件后,授权 `provider.register` 与 `background.service`。 +3. 在设置里的服务商行填入 Key。 +4. 聊天窗模型选择器里选「ZCode免费额度」下的 `GLM-5.3-Flash`。 + +## Your API key never reaches this plugin + +The provider is declared with `authKind: "api_key"`, so PI-Desktop keeps the key in +its own secret store and attaches it to the outgoing request. The relay forwards +request headers unchanged and has no code path that reads, stores or logs a +credential. There is no `.env`, no hard-coded key, and nothing to revoke here. + +provider 声明用的是 `authKind: "api_key"`,Key 由 PI-Desktop 存在自己的密钥库里、 +随出站请求附上。中继原样透传请求头,代码里没有任何读取、保存或记录凭据的路径, +没有 `.env`,没有写死的 Key,这里也没有需要吊销的东西。 + +## What the relay does not do + +- **It does not force `stream`.** A caller that sends `stream: false` gets a complete + JSON object back; a caller that sends `stream: true` gets SSE. This is deliberate: + `JasonYu0822/opencode2pi-desktop` had to force `stream: true` upstream to pass + OpenCode's anonymous free-lane gate, and that hack is what made non-streaming + consumers receive unparseable SSE fragments + ([issue #3](https://github.com/JasonYu0822/opencode2pi-desktop/issues/3)). BigModel's + Coding Plan has no such gate, so this relay simply forwards what it was given. +- **It does not inject placeholder tools.** The free-lane gate that + [issue #1](https://github.com/JasonYu0822/opencode2pi-desktop/issues/1) describes + (`bash` + `read` stubs) is an OpenCode anonymous-channel requirement, not a + BigModel one. +- **It does not rewrite the request body at all.** What the model sees is exactly what + PI-Desktop sent. `passthrough.test.mjs` asserts this against a local echo upstream. + +- **不强制 `stream`。** 发 `stream: false` 的调用方拿回完整 JSON 对象,发 `stream: true` + 的拿回 SSE。这是刻意的:`JasonYu0822/opencode2pi-desktop` 为了过 OpenCode 匿名通道的门禁 + 被迫对上游强制 `stream: true`,而那个 hack 正是让非流式消费者收到无法解析的 SSE 碎片的原因 + ([issue #3](https://github.com/JasonYu0822/opencode2pi-desktop/issues/3))。 + BigModel Coding Plan 没有这道门,所以这个中继原样转发。 +- **不注入占位工具。** [issue #1](https://github.com/JasonYu0822/opencode2pi-desktop/issues/1) + 里的 `bash` + `read` 占位桩是 OpenCode 匿名通道的要求,不是 BigModel 的。 +- **完全不改写请求体**,模型看到的就是 PI-Desktop 发出的原样。 + `passthrough.test.mjs` 用本地回显上游对这一点做了断言。 + +## Ports + +The relay prefers `41940` and falls back to `41941`, `41942` if those are taken, so a +stale process from a previous session cannot silently hijack the provider. + +中继优先用 `41940`,被占用则顺延到 `41941`/`41942`,避免上个会话的残留进程悄悄占住。 + +## Health check + +```bash +curl http://127.0.0.1:41940/healthz +``` + +Returns the bound port, the upstream URL, request/failure counters and the last error. +On an upstream 4xx/5xx the relay also logs one line describing the request's field +names and counts — never content, never credentials. + +返回绑定端口、上游地址、请求与失败计数、最近一次错误。上游报 4xx/5xx 时还会打一行 +请求里有哪些字段、各多少个——绝不打内容或凭据。 + +## Permissions + +| Permission | Why | +|---|---| +| `provider.register` | Materialize the declared provider row in the model picker | +| `background.service` | Run the 127.0.0.1 relay the provider's `baseUrl` points at | + +No network, filesystem, clipboard or shell permission is requested. The relay opens no +listening socket on anything but loopback, and its only outbound destination is +`open.bigmodel.cn`. + +## Tests + +```bash +node passthrough.test.mjs +``` + +Points the relay at a local echo upstream and asserts request/response fidelity — +12 checks covering byte-identical request bodies, unforced `stream`, non-streaming +callers receiving JSON instead of SSE, SSE chunk order, and the absence of injected +tools or `tool_choice`. + +## Credits + +The provider-declaration + loopback-relay shape follows +[`JasonYu0822/opencode2pi-desktop`](https://github.com/JasonYu0822/opencode2pi-desktop). +The `buildStoreZip` packaging format was reverse-engineered from PI-Desktop's own +`packages/plugin-devkit/src/pack.ts`. + +## License + +MIT \ No newline at end of file diff --git a/plugins/io.github.yahooyuan666.zcode2pi/main.js b/plugins/io.github.yahooyuan666.zcode2pi/main.js new file mode 100644 index 0000000..4a6eaf5 --- /dev/null +++ b/plugins/io.github.yahooyuan666.zcode2pi/main.js @@ -0,0 +1,324 @@ +'use strict'; + +/** + * ZCode免费额度 —— PI-Desktop provider 插件 + * + * 链路: + * 聊天窗模型选择器「ZCode免费额度」组 + * -> baseUrl http://127.0.0.1: (manifest 静态声明,authKind=api_key) + * -> 本进程(background.service,仅绑 127.0.0.1) + * -> https://open.bigmodel.cn/api/anthropic/v1/messages + * + * 本插件不接触 API Key:宿主密钥库把密钥放进请求头,本进程原样透传。 + * 两端同为 Anthropic Messages,不需要任何协议转换,SSE 逐块透传。 + */ + +const http = require('http'); + +const SERVICE_ID = 'zcode-proxy'; +// BigModel Coding Plan 的 OpenAI 兼容端点。这是 PI-Desktop 对智谱 Coding Plan 的 +// 一等支持路径(ADR 0155:zhipuai-coding-plan 预置就是 chat_completions + 这个 URL), +// 两端方言一致,所以本进程只转发、不转换。 +const DEFAULT_UPSTREAM = 'https://open.bigmodel.cn/api/coding/paas/v4/chat/completions'; +let upstream = DEFAULT_UPSTREAM; +const PORT_CANDIDATES = [41940, 41941, 41942]; + +// 逐跳头 + 会让 fetch 解压后又原样回写而损坏 body 的头,全部剥掉。 +// accept-encoding 换成 identity:上游不压缩,我们就能直接把字节流写回响应。 +const STRIP_REQUEST_HEADERS = new Set([ + 'host', + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'content-length', + 'accept-encoding', +]); + +const STRIP_RESPONSE_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'transfer-encoding', + 'content-encoding', + 'content-length', +]); + +const state = { + port: 0, + started: false, + requests: 0, + failures: 0, + lastError: '', +}; + +let server = null; +let pi = null; +let log = () => {}; + +/** 只接受回环 Host,防DNS rebinding / 本机其它端口误连。 */ +function isLoopbackHost(hostHeader) { + if (!hostHeader) return true; + const host = String(hostHeader).replace(/:\d+$/, '').replace(/^\[/, '').replace(/\]$/, ''); + return host === '127.0.0.1' || host === 'localhost' || host === '::1'; +} + +function sendJson(res, status, payload) { + const body = Buffer.from(JSON.stringify(payload), 'utf8'); + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'content-length': String(body.length), + }); + res.end(body); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +/** + * 宿主在 baseUrl 后拼的是 "/chat/completions"(见 provider-endpoint.ts 的 suffix 表), + * manifest 里 baseUrl 写成 http://127.0.0.1:/v1,拼出来正好是 /v1/chat/completions。 + * 转发时不看路径、只认健康检查,任何请求都送到固定的上游端点, + * 这样宿主换拼接方式也不会打偏。 + */ +function isHealthCheck(req, url) { + return url.pathname === '/healthz' || url.pathname === '/health'; +} + +async function handleForward(req, res) { + const body = req.method === 'GET' || req.method === 'HEAD' ? undefined : await readBody(req); + + const headers = {}; + for (const [name, value] of Object.entries(req.headers)) { + if (STRIP_REQUEST_HEADERS.has(name)) continue; + headers[name] = Array.isArray(value) ? value.join(', ') : value; + } + headers['accept-encoding'] = 'identity'; + + let response; + try { + response = await fetch(upstream, { method: req.method, headers, body }); + } catch (error) { + state.failures += 1; + state.lastError = String((error && error.message) || error); + log(`上游请求失败: ${state.lastError}`); + if (!res.headersSent) sendJson(res, 502, { error: { message: `上游请求失败: ${state.lastError}` } }); + else if (!res.writableEnded) res.end(); + return; + } + + const outHeaders = {}; + response.headers.forEach((value, name) => { + if (STRIP_RESPONSE_HEADERS.has(name)) return; + outHeaders[name] = value; + }); + + if (response.status >= 400) { + state.failures += 1; + log(`上游返回 ${response.status}|请求形状:${body ? describeShape(body) : '(无请求体)'}`); + } + + res.writeHead(response.status, outHeaders); + + if (!response.body) { + res.end(); + return; + } + + const reader = response.body.getReader(); + let streamDone = false; + + // 客户端中途取消(用户停了生成)时立刻断开上游,别把额度烧在没人要的内容上。 + // 注意 res 的 close 在正常结束时也会触发,所以必须用 streamDone 挡住, + // 且要 cancel 的是 reader 而不是 body —— 正在被读取时 body 是锁死的。 + const abortUpstream = () => { + if (streamDone) return; + streamDone = true; + try { reader.cancel().catch(() => {}); } catch { /* 已结束 */ } + }; + req.on('aborted', abortUpstream); + res.on('close', abortUpstream); + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (res.writableEnded) { + abortUpstream(); + break; + } + res.write(Buffer.from(value)); + } + streamDone = true; + } catch (error) { + streamDone = true; + state.lastError = String((error && error.message) || error); + log(`流式传输中断: ${state.lastError}`); + } finally { + if (!res.writableEnded) res.end(); + } +} + +/** + * 上游报错时打一行请求形状,方便对照到底是哪种方言。 + * 只看字段名和数量,绝不打印消息正文、工具定义或任何凭证。 + */ +function describeShape(buffer) { + try { + const parsed = JSON.parse(buffer.toString('utf8')); + const tools = Array.isArray(parsed.tools) ? parsed.tools : []; + const toolKeys = tools[0] && typeof tools[0] === 'object' ? Object.keys(tools[0]).sort() : []; + return [ + `model=${parsed.model}`, + `stream=${parsed.stream}`, + `tools=${tools.length}`, + `tools[0]字段=${toolKeys.join(',') || '(无)'}`, + `system=${Array.isArray(parsed.system) ? 'block[]' : typeof parsed.system}`, + `顶层=${Object.keys(parsed).sort().join(',')}`, + ].join(' '); + } catch { + return `请求体不是 JSON(${buffer.length} 字节)`; + } +} + +function handleRequest(req, res) { + let url; + try { + url = new URL(req.url || '/', 'http://127.0.0.1'); + } catch { + sendJson(res, 400, { error: { message: 'bad request path' } }); + return; + } + + if (!isLoopbackHost(req.headers.host)) { + log(`拒绝非回环请求: host=${req.headers.host}`); + sendJson(res, 403, { error: { message: '仅允许回环访问' } }); + return; + } + + if (isHealthCheck(req, url)) { + sendJson(res, 200, { + ok: state.started, + port: state.port, + upstream, + model: 'GLM-5.3-Flash', + requests: state.requests, + failures: state.failures, + lastError: state.lastError, + }); + return; + } + + state.requests += 1; + handleForward(req, res).catch((error) => { + state.failures += 1; + state.lastError = String((error && error.message) || error); + if (!res.headersSent) sendJson(res, 500, { error: { message: state.lastError } }); + else if (!res.writableEnded) res.end(); + }); +} + +/** 绑 127.0.0.1。manifest 里写死的是首选端口,被占用就顺延,宿主那边不用改。 */ +function listen(candidates) { + return new Promise((resolve, reject) => { + const ports = [...new Set(candidates.filter((p) => Number.isInteger(p) && p > 0))]; + const tryPort = (index) => { + if (index >= ports.length) { + reject(new Error('所有候选端口都被占用')); + return; + } + const onError = (error) => { + if (error.code === 'EADDRINUSE') { + server.removeListener('listening', onListening); + tryPort(index + 1); + return; + } + reject(error); + }; + const onListening = () => { + server.removeListener('error', onError); + resolve(server.address().port); + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(ports[index], '127.0.0.1'); + }; + tryPort(0); + }); +} + +async function startService(context = {}) { + if (typeof context.log === 'function') { + const hostLog = context.log; + log = (message) => { + try { hostLog(message); } catch { /* 宿主日志通道可能已关 */ } + try { console.log(`[zcode2pi] ${message}`); } catch { /* console 可能不可用 */ } + }; + } + + if (server) return; + + server = http.createServer(handleRequest); + const port = await listen(PORT_CANDIDATES); + state.port = port; + state.started = true; + log(`provider endpoint 已启动: http://127.0.0.1:${port} -> ${upstream}`); +} + +async function stopService() { + state.started = false; + if (!server) return; + await new Promise((resolve) => server.close(resolve)); + server = null; + state.port = 0; +} + +module.exports = { + /** + * 宿主调用 onLoad() 时不传参,插件 API 挂在全局 pi 上。 + */ + async onLoad() { + pi = globalThis.pi; + if (!pi) { + throw new Error('宿主插件 API 不可用:onLoad 之前应当存在全局 pi 对象'); + } + log = (message) => { + try { console.log(`[zcode2pi] ${message}`); } catch { /* console 可能不可用 */ } + }; + + // 先注册端点:宿主在 onLoad 返回后立刻启动声明过的服务。 + pi.services.register({ + id: SERVICE_ID, + start: (context) => startService(context), + stop: () => stopService(), + }); + + log('已加载;模型「GLM-5.3-Flash」可在聊天窗模型选择器的「ZCode免费额度」分组里选择'); + }, + + async onUnload() { + await stopService(); + }, + + /** 单测用的缝,不是宿主契约的一部分。 */ + _internals: { + state, + PORT_CANDIDATES, + get upstream() { return upstream; }, + /** 测试时把转发目标换成本地回显服务器,验证请求体逐字节透传。 */ + setUpstream(value) { upstream = value; }, + isLoopbackHost, + handleRequest, + startService, + stopService, + }, +}; \ No newline at end of file diff --git a/plugins/io.github.yahooyuan666.zcode2pi/manifest.json b/plugins/io.github.yahooyuan666.zcode2pi/manifest.json new file mode 100644 index 0000000..c721132 --- /dev/null +++ b/plugins/io.github.yahooyuan666.zcode2pi/manifest.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 1, + "id": "io.github.yahooyuan666.zcode2pi", + "name": "ZCode Free Quota", + "version": "0.1.0", + "description": "Use the ZCode / BigModel Coding Plan free quota inside PI-Desktop: declares a model provider in the manifest (provider.register) and runs a loopback relay (background.service) that forwards requests to BigModel's OpenAI-compatible Coding Plan endpoint. Both ends speak Chat Completions, so no protocol translation happens and streaming is passed through untouched. Your API key stays in PI-Desktop's own secret store — the relay never sees it.", + "i18n": { + "en": { + "name": "ZCode Free Quota", + "description": "Use the ZCode / BigModel Coding Plan free quota inside PI-Desktop: declares a model provider in the manifest (provider.register) and runs a loopback relay (background.service) that forwards requests to BigModel's OpenAI-compatible Coding Plan endpoint. Both ends speak Chat Completions, so no protocol translation happens and streaming is passed through untouched. Your API key stays in PI-Desktop's own secret store — the relay never sees it.", + "safetyNotes": "The relay binds 127.0.0.1 only and rejects any request whose Host header is not loopback. Its one and only network destination is open.bigmodel.cn. It never reads, stores or logs your API key: PI-Desktop attaches the key from its own secret store to the outgoing request and the relay forwards request headers unchanged. It does not read or write any file, touch the clipboard, run a shell, or load remote code. On an upstream error it logs one line describing the request's field names and counts only — never message content, tool arguments or credentials. It rewrites nothing in the request body, so what the model sees is exactly what PI-Desktop sent." + }, + "zh-CN": { + "name": "ZCode免费额度", + "description": "把 ZCode / BigModel Coding Plan 的免费额度接进 PI-Desktop:manifest 静态声明模型 provider(provider.register),后台常驻一个回环中继(background.service),把请求转发到 BigModel 的 OpenAI 兼容 Coding Plan 端点。两端都是 Chat Completions 方言,不做任何协议转换,流式输出逐块透传。API Key 始终留在 PI-Desktop 自己的密钥库里,中继接触不到它。", + "safetyNotes": "中继只绑定 127.0.0.1,凡 Host 头不是回环地址的请求一律拒绝。它唯一的对外连接目标是 open.bigmodel.cn。它不读取、不保存、不记录你的 API Key:Key 由 PI-Desktop 从自己的密钥库附到出站请求上,中继原样透传请求头。它不读写任何文件、不碰剪贴板、不起 shell、不加载远程代码。上游报错时它只打一行「请求里有哪些字段、各多少个」——绝不打消息正文、工具参数或凭据。它不改写请求体,模型看到的就是 PI-Desktop 发出的原样。" + } + }, + "author": "YahooYuan666", + "main": "main.js", + "categories": [ + "developer-tools", + "community" + ], + "changelog": "Release 0.1.0: first release. Declares GLM-5.3-Flash over BigModel's OpenAI-compatible Coding Plan endpoint (the wire style PI-Desktop uses natively for Zhipu Coding Plan, see PI-Desktop ADR 0155), so no protocol translation is involved and SSE is forwarded chunk by chunk. Request bodies are passed through byte for byte: `stream` is never forced, placeholder tools are never injected, and a non-streaming caller gets a complete JSON object back rather than SSE fragments. The relay binds loopback only, rejects non-loopback Host headers, and never touches the API key. 0.1.0:首个版本。经 BigModel 的 OpenAI 兼容 Coding Plan 端点接入 GLM-5.3-Flash(这是 PI-Desktop 对智谱 Coding Plan 原生使用的线路,见 ADR 0155),因此不涉及协议转换,SSE 逐块透传。请求体逐字节透传:不强制 stream、不注入占位工具,非流式调用方拿到的是完整 JSON 对象而不是 SSE 碎片。中继只绑回环、拒绝非回环 Host、不接触 API Key。", + "safetyNotes": "The relay binds 127.0.0.1 only and rejects any request whose Host header is not loopback. Its one and only network destination is open.bigmodel.cn. It never reads, stores or logs your API key: PI-Desktop attaches the key from its own secret store to the outgoing request and the relay forwards request headers unchanged. It does not read or write any file, touch the clipboard, run a shell, or load remote code. On an upstream error it logs one line describing the request's field names and counts only — never message content, tool arguments or credentials. It rewrites nothing in the request body, so what the model sees is exactly what PI-Desktop sent.", + "permissions": [ + "provider.register", + "background.service" + ], + "engines": { + "piDesktop": ">=0.9.0" + }, + "activationEvents": [ + "onStartup" + ], + "contributes": { + "services": [ + { + "id": "zcode-proxy", + "label": "ZCode free quota provider endpoint" + } + ], + "providers": [ + { + "id": "bigmodel-free", + "name": "ZCode Free Quota", + "vendorKey": "custom", + "baseUrl": "http://127.0.0.1:41940/v1", + "apiStyle": "chat_completions", + "authKind": "api_key", + "models": [ + { + "id": "GLM-5.3-Flash", + "name": "GLM-5.3-Flash", + "contextWindow": 1000000, + "maxTokens": 128000, + "supportsImages": true + } + ] + } + ] + } +} \ No newline at end of file diff --git a/plugins/io.github.yahooyuan666.zcode2pi/passthrough.test.mjs b/plugins/io.github.yahooyuan666.zcode2pi/passthrough.test.mjs new file mode 100644 index 0000000..cc1877a --- /dev/null +++ b/plugins/io.github.yahooyuan666.zcode2pi/passthrough.test.mjs @@ -0,0 +1,109 @@ +/** + * 验证 JasonYu0822/opencode2pi-desktop#3 那类 bug 在本插件里不存在。 + * + * 那个 issue 说的是:代理为了过 Zen 匿名通道的门禁,对上游强制 stream:true, + * 然后把 SSE 无脑管回客户端,于是发 stream:false 的客户端收到的是 data: 碎片。 + * + * 本插件不需要那个 hack(上游是 BigModel Coding Plan,没有匿名通道门禁), + * 所以请求体必须逐字节透传:宿主说 stream:false,上游就收到 stream:false, + * 上游回单个 JSON,本插件就把单个 JSON 交回去。 + * + * 跑法:node tests/passthrough.test.mjs + */ + +import http from 'node:http'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const plugin = require('./main.js'); +const { startService, stopService, state, setUpstream } = plugin._internals; + +let pass = 0; +let fail = 0; +const chk = (name, ok, detail = '') => { + if (ok) { pass += 1; console.log(` PASS ${name}`); } + else { fail += 1; console.log(` FAIL ${name} ${detail}`); } +}; + +// 上游回显服务器:按收到的 stream 字段决定回单个 JSON 还是回 SSE。 +let echo = null; +const echoServer = http.createServer((req, res) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + let parsed = null; + try { parsed = JSON.parse(raw); } catch { /* 原样记下非 JSON 体 */ } + echo = { raw, parsed, method: req.method, path: req.url }; + + if (parsed && parsed.stream === true) { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.write('data: {"choices":[{"delta":{"content":"你"}}]}\n\n'); + res.write('data: {"choices":[{"delta":{"content":"好"}}]}\n\n'); + res.write('data: [DONE]\n\n'); + res.end(); + return; + } + const payload = JSON.stringify({ object: 'chat.completion', choices: [{ message: { content: '完整响应' } }] }); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(payload); + }); +}); +await new Promise((r) => echoServer.listen(0, '127.0.0.1', r)); +setUpstream(`http://127.0.0.1:${echoServer.address().port}/v1/chat/completions`); + +await startService({ log: () => {} }); +const base = `http://127.0.0.1:${state.port}`; + +const post = async (payload, headers = {}) => { + const res = await fetch(`${base}/v1/chat/completions`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: 'Bearer test', ...headers }, + body: JSON.stringify(payload), + }); + return { status: res.status, type: res.headers.get('content-type'), text: await res.text() }; +}; + +console.log(`\n#3 核对:非流式客户端不能收到 SSE 碎片`); + +// 1) 非流式:宿主说 stream:false,上游必须原样收到 false,客户端必须拿到单个 JSON +const nonStream = await post({ + model: 'GLM-5.3-Flash', max_tokens: 32, stream: false, + messages: [{ role: 'user', content: 'hi' }], +}); +chk('上游收到的 stream 字段是 false(没被强制改成 true)', echo?.parsed?.stream === false, `got ${echo?.parsed?.stream}`); +chk('请求体逐字节透传(未被改写)', echo?.raw === JSON.stringify({ + model: 'GLM-5.3-Flash', max_tokens: 32, stream: false, + messages: [{ role: 'user', content: 'hi' }], +})); +chk('客户端拿到的是单个 JSON 对象', nonStream.text.startsWith('{') && nonStream.text.includes('chat.completion'), nonStream.text.slice(0, 80)); +chk('响应 Content-Type 是 application/json', (nonStream.type || '').includes('application/json'), nonStream.type); +chk('响应里没有 data: 碎片', !nonStream.text.includes('data:')); + +// 2) 不带 stream 字段:上游原样收到 undefined,仍回单个 JSON +const noStream = await post({ model: 'GLM-5.3-Flash', max_tokens: 8, messages: [{ role: 'user', content: 'hi' }] }); +chk('省略 stream 时上游也没被塞进 stream', echo?.parsed?.stream === undefined, `got ${echo?.parsed?.stream}`); +chk('省略 stream 时客户端拿到的仍是单个 JSON', noStream.text.startsWith('{') && !noStream.text.includes('data:')); + +// 3) 流式:上游说 stream:true,SSE 逐块原样透传 +const streaming = await post({ + model: 'GLM-5.3-Flash', max_tokens: 8, stream: true, + messages: [{ role: 'user', content: 'hi' }], +}); +chk('上游收到的 stream 字段是 true', echo?.parsed?.stream === true); +chk('SSE Content-Type 透传', (streaming.type || '').includes('text/event-stream'), streaming.type); +chk('SSE 分片逐块透传且顺序不变', + streaming.text === 'data: {"choices":[{"delta":{"content":"你"}}]}\n\ndata: {"choices":[{"delta":{"content":"好"}}]}\n\ndata: [DONE]\n\n', + JSON.stringify(streaming.text.slice(0, 60))); + +// 4) 请求体没被塞进任何占位工具(那是 opencode2pi#1 的做法,本插件不该有) +await post({ model: 'GLM-5.3-Flash', max_tokens: 8, messages: [{ role: 'user', content: 'hi' }] }); +chk('没有往请求体里塞占位工具', echo?.parsed?.tools === undefined, JSON.stringify(echo?.parsed?.tools)); +chk('没有往请求体里塞 tool_choice', echo?.parsed?.tool_choice === undefined); + +await stopService(); +await new Promise((r) => echoServer.close(r)); +console.log(`\n结果: ${pass} 通过, ${fail} 失败`); +// 用 exitCode 而不是 process.exit():后者会和 libuv 正在关闭的句柄赛跑, +// 在 Windows 上触发 `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)`。 +process.exitCode = fail ? 1 : 0; \ No newline at end of file