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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions .github/workflows/plugins.yml
Original file line number Diff line number Diff line change
@@ -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
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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/<name>/` 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/<name>/package.json` and `plugin.json` together and merge to main.
3. Create and push `<name>-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`, `<name>.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 `<name>-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:
Expand Down
2 changes: 2 additions & 0 deletions packages/zen-free/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist/
node_modules/
19 changes: 19 additions & 0 deletions packages/zen-free/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Zen Free for Godwit

在 Godwit 中使用 OpenCode Zen 的免费模型,无需 API Key。

## 使用

插件尚未发布。发布并收录后:

1. 打开 Godwit「设置 → 扩展」,在插件目录中安装 Zen Free。
2. 启用插件,允许它声明的网络权限。
3. 打开「设置 → 模型服务」,选择 Zen Free 的模型。

可用模型由上游免费目录决定,无需手动配置接口或协议。需要支持模型插件的 Godwit 版本。

## 数据与可用性

使用模型时,提示、邮件上下文、工具定义及工具结果会发送到 OpenCode 及其模型服务商,请阅读上游的数据使用条款。

插件依赖非官方公共接口。免费模型、限流和免费期限可能变化,不保证配额或持续可用。
9 changes: 9 additions & 0 deletions packages/zen-free/build.test.ts
Original file line number Diff line number Diff line change
@@ -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)
7 changes: 7 additions & 0 deletions packages/zen-free/build.ts
Original file line number Diff line number Diff line change
@@ -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")
104 changes: 104 additions & 0 deletions packages/zen-free/build_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
13 changes: 13 additions & 0 deletions packages/zen-free/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
12 changes: 12 additions & 0 deletions packages/zen-free/plugin.json
Original file line number Diff line number Diff line change
@@ -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": []
}
18 changes: 18 additions & 0 deletions packages/zen-free/src/index.ts
Original file line number Diff line number Diff line change
@@ -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)
},
})
}
Loading
Loading