From 86c6981859b36fcbeaa3c1a8fb1be0a367175c0a Mon Sep 17 00:00:00 2001 From: baranwang Date: Sun, 18 Oct 2026 01:33:56 +0800 Subject: [PATCH 1/9] feat: add Zen Free model provider plugin Discover current free OpenCode models and adapt model-specific protocols through the Godwit models API. Co-authored-by: Codex --- zen-free/.gitignore | 2 + zen-free/LICENSE | 21 +++++++++ zen-free/README.md | 51 +++++++++++++++++++++ zen-free/build.ts | 5 +++ zen-free/package.json | 8 ++++ zen-free/plugin.json | 12 +++++ zen-free/src/index.ts | 7 +++ zen-free/src/models.ts | 85 +++++++++++++++++++++++++++++++++++ zen-free/src/request.ts | 60 +++++++++++++++++++++++++ zen-free/test/host.test.ts | 9 ++++ zen-free/test/models.test.ts | 27 +++++++++++ zen-free/test/request.test.ts | 13 ++++++ 12 files changed, 300 insertions(+) create mode 100644 zen-free/.gitignore create mode 100644 zen-free/LICENSE create mode 100644 zen-free/README.md create mode 100644 zen-free/build.ts create mode 100644 zen-free/package.json create mode 100644 zen-free/plugin.json create mode 100644 zen-free/src/index.ts create mode 100644 zen-free/src/models.ts create mode 100644 zen-free/src/request.ts create mode 100644 zen-free/test/host.test.ts create mode 100644 zen-free/test/models.test.ts create mode 100644 zen-free/test/request.test.ts diff --git a/zen-free/.gitignore b/zen-free/.gitignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/zen-free/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/zen-free/LICENSE b/zen-free/LICENSE new file mode 100644 index 0000000..85e65f8 --- /dev/null +++ b/zen-free/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 xiaozhou26 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/zen-free/README.md b/zen-free/README.md new file mode 100644 index 0000000..6f56c81 --- /dev/null +++ b/zen-free/README.md @@ -0,0 +1,51 @@ +# Zen Free for Godwit + +在 Godwit 中启用插件并允许声明的网络权限后,打开「模型服务」即可选择 Zen Free 模型,无需填写 API Key 或手建服务。需要包含 `models` 插件 API 的 Godwit 版本。 + +提示、邮件上下文、工具定义及工具结果会发送到 OpenCode 及其模型服务商。请阅读上游的数据使用条款。此插件依赖非官方公共接口;可用模型、限流和免费期限会变化,不保证配额或可用性。 + +模型来自当前 `/models` 列表,并结合上游目录或官方文档确认免费价格和协议。收费、退役、未知协议以及未经实际验证的判断模型不会被列出。目录失败时 Godwit 保留上次成功的列表;首次失败不会编造可用模型。元数据仅在当前插件实例内保留一小时。完整目录可能超过 Godwit 的 fetch 上限,此时使用当前文档中的价格和协议表,不扩大宿主限制。 + +插件支持 Chat Completions、Responses 和 Anthropic Messages 的请求适配;实际能选到哪些协议取决于当时上游的免费目录。Godwit 处理流、重试、工具执行和授权。插件补入的 bash/edit/glob/grep/read 占位定义明确不可调用,也不会获得执行权限。 + +## 构建与检查 + +源码无运行时依赖,Bun 仅用于构建和测试: + +```sh +bun run --cwd zen-free build +bun test ./zen-free/test +``` + +安装 `zen-free/dist`。它包含 `plugin.json` 和一份无外部 import 的 `index.js`。 + +源码类型导入指向相邻的 `godwit/sdk/godwit.d.ts`。运行真实 goja 沙箱测试时指定宿主工作树: + +```sh +PATH=/path/to/go/bin:$PATH GODWIT_CHECKOUT=/path/to/godwit bun test ./zen-free/test +``` + +可选的真实接口验收仅发送公共测试文字,工具只返回固定测试值: + +```sh +GODWIT_ZEN_FREE_DIST=/absolute/path/to/plugins/zen-free/dist \ +GODWIT_ZEN_LIVE=1 go test ./core -run '^TestZenFreeLive$' -count=1 -v +``` + +应在 Godwit 工作树执行最后一条命令。它验证目录、文本、真实工具往返并探测明确免费的 Jev。未提供环境变量时网络测试跳过;单元测试本身不代表上游可用。 + +## 验收记录 + +2026-10-17,在包含新 models API 的 Godwit 隔离工作树中: + +- 插件单元测试和真实 goja 加载检查:11 项通过,构建通过。 +- 上游目录列出 9 个 Chat Completions 生成模型和 `jev-1.13-free` 判断模型。 +- `big-pickle`(`openai-completions`):公共文字生成、真实 `zen_echo` 工具往返通过。 +- `jev-1.13-free`(`typesafe-systemone`):在设置中选用后,公共文字的判断请求通过。 +- 当时没有明确免费的 Responses / Anthropic Messages 生成模型,因此未做这两种协议的真实上游推理;三种生成协议均通过宿主的本地 TLS 集成测试。没有使用收费模型补足验收。 + +这些结果只说明该次验收可用,后续仍受上游目录和限流影响。本地模型的真实下载器、平台内存检查和 StartLux 加载由 Godwit #90 接入,不包含在此插件中。 + +## 来源 + +参考 [magpie-community/plugins 的 zen-free 0.1.10](https://github.com/magpie-community/plugins/tree/0b58032fa37b57e7a003ea5570d3674dc52fedea/packages/zen-free),采用其公共凭据、OpenCode 兼容请求头与占位工具约定。保留原 MIT 许可和版权。未移植其 Node crypto、流解析或工具名修复逻辑。未修改 registry;发布后才能填入真实、不可变的 URL、大小和 SHA-256。 diff --git a/zen-free/build.ts b/zen-free/build.ts new file mode 100644 index 0000000..0975cdc --- /dev/null +++ b/zen-free/build.ts @@ -0,0 +1,5 @@ +const result = await Bun.build({ entrypoints: [import.meta.dir + "/src/index.ts"], outdir: import.meta.dir + "/dist", target: "browser", format: "esm", minify: false, external: [] }) +if (!result.success) throw new AggregateError(result.logs, "Zen Free build failed") +await Bun.write(import.meta.dir + "/dist/plugin.json", Bun.file(import.meta.dir + "/plugin.json")) +await Bun.write(import.meta.dir + "/dist/LICENSE", Bun.file(import.meta.dir + "/LICENSE")) +if (Bun.file(import.meta.dir + "/dist/index.js").size > 5 * 1024 * 1024) throw new Error("Plugin is larger than the host limit") diff --git a/zen-free/package.json b/zen-free/package.json new file mode 100644 index 0000000..32daf6f --- /dev/null +++ b/zen-free/package.json @@ -0,0 +1,8 @@ +{ + "name": "godwit-zen-free", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { "build": "bun run build.ts", "test": "bun test test" } +} diff --git a/zen-free/plugin.json b/zen-free/plugin.json new file mode 100644 index 0000000..2a7aadd --- /dev/null +++ b/zen-free/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "zen-free", + "version": "0.1.0", + "main": "index.js", + "description": { + "default": "Free OpenCode Zen models. Prompts, context and tools are sent to a third-party service through an unofficial interface.", + "zh-CN": "OpenCode Zen 免费模型。提示、上下文与工具会通过非官方接口发送到第三方服务。" + }, + "capabilities": ["models"], + "permissions": { "net": ["opencode.ai", "models.opencode.ai", "raw.githubusercontent.com"] }, + "config": [] +} diff --git a/zen-free/src/index.ts b/zen-free/src/index.ts new file mode 100644 index 0000000..dbab879 --- /dev/null +++ b/zen-free/src/index.ts @@ -0,0 +1,7 @@ +import type { Ctx } from "../../../godwit/sdk/godwit" +import { discoverFreeModels } from "./models" +import { adaptZenRequest } from "./request" + +export default function(ctx: Ctx): void { + ctx.models.register({ listModels: async () => ({ models: await discoverFreeModels(ctx) }), prepareRequest: adaptZenRequest }) +} diff --git a/zen-free/src/models.ts b/zen-free/src/models.ts new file mode 100644 index 0000000..480afcb --- /dev/null +++ b/zen-free/src/models.ts @@ -0,0 +1,85 @@ +import type { Ctx, HTTPModel, ModelProtocol } from "../../../godwit/sdk/godwit" + +export const BASE = "https://opencode.ai/inference/v1" +const CATALOG = "https://models.opencode.ai/api.json" +const DOCS = "https://raw.githubusercontent.com/anomalyco/opencode/dev/services/www/src/docs/content/console/models.mdx" +type DocModel = { name: string; protocol: ModelProtocol; free?: boolean } +type Metadata = { npm?: string; models?: Record } +const protocols: Record = { + "chat/completions": "openai-completions", responses: "openai-responses", + messages: "anthropic-message", systemone: "typesafe-systemone", decisions: "openai-decisions", +} +// Add a judge only after its public-credential request passes a real host test. +export const validatedJudgeModels: string[] = ["jev-1.13-free"] + +export function parseDocs(text: string): Record { + const result: Record = Object.create(null) + const pricing: Record = Object.create(null) + for (const line of text.split("\n")) { + const cells = line.split("|").slice(1, -1).map(c => c.trim().replace(/`/g, "")) + if (cells.length < 3) continue + const endpoint = (cells[2] || "").match(/^https:\/\/opencode\.ai\/[^\s]*\/v1\/(chat\/completions|responses|messages|systemone|decisions)$/) + if (endpoint && cells[1] && !/\s/.test(cells[1])) { + result[cells[1]] = { name: cells[0], protocol: protocols[endpoint[1]] } + } else if (/^(free|\$)/i.test(cells[1]) && /^(free|\$)/i.test(cells[2])) { + pricing[cells[0].toLowerCase()] = /^free$/i.test(cells[1]) && /^free$/i.test(cells[2]) + } + } + for (const id of Object.keys(result)) result[id].free = pricing[result[id].name.toLowerCase()] + return result +} + +export function buildModels(ids: string[], provider: Metadata, docs: Record): HTTPModel[] { + const result: HTTPModel[] = [] + const seen = new Set() + for (const id of ids) { + if (!id || id === "__proto__" || seen.has(id)) continue + seen.add(id) + const raw = provider.models?.[id] + const doc = docs[id] + if (raw && (raw.deprecated === true || ["deprecated", "retired", "disabled"].includes(raw.status ?? raw.lifecycle) || raw.deprecated_at != null || raw.retirement_date != null)) continue + const cost = raw?.cost + if ((cost && (cost.input !== 0 || cost.output !== 0)) || doc?.free === false) continue + if (!(cost?.input === 0 && cost?.output === 0) && doc?.free !== true) continue + const npm = raw?.provider?.npm ?? (raw ? provider.npm : undefined) + const protocol = doc?.protocol ?? (npm === "@ai-sdk/openai" ? "openai-responses" : npm?.includes("anthropic") ? "anthropic-message" : npm?.includes("openai-compatible") ? "openai-completions" : undefined) + if (!protocol) continue + if ((protocol === "typesafe-systemone" || protocol === "openai-decisions") && !validatedJudgeModels.includes(id)) continue + result.push({ id, name: raw?.name || doc?.name || id, transport: "http", protocol, baseURL: BASE }) + } + return result +} + +let lastProvider: Metadata = {} +let lastDocs: Record = {} +let providerAt = 0, docsAt = 0 +const recent = 60 * 60 * 1000 +const upstream = () => ({ code: "upstream", message: "Zen Free model discovery is unavailable; retry later.", retryable: true }) +export async function discoverFreeModels(ctx: Ctx): Promise { + const read = async (url: string) => { + const response = await ctx.net.fetch(url, url === BASE + "/models" ? { headers: { authorization: "Bearer public", "x-opencode-client": "cli" } } : undefined) + if (response.status !== 200) throw upstream() + return response.text() + } + const [listing, catalog, document] = await Promise.allSettled([read(BASE + "/models"), read(CATALOG), read(DOCS)]) + if (listing.status !== "fulfilled") throw upstream() + let ids: string[] + try { + const data = JSON.parse(listing.value).data + if (!Array.isArray(data) || data.some(m => !m || typeof m.id !== "string")) throw upstream() + ids = data.map(m => m.id) + } catch { throw upstream() } + if (!ids.length) return [] + const now = Date.now() + if (catalog.status === "fulfilled") { + try { const p = JSON.parse(catalog.value).opencode; if (p?.models && typeof p.models === "object") { lastProvider = p; providerAt = now } } catch { /* retain recent trusted metadata */ } + } + if (document.status === "fulfilled") { + const parsed = parseDocs(document.value) + if (Object.keys(parsed).length) { lastDocs = parsed; docsAt = now } + } + const provider = now - providerAt < recent ? lastProvider : {} + const docs = now - docsAt < recent ? lastDocs : {} + if (!Object.keys(provider).length && !Object.keys(docs).length) throw upstream() + return buildModels(ids, provider, docs) +} diff --git a/zen-free/src/request.ts b/zen-free/src/request.ts new file mode 100644 index 0000000..5edf1ae --- /dev/null +++ b/zen-free/src/request.ts @@ -0,0 +1,60 @@ +import type { ModelHTTPRequest, PrepareModelRequest } from "../../../godwit/sdk/godwit" +import { BASE } from "./models" + +const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +function tail(seed: string): string { + let result = "" + for (let i = 0; i < 14; i++) { + let hash = 2166136261 + for (let j = 0; j < seed.length; j++) hash = Math.imul(hash ^ seed.charCodeAt(j), 16777619) + hash = Math.imul(hash ^ i, 16777619) >>> 0 + result += alphabet[hash % 62] + } + return result +} +function identifier(prefix: string, descending: boolean, now: number, counter: number, seed: string): string { + const stamp = (Math.floor(now) % 0x1000000000) * 4096 + counter + const hex = (descending ? 0xffffffffffff - stamp : stamp).toString(16).padStart(12, "0") + return prefix + "_" + hex + tail(seed) +} +export function sessionID(id: string, createdAt: number): string { return identifier("ses", true, createdAt, 1, id) } +let stamp = 0, counter = 0 +const paths: Record = { "openai-completions": "/chat/completions", "openai-responses": "/responses", "anthropic-message": "/messages", "typesafe-systemone": "/systemone", "openai-decisions": "/decisions" } +const versions: Record = { "openai-completions": "4.0.23", "openai-responses": "4.0.40", "anthropic-message": "4.0.46" } +const coreParameters: Record = { bash: "command", edit: "filePath", glob: "pattern", grep: "pattern", read: "filePath" } +export function adaptZenRequest(call: PrepareModelRequest): ModelHTTPRequest { + const protocol = call.model.protocol + if (!paths[protocol]) throw { code: "invalid_request", message: "Unsupported Zen protocol", retryable: false } + const body = JSON.parse(JSON.stringify(call.request.body)) + if (versions[protocol]) { + if (body.tools !== undefined && !Array.isArray(body.tools)) throw { code: "invalid_request", message: "Invalid tools", retryable: false } + body.tools ??= [] + const present = new Set(body.tools.map((tool: any) => protocol === "openai-completions" ? tool?.function?.name : tool?.name)) + if (protocol === "openai-completions" && Array.isArray(body.functions)) for (const tool of body.functions) present.add(tool?.name) + for (const name of Object.keys(coreParameters)) { + if (present.has(name)) continue + const parameters = { type: "object", properties: { [coreParameters[name]]: { type: "string" } }, additionalProperties: false } + const definition = { name, description: "Not available. Never call this tool." } + body.tools.push(protocol === "openai-completions" ? { type: "function", function: { ...definition, parameters } } : protocol === "anthropic-message" ? { ...definition, input_schema: parameters } : { type: "function", ...definition, parameters }) + } + if (protocol === "openai-completions") body.stream_options = { ...body.stream_options, include_usage: true } + } + const now = Date.now() + if (now !== stamp) { stamp = now; counter = 0 } + counter = (counter + 1) % 4096 + const session = sessionID(call.sessionId, call.sessionCreatedAt) + const headers: Record = { + "content-type": "application/json", accept: "*/*", + "user-agent": `opencode/1.19.0 ai-sdk/provider-utils/${versions[protocol] || "4.0.23"} runtime/bun/1.3.14`, + "x-opencode-project": "global", "x-opencode-client": "cli", + "x-opencode-session": session, "x-opencode-session-id": session, + "x-opencode-request": identifier("msg", false, now, counter, call.requestId), + } + if (protocol === "anthropic-message") { + headers["x-api-key"] = "public" + headers["anthropic-version"] = "2023-06-01" + const beta = Object.keys(call.request.headers).find(k => k.toLowerCase() === "anthropic-beta") + if (beta) headers["anthropic-beta"] = call.request.headers[beta] + } else headers.authorization = "Bearer public" + return { url: BASE + paths[protocol], headers, body } +} diff --git a/zen-free/test/host.test.ts b/zen-free/test/host.test.ts new file mode 100644 index 0000000..12450d1 --- /dev/null +++ b/zen-free/test/host.test.ts @@ -0,0 +1,9 @@ +import {expect,test} from "bun:test" +import {resolve} from "node:path" +const checkout=process.env.GODWIT_CHECKOUT +test.skipIf(!checkout)("built plugin runs in Godwit's actual goja sandbox",()=>{ + const built=Bun.spawnSync(["bun","run","build.ts"],{cwd:resolve(import.meta.dir,"..")});expect(built.exitCode).toBe(0) + const host=Bun.spawnSync(["go","test","./core","-run","^TestZenFreeVM$","-count=1"],{cwd:checkout,env:{...process.env,GODWIT_ZEN_FREE_DIST:resolve(import.meta.dir,"../dist")},timeout:120000}) + if(host.exitCode!==0)throw new Error(host.stdout.toString()+host.stderr.toString()) + expect(host.exitCode).toBe(0) +}) diff --git a/zen-free/test/models.test.ts b/zen-free/test/models.test.ts new file mode 100644 index 0000000..35ca26f --- /dev/null +++ b/zen-free/test/models.test.ts @@ -0,0 +1,27 @@ +import {describe,expect,test} from "bun:test" +import {buildModels,discoverFreeModels,parseDocs} from "../src/models" +const docs=`| Name | Model | Endpoint | SDK | +| Free | free | \`https://opencode.ai/inference/openai/v1/responses\` | sdk | +| Pickle | pickle | \`https://opencode.ai/inference/openai/v1/chat/completions\` | sdk | +| Claude | claude | \`https://opencode.ai/inference/anthropic/v1/messages\` | sdk | +| Name | Input | Output | +| Free | Free | Free | +| Pickle | Free | Free | +| Claude | Free | Free |` +describe("discovery",()=>{ + test("protocols and current free pricing",()=>{const parsed=parseDocs(docs);expect(parsed.free.protocol).toBe("openai-responses");expect(parsed.claude.protocol).toBe("anthropic-message");expect(buildModels(["free","pickle","claude","unknown"],{},parsed).map(m=>m.id)).toEqual(["free","pickle","claude"])}) + test("paid and retired win over free names/docs",()=>{expect(buildModels(["paid-free","retired","free"],{npm:"@ai-sdk/openai-compatible",models:{"paid-free":{cost:{input:1,output:1}},retired:{status:"deprecated",cost:{input:0,output:0}},free:{retirement_date:"2026-10-01",cost:{input:0,output:0}}}},parseDocs(docs))).toEqual([])}) + test("unknown protocol is omitted",()=>{expect(buildModels(["x"],{models:{x:{cost:{input:0,output:0}}}},{})).toEqual([])}) + test("live list failure is a business error, empty list stays empty",async()=>{ + const ctx={net:{fetch:async(url:string)=>({status:url.endsWith("/models")?503:200,text:async()=>"{}"})}} as any + await expect(discoverFreeModels(ctx)).rejects.toMatchObject({code:"upstream"}) + ctx.net.fetch=async(url:string)=>({status:200,text:async()=>url.endsWith("/models")?'{"data":[]}':"{}"}) + expect(await discoverFreeModels(ctx)).toEqual([]) + }) + test("current docs work when oversized metadata cannot be fetched",async()=>{ + const ctx={net:{fetch:async(url:string)=>{if(url.includes("api.json"))throw new Error("too large");return {status:200,text:async()=>url.endsWith("/models")?'{"data":[{"id":"free"},{"id":"paid"}]}':docs}}}} as any + expect((await discoverFreeModels(ctx)).map(m=>m.id)).toEqual(["free"]) + }) +}) + +test("only the verified free judge is discoverable",()=>{const text="| Jev Free | jev-1.13-free | `https://opencode.ai/inference/systemone/v1/systemone` | - |\n| Unknown Free | other-free | `https://opencode.ai/inference/systemone/v1/systemone` | - |\n| Jev Free | Free | Free |\n| Unknown Free | Free | Free |";expect(buildModels(["jev-1.13-free","other-free"],{},parseDocs(text)).map(m=>m.id)).toEqual(["jev-1.13-free"])}) diff --git a/zen-free/test/request.test.ts b/zen-free/test/request.test.ts new file mode 100644 index 0000000..0a14a9a --- /dev/null +++ b/zen-free/test/request.test.ts @@ -0,0 +1,13 @@ +import {expect,test} from "bun:test" +import {adaptZenRequest,sessionID} from "../src/request" +for(const protocol of ["openai-completions","openai-responses","anthropic-message"]){test(protocol+" preserves real tools and input",()=>{ + const real=protocol==="openai-completions"?{type:"function",function:{name:"read",parameters:{type:"object",properties:{path:{type:"string"}}}}}:protocol==="anthropic-message"?{name:"read",input_schema:{type:"object"}}:{type:"function",name:"read",parameters:{type:"object"}} + const call={model:{id:"free",name:"Free",transport:"http",protocol,baseURL:"https://opencode.ai/inference/v1"},sessionId:"random-host-session",sessionCreatedAt:1792240000000,requestId:"attempt-one",request:{url:"ignored",headers:{"Authorization":"Bearer secret"},body:{model:"free",stream:true,tools:[real]}}} as any + const before=JSON.stringify(call);const a=adaptZenRequest(call);const b=adaptZenRequest({...call,requestId:"attempt-two"}) + expect(JSON.stringify(call)).toBe(before);expect(a.body.tools[0]).toEqual(real);expect(a.body.tools.length).toBe(5) + expect(a.headers["x-opencode-session"]).toBe(b.headers["x-opencode-session"]);expect(a.headers["x-opencode-request"]).not.toBe(b.headers["x-opencode-request"]) + expect(a.headers["x-opencode-session"]).toMatch(/^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/) + expect(4095-parseInt(a.headers["x-opencode-session"].slice(13,16),16)).toBeLessThan(16) + expect(JSON.stringify(a.headers)).not.toContain("secret");expect(a.headers[protocol==="anthropic-message"?"x-api-key":"authorization"]).toBe(protocol==="anthropic-message"?"public":"Bearer public") + })} +test("session is stable without VM memory",()=>{expect(sessionID("random",1792240000000)).toBe(sessionID("random",1792240000000));expect(sessionID("other",1792240000000)).not.toBe(sessionID("random",1792240000000))}) From ccc96e65ec7ba1acc2d27b67d25f781fd867903c Mon Sep 17 00:00:00 2001 From: baranwang Date: Sun, 18 Oct 2026 01:46:16 +0800 Subject: [PATCH 2/9] fix(zen-free): consume SDK package and move source under packages Replace sibling-repository imports with @godwit/sdk, add strict type checking, and document tarball validation before the first SDK publication. Co-authored-by: Codex --- {zen-free => packages/zen-free}/.gitignore | 0 {zen-free => packages/zen-free}/LICENSE | 0 {zen-free => packages/zen-free}/README.md | 29 +++++++++++++++---- {zen-free => packages/zen-free}/build.ts | 0 {zen-free => packages/zen-free}/package.json | 7 ++++- {zen-free => packages/zen-free}/plugin.json | 0 {zen-free => packages/zen-free}/src/index.ts | 2 +- {zen-free => packages/zen-free}/src/models.ts | 2 +- .../zen-free}/src/request.ts | 2 +- .../zen-free}/test/host.test.ts | 0 .../zen-free}/test/models.test.ts | 0 .../zen-free}/test/request.test.ts | 7 +++-- packages/zen-free/tsconfig.json | 11 +++++++ 13 files changed, 48 insertions(+), 12 deletions(-) rename {zen-free => packages/zen-free}/.gitignore (100%) rename {zen-free => packages/zen-free}/LICENSE (100%) rename {zen-free => packages/zen-free}/README.md (77%) rename {zen-free => packages/zen-free}/build.ts (100%) rename {zen-free => packages/zen-free}/package.json (55%) rename {zen-free => packages/zen-free}/plugin.json (100%) rename {zen-free => packages/zen-free}/src/index.ts (82%) rename {zen-free => packages/zen-free}/src/models.ts (98%) rename {zen-free => packages/zen-free}/src/request.ts (97%) rename {zen-free => packages/zen-free}/test/host.test.ts (100%) rename {zen-free => packages/zen-free}/test/models.test.ts (100%) rename {zen-free => packages/zen-free}/test/request.test.ts (72%) create mode 100644 packages/zen-free/tsconfig.json diff --git a/zen-free/.gitignore b/packages/zen-free/.gitignore similarity index 100% rename from zen-free/.gitignore rename to packages/zen-free/.gitignore diff --git a/zen-free/LICENSE b/packages/zen-free/LICENSE similarity index 100% rename from zen-free/LICENSE rename to packages/zen-free/LICENSE diff --git a/zen-free/README.md b/packages/zen-free/README.md similarity index 77% rename from zen-free/README.md rename to packages/zen-free/README.md index 6f56c81..8bc3bdd 100644 --- a/zen-free/README.md +++ b/packages/zen-free/README.md @@ -13,22 +13,39 @@ 源码无运行时依赖,Bun 仅用于构建和测试: ```sh -bun run --cwd zen-free build -bun test ./zen-free/test +bun run --cwd packages/zen-free build +bun test ./packages/zen-free/test ``` -安装 `zen-free/dist`。它包含 `plugin.json` 和一份无外部 import 的 `index.js`。 +安装 `packages/zen-free/dist`。它包含 `plugin.json` 和一份无外部 import 的 `index.js`。 -源码类型导入指向相邻的 `godwit/sdk/godwit.d.ts`。运行真实 goja 沙箱测试时指定宿主工作树: +源码从 npm 包 `@godwit/sdk` 导入类型,不要求相邻的 Godwit 仓库。首次安装及类型检查: ```sh -PATH=/path/to/go/bin:$PATH GODWIT_CHECKOUT=/path/to/godwit bun test ./zen-free/test +cd packages/zen-free +npm install +npm run typecheck +``` + +`@godwit/sdk@0.1.0` 首次发布前,可用主仓打出的真实 npm 包验证,不修改依赖声明: + +```sh +# 在主仓 sdk/ 执行 npm pack --pack-destination /tmp +# 然后在 packages/zen-free/ 执行: +npm install --no-save --package-lock=false /tmp/godwit-sdk-0.1.0.tgz +npm run typecheck +``` + +SDK 发布之前,直接从 npm 安装会失败;应先合入并发布宿主 SDK,再合入本插件。运行真实 goja 沙箱测试时指定宿主工作树: + +```sh +PATH=/path/to/go/bin:$PATH GODWIT_CHECKOUT=/path/to/godwit bun test ./packages/zen-free/test ``` 可选的真实接口验收仅发送公共测试文字,工具只返回固定测试值: ```sh -GODWIT_ZEN_FREE_DIST=/absolute/path/to/plugins/zen-free/dist \ +GODWIT_ZEN_FREE_DIST=/absolute/path/to/plugins/packages/zen-free/dist \ GODWIT_ZEN_LIVE=1 go test ./core -run '^TestZenFreeLive$' -count=1 -v ``` diff --git a/zen-free/build.ts b/packages/zen-free/build.ts similarity index 100% rename from zen-free/build.ts rename to packages/zen-free/build.ts diff --git a/zen-free/package.json b/packages/zen-free/package.json similarity index 55% rename from zen-free/package.json rename to packages/zen-free/package.json index 32daf6f..61a48ab 100644 --- a/zen-free/package.json +++ b/packages/zen-free/package.json @@ -4,5 +4,10 @@ "private": true, "type": "module", "license": "MIT", - "scripts": { "build": "bun run build.ts", "test": "bun test test" } + "scripts": { "build": "bun run build.ts", "test": "bun test test", "typecheck": "tsc --noEmit" }, + "devDependencies": { + "@godwit/sdk": "0.1.0", + "@types/bun": "1.4.3", + "typescript": "7.0.2" + } } diff --git a/zen-free/plugin.json b/packages/zen-free/plugin.json similarity index 100% rename from zen-free/plugin.json rename to packages/zen-free/plugin.json diff --git a/zen-free/src/index.ts b/packages/zen-free/src/index.ts similarity index 82% rename from zen-free/src/index.ts rename to packages/zen-free/src/index.ts index dbab879..8f88769 100644 --- a/zen-free/src/index.ts +++ b/packages/zen-free/src/index.ts @@ -1,4 +1,4 @@ -import type { Ctx } from "../../../godwit/sdk/godwit" +import type { Ctx } from "@godwit/sdk" import { discoverFreeModels } from "./models" import { adaptZenRequest } from "./request" diff --git a/zen-free/src/models.ts b/packages/zen-free/src/models.ts similarity index 98% rename from zen-free/src/models.ts rename to packages/zen-free/src/models.ts index 480afcb..371db40 100644 --- a/zen-free/src/models.ts +++ b/packages/zen-free/src/models.ts @@ -1,4 +1,4 @@ -import type { Ctx, HTTPModel, ModelProtocol } from "../../../godwit/sdk/godwit" +import type { Ctx, HTTPModel, ModelProtocol } from "@godwit/sdk" export const BASE = "https://opencode.ai/inference/v1" const CATALOG = "https://models.opencode.ai/api.json" diff --git a/zen-free/src/request.ts b/packages/zen-free/src/request.ts similarity index 97% rename from zen-free/src/request.ts rename to packages/zen-free/src/request.ts index 5edf1ae..e07b4f5 100644 --- a/zen-free/src/request.ts +++ b/packages/zen-free/src/request.ts @@ -1,4 +1,4 @@ -import type { ModelHTTPRequest, PrepareModelRequest } from "../../../godwit/sdk/godwit" +import type { ModelHTTPRequest, PrepareModelRequest } from "@godwit/sdk" import { BASE } from "./models" const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" diff --git a/zen-free/test/host.test.ts b/packages/zen-free/test/host.test.ts similarity index 100% rename from zen-free/test/host.test.ts rename to packages/zen-free/test/host.test.ts diff --git a/zen-free/test/models.test.ts b/packages/zen-free/test/models.test.ts similarity index 100% rename from zen-free/test/models.test.ts rename to packages/zen-free/test/models.test.ts diff --git a/zen-free/test/request.test.ts b/packages/zen-free/test/request.test.ts similarity index 72% rename from zen-free/test/request.test.ts rename to packages/zen-free/test/request.test.ts index 0a14a9a..c72a605 100644 --- a/zen-free/test/request.test.ts +++ b/packages/zen-free/test/request.test.ts @@ -1,10 +1,13 @@ import {expect,test} from "bun:test" +import type {JSONValue} from "@godwit/sdk" import {adaptZenRequest,sessionID} from "../src/request" for(const protocol of ["openai-completions","openai-responses","anthropic-message"]){test(protocol+" preserves real tools and input",()=>{ - const real=protocol==="openai-completions"?{type:"function",function:{name:"read",parameters:{type:"object",properties:{path:{type:"string"}}}}}:protocol==="anthropic-message"?{name:"read",input_schema:{type:"object"}}:{type:"function",name:"read",parameters:{type:"object"}} + const real:JSONValue=protocol==="openai-completions"?{type:"function",function:{name:"read",parameters:{type:"object",properties:{path:{type:"string"}}}}}:protocol==="anthropic-message"?{name:"read",input_schema:{type:"object"}}:{type:"function",name:"read",parameters:{type:"object"}} const call={model:{id:"free",name:"Free",transport:"http",protocol,baseURL:"https://opencode.ai/inference/v1"},sessionId:"random-host-session",sessionCreatedAt:1792240000000,requestId:"attempt-one",request:{url:"ignored",headers:{"Authorization":"Bearer secret"},body:{model:"free",stream:true,tools:[real]}}} as any const before=JSON.stringify(call);const a=adaptZenRequest(call);const b=adaptZenRequest({...call,requestId:"attempt-two"}) - expect(JSON.stringify(call)).toBe(before);expect(a.body.tools[0]).toEqual(real);expect(a.body.tools.length).toBe(5) + const tools=a.body.tools + if(!Array.isArray(tools))throw new Error("prepared request must contain a tools array") + expect(JSON.stringify(call)).toBe(before);expect(tools[0]).toEqual(real);expect(tools.length).toBe(5) expect(a.headers["x-opencode-session"]).toBe(b.headers["x-opencode-session"]);expect(a.headers["x-opencode-request"]).not.toBe(b.headers["x-opencode-request"]) expect(a.headers["x-opencode-session"]).toMatch(/^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/) expect(4095-parseInt(a.headers["x-opencode-session"].slice(13,16),16)).toBeLessThan(16) diff --git a/packages/zen-free/tsconfig.json b/packages/zen-free/tsconfig.json new file mode 100644 index 0000000..07b241f --- /dev/null +++ b/packages/zen-free/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "strict": true, + "noEmit": true, + "target": "ES2022", + "module": "Preserve", + "moduleResolution": "Bundler", + "types": ["bun"] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "build.ts"] +} From d65a1ba500a2561d01024b5ec5a4f5056df49e1e Mon Sep 17 00:00:00 2001 From: baranwang Date: Sun, 18 Oct 2026 01:57:24 +0800 Subject: [PATCH 3/9] ci(zen-free): add verified plugin release workflow Co-authored-by: Codex --- .github/workflows/zen-free.yml | 106 +++++++++++++++++++++++++ README.md | 24 ++++++ packages/zen-free/LICENSE | 21 ----- packages/zen-free/README.md | 69 +++------------- packages/zen-free/build.ts | 4 +- packages/zen-free/scripts/release.ts | 30 +++++++ packages/zen-free/test/release.test.ts | 26 ++++++ packages/zen-free/tsconfig.json | 2 +- 8 files changed, 200 insertions(+), 82 deletions(-) create mode 100644 .github/workflows/zen-free.yml delete mode 100644 packages/zen-free/LICENSE create mode 100644 packages/zen-free/scripts/release.ts create mode 100644 packages/zen-free/test/release.test.ts diff --git a/.github/workflows/zen-free.yml b/.github/workflows/zen-free.yml new file mode 100644 index 0000000..0b2a2e0 --- /dev/null +++ b/.github/workflows/zen-free.yml @@ -0,0 +1,106 @@ +name: Zen Free + +on: + pull_request: + paths: ["packages/zen-free/**", ".github/workflows/zen-free.yml"] + push: + branches: [main] + paths: ["packages/zen-free/**", ".github/workflows/zen-free.yml"] + workflow_dispatch: + inputs: + tag: + description: Existing plugin tag on main, e.g. zen-free-v0.1.0 + required: true + min_godwit: + description: First released Godwit version supporting this plugin, e.g. 1.6.0 + required: true + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + env: + GODWIT_CHECKOUT: ${{ github.workspace }}/.godwit-ci + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.sha }} + fetch-depth: 0 + - name: Require a tagged commit from main for releases + if: github.event_name == 'workflow_dispatch' + run: git merge-base --is-ancestor HEAD origin/main + # Pin the host/API used by the real goja integration test. + - uses: actions/checkout@v4 + with: + repository: godwit-email/godwit + ref: 4cacafebc431de5d9fd1bfbb7d8528dcef47b6f7 + path: .godwit-ci + - uses: actions/setup-go@v5 + with: + go-version-file: .godwit-ci/go.mod + cache-dependency-path: .godwit-ci/go.sum + - uses: actions/setup-node@v4 + with: + node-version: 24 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.3 + - name: Install published SDK for releases + if: github.event_name == 'workflow_dispatch' + working-directory: packages/zen-free + run: npm install --ignore-scripts --no-audit --no-fund + - name: Install packed SDK for source checks + if: github.event_name != 'workflow_dispatch' + working-directory: packages/zen-free + run: | + # shortcut: pack the pinned SDK until its first npm publication; no sibling imports. + (cd "$GODWIT_CHECKOUT/sdk" && npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP") + sdk_version=$(node -p 'require("./package.json").devDependencies["@godwit/sdk"]') + npm install --no-save --package-lock=false --ignore-scripts --no-audit --no-fund "$RUNNER_TEMP/godwit-sdk-$sdk_version.tgz" + - run: npm run typecheck + working-directory: packages/zen-free + - run: bun test test + working-directory: packages/zen-free + - run: bun run build + working-directory: packages/zen-free + - name: Prepare release metadata and archive + if: github.event_name == 'workflow_dispatch' + working-directory: packages/zen-free + env: + RELEASE_TAG: ${{ inputs.tag }} + MIN_GODWIT: ${{ inputs.min_godwit }} + run: | + bun scripts/release.ts "$RELEASE_TAG" "$MIN_GODWIT" + tar -czf dist/zen-free.tar.gz -C dist index.js plugin.json + cd dist + sha256sum index.js plugin.json zen-free.tar.gz registry-plugin.json > SHA256SUMS + - uses: actions/upload-artifact@v4 + with: + name: zen-free-dist + path: packages/zen-free/dist/ + if-no-files-found: error + retention-days: 7 + + release: + needs: check + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: zen-free-dist + path: dist + - name: Publish the verified release files + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + MIN_GODWIT: ${{ inputs.min_godwit }} + run: | + gh release create "$RELEASE_TAG" dist/index.js dist/plugin.json dist/zen-free.tar.gz dist/registry-plugin.json dist/SHA256SUMS \ + --repo "$GITHUB_REPOSITORY" --verify-tag --draft --title "Zen Free $RELEASE_TAG" \ + --notes "Requires Godwit $MIN_GODWIT or later. Extract zen-free.tar.gz for manual installation. registry-plugin.json contains the registry entry and exact index.js checksum." + gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false diff --git a/README.md b/README.md index e59977b..70c9844 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,11 @@ The plugins [Godwit](https://github.com/godwit-email/godwit)'s Extensions page o ## Adding a plugin or a release +Zen Free source lives in [`packages/zen-free`](packages/zen-free). Its **Zen Free** workflow +checks and builds the plugin, and publishes GitHub Release files on manual dispatch; see +the maintainer steps below. The **Publish** workflow +separately validates, signs, and uploads the registry after a registry change is merged. + Open a pull request that adds to `plugins` (or to an existing plugin's `releases`): ```json @@ -50,6 +55,25 @@ Open a pull request that adds to `plugins` (or to an existing plugin's `releases `manifest.main` names, at most 5 MiB. - Bump the top-level `updated` (RFC 3339) in every pull request. +## Releasing Zen Free + +1. Publish the required `@godwit/sdk` package and a Godwit version supporting model plugins. +2. Bump `packages/zen-free/package.json` and `plugin.json` together and merge to main. +3. Create and push `zen-free-vX.Y.Z` for that main commit. +4. Run **Actions → Zen Free → Run workflow** on main, supplying the tag and the actual + minimum released Godwit version. The workflow checks the tagged source, installs the + published SDK, and publishes `index.js`, `plugin.json`, `zen-free.tar.gz`, + `registry-plugin.json`, and `SHA256SUMS` to GitHub Releases. +5. Add the released `registry-plugin.json` entry to `registry.json`, or merge its release + into the existing entry while preserving older releases. Bump `updated` and open a PR. + **Publish** validates the real downloads, signs, and uploads the registry. + +PR checks provide a `zen-free-dist` artifact. They pack the SDK from the pinned Godwit +commit used by the goja integration test; update that pin when updating the SDK/API. +Release runs require `contents: write`. If a failed run leaves a draft release, inspect +and delete that unpublished draft before retrying. Never replace published files; bump +the version instead. The workflow does not modify the registry. + ## Review checklist Before approving: diff --git a/packages/zen-free/LICENSE b/packages/zen-free/LICENSE deleted file mode 100644 index 85e65f8..0000000 --- a/packages/zen-free/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 xiaozhou26 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/zen-free/README.md b/packages/zen-free/README.md index 8bc3bdd..bfa53c2 100644 --- a/packages/zen-free/README.md +++ b/packages/zen-free/README.md @@ -1,68 +1,19 @@ # Zen Free for Godwit -在 Godwit 中启用插件并允许声明的网络权限后,打开「模型服务」即可选择 Zen Free 模型,无需填写 API Key 或手建服务。需要包含 `models` 插件 API 的 Godwit 版本。 +在 Godwit 中使用 OpenCode Zen 的免费模型,无需 API Key。 -提示、邮件上下文、工具定义及工具结果会发送到 OpenCode 及其模型服务商。请阅读上游的数据使用条款。此插件依赖非官方公共接口;可用模型、限流和免费期限会变化,不保证配额或可用性。 +## 使用 -模型来自当前 `/models` 列表,并结合上游目录或官方文档确认免费价格和协议。收费、退役、未知协议以及未经实际验证的判断模型不会被列出。目录失败时 Godwit 保留上次成功的列表;首次失败不会编造可用模型。元数据仅在当前插件实例内保留一小时。完整目录可能超过 Godwit 的 fetch 上限,此时使用当前文档中的价格和协议表,不扩大宿主限制。 +插件尚未发布。发布并收录后: -插件支持 Chat Completions、Responses 和 Anthropic Messages 的请求适配;实际能选到哪些协议取决于当时上游的免费目录。Godwit 处理流、重试、工具执行和授权。插件补入的 bash/edit/glob/grep/read 占位定义明确不可调用,也不会获得执行权限。 +1. 打开 Godwit「设置 → 扩展」,在插件目录中安装 Zen Free。 +2. 启用插件,允许它声明的网络权限。 +3. 打开「设置 → 模型服务」,选择 Zen Free 的模型。 -## 构建与检查 +可用模型由上游免费目录决定,无需手动配置接口或协议。需要支持模型插件的 Godwit 版本。 -源码无运行时依赖,Bun 仅用于构建和测试: +## 数据与可用性 -```sh -bun run --cwd packages/zen-free build -bun test ./packages/zen-free/test -``` +使用模型时,提示、邮件上下文、工具定义及工具结果会发送到 OpenCode 及其模型服务商,请阅读上游的数据使用条款。 -安装 `packages/zen-free/dist`。它包含 `plugin.json` 和一份无外部 import 的 `index.js`。 - -源码从 npm 包 `@godwit/sdk` 导入类型,不要求相邻的 Godwit 仓库。首次安装及类型检查: - -```sh -cd packages/zen-free -npm install -npm run typecheck -``` - -`@godwit/sdk@0.1.0` 首次发布前,可用主仓打出的真实 npm 包验证,不修改依赖声明: - -```sh -# 在主仓 sdk/ 执行 npm pack --pack-destination /tmp -# 然后在 packages/zen-free/ 执行: -npm install --no-save --package-lock=false /tmp/godwit-sdk-0.1.0.tgz -npm run typecheck -``` - -SDK 发布之前,直接从 npm 安装会失败;应先合入并发布宿主 SDK,再合入本插件。运行真实 goja 沙箱测试时指定宿主工作树: - -```sh -PATH=/path/to/go/bin:$PATH GODWIT_CHECKOUT=/path/to/godwit bun test ./packages/zen-free/test -``` - -可选的真实接口验收仅发送公共测试文字,工具只返回固定测试值: - -```sh -GODWIT_ZEN_FREE_DIST=/absolute/path/to/plugins/packages/zen-free/dist \ -GODWIT_ZEN_LIVE=1 go test ./core -run '^TestZenFreeLive$' -count=1 -v -``` - -应在 Godwit 工作树执行最后一条命令。它验证目录、文本、真实工具往返并探测明确免费的 Jev。未提供环境变量时网络测试跳过;单元测试本身不代表上游可用。 - -## 验收记录 - -2026-10-17,在包含新 models API 的 Godwit 隔离工作树中: - -- 插件单元测试和真实 goja 加载检查:11 项通过,构建通过。 -- 上游目录列出 9 个 Chat Completions 生成模型和 `jev-1.13-free` 判断模型。 -- `big-pickle`(`openai-completions`):公共文字生成、真实 `zen_echo` 工具往返通过。 -- `jev-1.13-free`(`typesafe-systemone`):在设置中选用后,公共文字的判断请求通过。 -- 当时没有明确免费的 Responses / Anthropic Messages 生成模型,因此未做这两种协议的真实上游推理;三种生成协议均通过宿主的本地 TLS 集成测试。没有使用收费模型补足验收。 - -这些结果只说明该次验收可用,后续仍受上游目录和限流影响。本地模型的真实下载器、平台内存检查和 StartLux 加载由 Godwit #90 接入,不包含在此插件中。 - -## 来源 - -参考 [magpie-community/plugins 的 zen-free 0.1.10](https://github.com/magpie-community/plugins/tree/0b58032fa37b57e7a003ea5570d3674dc52fedea/packages/zen-free),采用其公共凭据、OpenCode 兼容请求头与占位工具约定。保留原 MIT 许可和版权。未移植其 Node crypto、流解析或工具名修复逻辑。未修改 registry;发布后才能填入真实、不可变的 URL、大小和 SHA-256。 +插件依赖非官方公共接口。免费模型、限流和免费期限可能变化,不保证配额或持续可用。 diff --git a/packages/zen-free/build.ts b/packages/zen-free/build.ts index 0975cdc..4bae3b0 100644 --- a/packages/zen-free/build.ts +++ b/packages/zen-free/build.ts @@ -1,5 +1,7 @@ +import {rm} from "node:fs/promises" + +await rm(import.meta.dir + "/dist", {recursive: true, force: true}) const result = await Bun.build({ entrypoints: [import.meta.dir + "/src/index.ts"], outdir: import.meta.dir + "/dist", target: "browser", format: "esm", minify: false, external: [] }) if (!result.success) throw new AggregateError(result.logs, "Zen Free build failed") await Bun.write(import.meta.dir + "/dist/plugin.json", Bun.file(import.meta.dir + "/plugin.json")) -await Bun.write(import.meta.dir + "/dist/LICENSE", Bun.file(import.meta.dir + "/LICENSE")) if (Bun.file(import.meta.dir + "/dist/index.js").size > 5 * 1024 * 1024) throw new Error("Plugin is larger than the host limit") diff --git a/packages/zen-free/scripts/release.ts b/packages/zen-free/scripts/release.ts new file mode 100644 index 0000000..e9d7f70 --- /dev/null +++ b/packages/zen-free/scripts/release.ts @@ -0,0 +1,30 @@ +import {createHash} from "node:crypto" + +type Manifest = {name: string; version: string; main: string; description: Record} +const stableVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ + +export function registryEntry(manifest: Manifest, packageVersion: string, tag: string, minGodwit: string, source: Uint8Array) { + if (manifest.name !== "zen-free" || manifest.main !== "index.js") throw new Error("Unexpected plugin manifest") + if (!stableVersion.test(manifest.version) || manifest.version !== packageVersion || tag !== `zen-free-v${manifest.version}`) throw new Error("Tag, package and manifest versions must match") + if (!stableVersion.test(minGodwit)) throw new Error("Specify the stable Godwit version that first supports this plugin") + if (!source.length || source.length > 5 * 1024 * 1024) throw new Error("Plugin size is outside the host limit") + return { + name: manifest.name, + title: {default: "Zen Free"}, + summary: manifest.description, + homepage: "https://github.com/godwit-email/plugins/tree/main/packages/zen-free", + releases: [{version: manifest.version, minGodwit, manifest, artifacts: {any: { + url: `https://github.com/godwit-email/plugins/releases/download/${tag}/index.js`, + size: source.length, + sha256: createHash("sha256").update(source).digest("hex"), + }}}], + } +} + +if (import.meta.main) { + const dir = import.meta.dir + "/../dist" + const manifest = await Bun.file(dir + "/plugin.json").json() + const pkg = await Bun.file(import.meta.dir + "/../package.json").json() + const entry = registryEntry(manifest, pkg.version, process.argv[2] ?? "", process.argv[3] ?? "", new Uint8Array(await Bun.file(dir + "/index.js").arrayBuffer())) + await Bun.write(dir + "/registry-plugin.json", JSON.stringify(entry, null, 2) + "\n") +} diff --git a/packages/zen-free/test/release.test.ts b/packages/zen-free/test/release.test.ts new file mode 100644 index 0000000..3e2d0b6 --- /dev/null +++ b/packages/zen-free/test/release.test.ts @@ -0,0 +1,26 @@ +import {expect, test} from "bun:test" +import manifest from "../plugin.json" +import pkg from "../package.json" +import {registryEntry} from "../scripts/release" + +test("registry metadata describes the exact release file", () => { + const entry = registryEntry(manifest, pkg.version, "zen-free-v0.1.0", "1.6.0", new TextEncoder().encode("hello")) + expect(entry.name).toBe("zen-free") + expect(entry.releases[0].manifest).toEqual(manifest) + expect(entry.releases[0].minGodwit).toBe("1.6.0") + expect(entry.releases[0].artifacts.any).toEqual({ + url: "https://github.com/godwit-email/plugins/releases/download/zen-free-v0.1.0/index.js", + size: 5, + sha256: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + }) +}) + +test("release metadata rejects inconsistent versions and missing host compatibility", () => { + const bytes = new Uint8Array([1]) + expect(() => registryEntry(manifest, "0.2.0", "zen-free-v0.1.0", "1.6.0", bytes)).toThrow() + expect(() => registryEntry(manifest, pkg.version, "v0.1.0", "1.6.0", bytes)).toThrow() + for (const version of ["", "latest", "01.6.0"]) { + expect(() => registryEntry(manifest, pkg.version, "zen-free-v0.1.0", version, bytes)).toThrow() + } + expect(() => registryEntry(manifest, pkg.version, "zen-free-v0.1.0", "1.6.0", new Uint8Array())).toThrow() +}) diff --git a/packages/zen-free/tsconfig.json b/packages/zen-free/tsconfig.json index 07b241f..450b2a2 100644 --- a/packages/zen-free/tsconfig.json +++ b/packages/zen-free/tsconfig.json @@ -7,5 +7,5 @@ "moduleResolution": "Bundler", "types": ["bun"] }, - "include": ["src/**/*.ts", "test/**/*.ts", "build.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "build.ts"] } From db13180e87b915b646c22cd4f9f63da6611160bb Mon Sep 17 00:00:00 2001 From: baranwang Date: Sun, 18 Oct 2026 02:00:03 +0800 Subject: [PATCH 4/9] ci: share script plugin checks and release workflow Co-authored-by: Codex --- .../workflows/{zen-free.yml => plugins.yml} | 86 ++++++++++++++----- README.md | 24 +++--- packages/zen-free/test/host.test.ts | 2 +- packages/zen-free/test/release.test.ts | 26 ------ packages/zen-free/tsconfig.json | 2 +- scripts/release.test.ts | 35 ++++++++ .../zen-free/scripts => scripts}/release.ts | 21 +++-- 7 files changed, 128 insertions(+), 68 deletions(-) rename .github/workflows/{zen-free.yml => plugins.yml} (51%) delete mode 100644 packages/zen-free/test/release.test.ts create mode 100644 scripts/release.test.ts rename {packages/zen-free/scripts => scripts}/release.ts (51%) diff --git a/.github/workflows/zen-free.yml b/.github/workflows/plugins.yml similarity index 51% rename from .github/workflows/zen-free.yml rename to .github/workflows/plugins.yml index 0b2a2e0..6aecf21 100644 --- a/.github/workflows/zen-free.yml +++ b/.github/workflows/plugins.yml @@ -1,13 +1,16 @@ -name: Zen Free +name: Plugins on: pull_request: - paths: ["packages/zen-free/**", ".github/workflows/zen-free.yml"] + paths: ["packages/**", "scripts/**", ".github/workflows/plugins.yml"] push: branches: [main] - paths: ["packages/zen-free/**", ".github/workflows/zen-free.yml"] + paths: ["packages/**", "scripts/**", ".github/workflows/plugins.yml"] workflow_dispatch: inputs: + plugin: + description: Plugin directory name under packages, e.g. zen-free + required: true tag: description: Existing plugin tag on main, e.g. zen-free-v0.1.0 required: true @@ -19,7 +22,46 @@ permissions: contents: read jobs: + packages: + runs-on: ubuntu-latest + outputs: + plugins: ${{ steps.discover.outputs.plugins }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.sha }} + - id: discover + env: + REQUESTED_PLUGIN: ${{ inputs.plugin }} + IS_RELEASE: ${{ github.event_name == 'workflow_dispatch' }} + run: | + python3 - <<'PY' + import json, os, pathlib, re + names = [] + for manifest in sorted(pathlib.Path("packages").glob("*/plugin.json")): + name = manifest.parent.name + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name): + raise SystemExit(f"Invalid plugin directory: {name}") + if json.loads(manifest.read_text())["name"] != name or not (manifest.parent / "package.json").is_file(): + raise SystemExit(f"Invalid plugin package: {name}") + names.append(name) + if os.environ["IS_RELEASE"] == "true": + requested = os.environ["REQUESTED_PLUGIN"] + if requested not in names: + raise SystemExit("Requested plugin does not exist") + names = [requested] + if not names: + raise SystemExit("No plugin packages found") + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write("plugins=" + json.dumps(names) + "\n") + PY + check: + needs: packages + strategy: + fail-fast: false + matrix: + plugin: ${{ fromJSON(needs.packages.outputs.plugins) }} runs-on: ubuntu-latest env: GODWIT_CHECKOUT: ${{ github.workspace }}/.godwit-ci @@ -49,37 +91,38 @@ jobs: bun-version: 1.4.3 - name: Install published SDK for releases if: github.event_name == 'workflow_dispatch' - working-directory: packages/zen-free + working-directory: packages/${{ matrix.plugin }} run: npm install --ignore-scripts --no-audit --no-fund - name: Install packed SDK for source checks if: github.event_name != 'workflow_dispatch' - working-directory: packages/zen-free + working-directory: packages/${{ matrix.plugin }} run: | # shortcut: pack the pinned SDK until its first npm publication; no sibling imports. (cd "$GODWIT_CHECKOUT/sdk" && npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP") sdk_version=$(node -p 'require("./package.json").devDependencies["@godwit/sdk"]') npm install --no-save --package-lock=false --ignore-scripts --no-audit --no-fund "$RUNNER_TEMP/godwit-sdk-$sdk_version.tgz" - run: npm run typecheck - working-directory: packages/zen-free - - run: bun test test - working-directory: packages/zen-free - - run: bun run build - working-directory: packages/zen-free + working-directory: packages/${{ matrix.plugin }} + - run: bun test scripts + - run: npm test + working-directory: packages/${{ matrix.plugin }} + - run: npm run build + working-directory: packages/${{ matrix.plugin }} - name: Prepare release metadata and archive if: github.event_name == 'workflow_dispatch' - working-directory: packages/zen-free env: + PLUGIN: ${{ matrix.plugin }} RELEASE_TAG: ${{ inputs.tag }} MIN_GODWIT: ${{ inputs.min_godwit }} run: | - bun scripts/release.ts "$RELEASE_TAG" "$MIN_GODWIT" - tar -czf dist/zen-free.tar.gz -C dist index.js plugin.json - cd dist - sha256sum index.js plugin.json zen-free.tar.gz registry-plugin.json > SHA256SUMS + bun scripts/release.ts "$PLUGIN" "$RELEASE_TAG" "$MIN_GODWIT" + cd "packages/$PLUGIN/dist" + tar -czf "$PLUGIN.tar.gz" index.js plugin.json + sha256sum index.js plugin.json "$PLUGIN.tar.gz" registry-plugin.json > SHA256SUMS - uses: actions/upload-artifact@v4 with: - name: zen-free-dist - path: packages/zen-free/dist/ + name: ${{ matrix.plugin }}-dist + path: packages/${{ matrix.plugin }}/dist/ if-no-files-found: error retention-days: 7 @@ -92,15 +135,16 @@ jobs: steps: - uses: actions/download-artifact@v4 with: - name: zen-free-dist + name: ${{ inputs.plugin }}-dist path: dist - name: Publish the verified release files env: + PLUGIN: ${{ inputs.plugin }} GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} MIN_GODWIT: ${{ inputs.min_godwit }} run: | - gh release create "$RELEASE_TAG" dist/index.js dist/plugin.json dist/zen-free.tar.gz dist/registry-plugin.json dist/SHA256SUMS \ - --repo "$GITHUB_REPOSITORY" --verify-tag --draft --title "Zen Free $RELEASE_TAG" \ - --notes "Requires Godwit $MIN_GODWIT or later. Extract zen-free.tar.gz for manual installation. registry-plugin.json contains the registry entry and exact index.js checksum." + gh release create "$RELEASE_TAG" dist/index.js dist/plugin.json "dist/$PLUGIN.tar.gz" dist/registry-plugin.json dist/SHA256SUMS \ + --repo "$GITHUB_REPOSITORY" --verify-tag --draft --title "$RELEASE_TAG" \ + --notes "Requires Godwit $MIN_GODWIT or later. Extract $PLUGIN.tar.gz for manual installation. registry-plugin.json contains the registry entry and exact index.js checksum." gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false diff --git a/README.md b/README.md index 70c9844..625772d 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,9 @@ The plugins [Godwit](https://github.com/godwit-email/godwit)'s Extensions page o ## Adding a plugin or a release -Zen Free source lives in [`packages/zen-free`](packages/zen-free). Its **Zen Free** workflow -checks and builds the plugin, and publishes GitHub Release files on manual dispatch; see -the maintainer steps below. The **Publish** workflow -separately validates, signs, and uploads the registry after a registry change is merged. +In-repository script plugins live under `packages/`, including [Zen Free](packages/zen-free). +One **Plugins** workflow discovers and checks all packages and publishes a selected plugin +on manual dispatch. **Publish** separately validates, signs, and uploads the registry. Open a pull request that adds to `plugins` (or to an existing plugin's `releases`): @@ -55,20 +54,25 @@ Open a pull request that adds to `plugins` (or to an existing plugin's `releases `manifest.main` names, at most 5 MiB. - Bump the top-level `updated` (RFC 3339) in every pull request. -## Releasing Zen Free +## Building and releasing script plugins + +Each `packages//` contains `plugin.json` and `package.json`, whose scripts provide +`typecheck`, `test`, and `build`. The directory name must match the manifest name. Builds +write `dist/plugin.json` and a bundled `dist/index.js`. New packages join the shared CI +without adding a workflow. 1. Publish the required `@godwit/sdk` package and a Godwit version supporting model plugins. -2. Bump `packages/zen-free/package.json` and `plugin.json` together and merge to main. -3. Create and push `zen-free-vX.Y.Z` for that main commit. -4. Run **Actions → Zen Free → Run workflow** on main, supplying the tag and the actual +2. Bump `packages//package.json` and `plugin.json` together and merge to main. +3. Create and push `-vX.Y.Z` for that main commit. +4. Run **Actions → Plugins → Run workflow** on main, supplying the plugin name, tag, and actual minimum released Godwit version. The workflow checks the tagged source, installs the - published SDK, and publishes `index.js`, `plugin.json`, `zen-free.tar.gz`, + published SDK, and publishes `index.js`, `plugin.json`, `.tar.gz`, `registry-plugin.json`, and `SHA256SUMS` to GitHub Releases. 5. Add the released `registry-plugin.json` entry to `registry.json`, or merge its release into the existing entry while preserving older releases. Bump `updated` and open a PR. **Publish** validates the real downloads, signs, and uploads the registry. -PR checks provide a `zen-free-dist` artifact. They pack the SDK from the pinned Godwit +PR checks provide a `-dist` artifact. They pack the SDK from the pinned Godwit commit used by the goja integration test; update that pin when updating the SDK/API. Release runs require `contents: write`. If a failed run leaves a draft release, inspect and delete that unpublished draft before retrying. Never replace published files; bump diff --git a/packages/zen-free/test/host.test.ts b/packages/zen-free/test/host.test.ts index 12450d1..2f2ae56 100644 --- a/packages/zen-free/test/host.test.ts +++ b/packages/zen-free/test/host.test.ts @@ -6,4 +6,4 @@ test.skipIf(!checkout)("built plugin runs in Godwit's actual goja sandbox",()=>{ const host=Bun.spawnSync(["go","test","./core","-run","^TestZenFreeVM$","-count=1"],{cwd:checkout,env:{...process.env,GODWIT_ZEN_FREE_DIST:resolve(import.meta.dir,"../dist")},timeout:120000}) if(host.exitCode!==0)throw new Error(host.stdout.toString()+host.stderr.toString()) expect(host.exitCode).toBe(0) -}) +},125000) diff --git a/packages/zen-free/test/release.test.ts b/packages/zen-free/test/release.test.ts deleted file mode 100644 index 3e2d0b6..0000000 --- a/packages/zen-free/test/release.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {expect, test} from "bun:test" -import manifest from "../plugin.json" -import pkg from "../package.json" -import {registryEntry} from "../scripts/release" - -test("registry metadata describes the exact release file", () => { - const entry = registryEntry(manifest, pkg.version, "zen-free-v0.1.0", "1.6.0", new TextEncoder().encode("hello")) - expect(entry.name).toBe("zen-free") - expect(entry.releases[0].manifest).toEqual(manifest) - expect(entry.releases[0].minGodwit).toBe("1.6.0") - expect(entry.releases[0].artifacts.any).toEqual({ - url: "https://github.com/godwit-email/plugins/releases/download/zen-free-v0.1.0/index.js", - size: 5, - sha256: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", - }) -}) - -test("release metadata rejects inconsistent versions and missing host compatibility", () => { - const bytes = new Uint8Array([1]) - expect(() => registryEntry(manifest, "0.2.0", "zen-free-v0.1.0", "1.6.0", bytes)).toThrow() - expect(() => registryEntry(manifest, pkg.version, "v0.1.0", "1.6.0", bytes)).toThrow() - for (const version of ["", "latest", "01.6.0"]) { - expect(() => registryEntry(manifest, pkg.version, "zen-free-v0.1.0", version, bytes)).toThrow() - } - expect(() => registryEntry(manifest, pkg.version, "zen-free-v0.1.0", "1.6.0", new Uint8Array())).toThrow() -}) diff --git a/packages/zen-free/tsconfig.json b/packages/zen-free/tsconfig.json index 450b2a2..955d3c4 100644 --- a/packages/zen-free/tsconfig.json +++ b/packages/zen-free/tsconfig.json @@ -7,5 +7,5 @@ "moduleResolution": "Bundler", "types": ["bun"] }, - "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "build.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "../../scripts/**/*.ts", "build.ts"] } diff --git a/scripts/release.test.ts b/scripts/release.test.ts new file mode 100644 index 0000000..46e8ff2 --- /dev/null +++ b/scripts/release.test.ts @@ -0,0 +1,35 @@ +import {expect, test} from "bun:test" +import {registryEntry} from "./release" + +const manifest = {name: "example-plugin", version: "1.2.3", main: "index.js", description: {default: "Example plugin"}} + +test("registry metadata describes the exact release file", () => { + const entry = registryEntry(manifest, manifest.version, "example-plugin-v1.2.3", "1.6.0", new TextEncoder().encode("hello")) + expect(entry.name).toBe("example-plugin") + expect(entry.releases[0].manifest).toEqual(manifest) + expect(entry.releases[0].minGodwit).toBe("1.6.0") + expect(entry.releases[0].artifacts.any).toEqual({ + url: "https://github.com/godwit-email/plugins/releases/download/example-plugin-v1.2.3/index.js", + size: 5, + sha256: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + }) +}) + +test("release metadata rejects inconsistent versions and missing host compatibility", () => { + const bytes = new Uint8Array([1]) + expect(() => registryEntry(manifest, "0.2.0", "example-plugin-v1.2.3", "1.6.0", bytes)).toThrow() + expect(() => registryEntry(manifest, manifest.version, "v1.2.3", "1.6.0", bytes)).toThrow() + for (const version of ["", "latest", "01.6.0"]) { + expect(() => registryEntry(manifest, manifest.version, "example-plugin-v1.2.3", version, bytes)).toThrow() + } + expect(() => registryEntry(manifest, manifest.version, "example-plugin-v1.2.3", "1.6.0", new Uint8Array())).toThrow() +}) + +test("release metadata is shared across plugin packages", () => { + const other = {...manifest, name: "another-provider", version: "2.0.0"} + const entry = registryEntry(other, "2.0.0", "another-provider-v2.0.0", "1.6.0", new Uint8Array([1])) + expect(entry.name).toBe("another-provider") + expect(entry.homepage).toEndWith("/packages/another-provider") + expect(entry.releases[0].artifacts.any.url).toEndWith("/another-provider-v2.0.0/index.js") + expect(() => registryEntry({...other, name: "../outside"}, "2.0.0", "../outside-v2.0.0", "1.6.0", new Uint8Array([1]))).toThrow() +}) diff --git a/packages/zen-free/scripts/release.ts b/scripts/release.ts similarity index 51% rename from packages/zen-free/scripts/release.ts rename to scripts/release.ts index e9d7f70..e01c734 100644 --- a/packages/zen-free/scripts/release.ts +++ b/scripts/release.ts @@ -4,15 +4,15 @@ type Manifest = {name: string; version: string; main: string; description: Recor const stableVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ export function registryEntry(manifest: Manifest, packageVersion: string, tag: string, minGodwit: string, source: Uint8Array) { - if (manifest.name !== "zen-free" || manifest.main !== "index.js") throw new Error("Unexpected plugin manifest") - if (!stableVersion.test(manifest.version) || manifest.version !== packageVersion || tag !== `zen-free-v${manifest.version}`) throw new Error("Tag, package and manifest versions must match") + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(manifest.name) || manifest.main !== "index.js") throw new Error("Unexpected plugin manifest") + if (!stableVersion.test(manifest.version) || manifest.version !== packageVersion || tag !== `${manifest.name}-v${manifest.version}`) throw new Error("Tag, package and manifest versions must match") if (!stableVersion.test(minGodwit)) throw new Error("Specify the stable Godwit version that first supports this plugin") if (!source.length || source.length > 5 * 1024 * 1024) throw new Error("Plugin size is outside the host limit") return { name: manifest.name, - title: {default: "Zen Free"}, + title: {default: manifest.name}, summary: manifest.description, - homepage: "https://github.com/godwit-email/plugins/tree/main/packages/zen-free", + homepage: `https://github.com/godwit-email/plugins/tree/main/packages/${manifest.name}`, releases: [{version: manifest.version, minGodwit, manifest, artifacts: {any: { url: `https://github.com/godwit-email/plugins/releases/download/${tag}/index.js`, size: source.length, @@ -22,9 +22,12 @@ export function registryEntry(manifest: Manifest, packageVersion: string, tag: s } if (import.meta.main) { - const dir = import.meta.dir + "/../dist" - const manifest = await Bun.file(dir + "/plugin.json").json() - const pkg = await Bun.file(import.meta.dir + "/../package.json").json() - const entry = registryEntry(manifest, pkg.version, process.argv[2] ?? "", process.argv[3] ?? "", new Uint8Array(await Bun.file(dir + "/index.js").arrayBuffer())) - await Bun.write(dir + "/registry-plugin.json", JSON.stringify(entry, null, 2) + "\n") + const plugin = process.argv[2] ?? "" + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(plugin)) throw new Error("Invalid plugin directory name") + const dir = import.meta.dir + `/../packages/${plugin}` + const manifest = await Bun.file(dir + "/dist/plugin.json").json() + if (manifest.name !== plugin) throw new Error("Plugin directory and manifest name must match") + const pkg = await Bun.file(dir + "/package.json").json() + const entry = registryEntry(manifest, pkg.version, process.argv[3] ?? "", process.argv[4] ?? "", new Uint8Array(await Bun.file(dir + "/dist/index.js").arrayBuffer())) + await Bun.write(dir + "/dist/registry-plugin.json", JSON.stringify(entry, null, 2) + "\n") } From 2770b2066f27002b55936d7984fa16d5e8bfa793 Mon Sep 17 00:00:00 2001 From: baranwang Date: Sun, 18 Oct 2026 02:03:28 +0800 Subject: [PATCH 5/9] ci: use Bun throughout plugin checks and packaging Co-authored-by: Codex --- .github/workflows/plugins.yml | 23 +++++++++++++---------- packages/zen-free/package.json | 2 +- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml index 6aecf21..0eb2dfd 100644 --- a/.github/workflows/plugins.yml +++ b/.github/workflows/plugins.yml @@ -83,30 +83,33 @@ jobs: with: go-version-file: .godwit-ci/go.mod cache-dependency-path: .godwit-ci/go.sum - - uses: actions/setup-node@v4 - with: - node-version: 24 - uses: oven-sh/setup-bun@v2 with: bun-version: 1.4.3 + - name: Install host frontend dependencies for code generation + working-directory: .godwit-ci/web + run: bun install --frozen-lockfile + - name: Generate host bindings and catalogs for integration tests + working-directory: .godwit-ci + run: go tool mygo generate - name: Install published SDK for releases if: github.event_name == 'workflow_dispatch' working-directory: packages/${{ matrix.plugin }} - run: npm install --ignore-scripts --no-audit --no-fund + run: bun install --no-save --ignore-scripts - name: Install packed SDK for source checks if: github.event_name != 'workflow_dispatch' working-directory: packages/${{ matrix.plugin }} run: | # shortcut: pack the pinned SDK until its first npm publication; no sibling imports. - (cd "$GODWIT_CHECKOUT/sdk" && npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP") - sdk_version=$(node -p 'require("./package.json").devDependencies["@godwit/sdk"]') - npm install --no-save --package-lock=false --ignore-scripts --no-audit --no-fund "$RUNNER_TEMP/godwit-sdk-$sdk_version.tgz" - - run: npm run typecheck + (cd "$GODWIT_CHECKOUT/sdk" && bun pm pack --ignore-scripts --destination "$RUNNER_TEMP") + sdk_version=$(bun -p 'require("./package.json").devDependencies["@godwit/sdk"]') + bun add --dev --no-save --ignore-scripts "@godwit/sdk@file:$RUNNER_TEMP/godwit-sdk-$sdk_version.tgz" + - run: bun run --bun typecheck working-directory: packages/${{ matrix.plugin }} - run: bun test scripts - - run: npm test + - run: bun run --bun test working-directory: packages/${{ matrix.plugin }} - - run: npm run build + - run: bun run --bun build working-directory: packages/${{ matrix.plugin }} - name: Prepare release metadata and archive if: github.event_name == 'workflow_dispatch' diff --git a/packages/zen-free/package.json b/packages/zen-free/package.json index 61a48ab..d8aa534 100644 --- a/packages/zen-free/package.json +++ b/packages/zen-free/package.json @@ -1,5 +1,5 @@ { - "name": "godwit-zen-free", + "name": "@godwit/plugin-zen-free", "version": "0.1.0", "private": true, "type": "module", From f4c05a2cff60f9fe864578a17f5699139843d474 Mon Sep 17 00:00:00 2001 From: baranwang Date: Sun, 18 Oct 2026 02:04:42 +0800 Subject: [PATCH 6/9] fix(zen-free): use canonical zh-Hans locale Co-authored-by: Codex --- README.md | 2 +- packages/zen-free/plugin.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 625772d..fbc925a 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Open a pull request that adds to `plugins` (or to an existing plugin's `releases ```json { "name": "example", - "title": { "default": "Example", "zh-CN": "示例" }, + "title": { "default": "Example", "zh-Hans": "示例" }, "summary": { "default": "Translates mail with an example service. Sends the text to example.com." }, "homepage": "https://github.com/someone/godwit-example", "releases": [ diff --git a/packages/zen-free/plugin.json b/packages/zen-free/plugin.json index 2a7aadd..5829810 100644 --- a/packages/zen-free/plugin.json +++ b/packages/zen-free/plugin.json @@ -4,7 +4,7 @@ "main": "index.js", "description": { "default": "Free OpenCode Zen models. Prompts, context and tools are sent to a third-party service through an unofficial interface.", - "zh-CN": "OpenCode Zen 免费模型。提示、上下文与工具会通过非官方接口发送到第三方服务。" + "zh-Hans": "OpenCode Zen 免费模型。提示、上下文与工具会通过非官方接口发送到第三方服务。" }, "capabilities": ["models"], "permissions": { "net": ["opencode.ai", "models.opencode.ai", "raw.githubusercontent.com"] }, From 08efa44c3cc168778b2dead0f2133a2c1dc8d73f Mon Sep 17 00:00:00 2001 From: baranwang Date: Sun, 18 Oct 2026 02:06:55 +0800 Subject: [PATCH 7/9] test(zen-free): colocate tests with implementation Co-authored-by: Codex --- packages/zen-free/{test/host.test.ts => build.test.ts} | 4 ++-- packages/zen-free/package.json | 2 +- packages/zen-free/{test => src}/models.test.ts | 2 +- packages/zen-free/{test => src}/request.test.ts | 2 +- packages/zen-free/tsconfig.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) rename packages/zen-free/{test/host.test.ts => build.test.ts} (70%) rename packages/zen-free/{test => src}/models.test.ts (97%) rename packages/zen-free/{test => src}/request.test.ts (97%) diff --git a/packages/zen-free/test/host.test.ts b/packages/zen-free/build.test.ts similarity index 70% rename from packages/zen-free/test/host.test.ts rename to packages/zen-free/build.test.ts index 2f2ae56..ead197d 100644 --- a/packages/zen-free/test/host.test.ts +++ b/packages/zen-free/build.test.ts @@ -2,8 +2,8 @@ import {expect,test} from "bun:test" import {resolve} from "node:path" const checkout=process.env.GODWIT_CHECKOUT test.skipIf(!checkout)("built plugin runs in Godwit's actual goja sandbox",()=>{ - const built=Bun.spawnSync(["bun","run","build.ts"],{cwd:resolve(import.meta.dir,"..")});expect(built.exitCode).toBe(0) - const host=Bun.spawnSync(["go","test","./core","-run","^TestZenFreeVM$","-count=1"],{cwd:checkout,env:{...process.env,GODWIT_ZEN_FREE_DIST:resolve(import.meta.dir,"../dist")},timeout:120000}) + const built=Bun.spawnSync(["bun","run","build.ts"],{cwd:import.meta.dir});expect(built.exitCode).toBe(0) + const host=Bun.spawnSync(["go","test","./core","-run","^TestZenFreeVM$","-count=1"],{cwd:checkout,env:{...process.env,GODWIT_ZEN_FREE_DIST:resolve(import.meta.dir,"dist")},timeout:120000}) if(host.exitCode!==0)throw new Error(host.stdout.toString()+host.stderr.toString()) expect(host.exitCode).toBe(0) },125000) diff --git a/packages/zen-free/package.json b/packages/zen-free/package.json index d8aa534..815cc32 100644 --- a/packages/zen-free/package.json +++ b/packages/zen-free/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "license": "MIT", - "scripts": { "build": "bun run build.ts", "test": "bun test test", "typecheck": "tsc --noEmit" }, + "scripts": { "build": "bun run build.ts", "test": "bun test", "typecheck": "tsc --noEmit" }, "devDependencies": { "@godwit/sdk": "0.1.0", "@types/bun": "1.4.3", diff --git a/packages/zen-free/test/models.test.ts b/packages/zen-free/src/models.test.ts similarity index 97% rename from packages/zen-free/test/models.test.ts rename to packages/zen-free/src/models.test.ts index 35ca26f..cb02479 100644 --- a/packages/zen-free/test/models.test.ts +++ b/packages/zen-free/src/models.test.ts @@ -1,5 +1,5 @@ import {describe,expect,test} from "bun:test" -import {buildModels,discoverFreeModels,parseDocs} from "../src/models" +import {buildModels,discoverFreeModels,parseDocs} from "./models" const docs=`| Name | Model | Endpoint | SDK | | Free | free | \`https://opencode.ai/inference/openai/v1/responses\` | sdk | | Pickle | pickle | \`https://opencode.ai/inference/openai/v1/chat/completions\` | sdk | diff --git a/packages/zen-free/test/request.test.ts b/packages/zen-free/src/request.test.ts similarity index 97% rename from packages/zen-free/test/request.test.ts rename to packages/zen-free/src/request.test.ts index c72a605..f6be6d0 100644 --- a/packages/zen-free/test/request.test.ts +++ b/packages/zen-free/src/request.test.ts @@ -1,6 +1,6 @@ import {expect,test} from "bun:test" import type {JSONValue} from "@godwit/sdk" -import {adaptZenRequest,sessionID} from "../src/request" +import {adaptZenRequest,sessionID} from "./request" for(const protocol of ["openai-completions","openai-responses","anthropic-message"]){test(protocol+" preserves real tools and input",()=>{ const real:JSONValue=protocol==="openai-completions"?{type:"function",function:{name:"read",parameters:{type:"object",properties:{path:{type:"string"}}}}}:protocol==="anthropic-message"?{name:"read",input_schema:{type:"object"}}:{type:"function",name:"read",parameters:{type:"object"}} const call={model:{id:"free",name:"Free",transport:"http",protocol,baseURL:"https://opencode.ai/inference/v1"},sessionId:"random-host-session",sessionCreatedAt:1792240000000,requestId:"attempt-one",request:{url:"ignored",headers:{"Authorization":"Bearer secret"},body:{model:"free",stream:true,tools:[real]}}} as any diff --git a/packages/zen-free/tsconfig.json b/packages/zen-free/tsconfig.json index 955d3c4..e1cda17 100644 --- a/packages/zen-free/tsconfig.json +++ b/packages/zen-free/tsconfig.json @@ -7,5 +7,5 @@ "moduleResolution": "Bundler", "types": ["bun"] }, - "include": ["src/**/*.ts", "test/**/*.ts", "../../scripts/**/*.ts", "build.ts"] + "include": ["src/**/*.ts", "../../scripts/**/*.ts", "*.ts"] } From 95cd439eecf3a4b3279ae7e3795623950f395f59 Mon Sep 17 00:00:00 2001 From: baranwang Date: Sun, 18 Oct 2026 02:14:20 +0800 Subject: [PATCH 8/9] chore(zen-free): follow app-aligned SDK versions Co-authored-by: Codex --- .github/workflows/plugins.yml | 7 +++---- README.md | 3 ++- packages/zen-free/package.json | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml index 0eb2dfd..b62013b 100644 --- a/.github/workflows/plugins.yml +++ b/.github/workflows/plugins.yml @@ -77,7 +77,7 @@ jobs: - uses: actions/checkout@v4 with: repository: godwit-email/godwit - ref: 4cacafebc431de5d9fd1bfbb7d8528dcef47b6f7 + ref: 38816d38824c69f4bccff5a9e636f81e5483d60f path: .godwit-ci - uses: actions/setup-go@v5 with: @@ -101,9 +101,8 @@ jobs: working-directory: packages/${{ matrix.plugin }} run: | # shortcut: pack the pinned SDK until its first npm publication; no sibling imports. - (cd "$GODWIT_CHECKOUT/sdk" && bun pm pack --ignore-scripts --destination "$RUNNER_TEMP") - sdk_version=$(bun -p 'require("./package.json").devDependencies["@godwit/sdk"]') - bun add --dev --no-save --ignore-scripts "@godwit/sdk@file:$RUNNER_TEMP/godwit-sdk-$sdk_version.tgz" + (cd "$GODWIT_CHECKOUT/sdk" && bun pm pack --ignore-scripts --filename "$RUNNER_TEMP/godwit-sdk.tgz") + bun add --dev --no-save --ignore-scripts "@godwit/sdk@file:$RUNNER_TEMP/godwit-sdk.tgz" - run: bun run --bun typecheck working-directory: packages/${{ matrix.plugin }} - run: bun test scripts diff --git a/README.md b/README.md index fbc925a..8b2528a 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,8 @@ Each `packages//` contains `plugin.json` and `package.json`, whose scripts write `dist/plugin.json` and a bundled `dist/index.js`. New packages join the shared CI without adding a workflow. -1. Publish the required `@godwit/sdk` package and a Godwit version supporting model plugins. +1. Release a Godwit version supporting model plugins; its release publishes the matching + `@godwit/sdk` version. 2. Bump `packages//package.json` and `plugin.json` together and merge to main. 3. Create and push `-vX.Y.Z` for that main commit. 4. Run **Actions → Plugins → Run workflow** on main, supplying the plugin name, tag, and actual diff --git a/packages/zen-free/package.json b/packages/zen-free/package.json index 815cc32..826c46f 100644 --- a/packages/zen-free/package.json +++ b/packages/zen-free/package.json @@ -6,7 +6,7 @@ "license": "MIT", "scripts": { "build": "bun run build.ts", "test": "bun test", "typecheck": "tsc --noEmit" }, "devDependencies": { - "@godwit/sdk": "0.1.0", + "@godwit/sdk": "^1.5.0", "@types/bun": "1.4.3", "typescript": "7.0.2" } From 860a6c66aab1bf74c198a422449751e6fc066e21 Mon Sep 17 00:00:00 2001 From: baranwang Date: Sun, 18 Oct 2026 09:43:47 +0800 Subject: [PATCH 9/9] fix(zen-free): adopt the merged model provider API Co-authored-by: Codex --- .github/workflows/plugins.yml | 12 +-- README.md | 9 ++- packages/zen-free/build.test.ts | 2 +- packages/zen-free/build_test.go | 104 ++++++++++++++++++++++++++ packages/zen-free/package.json | 2 +- packages/zen-free/src/index.ts | 15 +++- packages/zen-free/src/models.ts | 5 +- packages/zen-free/src/request.test.ts | 64 ++++++++++++---- packages/zen-free/src/request.ts | 49 +++++------- 9 files changed, 199 insertions(+), 63 deletions(-) create mode 100644 packages/zen-free/build_test.go diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml index b62013b..d1fa2d3 100644 --- a/.github/workflows/plugins.yml +++ b/.github/workflows/plugins.yml @@ -15,7 +15,7 @@ on: description: Existing plugin tag on main, e.g. zen-free-v0.1.0 required: true min_godwit: - description: First released Godwit version supporting this plugin, e.g. 1.6.0 + description: First released Godwit version supporting this plugin, e.g. 1.7.0 required: true permissions: @@ -77,7 +77,7 @@ jobs: - uses: actions/checkout@v4 with: repository: godwit-email/godwit - ref: 38816d38824c69f4bccff5a9e636f81e5483d60f + ref: 95eb0180654353e4e0a026a33258d6f9f0c6fb54 path: .godwit-ci - uses: actions/setup-go@v5 with: @@ -86,12 +86,6 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: 1.4.3 - - name: Install host frontend dependencies for code generation - working-directory: .godwit-ci/web - run: bun install --frozen-lockfile - - name: Generate host bindings and catalogs for integration tests - working-directory: .godwit-ci - run: go tool mygo generate - name: Install published SDK for releases if: github.event_name == 'workflow_dispatch' working-directory: packages/${{ matrix.plugin }} @@ -100,7 +94,7 @@ jobs: if: github.event_name != 'workflow_dispatch' working-directory: packages/${{ matrix.plugin }} run: | - # shortcut: pack the pinned SDK until its first npm publication; no sibling imports. + # shortcut: model types ship in the next SDK release; use the pinned API until then. (cd "$GODWIT_CHECKOUT/sdk" && bun pm pack --ignore-scripts --filename "$RUNNER_TEMP/godwit-sdk.tgz") bun add --dev --no-save --ignore-scripts "@godwit/sdk@file:$RUNNER_TEMP/godwit-sdk.tgz" - run: bun run --bun typecheck diff --git a/README.md b/README.md index 8b2528a..3b9dfbe 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ Each `packages//` contains `plugin.json` and `package.json`, whose scripts write `dist/plugin.json` and a bundled `dist/index.js`. New packages join the shared CI without adding a workflow. -1. Release a Godwit version supporting model plugins; its release publishes the matching - `@godwit/sdk` version. +1. Release Godwit 1.7.0 or later with model plugin support; its release publishes the matching + `@godwit/sdk` version. The published 1.6.0 SDK predates the model API. 2. Bump `packages//package.json` and `plugin.json` together and merge to main. 3. Create and push `-vX.Y.Z` for that main commit. 4. Run **Actions → Plugins → Run workflow** on main, supplying the plugin name, tag, and actual @@ -73,8 +73,9 @@ without adding a workflow. into the existing entry while preserving older releases. Bump `updated` and open a PR. **Publish** validates the real downloads, signs, and uploads the registry. -PR checks provide a `-dist` artifact. They pack the SDK from the pinned Godwit -commit used by the goja integration test; update that pin when updating the SDK/API. +PR checks provide a `-dist` artifact. Until the model API's SDK is published, they +pack it from the pinned Godwit commit used by the goja integration test. Release runs +install the published SDK from npm. Update the host pin when updating the SDK/API. Release runs require `contents: write`. If a failed run leaves a draft release, inspect and delete that unpublished draft before retrying. Never replace published files; bump the version instead. The workflow does not modify the registry. diff --git a/packages/zen-free/build.test.ts b/packages/zen-free/build.test.ts index ead197d..558f72d 100644 --- a/packages/zen-free/build.test.ts +++ b/packages/zen-free/build.test.ts @@ -3,7 +3,7 @@ import {resolve} from "node:path" const checkout=process.env.GODWIT_CHECKOUT test.skipIf(!checkout)("built plugin runs in Godwit's actual goja sandbox",()=>{ const built=Bun.spawnSync(["bun","run","build.ts"],{cwd:import.meta.dir});expect(built.exitCode).toBe(0) - const host=Bun.spawnSync(["go","test","./core","-run","^TestZenFreeVM$","-count=1"],{cwd:checkout,env:{...process.env,GODWIT_ZEN_FREE_DIST:resolve(import.meta.dir,"dist")},timeout:120000}) + const host=Bun.spawnSync(["go","test",resolve(import.meta.dir,"build_test.go"),"-count=1"],{cwd:checkout,env:{...process.env,GODWIT_ZEN_FREE_DIST:resolve(import.meta.dir,"dist")},timeout:120000}) if(host.exitCode!==0)throw new Error(host.stdout.toString()+host.stderr.toString()) expect(host.exitCode).toBe(0) },125000) diff --git a/packages/zen-free/build_test.go b/packages/zen-free/build_test.go new file mode 100644 index 0000000..e09fb6b --- /dev/null +++ b/packages/zen-free/build_test.go @@ -0,0 +1,104 @@ +package zenfree_test + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/dop251/goja" + "github.com/godwit-email/godwit/core/plugins" + "github.com/stretchr/testify/require" +) + +type catalogHost struct{ plugins.Host } + +func (catalogHost) Fetch(_ context.Context, _ plugins.Invocation, req plugins.FetchRequest) (plugins.FetchResponse, error) { + var body string + switch req.URL { + case "https://opencode.ai/inference/v1/models": + body = `{"data":[{"id":"chat"},{"id":"responses"},{"id":"claude"},{"id":"jev-1.13-free"}]}` + case "https://models.opencode.ai/api.json": + body = `{"opencode":{"models":{ + "chat":{"cost":{"input":0,"output":0},"provider":{"npm":"@ai-sdk/openai-compatible"}}, + "responses":{"cost":{"input":0,"output":0},"provider":{"npm":"@ai-sdk/openai"}}, + "claude":{"cost":{"input":0,"output":0},"provider":{"npm":"@ai-sdk/anthropic"}} + }}}` + case "https://raw.githubusercontent.com/anomalyco/opencode/dev/services/www/src/docs/content/console/models.mdx": + body = "| Jev Free | jev-1.13-free | `https://opencode.ai/inference/systemone/v1/systemone` | - |\n| Jev Free | Free | Free |" + default: + return plugins.FetchResponse{}, fmt.Errorf("unexpected fetch: %s", req.URL) + } + return plugins.FetchResponse{Status: 200, Body: body}, nil +} + +func TestZenFree(t *testing.T) { + dist := os.Getenv("GODWIT_ZEN_FREE_DIST") + require.NotEmpty(t, dist) + manifest, err := os.ReadFile(filepath.Join(dist, "plugin.json")) + require.NoError(t, err) + m, err := plugins.ParseManifest(manifest, "zen-free") + require.NoError(t, err) + code, err := plugins.Build(dist, m.Main) + require.NoError(t, err) + program, err := goja.Compile("zen-free", string(code), true) + require.NoError(t, err) + vm := plugins.NewVM("zen-free", program, plugins.VMOptions{}) + t.Cleanup(vm.Close) + cfg := &plugins.ConfigSnapshot{Values: map[string]any{}} + p, err := plugins.Load(context.Background(), vm, m, catalogHost{}, cfg) + require.NoError(t, err) + inv := plugins.Invocation{ID: "models:test", Kind: plugins.KindModels, Config: cfg, EgressHosts: m.Permissions.Net} + listed, err := p.ListModels(context.Background(), inv) + require.NoError(t, err) + var models plugins.ModelsResult + raw, err := json.Marshal(listed) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(raw, &models)) + require.NoError(t, plugins.ValidateModels(models, false)) + require.Len(t, models.Models, 4) + require.Empty(t, p.Tools(), "placeholder definitions grant no executable tools") + var session, requestID string + for _, fixture := range []struct{ id, name, protocol, path string }{ + {"chat", "chat", "openai-completions", "chat/completions"}, + {"responses", "responses", "openai-responses", "responses"}, + {"claude", "claude", "anthropic-message", "messages"}, + {"jev-1.13-free", "Jev Free", "typesafe-systemone", "systemone"}, + } { + t.Run(fixture.id, func(t *testing.T) { + require.Contains(t, models.Models, plugins.ProviderModel{ID: fixture.id, Name: fixture.name, Transport: "http", Protocol: fixture.protocol, BaseURL: "https://opencode.ai/inference/v1"}) + body := fmt.Sprintf(`{"model":%q,"stream":true,"messages":[]}`, fixture.id) + out, err := p.PrepareRequest(context.Background(), inv, plugins.ModelRequest{ModelID: fixture.id, Method: "POST", URL: "https://opencode.ai/inference/v1/" + fixture.path, Headers: map[string]string{"Authorization": "Bearer old-secret"}, Body: body}) + require.NoError(t, err) + var prepared plugins.ModelRequest + raw, err := json.Marshal(out) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(raw, &prepared)) + require.Equal(t, "https://opencode.ai/inference/v1/"+fixture.path, prepared.URL) + require.NotContains(t, fmt.Sprint(prepared.Headers), "old-secret") + if fixture.protocol == "anthropic-message" { + require.Equal(t, "public", prepared.Headers["x-api-key"]) + } else { + require.Equal(t, "Bearer public", prepared.Headers["authorization"]) + } + require.Regexp(t, `^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$`, prepared.Headers["x-opencode-session"]) + if session != "" { + require.Equal(t, session, prepared.Headers["x-opencode-session"]) + require.NotEqual(t, requestID, prepared.Headers["x-opencode-request"]) + } + session, requestID = prepared.Headers["x-opencode-session"], prepared.Headers["x-opencode-request"] + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(prepared.Body), &payload)) + require.Equal(t, fixture.id, payload["model"]) + if fixture.protocol == "typesafe-systemone" { + require.NotContains(t, payload, "tools") + } else { + require.Len(t, payload["tools"], 5) + } + }) + } + _, err = p.PrepareRequest(context.Background(), inv, plugins.ModelRequest{ModelID: "unknown"}) + require.Error(t, err) +} diff --git a/packages/zen-free/package.json b/packages/zen-free/package.json index 826c46f..eed40e0 100644 --- a/packages/zen-free/package.json +++ b/packages/zen-free/package.json @@ -6,7 +6,7 @@ "license": "MIT", "scripts": { "build": "bun run build.ts", "test": "bun test", "typecheck": "tsc --noEmit" }, "devDependencies": { - "@godwit/sdk": "^1.5.0", + "@godwit/sdk": "^1.7.0", "@types/bun": "1.4.3", "typescript": "7.0.2" } diff --git a/packages/zen-free/src/index.ts b/packages/zen-free/src/index.ts index 8f88769..969c4ac 100644 --- a/packages/zen-free/src/index.ts +++ b/packages/zen-free/src/index.ts @@ -1,7 +1,18 @@ import type { Ctx } from "@godwit/sdk" -import { discoverFreeModels } from "./models" +import { discoverFreeModels, type HTTPModel } from "./models" import { adaptZenRequest } from "./request" export default function(ctx: Ctx): void { - ctx.models.register({ listModels: async () => ({ models: await discoverFreeModels(ctx) }), prepareRequest: adaptZenRequest }) + let models: HTTPModel[] = [] + ctx.models.register({ + async listModels() { + models = await discoverFreeModels(ctx) + return { models } + }, + prepareRequest(request) { + const model = models.find(model => model.id === request.modelId) + if (!model) throw { code: "invalid_request", message: "Unknown Zen model", retryable: false } + return adaptZenRequest(request, model.protocol) + }, + }) } diff --git a/packages/zen-free/src/models.ts b/packages/zen-free/src/models.ts index 371db40..e73096f 100644 --- a/packages/zen-free/src/models.ts +++ b/packages/zen-free/src/models.ts @@ -1,4 +1,7 @@ -import type { Ctx, HTTPModel, ModelProtocol } from "@godwit/sdk" +import type { Ctx, Model } from "@godwit/sdk" + +export type HTTPModel = Extract +export type ModelProtocol = HTTPModel["protocol"] export const BASE = "https://opencode.ai/inference/v1" const CATALOG = "https://models.opencode.ai/api.json" diff --git a/packages/zen-free/src/request.test.ts b/packages/zen-free/src/request.test.ts index f6be6d0..bfcb0a5 100644 --- a/packages/zen-free/src/request.test.ts +++ b/packages/zen-free/src/request.test.ts @@ -1,16 +1,48 @@ -import {expect,test} from "bun:test" -import type {JSONValue} from "@godwit/sdk" -import {adaptZenRequest,sessionID} from "./request" -for(const protocol of ["openai-completions","openai-responses","anthropic-message"]){test(protocol+" preserves real tools and input",()=>{ - const real:JSONValue=protocol==="openai-completions"?{type:"function",function:{name:"read",parameters:{type:"object",properties:{path:{type:"string"}}}}}:protocol==="anthropic-message"?{name:"read",input_schema:{type:"object"}}:{type:"function",name:"read",parameters:{type:"object"}} - const call={model:{id:"free",name:"Free",transport:"http",protocol,baseURL:"https://opencode.ai/inference/v1"},sessionId:"random-host-session",sessionCreatedAt:1792240000000,requestId:"attempt-one",request:{url:"ignored",headers:{"Authorization":"Bearer secret"},body:{model:"free",stream:true,tools:[real]}}} as any - const before=JSON.stringify(call);const a=adaptZenRequest(call);const b=adaptZenRequest({...call,requestId:"attempt-two"}) - const tools=a.body.tools - if(!Array.isArray(tools))throw new Error("prepared request must contain a tools array") - expect(JSON.stringify(call)).toBe(before);expect(tools[0]).toEqual(real);expect(tools.length).toBe(5) - expect(a.headers["x-opencode-session"]).toBe(b.headers["x-opencode-session"]);expect(a.headers["x-opencode-request"]).not.toBe(b.headers["x-opencode-request"]) - expect(a.headers["x-opencode-session"]).toMatch(/^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/) - expect(4095-parseInt(a.headers["x-opencode-session"].slice(13,16),16)).toBeLessThan(16) - expect(JSON.stringify(a.headers)).not.toContain("secret");expect(a.headers[protocol==="anthropic-message"?"x-api-key":"authorization"]).toBe(protocol==="anthropic-message"?"public":"Bearer public") - })} -test("session is stable without VM memory",()=>{expect(sessionID("random",1792240000000)).toBe(sessionID("random",1792240000000));expect(sessionID("other",1792240000000)).not.toBe(sessionID("random",1792240000000))}) +import {expect, test} from "bun:test" +import type {ModelRequest} from "@godwit/sdk" +import {adaptZenRequest} from "./request" + +for (const protocol of ["openai-completions", "openai-responses", "anthropic-message"] as const) { + test(protocol + " adapts the SDK's JSON text and preserves real tools", () => { + const real = protocol === "openai-completions" + ? {type: "function", function: {name: "read", parameters: {type: "object", properties: {path: {type: "string"}}}}} + : protocol === "anthropic-message" + ? {name: "read", input_schema: {type: "object"}} + : {type: "function", name: "read", parameters: {type: "object"}} + const request: ModelRequest = { + modelId: "free", method: "POST", url: "https://opencode.ai/inference/v1/unused", + headers: {Authorization: "Bearer secret", "Anthropic-Beta": "test-beta"}, + body: JSON.stringify({model: "free", stream: true, messages: [{role: "user", content: "hi"}], tools: [real]}), + } + const before = JSON.stringify(request) + const a = adaptZenRequest(request, protocol) + const b = adaptZenRequest(request, protocol) + const body = JSON.parse(a.body) + expect(JSON.stringify(request)).toBe(before) + expect(body.model).toBe("free") + expect(body.messages).toEqual([{role: "user", content: "hi"}]) + expect(body.tools[0]).toEqual(real) + expect(body.tools).toHaveLength(5) + const path = protocol === "openai-completions" ? "chat/completions" : protocol === "openai-responses" ? "responses" : "messages" + expect(a.url).toBe("https://opencode.ai/inference/v1/" + path) + expect(a.headers["x-opencode-session"]).toBe(b.headers["x-opencode-session"]) + expect(a.headers["x-opencode-request"]).not.toBe(b.headers["x-opencode-request"]) + expect(a.headers["x-opencode-session"]).toMatch(/^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/) + expect(a.headers["x-opencode-request"]).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/) + expect(JSON.stringify(a.headers)).not.toContain("secret") + expect(a.headers[protocol === "anthropic-message" ? "x-api-key" : "authorization"]).toBe(protocol === "anthropic-message" ? "public" : "Bearer public") + if (protocol === "anthropic-message") expect(a.headers["anthropic-beta"]).toBe("test-beta") + if (protocol === "openai-completions") expect(body.stream_options.include_usage).toBe(true) + }) +} + +test("judge request keeps its payload without adding generation tools", () => { + const request: ModelRequest = { + modelId: "jev-1.13-free", method: "POST", url: "https://opencode.ai/inference/v1/systemone", + headers: {}, body: '{"model":"jev-1.13-free","input":"blue","questions":{"blue":{"type":"noul","predicate":"Is it blue?"}}}', + } + const prepared = adaptZenRequest(request, "typesafe-systemone") + expect(prepared.url).toBe(request.url) + expect(JSON.parse(prepared.body)).toEqual(JSON.parse(request.body)) + expect(prepared.headers.authorization).toBe("Bearer public") +}) diff --git a/packages/zen-free/src/request.ts b/packages/zen-free/src/request.ts index e07b4f5..e4b62a5 100644 --- a/packages/zen-free/src/request.ts +++ b/packages/zen-free/src/request.ts @@ -1,31 +1,26 @@ -import type { ModelHTTPRequest, PrepareModelRequest } from "@godwit/sdk" -import { BASE } from "./models" +import type { ModelRequest } from "@godwit/sdk" +import { BASE, type ModelProtocol } from "./models" const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" -function tail(seed: string): string { - let result = "" - for (let i = 0; i < 14; i++) { - let hash = 2166136261 - for (let j = 0; j < seed.length; j++) hash = Math.imul(hash ^ seed.charCodeAt(j), 16777619) - hash = Math.imul(hash ^ i, 16777619) >>> 0 - result += alphabet[hash % 62] - } - return result -} -function identifier(prefix: string, descending: boolean, now: number, counter: number, seed: string): string { - const stamp = (Math.floor(now) % 0x1000000000) * 4096 + counter - const hex = (descending ? 0xffffffffffff - stamp : stamp).toString(16).padStart(12, "0") - return prefix + "_" + hex + tail(seed) -} -export function sessionID(id: string, createdAt: number): string { return identifier("ses", true, createdAt, 1, id) } let stamp = 0, counter = 0 +function identifier(prefix: string, descending: boolean): string { + const now = Date.now() + if (now !== stamp) { stamp = now; counter = 0 } + counter = (counter + 1) % 4096 + const value = (Math.floor(now) % 0x1000000000) * 4096 + counter + const hex = (descending ? 0xffffffffffff - value : value).toString(16).padStart(12, "0") + let suffix = "" + for (let i = 0; i < 14; i++) suffix += alphabet[Math.floor(Math.random() * alphabet.length)] + return prefix + "_" + hex + suffix +} +// The host supplies no conversation identity; keep one Zen session per plugin instance. +const session = identifier("ses", true) const paths: Record = { "openai-completions": "/chat/completions", "openai-responses": "/responses", "anthropic-message": "/messages", "typesafe-systemone": "/systemone", "openai-decisions": "/decisions" } const versions: Record = { "openai-completions": "4.0.23", "openai-responses": "4.0.40", "anthropic-message": "4.0.46" } const coreParameters: Record = { bash: "command", edit: "filePath", glob: "pattern", grep: "pattern", read: "filePath" } -export function adaptZenRequest(call: PrepareModelRequest): ModelHTTPRequest { - const protocol = call.model.protocol +export function adaptZenRequest(call: ModelRequest, protocol: ModelProtocol): Pick { if (!paths[protocol]) throw { code: "invalid_request", message: "Unsupported Zen protocol", retryable: false } - const body = JSON.parse(JSON.stringify(call.request.body)) + const body = JSON.parse(call.body) if (versions[protocol]) { if (body.tools !== undefined && !Array.isArray(body.tools)) throw { code: "invalid_request", message: "Invalid tools", retryable: false } body.tools ??= [] @@ -39,22 +34,18 @@ export function adaptZenRequest(call: PrepareModelRequest): ModelHTTPRequest { } if (protocol === "openai-completions") body.stream_options = { ...body.stream_options, include_usage: true } } - const now = Date.now() - if (now !== stamp) { stamp = now; counter = 0 } - counter = (counter + 1) % 4096 - const session = sessionID(call.sessionId, call.sessionCreatedAt) const headers: Record = { "content-type": "application/json", accept: "*/*", "user-agent": `opencode/1.19.0 ai-sdk/provider-utils/${versions[protocol] || "4.0.23"} runtime/bun/1.3.14`, "x-opencode-project": "global", "x-opencode-client": "cli", "x-opencode-session": session, "x-opencode-session-id": session, - "x-opencode-request": identifier("msg", false, now, counter, call.requestId), + "x-opencode-request": identifier("msg", false), } if (protocol === "anthropic-message") { headers["x-api-key"] = "public" headers["anthropic-version"] = "2023-06-01" - const beta = Object.keys(call.request.headers).find(k => k.toLowerCase() === "anthropic-beta") - if (beta) headers["anthropic-beta"] = call.request.headers[beta] + const beta = Object.keys(call.headers).find(k => k.toLowerCase() === "anthropic-beta") + if (beta) headers["anthropic-beta"] = call.headers[beta] } else headers.authorization = "Bearer public" - return { url: BASE + paths[protocol], headers, body } + return { url: BASE + paths[protocol], headers, body: JSON.stringify(body) } }