@weiping/magpie-cf-jev 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -2
- package/index.mjs +188 -0
- package/package.json +26 -4
package/README.md
CHANGED
|
@@ -1,3 +1,86 @@
|
|
|
1
|
-
#
|
|
1
|
+
# magpie-cf-jev
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Cloudflare Workers AI 的 Jev / Clef 作为 **magpie 决策模型供应商**(provider id `cf-jev`)的一个插件。
|
|
4
|
+
|
|
5
|
+
它存在的唯一理由:**绕过 magpie 上游的一个信封 bug**。
|
|
6
|
+
|
|
7
|
+
## 问题
|
|
8
|
+
|
|
9
|
+
Workers AI 跑 `typesafe/jev` 时,回答套了两层信封:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{"result": {"state":"Completed", "result": {"model":"jev-1.13.0","answers":{…},"usage":{…}}}, "success": true, …}
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
magpie 的 `provider.DecideAnswer`(`internal/provider/decide_wire.go`)只拆**一层** `result`,于是交给调用方的是 `{"state":"Completed","result":{…}}` —— 顶层没有 `answers`。后果:
|
|
16
|
+
|
|
17
|
+
- magpie 自己的分类器(`readJev`)读不到答案,`not a System One answer`;
|
|
18
|
+
- `/v1/systemone` 的客户端(例如 mbridge 的分类器)读不到 `answers`,落回默认档。
|
|
19
|
+
|
|
20
|
+
Clef 只有一层信封,所以只测 Clef 时看不出来。
|
|
21
|
+
|
|
22
|
+
## 这插件怎么修的
|
|
23
|
+
|
|
24
|
+
magpie 的插件可以声明自己是决策供应商:`config` 钩子里 `decide: true`,模型的 `decides: true`,于是 magpie 把该 provider 的 `Decide` 指向 `plugin://cf-jev/v1`,所有 System One 请求都过插件的 `fetch`。
|
|
25
|
+
|
|
26
|
+
插件在这个 `fetch` 里调 Cloudflare,**把两层信封拆干净**,只把 `{"model":…,"answers":…,"usage":…}` 交回 magpie。magpie 对 System One 路径不再拆信封(`DecideAnswer` 原样返回),所以答案带着顶层 `answers` 到达 magpie 自己和它的所有客户端。
|
|
27
|
+
|
|
28
|
+
实测(本机,2026-10-25):
|
|
29
|
+
|
|
30
|
+
| 模型 | 插件直连 | 经 magpie `/v1/systemone` |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| `cf-jev/typesafe/jev` | 200 · 1.0s | 200 · 1.2s · `keys=[model,answers,usage]` |
|
|
33
|
+
| `cf-jev/@cf/cloudflare/clef` | 200 · 0.5s | 200 · 0.5s |
|
|
34
|
+
| `cf-jev/@cf/cloudflare/clef-flash` | 200 · 0.4s | 200 · 0.5s |
|
|
35
|
+
|
|
36
|
+
## 用法
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
# 1. 装插件(本地目录,不走 npm)
|
|
40
|
+
magpie plugin add ~/workspace/dev/cf-jev-plugin
|
|
41
|
+
|
|
42
|
+
# 2. 登录,粘贴 Cloudflare API token
|
|
43
|
+
# 只做 Workers AI 的 token 可能列不出账号,那就按 "<account id>:<token>" 粘
|
|
44
|
+
magpie plugin login cf-jev
|
|
45
|
+
|
|
46
|
+
# 3. 确认三个决策模型都在,且 decide 探针通过
|
|
47
|
+
magpie provider models cf-jev
|
|
48
|
+
magpie provider test cf-jev # ✓ decide … typesafe/jev
|
|
49
|
+
|
|
50
|
+
# 4. 把分类组指到插件(order:快的在前)
|
|
51
|
+
magpie group set mb-classifier \
|
|
52
|
+
models=cf-jev/typesafe/jev,cf-jev/@cf/cloudflare/clef,cf-jev/@cf/cloudflare/clef-flash,typesafe/jev-latest \
|
|
53
|
+
routing=order
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
> `magpie group mb-classifier` 对决策组总是显示 `not served now, skipped` / `no member ready` —— 那是 magpie CLI 的显示假象(它拿「给 agent 的模型目录」查成员,而决策模型本来就不在里面),不代表插件不能用。真要看可用性:`magpie provider test cf-jev` 或直接发一个 `/v1/systemone`。
|
|
57
|
+
|
|
58
|
+
## 回滚
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
magpie group set mb-classifier \
|
|
62
|
+
models=cloudflare-jev/typesafe/jev,cloudflare-jev/@cf/cloudflare/clef,cloudflare-jev/@cf/cloudflare/clef-flash,typesafe/jev-latest \
|
|
63
|
+
routing=order
|
|
64
|
+
magpie plugin rm cf-jev # 或 magpie plugin off cf-jev
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 什么时候不再需要它
|
|
68
|
+
|
|
69
|
+
上游 `yetone/magpie` 把 `DecideAnswer` 改成「还剩一层 `result` 就继续拆」之后:
|
|
70
|
+
|
|
71
|
+
- magpie 自带的 `cloudflare-jev` 供应商会直接可用,这个插件就可以删掉;
|
|
72
|
+
- 补丁已经写好并验证过(`TestCloudflareAnswerPeelsTheEnvelopes` 在旧代码上红、新代码上绿),
|
|
73
|
+
只是还没有 PR 权限能推上去。
|
|
74
|
+
|
|
75
|
+
## 文件
|
|
76
|
+
|
|
77
|
+
| 文件 | 作用 |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `index.mjs` | 插件本体:`config` 声明决策供应商,`auth.loader` 返回自带 `fetch` 的实现,`peel()` 拆信封 |
|
|
80
|
+
| `smoke.mjs` | 不经 magpie、直接按 host 的方式调 `fetch` 的自检(token 从 magpie 的 `providers.json` 读,不落盘) |
|
|
81
|
+
| `peel.test.mjs` | `peel()` 的单元测试:两层信封、一层信封、错误体、已经扁平的答案 |
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
node --test peel.test.mjs # 单元测试
|
|
85
|
+
node smoke.mjs # 打真实 Cloudflare
|
|
86
|
+
```
|
package/index.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// cf-jev: Cloudflare Workers AI as a magpie decision provider.
|
|
2
|
+
//
|
|
3
|
+
// magpie can serve a plugin's decision API (its config hook's `decide`, a
|
|
4
|
+
// model's `decides`), asked at the provider's own base /systemone through
|
|
5
|
+
// the plugin's fetch. That fetch is where this plugin belongs: Workers AI
|
|
6
|
+
// answers a Jev in two envelopes — Cloudflare's own {"result": …} and the
|
|
7
|
+
// Jev's {"state": …,"result": …} — and magpie unwraps one of them
|
|
8
|
+
// (provider.DecideAnswer), so a Jev asked directly comes back with no
|
|
9
|
+
// answers at the top and every System One client refuses it (magpie's own
|
|
10
|
+
// classifier included). Here the envelopes are peeled before the answer
|
|
11
|
+
// leaves the plugin, so magpie and its clients see the System One answer
|
|
12
|
+
// as TypeSafe's own API and a Clef give it: `answers` at the top.
|
|
13
|
+
//
|
|
14
|
+
// The upstream fix (peel while another envelope is there) has been sent
|
|
15
|
+
// to yetone/magpie; until it ships, this plugin is the way to route a
|
|
16
|
+
// group's classifier through Workers AI.
|
|
17
|
+
|
|
18
|
+
const PROVIDER = "cf-jev";
|
|
19
|
+
const API = "https://api.cloudflare.com/client/v4";
|
|
20
|
+
|
|
21
|
+
// MODELS are the decision models Workers AI serves, and how each is asked:
|
|
22
|
+
// a Jev is a run of typesafe/jev whose question is the run's input, while
|
|
23
|
+
// a Clef is asked as it is, at its own path, naming itself.
|
|
24
|
+
const MODELS = [
|
|
25
|
+
{ id: "typesafe/jev", name: "Jev (Workers AI)", clef: "" },
|
|
26
|
+
{ id: "@cf/cloudflare/clef", name: "Clef", clef: "clef" },
|
|
27
|
+
{ id: "@cf/cloudflare/clef-flash", name: "Clef Flash", clef: "clef-flash" },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
// modelOf is the model a System One question names. A probe asks with no
|
|
31
|
+
// model, or with the Jev's own name (jev-latest, jev-preview, as
|
|
32
|
+
// TypeSafe's API calls it): that is the Jev Workers AI runs.
|
|
33
|
+
const modelOf = (id) => {
|
|
34
|
+
const said = String(id ?? "").trim();
|
|
35
|
+
const hit = MODELS.find((m) => m.id === said);
|
|
36
|
+
if (hit) return hit;
|
|
37
|
+
return /^(|(typesafe\/)?jev(-latest|-preview)?)$/.test(said) ? MODELS[0] : undefined;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// peel is an answer out of whatever envelopes it came in: Cloudflare's own
|
|
41
|
+
// {"result": …}, the Jev's {"state": …,"result": …}, or neither. It stops
|
|
42
|
+
// as soon as the body has answers of its own, so a failure's errors and an
|
|
43
|
+
// already-flat answer are left as they came.
|
|
44
|
+
export function peel(body) {
|
|
45
|
+
let cur = body;
|
|
46
|
+
for (let i = 0; i < 4; i++) {
|
|
47
|
+
if (!cur || typeof cur !== "object") return cur;
|
|
48
|
+
if (cur.answers) return cur;
|
|
49
|
+
if (!cur.result || typeof cur.result !== "object") return cur;
|
|
50
|
+
cur = cur.result;
|
|
51
|
+
}
|
|
52
|
+
return cur;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// account is the Cloudflare account the token works in: the one the key
|
|
56
|
+
// names (a key pasted as "<account id>:<token>" says so), else the first
|
|
57
|
+
// the token can list, as magpie's built-in provider looks it up.
|
|
58
|
+
const accounts = new Map();
|
|
59
|
+
async function account(token, named) {
|
|
60
|
+
if (named) return named;
|
|
61
|
+
if (accounts.has(token)) return accounts.get(token);
|
|
62
|
+
const res = await fetch(`${API}/accounts`, {
|
|
63
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
64
|
+
});
|
|
65
|
+
const body = await res.json().catch(() => null);
|
|
66
|
+
if (!res.ok || !Array.isArray(body?.result) || !body.result[0]?.id) {
|
|
67
|
+
const why = body?.errors?.[0]?.message ?? `${res.status} ${res.statusText}`;
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Cloudflare listed no account for this token: ${why}. ` +
|
|
70
|
+
`A token made for Workers AI alone may not list accounts — sign in with "<account id>:<token>" then.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
accounts.set(token, body.result[0].id);
|
|
74
|
+
return body.result[0].id;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ask posts a System One question to Workers AI the way it takes it, and
|
|
78
|
+
// gives back the answer as System One gives it: answers at the top.
|
|
79
|
+
export async function ask({ token, named, model, question, signal }) {
|
|
80
|
+
// magpie probes a decision API while it lists the provider, before anyone
|
|
81
|
+
// has signed in: told so plainly rather than as a failed account lookup
|
|
82
|
+
if (!token) return { status: 401, body: "cf-jev is not signed in: magpie plugin login cf-jev" };
|
|
83
|
+
const m = modelOf(model);
|
|
84
|
+
if (!m) throw new Error(`${PROVIDER} does not serve ${model}`); const acct = await account(token, named);
|
|
85
|
+
const url = m.clef
|
|
86
|
+
? `${API}/accounts/${acct}/ai/run/${m.id}`
|
|
87
|
+
: `${API}/accounts/${acct}/ai/run`;
|
|
88
|
+
const body = m.clef
|
|
89
|
+
? { model: m.clef, state: question.state, questions: question.questions }
|
|
90
|
+
: { model: m.id, input: { state: question.state, questions: question.questions } };
|
|
91
|
+
const res = await fetch(url, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
94
|
+
body: JSON.stringify(body),
|
|
95
|
+
signal,
|
|
96
|
+
});
|
|
97
|
+
const raw = await res.text();
|
|
98
|
+
let parsed;
|
|
99
|
+
try {
|
|
100
|
+
parsed = JSON.parse(raw);
|
|
101
|
+
} catch {
|
|
102
|
+
return { status: 502, body: raw || `${res.status} ${res.statusText}` };
|
|
103
|
+
}
|
|
104
|
+
if (!res.ok || parsed.success === false) {
|
|
105
|
+
const why =
|
|
106
|
+
parsed?.errors?.map?.((e) => e?.message).filter(Boolean).join("; ") ||
|
|
107
|
+
`${res.status} ${res.statusText}`;
|
|
108
|
+
return { status: res.ok ? 502 : res.status, body: why };
|
|
109
|
+
}
|
|
110
|
+
return { status: 200, body: JSON.stringify(peel(parsed)) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function CfJevPlugin() {
|
|
114
|
+
return {
|
|
115
|
+
config: async (cfg) => {
|
|
116
|
+
cfg.provider ??= {};
|
|
117
|
+
const was = cfg.provider[PROVIDER] ?? {};
|
|
118
|
+
cfg.provider[PROVIDER] = {
|
|
119
|
+
name: "Cloudflare Workers AI · Jev (cf-jev)",
|
|
120
|
+
...was,
|
|
121
|
+
// magpie's own field: this provider serves a decision API
|
|
122
|
+
// (System One), asked at its base /systemone through this fetch
|
|
123
|
+
decide: true,
|
|
124
|
+
models: {
|
|
125
|
+
...Object.fromEntries(MODELS.map((m) => [m.id, { name: m.name, decides: true }])),
|
|
126
|
+
...(was.models ?? {}),
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
auth: {
|
|
131
|
+
provider: PROVIDER,
|
|
132
|
+
methods: [
|
|
133
|
+
{
|
|
134
|
+
type: "api",
|
|
135
|
+
label: "Cloudflare API token (Workers AI)",
|
|
136
|
+
placeholder: "token, or <account id>:<token>",
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
async loader(getAuth) {
|
|
140
|
+
const auth = (await getAuth()) ?? {};
|
|
141
|
+
const raw = String(auth.key ?? auth.apiKey ?? "").trim();
|
|
142
|
+
// a key pasted as "<account id>:<token>" names the account itself,
|
|
143
|
+
// which a token made for Workers AI alone can't be asked for
|
|
144
|
+
const cut = raw.indexOf(":");
|
|
145
|
+
const named = cut > 0 ? raw.slice(0, cut).trim() : "";
|
|
146
|
+
const token = cut > 0 ? raw.slice(cut + 1).trim() : raw;
|
|
147
|
+
return {
|
|
148
|
+
apiKey: token,
|
|
149
|
+
// where magpie thinks the provider's requests go: this fetch is
|
|
150
|
+
// what answers them, so it is told a Cloudflare-shaped base and
|
|
151
|
+
// never asked to reach it
|
|
152
|
+
baseURL: API,
|
|
153
|
+
async fetch(input, init) {
|
|
154
|
+
const url = typeof input === "string" ? input : (input?.url ?? String(input));
|
|
155
|
+
const path = (() => {
|
|
156
|
+
try {
|
|
157
|
+
return new URL(url).pathname;
|
|
158
|
+
} catch {
|
|
159
|
+
return url;
|
|
160
|
+
}
|
|
161
|
+
})();
|
|
162
|
+
if (!path.endsWith("/systemone")) {
|
|
163
|
+
return new Response(`cf-jev serves System One only: ${path}`, { status: 404 });
|
|
164
|
+
}
|
|
165
|
+
let question;
|
|
166
|
+
try {
|
|
167
|
+
question = JSON.parse(String(init?.body ?? "{}"));
|
|
168
|
+
} catch {
|
|
169
|
+
return new Response("cf-jev got no System One question", { status: 400 });
|
|
170
|
+
}
|
|
171
|
+
const out = await ask({
|
|
172
|
+
token,
|
|
173
|
+
named,
|
|
174
|
+
model: question.model,
|
|
175
|
+
question,
|
|
176
|
+
signal: init?.signal,
|
|
177
|
+
});
|
|
178
|
+
if (out.status !== 200) return new Response(out.body, { status: out.status });
|
|
179
|
+
return new Response(out.body, {
|
|
180
|
+
status: 200,
|
|
181
|
+
headers: { "Content-Type": "application/json" },
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weiping/magpie-cf-jev",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cloudflare Workers AI (Jev and Clef) as a magpie decision provider, with the answers read out of Cloudflare's envelope",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.mjs",
|
|
7
|
+
"exports": "./index.mjs",
|
|
8
|
+
"files": ["index.mjs", "README.md"],
|
|
9
|
+
"keywords": [
|
|
10
|
+
"magpie",
|
|
11
|
+
"magpie-plugin",
|
|
12
|
+
"opencode",
|
|
13
|
+
"opencode-plugin",
|
|
14
|
+
"cloudflare",
|
|
15
|
+
"workers-ai",
|
|
16
|
+
"jev",
|
|
17
|
+
"clef",
|
|
18
|
+
"system-one"
|
|
19
|
+
],
|
|
20
|
+
"author": "weiping",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test peel.test.mjs"
|
|
27
|
+
}
|
|
28
|
+
}
|