diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml new file mode 100644 index 0000000..d1fa2d3 --- /dev/null +++ b/.github/workflows/plugins.yml @@ -0,0 +1,146 @@ +name: Plugins + +on: + pull_request: + paths: ["packages/**", "scripts/**", ".github/workflows/plugins.yml"] + push: + branches: [main] + 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 + min_godwit: + description: First released Godwit version supporting this plugin, e.g. 1.7.0 + required: true + +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 + 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: 95eb0180654353e4e0a026a33258d6f9f0c6fb54 + path: .godwit-ci + - uses: actions/setup-go@v5 + with: + go-version-file: .godwit-ci/go.mod + cache-dependency-path: .godwit-ci/go.sum + - 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/${{ matrix.plugin }} + 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: 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 + working-directory: packages/${{ matrix.plugin }} + - run: bun test scripts + - run: bun run --bun test + working-directory: packages/${{ matrix.plugin }} + - run: bun run --bun build + working-directory: packages/${{ matrix.plugin }} + - name: Prepare release metadata and archive + if: github.event_name == 'workflow_dispatch' + env: + PLUGIN: ${{ matrix.plugin }} + RELEASE_TAG: ${{ inputs.tag }} + MIN_GODWIT: ${{ inputs.min_godwit }} + run: | + 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: ${{ matrix.plugin }}-dist + path: packages/${{ matrix.plugin }}/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: ${{ 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/$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 e59977b..3b9dfbe 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,16 @@ The plugins [Godwit](https://github.com/godwit-email/godwit)'s Extensions page o ## Adding a plugin or a release +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`): ```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": [ @@ -50,6 +54,32 @@ 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. +## 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. 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 + minimum released Godwit version. The workflow checks the tagged source, installs the + 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 `-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. + ## Review checklist Before approving: diff --git a/packages/zen-free/.gitignore b/packages/zen-free/.gitignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/packages/zen-free/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/packages/zen-free/README.md b/packages/zen-free/README.md new file mode 100644 index 0000000..bfa53c2 --- /dev/null +++ b/packages/zen-free/README.md @@ -0,0 +1,19 @@ +# Zen Free for Godwit + +在 Godwit 中使用 OpenCode Zen 的免费模型,无需 API Key。 + +## 使用 + +插件尚未发布。发布并收录后: + +1. 打开 Godwit「设置 → 扩展」,在插件目录中安装 Zen Free。 +2. 启用插件,允许它声明的网络权限。 +3. 打开「设置 → 模型服务」,选择 Zen Free 的模型。 + +可用模型由上游免费目录决定,无需手动配置接口或协议。需要支持模型插件的 Godwit 版本。 + +## 数据与可用性 + +使用模型时,提示、邮件上下文、工具定义及工具结果会发送到 OpenCode 及其模型服务商,请阅读上游的数据使用条款。 + +插件依赖非官方公共接口。免费模型、限流和免费期限可能变化,不保证配额或持续可用。 diff --git a/packages/zen-free/build.test.ts b/packages/zen-free/build.test.ts new file mode 100644 index 0000000..558f72d --- /dev/null +++ b/packages/zen-free/build.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:import.meta.dir});expect(built.exitCode).toBe(0) + 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.ts b/packages/zen-free/build.ts new file mode 100644 index 0000000..4bae3b0 --- /dev/null +++ b/packages/zen-free/build.ts @@ -0,0 +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")) +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/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 new file mode 100644 index 0000000..eed40e0 --- /dev/null +++ b/packages/zen-free/package.json @@ -0,0 +1,13 @@ +{ + "name": "@godwit/plugin-zen-free", + "version": "0.1.0", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { "build": "bun run build.ts", "test": "bun test", "typecheck": "tsc --noEmit" }, + "devDependencies": { + "@godwit/sdk": "^1.7.0", + "@types/bun": "1.4.3", + "typescript": "7.0.2" + } +} diff --git a/packages/zen-free/plugin.json b/packages/zen-free/plugin.json new file mode 100644 index 0000000..5829810 --- /dev/null +++ b/packages/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-Hans": "OpenCode Zen 免费模型。提示、上下文与工具会通过非官方接口发送到第三方服务。" + }, + "capabilities": ["models"], + "permissions": { "net": ["opencode.ai", "models.opencode.ai", "raw.githubusercontent.com"] }, + "config": [] +} diff --git a/packages/zen-free/src/index.ts b/packages/zen-free/src/index.ts new file mode 100644 index 0000000..969c4ac --- /dev/null +++ b/packages/zen-free/src/index.ts @@ -0,0 +1,18 @@ +import type { Ctx } from "@godwit/sdk" +import { discoverFreeModels, type HTTPModel } from "./models" +import { adaptZenRequest } from "./request" + +export default function(ctx: Ctx): void { + 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.test.ts b/packages/zen-free/src/models.test.ts new file mode 100644 index 0000000..cb02479 --- /dev/null +++ b/packages/zen-free/src/models.test.ts @@ -0,0 +1,27 @@ +import {describe,expect,test} from "bun:test" +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 | +| 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/packages/zen-free/src/models.ts b/packages/zen-free/src/models.ts new file mode 100644 index 0000000..e73096f --- /dev/null +++ b/packages/zen-free/src/models.ts @@ -0,0 +1,88 @@ +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" +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/packages/zen-free/src/request.test.ts b/packages/zen-free/src/request.test.ts new file mode 100644 index 0000000..bfcb0a5 --- /dev/null +++ b/packages/zen-free/src/request.test.ts @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..e4b62a5 --- /dev/null +++ b/packages/zen-free/src/request.ts @@ -0,0 +1,51 @@ +import type { ModelRequest } from "@godwit/sdk" +import { BASE, type ModelProtocol } from "./models" + +const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +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: ModelRequest, protocol: ModelProtocol): Pick { + if (!paths[protocol]) throw { code: "invalid_request", message: "Unsupported Zen protocol", retryable: false } + 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 ??= [] + 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 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), + } + if (protocol === "anthropic-message") { + headers["x-api-key"] = "public" + headers["anthropic-version"] = "2023-06-01" + 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: JSON.stringify(body) } +} diff --git a/packages/zen-free/tsconfig.json b/packages/zen-free/tsconfig.json new file mode 100644 index 0000000..e1cda17 --- /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", "../../scripts/**/*.ts", "*.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/scripts/release.ts b/scripts/release.ts new file mode 100644 index 0000000..e01c734 --- /dev/null +++ b/scripts/release.ts @@ -0,0 +1,33 @@ +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 (!/^[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: manifest.name}, + summary: manifest.description, + 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, + sha256: createHash("sha256").update(source).digest("hex"), + }}}], + } +} + +if (import.meta.main) { + 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") +}