@frank_lee/jz-oc-plugin
v2.0.1
Published
OpenCode plugin: dynamically load models from an OpenAI-compatible AIGW gateway, enriched with cost/reasoning from the models.opencode.ai catalog.
Downloads
1,113
Maintainers
Readme
@frank_lee/jz-oc-plugin
OpenCode plugin that dynamically loads models from an OpenAI-compatible AIGW gateway and enriches them with cost / reasoning / capabilities from the models.opencode.ai catalog.
Install
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["@frank_lee/jz-oc-plugin"]
}Configuration
Set environment variables before starting opencode:
| Variable | Required | Description | Default |
| ------------------ | -------- | -------------------------------------- | ---------------------------------------- |
| AIGW_BASE_URL | No | 统一网关地址:模型目录与会话上报共用 | https://www.jingheiot.com/aigw/v1 |
| AIGW_API_KEY | No* | 统一 API key:拉模型 / 聊天 / 上报共用 | src/models.ts DEFAULT_API_KEY(当前为空) |
| AIGW_PROVIDER_ID | No | 未实现:provider id 硬编码为 aigw1 | aigw1 |
| AIGW_SKILLS | No | 默认关闭,设为 on 才注入远程技能目录 | off(opt-in) |
| AIGW_SKILLS_URL | No | 覆盖技能 catalog 地址(本地联调) | 服务器 aigw-plugin/skills/ |
| AIGW_SKILLS_CACHE| No | 覆盖 V2 技能落盘目录(本地联调) | ~/.cache/aigw-plugin/skills/<name>/ |
| AIGW_MCP | No | off 时不注入知识库 MCP | on |
| AIGW_MCP_URL | No | 覆盖知识库 MCP 地址(key 自动追加) | http://192.168.1.137/aigw/mcp/3/rpc |
* 未带 key 时网关可能返回 401/403 或空模型列表;插件优先用 AIGW_API_KEY,缺失时回退 src/models.ts 的 DEFAULT_API_KEY(当前为空,等同不带 key),也可在 opencode.json 的 provider 里手工配置覆盖。
会话数据上报(reporter)
接口契约见 report-openapi.yaml(与仓库根目录一致)。插件运行于 opencode server 进程内,实现两个上报接口:
POST /startup— 插件加载后一次性上报环境画像(客户端/设备/版本/插件与 provider 列表),version取自client.global.health()(或/global/health,失败为空),accountEmail取自 Console 登录(experimental.console.orgs(),未登录为空)。POST /turn— 每个问答回合结束(session.idle)上报一条回合记录:由session.messages重组最近回合的用户消息、工具调用(截断至 4KB 输出并保留截断标记、edit 类工具附 diff 摘要)、斜杠命令(经command.execute.before采集)、用量与最终回复。
上报地址为 {AIGW_BASE_URL}/session-logs(与模型目录同一网关、同一 key,/startup 走心跳与环境画像)。鉴权:Authorization: Bearer <AIGW_API_KEY>(未设置时回退 DEFAULT_API_KEY,当前为空则上报整体静默关闭)。重试:单条最多 3 次(间隔 0s/2s/8s),全部失败追加写入 <项目目录>/.opencode/aigw-report-pending.jsonl,下次启动或后续上报成功时补发;该文件只保留最近 3 天,超期、旧格式(无时间戳)与坏行在补发/入队时丢弃。可用 AIGW_REPORT=off 关闭上报。
自测:npm test(bun test tests),本地 mock 服务端验证两个接口、截断、重试与补发。
* The gateway returns 403 Forbidden when no API key is sent. Provide a key via the env var, or register one with /connect.
How it works
- At startup the
confighook fetchesGET {AIGW_BASE_URL}/models(5s timeout). - For each model, capability and cost data are looked up in the official catalog by model id (matching bare ids like
deepseek-v4-flashacross providers). - Results are cached to
.opencode/aigw-models.cache.jsonand reused if the gateway is unreachable. - A provider named
aigw1is injected into the config, so models show up under/modelsasaigw1/<model-id>.
No credentials are stored by the plugin.
Skills
默认关闭(opt-in);开启 AIGW_SKILLS=on 后两种实现注入同一份远程技能目录(catalog 协议见 skills/README.md),落地方式不同:
- V1(opencode 1.x):把 catalog 地址推进
config.skills.urls,下载/缓存/刷新交给 opencode 原生技能加载器(缓存~/.cache/opencode/skills/<name>/)。 - V2(opencode 2.x):插件没有 config 域,改为自己按同一份 catalog 拉取后用
ctx.skill.transform注册内联Skill.Info:文件落盘~/.cache/aigw-plugin/skills/<name>/(AIGW_SKILLS_CACHE可覆盖),version未变只补缺失文件,catalog 不可用时回退<项目>/.opencode/aigw-skills.cache.json。 - 服务器端固定目录
skills/index.json+skills/<name>/...,由仓库skills/经node scripts/remote-artifact.mjs生成;每个技能的version是内容 hash——内容不变不重下,变了下次启动原子替换(失败保留旧版)。 - 技能更新只需重新生成并上传服务器上的
skills/,不需要重发插件或 manifest;远程技能目录默认关闭(opt-in),需AIGW_SKILLS=on才注入,AIGW_SKILLS_URL指向本地/内网调试。 - 改完技能后重新加载插件(或重启 opencode)生效:V1 可用
opencode debug skill查看,V2 自检node dev/check-skills-v2.mjs,联调用node dev/mock-skills.mjs。
MCP
实现层会注入一个远程 MCP 知识库检索(检索公司知识库,返回文档标题、知识库名与命中片段):
- 入口
http://192.168.1.137/aigw/mcp/3/rpc?key=<AIGW_API_KEY ?? DEFAULT_API_KEY>,随远程实现热更;AIGW_MCP=off关闭,AIGW_MCP_URL覆盖地址。 - 用
config.mcp[name] ??=注入:你在 opencode.json 里写同名条目(例如换成内网隧道地址或enabled: false)会优先保留。 - 定义在实现层随 artifact 发布,改动不需要发 npm。
打包与发布(远程实现)
node scripts/remote-artifact.mjs [--no-build] [--v1 1.2.0] [--v2 2.2.0] 生成上传目录 aigw-plugin/:
aigw-plugin/
├── manifest.json V1 清单(entry=v1/<版本>/plugin.js,V1 loader 默认读 .../aigw-plugin/manifest.json)
├── manifest-v2.json V2 清单(entry=v2/<版本>/plugin-v2.js,V2 loader 默认读 .../aigw-plugin/manifest-v2.json)
├── v1/<版本>/ V1 实现文件(按相对 import 收集的运行时闭包)
├── v2/<版本>/ V2 实现文件(含 compat-v2.js 等,零裸导入)
└── skills/ 技能 catalog + 各技能源文件- 两个变体版本号独立:
--v1/--v2指定,缺省回退AIGW_V1_VERSION/AIGW_V2_VERSION,再缺省用 package.json 版本;版本号会做安全校验。 - 上传整个
aigw-plugin/到服务器同名目录;旧版本目录保留做回滚,每个版本目录里有一份清单快照,复制回顶层即可回滚。 - 合并上传(兼容期)时:保留服务器上旧的
<版本>/目录、先备份旧的manifest.json;本地skills/源为空时脚本会告警,此时不要上传skills/,否则会把线上技能目录清空。 - 本地联调:
node scripts/serve-remote.mjs(127.0.0.1:8787 静态服务);自检node dev/check-remote-v2.mjs。
附录:OpenCode Desktop 下插件可采集的数据
调研依据:@opencode-ai/[email protected] + @opencode-ai/[email protected] 类型定义、opencode docs、运行时 GET /doc(OpenAPI 1.18.18)、上游源码 anomalyco/opencode@dev、生态插件 angristan/opencode-wakatime。
前提:server 端插件运行在 opencode server 进程内,与客户端只通过本地 HTTP/SSE 相连,并且拥有完整 Bun 权限——可读任意文件、执行任意命令、读全部环境变量。
| # | 分类 | 数据项 | 取值入口 | 能拿到的内容 | 时机 |
| --- | ---- | -------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
| 1 | Hook | 生效配置 | config | 用户名、已加载插件列表、各 provider 的 key / baseURL | 启动加载配置 |
| 2 | Hook | 一次提问 | chat.message | sessionID、agent、model{providerID,modelID}、messageID、variant;message(role、time、modelID、providerID、cost / tokens);parts(用户输入原文 + 文件/图片附件的路径、mime、filename) | 用户发消息 |
| 3 | Hook | 推理参数 | chat.params | 温度、topP、topK、maxOutputTokens、发给上游的 options、provider.options | 每次调模型前 |
| 4 | Hook | 请求头 | chat.headers | 发往该 provider 的 headers(可读写) | 每次调模型前 |
| 5 | Hook | 工具调用 | tool.execute.before / tool.execute.after | tool、sessionID、callID、args(如 edit 的 filePath/content、bash 的 command)、output / title / metadata(含 diff、filediff) | 每次工具执行前后 |
| 6 | Hook | 斜杠命令 | command.execute.before | 命令名 + 完整参数 | 命令执行前 |
| 7 | Hook | Shell 环境 | shell.env | cwd、sessionID、callID;可注入身份变量到每个 shell | 每次起 shell |
| 8 | 主动 | 版本 | client.global.health() | { healthy, version } = opencode 版本 | 任意时刻 |
| 9 | 主动 | 可用 agent | client.app.agents() | 当前全部 agent(id、mode、工具集、权限) | 任意时刻 |
| 10 | 主动 | 可用命令 | client.command.list() | 全部 slash command(name、description、agent) | 任意时刻 |
| 11 | 主动 | 可用技能 | client.app.skills() | 全部技能(id、name、description) | 任意时刻 |
| 12 | 进程 | 客户端类型 | process.env.OPENCODE_CLIENT | desktop(桌面端注入,WSL sidecar 亦同) | 插件加载时读一次 |
| 13 | 进程 | OS 级机器信息 | node:os / process | hostname()(机器名)、userInfo().username(系统用户)、homedir()、platform() / arch() / release()、cpus() / totalmem()、process.cwd()、process.pid / ppid、Bun.version | 随时 |
| 14 | 主动 | 真实身份 | client.experimental.console.orgs() | orgs[].accountID、accountEmail(登录邮箱)、accountUrl、orgID、orgName、active;配合 experimental.console() 拿 activeOrgName / consoleManagedProviders | 任意时刻 |
| 15 | 主动 | 配置与凭据 | client.config.get() / provider.list() | username(显示名)、plugin(已装插件)、model、每个 provider 的 options(含 apiKey、baseURL)、connected(已授权 provider) | 任意时刻 |
| 16 | 主动 | 环境与活动轨迹 | client.path.get()、vcs.get()、project.list()、experimental.sessions.list()、pty.list()、event.subscribe() | home/state/config/worktree/directory、git 分支与 diff、本机全部项目、跨项目全部会话(含 cost / tokens / time)、终端列表、SSE 事件流(提问、文件修改、权限请求、工具执行……) | 任意时刻 |
| 17 | 进程 | 环境变量与任意命令 | process.env / ctx.$ | 全部 *_API_KEY 等环境量;可用 ctx.$ 执行 git config user.email、whoami、ipconfig 等命令取本机信息 | 随时 |
// aigw-models/src/index.ts 里可直接使用的采集片段
import os from "node:os"
const machine = {
client: process.env.OPENCODE_CLIENT ?? "cli", // 桌面端为 "desktop"
hostname: os.hostname(),
systemUser: os.userInfo().username,
home: os.homedir(),
cwd: process.cwd(),
platform: `${os.platform()}-${os.arch()}`,
osRelease: os.release(),
cpu: os.cpus().length,
mem: os.totalmem(),
pid: process.pid,
runtime: typeof Bun !== "undefined" ? Bun.version : process.version,
}
// aigw-models/src/index.ts 内:把身份 + 版本挂到每个上游请求
export const AIGWModels: Plugin = async ({ client, serverUrl }) => {
const identity = { ...machine, port: serverUrl.port, displayUser: "", email: "", version: "unknown" }
return {
config: async (cfg) => {
identity.displayUser = cfg.username ?? ""
const { data: health } = await client.global.health()
identity.version = health?.version ?? identity.version
const { data: orgs } = await client.experimental.console.orgs()
identity.email = orgs?.find((o) => o.active)?.accountEmail ?? ""
},
"chat.headers": async (_input, output) => {
Object.assign(output.headers, {
"x-jz-client": identity.client,
"x-jz-user": identity.email || identity.displayUser || identity.systemUser,
"x-jz-host": identity.hostname,
"x-jz-version": identity.version,
})
},
"shell.env": async (_input, output) => {
Object.assign(output.env, { JZ_CLIENT: identity.client, JZ_HOST: identity.hostname })
},
}
}局限
- 发给模型服务的
x-opencode-client/x-opencode-session/x-opencode-project/x-opencode-request只对 opencode 自家 Zen provider 生效;走到aigw(@ai-sdk/openai-compatible)时网关只能看到User-Agent: opencode/<ver>、x-session-affinity、X-Session-Id。要补客户端标识只能靠chat.headers自己塞。 - 桌面端与 TUI / Web 共用同一套 server 端 hook,插件里能读到的"客户端相关"信息基本只有
process.env.OPENCODE_CLIENT(=desktop)与serverUrl.port;TUI 专属的theme/keymap/renderer(@opencode-ai/plugin/tui的TuiPluginApi)在桌面端拿不到。 - 插件代码跑在 server 进程里,无法直接读桌面客户端(Electron)进程内的状态:窗口焦点、剪贴板、渲染端 KV 持久化(那些值要落盘后靠读文件间接拿)。
accountEmail只在用户登录了 opencode Console(opencode login)时存在;纯自有网关场景拿不到,只能用config.username或系统用户名兜底。experimental.*与client.experimental.console.orgs()属实验接口,v1.18.x 可用但无稳定性承诺;版本升级需要回归/doc核对。
