@weiping/magpie-cf-jev 0.1.1 → 0.2.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 +19 -5
- package/account.test.mjs +40 -0
- package/index.mjs +30 -8
- package/package.json +5 -3
- package/peel.test.mjs +65 -0
package/README.md
CHANGED
|
@@ -39,8 +39,9 @@ magpie 的插件可以声明自己是决策供应商:`config` 钩子里 `decid
|
|
|
39
39
|
# 1. 装插件(npm 上发布的是 @weiping/magpie-cf-jev)
|
|
40
40
|
magpie plugin add @weiping/magpie-cf-jev@latest
|
|
41
41
|
|
|
42
|
-
# 2.
|
|
43
|
-
#
|
|
42
|
+
# 2. 登录:先问 Account ID,再粘 Cloudflare API token
|
|
43
|
+
# Account ID 是控制台里那 32 位十六进制;留空也行
|
|
44
|
+
# (token 若能列账号,插件会自己去查)
|
|
44
45
|
magpie plugin login cf-jev
|
|
45
46
|
|
|
46
47
|
# 3. 确认三个决策模型都在,且 decide 探针通过
|
|
@@ -55,6 +56,18 @@ magpie group set mb-classifier \
|
|
|
55
56
|
|
|
56
57
|
> `magpie group mb-classifier` 对决策组总是显示 `not served now, skipped` / `no member ready` —— 那是 magpie CLI 的显示假象(它拿「给 agent 的模型目录」查成员,而决策模型本来就不在里面),不代表插件不能用。真要看可用性:`magpie provider test cf-jev` 或直接发一个 `/v1/systemone`。
|
|
57
58
|
|
|
59
|
+
### Account ID 从哪来
|
|
60
|
+
|
|
61
|
+
Worker AI 的运行地址是 `…/accounts/<account id>/ai/run`,account id 是 Cloudflare 控制台
|
|
62
|
+
里那 32 位十六进制。插件登录时会**单独问一次**(magpie 的插件提问机制,答案存进该账户
|
|
63
|
+
的 metadata):
|
|
64
|
+
|
|
65
|
+
- token 有列账号的权限 → Account ID 留空,插件用 token 查第一个账号;
|
|
66
|
+
- token 只给 Workers AI(列不了账号)→ 必须填,否则跑不起来。
|
|
67
|
+
|
|
68
|
+
也仍然支持把两者粘在一起:登录时用 `<account id>:<token>` 的写法(0.1.1 就是这么登的,
|
|
69
|
+
登录信息还在就一直有效)。两种写法都以登录时回答的 Account ID 为准。
|
|
70
|
+
|
|
58
71
|
## 回滚
|
|
59
72
|
|
|
60
73
|
```sh
|
|
@@ -76,13 +89,14 @@ magpie plugin rm cf-jev # 或 magpie plugin off cf-jev
|
|
|
76
89
|
|
|
77
90
|
| 文件 | 作用 |
|
|
78
91
|
| --- | --- |
|
|
79
|
-
| `index.mjs` | 插件本体:`config` 声明决策供应商,`auth
|
|
92
|
+
| `index.mjs` | 插件本体:`config` 声明决策供应商,`auth` 的登录方法带 Account ID 提问,`loader` 返回自带 `fetch` 的实现,`peel()` 拆信封,`accountFor()` 解析账号 |
|
|
80
93
|
| `smoke.mjs` | 不经 magpie、直接按 host 的方式调 `fetch` 的自检(token 从 magpie 的 `providers.json` 读,不落盘) |
|
|
81
94
|
| `peel.test.mjs` | `peel()` 的单元测试:两层信封、一层信封、错误体、已经扁平的答案 |
|
|
95
|
+
| `account.test.mjs` | `accountFor()` 的单元测试:登录回答的 ID、key 里的 `<id>:<token>`、两者冲突、空值 |
|
|
82
96
|
|
|
83
97
|
```sh
|
|
84
|
-
node --test peel.test.mjs
|
|
85
|
-
node smoke.mjs
|
|
98
|
+
node --test peel.test.mjs account.test.mjs # 单元测试
|
|
99
|
+
node smoke.mjs # 打真实 Cloudflare
|
|
86
100
|
```
|
|
87
101
|
|
|
88
102
|
## 发布
|
package/account.test.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// account.test.mjs: the account a key works in. Run: node --test account.test.mjs
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { accountFor } from "./index.mjs";
|
|
5
|
+
|
|
6
|
+
test("the account id the sign-in answered names the account", () => {
|
|
7
|
+
const a = accountFor({ key: "tok", metadata: { accountId: "f14d88531a144167d91b1be6fb789b3f" } });
|
|
8
|
+
assert.equal(a.token, "tok");
|
|
9
|
+
assert.equal(a.named, "f14d88531a144167d91b1be6fb789b3f");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("a key pasted as '<account id>:<token>' names it too", () => {
|
|
13
|
+
const a = accountFor({ key: "f14d88531a144167d91b1be6fb789b3f:tok" });
|
|
14
|
+
assert.equal(a.token, "tok");
|
|
15
|
+
assert.equal(a.named, "f14d88531a144167d91b1be6fb789b3f");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("the answer wins over the key's own account id", () => {
|
|
19
|
+
const a = accountFor({ key: "old:tok", metadata: { accountId: "new" } });
|
|
20
|
+
assert.equal(a.token, "tok");
|
|
21
|
+
assert.equal(a.named, "new");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("a token alone names no account: the token is asked for it", () => {
|
|
25
|
+
const a = accountFor({ key: "tok" });
|
|
26
|
+
assert.equal(a.token, "tok");
|
|
27
|
+
assert.equal(a.named, "");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("an empty answer leaves the token's own account id", () => {
|
|
31
|
+
const a = accountFor({ key: "old:tok", metadata: { accountId: "" } });
|
|
32
|
+
assert.equal(a.named, "old");
|
|
33
|
+
assert.equal(a.token, "tok");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("no sign-in at all is no token and no account", () => {
|
|
37
|
+
const a = accountFor(undefined);
|
|
38
|
+
assert.equal(a.token, "");
|
|
39
|
+
assert.equal(a.named, "");
|
|
40
|
+
});
|
package/index.mjs
CHANGED
|
@@ -110,6 +110,18 @@ export async function ask({ token, named, model, question, signal }) {
|
|
|
110
110
|
return { status: 200, body: JSON.stringify(peel(parsed)) };
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// accountFor is the account a key works in, and the key itself: the
|
|
114
|
+
// account named in the sign-in's own answer (the prompt magpie asks and
|
|
115
|
+
// keeps), else the one a key pasted as "<account id>:<token>" names, else
|
|
116
|
+
// none — which asks the token for the account it can list.
|
|
117
|
+
export function accountFor(auth) {
|
|
118
|
+
const raw = String(auth?.key ?? auth?.apiKey ?? "").trim();
|
|
119
|
+
const named = String(auth?.metadata?.accountId ?? "").trim();
|
|
120
|
+
const cut = raw.indexOf(":");
|
|
121
|
+
if (named) return { token: cut > 0 ? raw.slice(cut + 1).trim() : raw, named };
|
|
122
|
+
return { token: cut > 0 ? raw.slice(cut + 1).trim() : raw, named: cut > 0 ? raw.slice(0, cut).trim() : "" };
|
|
123
|
+
}
|
|
124
|
+
|
|
113
125
|
export async function CfJevPlugin() {
|
|
114
126
|
return {
|
|
115
127
|
config: async (cfg) => {
|
|
@@ -133,17 +145,27 @@ export async function CfJevPlugin() {
|
|
|
133
145
|
{
|
|
134
146
|
type: "api",
|
|
135
147
|
label: "Cloudflare API token (Workers AI)",
|
|
136
|
-
placeholder: "token
|
|
148
|
+
placeholder: "token",
|
|
149
|
+
// the account the token works in, asked on its own: a token made
|
|
150
|
+
// for Workers AI alone cannot list the accounts, and the run
|
|
151
|
+
// needs the account's id. Left empty, the token is asked for it.
|
|
152
|
+
prompts: [
|
|
153
|
+
{
|
|
154
|
+
type: "text",
|
|
155
|
+
key: "accountId",
|
|
156
|
+
message: "Your Cloudflare account ID (the 32 hex digits in the dashboard; empty if the token can list accounts)",
|
|
157
|
+
placeholder: "f14d88531a144167d91b1be6fb789b3f",
|
|
158
|
+
validate: (v) => {
|
|
159
|
+
const s = String(v ?? "").trim();
|
|
160
|
+
if (!s || /^[0-9a-f]{32}$/i.test(s)) return undefined;
|
|
161
|
+
return "An account ID is 32 hexadecimal digits (or empty)";
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
],
|
|
137
165
|
},
|
|
138
166
|
],
|
|
139
167
|
async loader(getAuth) {
|
|
140
|
-
const
|
|
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;
|
|
168
|
+
const { token, named } = accountFor((await getAuth()) ?? {});
|
|
147
169
|
return {
|
|
148
170
|
apiKey: token,
|
|
149
171
|
// where magpie thinks the provider's requests go: this fetch is
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weiping/magpie-cf-jev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Cloudflare Workers AI (Jev and Clef) as a magpie decision provider, with the answers read out of Cloudflare's envelope",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
7
7
|
"exports": "./index.mjs",
|
|
8
8
|
"files": [
|
|
9
9
|
"index.mjs",
|
|
10
|
-
"README.md"
|
|
10
|
+
"README.md",
|
|
11
|
+
"peel.test.mjs",
|
|
12
|
+
"account.test.mjs"
|
|
11
13
|
],
|
|
12
14
|
"keywords": [
|
|
13
15
|
"magpie",
|
|
@@ -30,6 +32,6 @@
|
|
|
30
32
|
"access": "public"
|
|
31
33
|
},
|
|
32
34
|
"scripts": {
|
|
33
|
-
"test": "node --test peel.test.mjs"
|
|
35
|
+
"test": "node --test peel.test.mjs account.test.mjs"
|
|
34
36
|
}
|
|
35
37
|
}
|
package/peel.test.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// peel.test.mjs: what the answers are read out of, and what is left alone.
|
|
2
|
+
// Run: node --test peel.test.mjs
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { peel } from "./index.mjs";
|
|
6
|
+
|
|
7
|
+
// the two envelopes Workers AI puts a Jev's answer in
|
|
8
|
+
const jev = {
|
|
9
|
+
result: {
|
|
10
|
+
state: "Completed",
|
|
11
|
+
result: {
|
|
12
|
+
model: "jev-1.13.0",
|
|
13
|
+
answers: { tier: { type: "choice", choice: "fast", confidence: 0.98 } },
|
|
14
|
+
usage: { input_tokens: 330, output_tokens: 38 },
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
success: true,
|
|
18
|
+
errors: [],
|
|
19
|
+
messages: [],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// a Clef answers as it is asked: one envelope, and its answers inside
|
|
23
|
+
const clef = {
|
|
24
|
+
result: {
|
|
25
|
+
model: "clef",
|
|
26
|
+
answers: { tier: { type: "choice", choice: "balanced", confidence: 0.4 } },
|
|
27
|
+
usage: { input_tokens: 143, output_tokens: 0 },
|
|
28
|
+
},
|
|
29
|
+
success: true,
|
|
30
|
+
errors: [],
|
|
31
|
+
messages: [],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
test("a Jev's answer comes out of Cloudflare's and the Jev's own envelope", () => {
|
|
35
|
+
const out = peel(jev);
|
|
36
|
+
assert.equal(out.model, "jev-1.13.0");
|
|
37
|
+
assert.equal(out.answers.tier.choice, "fast");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a Clef's answer comes out of Cloudflare's envelope", () => {
|
|
41
|
+
const out = peel(clef);
|
|
42
|
+
assert.equal(out.model, "clef");
|
|
43
|
+
assert.equal(out.answers.tier.choice, "balanced");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("an answer with no envelope is left as it came", () => {
|
|
47
|
+
const flat = { model: "jev-1.13.0", answers: { tier: { choice: "fast" } } };
|
|
48
|
+
assert.deepEqual(peel(flat), flat);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a failure is left as it came: its errors are not peeled away", () => {
|
|
52
|
+
const bad = { success: false, errors: [{ code: 7003, message: "Bad input" }], messages: [] };
|
|
53
|
+
assert.deepEqual(peel(bad), bad);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("an answer that keeps an envelope past the fourth stops at four: never peeled forever", () => {
|
|
57
|
+
let deep = { answers: { tier: { choice: "fast" } } };
|
|
58
|
+
for (let i = 0; i < 6; i++) deep = { result: deep };
|
|
59
|
+
const out = peel(deep);
|
|
60
|
+
// four envelopes taken off, two left: the loop is bounded, not a hunt
|
|
61
|
+
let depth = 0;
|
|
62
|
+
for (let cur = out; cur && typeof cur === "object" && cur.result; cur = cur.result) depth++;
|
|
63
|
+
assert.equal(depth, 2);
|
|
64
|
+
assert.ok(out.result && out.result.result, "the fifth and sixth envelopes are still there");
|
|
65
|
+
});
|