@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 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. 登录,粘贴 Cloudflare API token
43
- # 只做 Workers AI 的 token 可能列不出账号,那就按 "<account id>:<token>" 粘
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.loader` 返回自带 `fetch` 的实现,`peel()` 拆信封 |
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 # 打真实 Cloudflare
98
+ node --test peel.test.mjs account.test.mjs # 单元测试
99
+ node smoke.mjs # 打真实 Cloudflare
86
100
  ```
87
101
 
88
102
  ## 发布
@@ -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, or <account id>:<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 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;
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.1.1",
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
+ });