@kukonce/dsh-loomy 0.0.0-stage → 0.3.1

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.
@@ -0,0 +1,28 @@
1
+ # dsh-loomy bundle patch.
2
+ #
3
+ # One Host line. The plugin registers one llm provider route per account itself:
4
+ #
5
+ # @loomy/dsh-loomy/<id>
6
+ #
7
+ # Accounts come from the plugin's own table ($DSH_HOME/dsh-loomy/accounts.json), which
8
+ # /loomy login and /loomy import write. The first start adopts an existing Loomy desktop
9
+ # login if it finds one, so a fresh install usually needs no configuration at all.
10
+ #
11
+ # Optional: declare the accounts up front, which only affects the display order and the
12
+ # labels shown in the model picker. The id is what the provider route is built from, so
13
+ # it must match the characters a route and a file name tolerate: lowercase letters,
14
+ # digits and dashes, starting with a letter.
15
+ #
16
+ # accounts:
17
+ # - id: loomy
18
+ # label: 我的 Loomy
19
+ # - id: work
20
+ # label: 工作号
21
+ #
22
+ # Login: /loomy import adopt the Loomy desktop app's existing session
23
+ # /loomy login <手机号> sign in with an SMS code
24
+
25
+ - insert:
26
+ - id: loomy
27
+ name: '@loomy/dsh-loomy'
28
+ config: {}
@@ -0,0 +1,87 @@
1
+ # 发布到社区
2
+
3
+ 目标列表:[awesome-dsh-plugin](https://awesome-dsh-plugin.com)(当前收录 10365 个插件)。
4
+
5
+ ## 官方要求核对
6
+
7
+ | 要求 | 本插件 | 说明 |
8
+ |---|---|---|
9
+ | `package.json` 声明 `dsh.bundle.patch` | ✅ | 已声明,且 `cordis.patch.yml` 在仓库根 |
10
+ | 有真实可用代码 | ✅ | 30 个源文件,47 项协议自检全通过 |
11
+ | 仓库创建满 1 天 | ⬜ | 你自己建仓后计时 |
12
+ | 加 `dsh-plugin` topic | ⬜ | GitHub 仓库设置里加 |
13
+ | 活跃维护 | ⬜ | 持续更新即可 |
14
+ | 描述属实、无营销词 | ✅ | 见 `docs/community-entry.yml` |
15
+ | 分类准确 | ✅ | `model`(模型提供方) |
16
+
17
+ > 官方明说:**最常见的被拒原因是只声明了 `dsh.client` 而没有 `dsh.bundle`** —— 那样无法安装。
18
+ > 本插件两者都有,这条不会踩。
19
+
20
+ ## 第 1 步:建 GitHub 仓库
21
+
22
+ ```sh
23
+ cd ~/.dsh/local-plugins/dsh-loomy
24
+ git init
25
+ git add .
26
+ git commit -m "dsh-loomy 0.1.0"
27
+ git branch -M main
28
+ git remote add origin https://github.com/kukonce/dsh-loomy.git
29
+ git push -u origin main
30
+ ```
31
+
32
+ **提交前务必确认**:`.gitignore` 已排除运行时状态(`accounts.json` 里有你的登录凭证)。
33
+ `.gitignore` 已经写好,但推完去 GitHub 网页上看一眼文件列表,确认那个文件不在。
34
+
35
+ 然后:
36
+ - 仓库 **Settings → Topics** 加 `dsh-plugin`
37
+ - 把 `package.json` 里 4 处 `kukonce` 换成你的用户名
38
+
39
+ ## 第 2 步:发布到 npm(可选但推荐)
40
+
41
+ 列表里 5123 个插件是 npm 包,5242 个是 GitHub 直装。**只发 GitHub 也能收录**,但 npm 的安装命令更短、有版本管理。
42
+
43
+ ```sh
44
+ npm login
45
+ npm publish --access public
46
+ ```
47
+
48
+ 包名 `@kukonce/dsh-loomy` 用 npm 用户 `kukonce` 的个人 scope 发布;
49
+ 例如 `@你的用户名/dsh-loomy`,同时同步修改 `cordis.patch.yml` 里的 `name`。
50
+
51
+ ## 第 3 步:提交收录 PR
52
+
53
+ 在 `awesome-dsh-plugin` 仓库新建**一个文件**:
54
+
55
+ 路径:data/plugins/kukonce__dsh-loomy.yml
56
+ 内容:docs/community-entry.yml(改掉 kukonce)
57
+
58
+ 就这一个文件,不要手工编辑 README(它们由脚本生成)。
59
+
60
+ ## 第 4 步:其他渠道
61
+
62
+ - 给 GitHub 仓库打 `dsh-plugin` topic 后,会自动出现在
63
+ [github.com/topics/dsh-plugin](https://github.com/topics/dsh-plugin)
64
+ - 装 `dsh-market` 插件的用户可以在「插件 → 发现」里直接搜到(它索引同一份
65
+ `dsh-plugin-catalog`)
66
+
67
+ ## 发布前最后检查
68
+
69
+ ```sh
70
+ # 1. 协议自检(会花约 1 次免费模型调用,不扣积分)
71
+ npm run verify
72
+
73
+ # 2. 确认没有被忽略的敏感文件混进提交
74
+ git status --ignored | grep -E 'accounts|expired|catalog|daily-grant'
75
+ ```
76
+
77
+ ## 已知的、要在 PR 描述里主动说明的点
78
+
79
+ 诚实说明这些,比被 reviewer 发现要好:
80
+
81
+ 1. **非官方集成** —— 接口是逆官方客户端得到的,无公开文档。
82
+ 2. **内嵌客户端凭据** —— `src/core/endpoints.js` 里有 iFlytek CAccount 的 app key
83
+ (随每个 Loomy 安装包以混淆形式分发,标识的是客户端应用而非用户,没有用户手机号+验证码无法使用)。
84
+ 这是为了让插件开箱即用,且可用环境变量覆盖。
85
+ 3. **会话明文存储** —— `accounts.json` 存 bearer session,权限 0600。Loomy 的 session
86
+ 本身不加密,没有可用的加密层。
87
+ 4. **生图模型不支持对话** —— 目录里有 3 个 `type: image` 的模型,只列在选择器外并标注。
@@ -0,0 +1,21 @@
1
+ # Ready-to-submit entry for awesome-dsh-plugin.
2
+ #
3
+ # Target path in that repo: data/plugins/<owner>__dsh-loomy.yml
4
+ # One file is the whole submission — the READMEs are generated from these.
5
+ #
6
+ # Two placeholders must be replaced with your own values before opening the PR:
7
+ # kukonce -> your GitHub username / org (appears 3 times)
8
+ #
9
+ # The description is a claim that reviewers check against the code, so it states only
10
+ # what the plugin demonstrably does. Category `model` is the one that matches: this is a
11
+ # model provider, not a UI/usage/memory plugin.
12
+
13
+ url: https://github.com/kukonce/dsh-loomy
14
+ name: kukonce/dsh-loomy
15
+ category: model
16
+ description:
17
+ en: 'Loomy (iFlytek) model provider: SMS or desktop-session login, live per-account model catalog, and automatic renewal of the daily 5000 free credits.'
18
+ zh: 'Loomy(讯飞)模型提供方:手机验证码或桌面端登录态登录,按账号实时拉取模型目录,并自动续领每日赠送的 5000 积分。'
19
+ # Optional: point this at a raster image committed to your repo. Omit and the list falls
20
+ # back to your GitHub avatar.
21
+ # icon: https://raw.githubusercontent.com/kukonce/dsh-loomy/main/icon.png
package/docs/models.md ADDED
@@ -0,0 +1,74 @@
1
+ # Loomy model catalog
2
+
3
+ Live snapshot of `GET /api/v1/models`, read from a real account on 2026-11-11
4
+ (catalog version `sha256:7c15cde8…1a8394`, `reasoning_enabled: true`).
5
+
6
+ This file is a **record**, not a source of truth. The plugin never reads it — it
7
+ calls the endpoint and caches the answer (`src/account/models.js`). The list is
8
+ per-account: a model an account may not use is simply absent from its response,
9
+ so an id missing here means nothing about whether another account gets it.
10
+
11
+ ## Chat models
12
+
13
+ | id | display name | ctx | max out | reasoning | vision | tools | input modalities |
14
+ |---|---|---|---|---|---|---|---|
15
+ | `spark-x2.6` | Spark-X2.6 正式版(限时免费)(x0.0) | 1 048 576 | 131 072 | yes | no | yes | text |
16
+ | `DeepSeek-V4.1-Flash` | DeepSeek V4.1 Flash(x2.0) | 1 048 576 | 384 000 | yes | yes | yes | text, image |
17
+ | `GLM-5.3-Flash` | GLM 5.3 Flash(x0.8) | 1 048 576 | 131 072 | yes | yes | yes | text, image, video |
18
+ | `qwen3.8-flash` | qwen 3.8 flash(x0.8) | 1 000 000 | 131 072 | yes | yes | yes | text, image, video |
19
+ | `mimo-v2.5` | MiMo V2.5(x3.3) | 1 048 576 | 131 072 | yes | yes | yes | text, audio, image, video |
20
+ | `auto` | 自动 · 均衡 | — | — | — | — | — | (undeclared) |
21
+
22
+ All five metered chat models plus `auto` report `reasoning_efforts` of
23
+ `["none","low","medium","high","xhigh"]` with `default_reasoning_effort: "low"`.
24
+ `auto` declares no capabilities block at all — the router picks a concrete model
25
+ per request and reports the chosen one in the stream's `model` field.
26
+
27
+ `is_default_model` and `is_default_small_model` are both `true` on
28
+ `spark-x2.6` and `false` everywhere else. That is why the plugin's default is
29
+ spark-x2.6: it is what a fresh Loomy install would use.
30
+
31
+ ## Image models
32
+
33
+ | id | display name | ctx | max out | streaming | input | output |
34
+ |---|---|---|---|---|---|---|
35
+ | `Hy-Image-3.5-preview` | Hy image 3.5 preview | 100 000 | — | no | text, image | image |
36
+ | `doubao-seedream-5-lite` | Doubao seedream 5 lite | 128 000 | 131 072 | yes | text, image | image |
37
+ | `qwen-image-3.0-pro` | qwen image 3.0 pro | — | — | no | text, image | image |
38
+
39
+ These carry `type: "image"` and `output_modalities: ["image"]`, and all report
40
+ `function_calling: false`, `reasoning: false`. **The plugin does not expose them
41
+ as chat routes** — a DSH provider route must answer chat-completions semantics, and
42
+ these answer an image-generation payload shape the harness has no surface for. They
43
+ are listed here only so the catalog table is complete.
44
+
45
+ ## How the `(xN.N)` suffix maps to cost
46
+
47
+ The parenthetical in the display name is the **points multiplier**: a turn on
48
+ `DeepSeek-V4.1-Flash` costs about twice the token-derived base rate, `GLM-5.3-Flash`
49
+ about 0.8×, and `spark-x2.6` is `x0.0` — free while it is labelled 限时免费
50
+ (limited-time free). The multiplier is presentation only; nothing in the response
51
+ exposes it as a field, so the plugin parses it out of `name` when it wants to warn
52
+ about cost. `usage.points_consumed` on the final SSE chunk is the authoritative
53
+ number for a given turn.
54
+
55
+ Observed: a 140-token turn on `spark-x2.6` reported `points_consumed: 0`; routing
56
+ the same turn through `auto` (which resolved to `GLM-5.3-Flash`) consumed 1 point.
57
+
58
+ ## Fields the plugin actually uses
59
+
60
+ ```
61
+ id → the model id DSH sends in a chat request
62
+ name → picker label
63
+ context_length → DSH context window
64
+ max_output_tokens → DSH output cap
65
+ capabilities.reasoning → whether to send reasoning effort
66
+ capabilities.function_calling → whether to offer tools
67
+ capabilities.input_modalities → whether to allow image attachments
68
+ reasoning_efforts / default_reasoning_effort → effort picker and its default
69
+ is_default_model → which id to preselect
70
+ ```
71
+
72
+ `capabilities.vision` is a redundant older field alongside
73
+ `input_modalities.includes("image")`; both were consistent in every entry above,
74
+ and the plugin prefers `input_modalities` because that is what the harness reads.
@@ -0,0 +1,403 @@
1
+ # Loomy upstream protocol reference
2
+
3
+ Everything here was read off the wire against a real account on 2026-11-11, not
4
+ guessed from documentation (Loomy publishes none). Where a claim is unverified it
5
+ says so and is repeated in [Unknowns](#unknowns).
6
+
7
+ This is an **unofficial** integration. No API key exists: auth is a session string
8
+ issued to the desktop client after an SMS login. See [README](../README.md) for the
9
+ risk statement.
10
+
11
+ ---
12
+
13
+ ## 1. Hosts
14
+
15
+ | host | purpose | auth style |
16
+ |---|---|---|
17
+ | `https://account.xfinfr.com` | iFlytek CAccount: SMS send/verify, userinfo | HMAC-SHA1 request signing |
18
+ | `https://loomyad.xunfei.cn` | points, models, onboarding, pet-work | envelope APIs, `token` header |
19
+ | `https://loomyad.xunfei.cn/api/v1` | chat gateway (OpenAI-compatible) | `Authorization: Bearer` |
20
+ | `https://loomy.xunfei.cn` | web pages (share, docs) | none |
21
+
22
+ `account` and `loomyad` are separate services that happen to share a vendor. A
23
+ session minted by CAccount is accepted by `loomyad` — that is the whole link.
24
+
25
+ ---
26
+
27
+ ## 2. Auth header matrix
28
+
29
+ **This is the single most error-prone part of the protocol.** The two API families
30
+ on the *same host* accept *different* headers, and the failure is a 200 OK carrying
31
+ an error envelope, not a 401.
32
+
33
+ | endpoint | `token: <session>` | `Authorization: Bearer <session>` | needs both? |
34
+ |---|---|---|---|
35
+ | `GET /api/v1/models` | **required** | ignored | no |
36
+ | `POST /api/v1/points/first-login` | **required** | ignored | no |
37
+ | `GET /api/v1/points/records` | **required** | ignored | no |
38
+ | `GET /api/v2/points/records` | **required** | ignored | no |
39
+ | `GET /api/v1/user/identity` | **required** | ignored | no |
40
+ | `GET /api/v1/onboarding/tasks` | **required** | ignored | no |
41
+ | `GET /api/v1/pet-work` | **required** | ignored | no |
42
+ | `GET /api/v1/points/activation` | **required** | ignored | no |
43
+ | `POST /api/v1/chat/completions` | **rejected** | **required** | no |
44
+
45
+ Measured behaviour for `GET /api/v1/models`:
46
+
47
+ ```
48
+ token only -> 200, bare model list
49
+ Authorization only -> 200, {"code":"100002","desc":"缺少 token", ...}
50
+ both -> 200, bare model list
51
+ neither -> 200, {"code":"100002","desc":"缺少 token", ...}
52
+ ```
53
+
54
+ Measured behaviour for `POST /api/v1/chat/completions`:
55
+
56
+ ```
57
+ token only -> 200, application/json, {"code":"100002","desc":"缺少 token"}
58
+ Authorization Bearer -> 200, text/event-stream, real SSE stream
59
+ both -> 200, text/event-stream, real SSE stream
60
+ lowercase "bearer" -> 200, {"code":"100002","desc":"缺少 token"}
61
+ ```
62
+
63
+ Note the last row: the scheme name is matched **case-sensitively**. `bearer` fails
64
+ where `Bearer` works. Sending `token` to the gateway is not a substitute for
65
+ `Authorization`, and reproduces a misleading `缺少 token` ("missing token") message
66
+ while a token is plainly present — the message means "missing *Authorization*".
67
+
68
+ Headers that are **optional** in practice (dropped one at a time, request still
69
+ streamed normally): `Accept`, `Content-Type`, `loomy-version`,
70
+ `X-Loomy-Request-Purpose`, `traceparent`. They are sent anyway because the real
71
+ client sends them and they carry points-attribution and tracing meaning.
72
+
73
+ `traceparent` when sent follows W3C trace-context at version `00`:
74
+ `00-<32 hex>-<16 hex>-01`. No observed validation of its contents.
75
+
76
+ ---
77
+
78
+ ## 3. Envelope shapes
79
+
80
+ Two families, distinguishable by which key is present:
81
+
82
+ ```jsonc
83
+ // family A — business envelope (nearly all /api/v1/* and /api/v2/*)
84
+ { "code": "000000", "desc": "成功", "trace_id": "<32 hex>", "data": { } }
85
+
86
+ // family B — bare OpenAI-style list (GET /api/v1/models ONLY)
87
+ { "object": "list", "reasoning_enabled": true,
88
+ "reasoning_catalog_version": "sha256:<64 hex>", "data": [ ] }
89
+ ```
90
+
91
+ **`code` is a string**, not a number. `"000000"` is success. An adapter that
92
+ checks `code === 0` fails on every response.
93
+
94
+ Family B has **no `code` field at all** — a parser that requires an envelope
95
+ rejects the model list as malformed. That is the bug this note exists to prevent.
96
+
97
+ ### Envelope codes seen
98
+
99
+ | code | meaning | recovery |
100
+ |---|---|---|
101
+ | `"000000"` | success | — |
102
+ | `"100002"` | session dead / missing token | re-login |
103
+ | `"020002"` | session invalid (seen in client source) | re-login |
104
+ | others | business error, `desc` is user-facing Chinese | surface `desc` |
105
+
106
+ `data` is sometimes `{}` on error rather than absent.
107
+
108
+ ---
109
+
110
+ ## 4. SSE event shapes (chat gateway)
111
+
112
+ Request: `POST https://loomyad.xunfei.cn/api/v1/chat/completions`.
113
+
114
+ Response is `text/event-stream`; each event is one `data: <json>` line followed by
115
+ a blank line. The stream ends with the literal sentinel `data: [DONE]`.
116
+
117
+ ### Event sequence observed (tool-calling turn)
118
+
119
+ ```
120
+ 1. data: {"id":"cht000d5341@dx1a1c...","object":"chat.completion.chunk",
121
+ "created":1794362374,"model":"spark-x2.6",
122
+ "choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
123
+
124
+ 2..n data: {...,"choices":[{"index":0,"delta":{"reasoning_content":"The"},"finish_reason":null}]}
125
+ ... {"delta":{"reasoning_content":" user is"}} ...
126
+
127
+ k. data: {...,"choices":[{"index":0,"delta":{"content":"..."},"finish_reason":null}]}
128
+
129
+ m. data: {...,"choices":[{"index":0,"delta":{"tool_calls":[
130
+ {"index":0,"id":"call_3eb7ceb997564af380b3d52f","type":"function",
131
+ "function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}
132
+
133
+ m+1.. data: {...,"delta":{"tool_calls":[{"index":0,"id":"","type":"function",
134
+ "function":{"name":"","arguments":"{\"city\":\"Bei"}}]}}
135
+
136
+ last. data: {...,"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}
137
+
138
+ final. data: {...,"choices":[],
139
+ "usage":{"prompt_tokens":92,"completion_tokens":48,
140
+ "total_tokens":140,"points_consumed":0}}
141
+
142
+ data: [DONE]
143
+ ```
144
+
145
+ Rules an adapter must follow:
146
+
147
+ - **`id` and `name` on `tool_calls` deltas are sent once, on the first fragment.**
148
+ Follow-up fragments carry `"id":""` and `"name":""` and must be treated as
149
+ continuations of the same `index`, not as new calls. This differs from some
150
+ OpenAI-compatible servers that repeat the id on every fragment.
151
+ - **`arguments` arrives as a stream of string fragments** that must be
152
+ concatenated in order; it is not valid JSON until the stream ends.
153
+ - The **usage chunk has `"choices": []`** — an adapter that indexes `choices[0]`
154
+ unguarded throws here, on the last event of every stream.
155
+ - `usage.points_consumed` is Loomy's own meter and has no OpenAI equivalent.
156
+ It is the only per-turn cost signal.
157
+ - `reasoning_content` is a separate delta key from `content`, and both appear on
158
+ the same model. It maps to the harness's reasoning/thinking channel.
159
+ - No `event:` field, no `retry:` field, no SSE comments observed.
160
+
161
+ ---
162
+
163
+ ## 5. Non-streaming and empty-input behaviour
164
+
165
+ | probe | result |
166
+ |---|---|
167
+ | `stream: false` | **works** — answers one `application/json` `chat.completion` object with `choices[0].message.{content,reasoning_content}` and a `usage` block |
168
+ | `messages: []` | **400** `{"error":{"message":"messages 不能为空","type":"invalid_request_error","param":"messages","code":400,"metadata":{"provider_name":"loomy"}}}` |
169
+ | no `messages` key | same 400 as above |
170
+ | no `model` key | **200**, silently defaults to `spark-x2.6` |
171
+ | `model: "gpt-4o"` (unknown) | **200**, silently falls back to `spark-x2.6` |
172
+ | `model: ""` | **200**, silently falls back to `spark-x2.6` |
173
+ | `model: "auto"` | **200**, router picks a concrete model (`GLM-5.3-Flash` here) and reports it in `model` |
174
+ | malformed JSON body | 400 `{"error":{"message":"请求参数错误","param":null,...}}` |
175
+ | wrong `Content-Type` (`text/plain`) | still accepted, streams normally |
176
+
177
+ **There is no free probe via an empty `messages` array** — unlike WorkBuddy, which
178
+ accepts one. Loomy rejects it with a 400 before doing any work. The equivalent
179
+ free probe is a one-token turn on a `x0.0` model: `spark-x2.6` is currently
180
+ 限时免费 and reports `points_consumed: 0`.
181
+
182
+ > **Trap:** an unknown model id does *not* error. It silently answers as
183
+ > `spark-x2.6`. A typo'd or removed model therefore looks like a working call
184
+ > returning a different model than requested — check the `model` field of the
185
+ > stream's first chunk, not the status code. The plugin validates the id against
186
+ > the live catalog rather than trusting the gateway to reject it.
187
+
188
+ ---
189
+
190
+ ## 6. The daily 5000 mechanism — what actually refreshes it
191
+
192
+ ### What the numbers are
193
+
194
+ `POST /api/v1/points/first-login` returns the full balance picture:
195
+
196
+ ```json
197
+ { "alreadyProcessed": true,
198
+ "currentBalance": 10500,
199
+ "permanentBalance": 5500,
200
+ "dailyBalance": 5000,
201
+ "dailyQuota": 5000,
202
+ "dailyConsumed": 0,
203
+ "dailyCycleDate": "2026-11-11",
204
+ "registerReward": 0, "inviteeReward": 0, "inviterReward": 0,
205
+ "invitationCodes": [ { "inviteCode": "6W9AD9", "usedCount": 0,
206
+ "maxUses": 20, "status": "active" } ] }
207
+ ```
208
+
209
+ `currentBalance === permanentBalance + dailyBalance` is the arithmetic identity
210
+ (5500 + 5000 = 10500). The metering has two pots: **每日赠送 (daily)**, capped at
211
+ `dailyQuota` = 5000 and consumed first, and **长期/永久 (permanent)**, which is
212
+ registration reward + onboarding points + redemption codes and does not expire daily.
213
+
214
+ ### What does *not* refresh it
215
+
216
+ `GET /api/v1/points/records` and `GET /api/v2/points/records` are pure reads. The
217
+ daily balance did not change across repeated calls, and no ledger entry was
218
+ appended. **Reading the records does not renew the daily pot.**
219
+
220
+ ### What does
221
+
222
+ `POST /api/v1/points/first-login` with body `{}`:
223
+
224
+ - is **idempotent per `dailyCycleDate`** — two consecutive calls both answered
225
+ `alreadyProcessed: true` with an unchanged `currentBalance`, and appended
226
+ nothing to the ledger;
227
+ - is **the call the official client makes exactly on login**, not on a timer.
228
+ In the shipped renderer bundle (`dist/assets/main-*.js`) the wrapper
229
+ `completeFirstLogin` has exactly four call sites: two in the SMS-login success
230
+ handler, one in the WeChat-login success handler, and one fallback when the
231
+ invitation-code list comes back empty. There is no startup timer, no midnight
232
+ timer, no interval;
233
+ - is documented by the client's own UI copy, verbatim:
234
+ > 每日赠送积分会在你每日登录后自动刷新为 5000。
235
+
236
+ ("Daily gift points are automatically refreshed to 5000 after your daily login.")
237
+
238
+ So the mechanism is: **the daily pot is reset by a server-side per-day ledger keyed
239
+ on `dailyCycleDate`, and `POST /points/first-login` is the client's idempotent
240
+ "begin today's cycle" signal.** Repeated calls within the same day are free and
241
+ harmless; that is precisely what makes it safe to call on every startup.
242
+
243
+ ### What this plugin does
244
+
245
+ - **On startup**, for each account: `POST /api/v1/points/first-login` (reason `启动`).
246
+ - **At local midnight**, for each account: the same call (reason `午夜`), scheduled
247
+ by `msUntilNextMidnight()`.
248
+ - **On panel refresh**, the same call — it doubles as the balance read because it
249
+ is the only endpoint returning all four numbers plus `dailyConsumed` at once.
250
+ - A per-account ledger (`daily-grant.json`) records the last attempt's day and
251
+ outcome so a restart inside the same day does not re-ask; the panel's manual
252
+ button forces it.
253
+
254
+ Whether the pot would eventually refresh *without* the call is **not something I
255
+ could observe** — the test account was created on the observation day, so no
256
+ rollover ever occurred. See [Unknowns](#unknowns). Calling it is correct either
257
+ way: it is the client's own mechanism and it is free.
258
+
259
+ ---
260
+
261
+ ## 7. Endpoint inventory
262
+
263
+ | method | path | envelope | notes |
264
+ |---|---|---|---|
265
+ | GET | `/api/v1/models` | **bare list** | per-account catalog; see models.md |
266
+ | POST | `/api/v1/points/first-login` | yes | daily claim + full balance |
267
+ | GET | `/api/v1/points/records` | yes | `?pageNo&pageSize&recordType=all`; returns `balance`(=permanent), `dailyBalance`, `availableBalance`(=total), `total`, `list` |
268
+ | GET | `/api/v2/points/records` | yes | `?pageNo&pageSize&direction=credit|debit&granularity`; `list[].points` + `source` instead of `pointsActual` |
269
+ | GET | `/api/v1/user/identity` | yes | `{isInternal, currentTeamId, teams}` |
270
+ | GET | `/api/v1/onboarding/tasks` | yes | `{tasks:{key:bool}, earned, total:10000}` |
271
+ | POST | `/api/v1/onboarding/tasks/complete` | yes | `{key}`; idempotent, returns `alreadyCompleted` + `balance` |
272
+ | GET | `/api/v1/pet-work` | yes | daily pet-work snapshot |
273
+ | GET | `/api/v1/points/activation` | yes | `{activated: bool}` — redemption-code state |
274
+ | GET | `/api/v1/invitation-codes` | yes | referrer codes |
275
+ | POST | `/api/v1/chat/completions` | **SSE / plain JSON** | the gateway |
276
+
277
+ ### `v1` vs `v2` records — a real shape difference
278
+
279
+ ```jsonc
280
+ // v1 record {ledgerId, description, direction, createdAt, pointsActual,
281
+ // balanceBefore, balanceAfter, dailyCycleDate, modelName, consumeSource}
282
+ // v2 record {ledgerId, description, direction, createdAt, points, source}
283
+ ```
284
+
285
+ The **point value lives at a different key** (`pointsActual` vs `points`), and
286
+ `balanceBefore`/`balanceAfter`/`dailyCycleDate` exist only in v1. v2 additionally
287
+ filters by `direction`. The plugin's `queryRecords` uses v2 and reads `points`.
288
+
289
+ Also note the top-level `balance` means different things: in **v1 records** it is
290
+ the permanent pot (5500); in **first-login** the comparable field is
291
+ `permanentBalance` (5500) while `currentBalance` is the total (10500). Mixing those
292
+ two up double-counts the daily pot.
293
+
294
+ ### Onboarding keys (one-time pool, 10 000 total)
295
+
296
+ `first_message` 500 · `pick_skill` 1000 · `generate_ppt` 1500 ·
297
+ `set_schedule` 1000 · `install_skill` 1500 · `configure_remote` 1000 ·
298
+ `create_soul` 1500 · `share_soul` 2000
299
+
300
+ Claiming these is **not** automatic in this plugin: they are a one-time resource
301
+ the user may want to spend deliberately, and auto-claiming on someone's behalf
302
+ would burn them without consent.
303
+
304
+ ---
305
+
306
+ ## 8. Error table
307
+
308
+ Every error below was produced by a live request.
309
+
310
+ | trigger | HTTP | Content-Type | body |
311
+ |---|---|---|---|
312
+ | bad/expired token, envelope endpoint | **200** | `application/json` | `{"code":"100002","desc":"登录已失效,请重新登录","trace_id":"…","data":{}}` |
313
+ | no `token` header, envelope endpoint | **200** | `application/json` | `{"code":"100002","desc":"缺少 token",…}` |
314
+ | no `Authorization`, chat endpoint | **200** | `application/json` | `{"code":"100002","desc":"缺少 token",…}` — *not* an SSE error event |
315
+ | chat with `messages: []` | **400** | `application/json` | `{"error":{"message":"messages 不能为空","type":"invalid_request_error","param":"messages","code":400,"metadata":{"provider_name":"loomy"}}}` |
316
+ | chat with malformed JSON | **400** | `application/json` | `{"error":{"message":"请求参数错误","type":"invalid_request_error","param":null,"code":400,"metadata":{"provider_name":"loomy"}}}` |
317
+ | unknown model id | **200** | `text/event-stream` | **no error** — silently answers as `spark-x2.6` |
318
+ | rate limited | — | — | **never observed** |
319
+
320
+ ### What this means for retry logic
321
+
322
+ - **HTTP status is not a reliable failure signal.** Auth failures arrive as
323
+ `200 OK`. Every response must be inspected: a model-list answer is valid only if
324
+ it has a `data` array; an envelope answer is valid only if `code === "000000"`;
325
+ a chat answer is valid only if `Content-Type` starts with `text/event-stream`
326
+ (or is a well-formed `chat.completion` when non-streaming).
327
+ - **There is no SSE error event.** Auth and validation failures are answered as
328
+ plain JSON with a non-SSE `Content-Type` before the stream opens. An adapter
329
+ that only parses `data:` lines will see an empty stream and hang or return
330
+ nothing. Check the content type first.
331
+ - No `Retry-After` was ever sent, and no error was retryable in the observed set —
332
+ all are terminal (bad credentials / bad request). The plugin does not retry auth
333
+ or validation errors; it surfaces them.
334
+
335
+ ---
336
+
337
+ ## 9. Rate limiting and cost signals
338
+
339
+ **No rate-limit headers exist on this API.** Responses carried only:
340
+ `server: xunfei`, `date`, `content-type`, `connection`, and either
341
+ `content-length` or `transfer-encoding: chunked` (plus `cache-control: no-cache`
342
+ on SSE). No `x-ratelimit-*`, no `retry-after`, no points headers — across every
343
+ endpoint and both auth states.
344
+
345
+ The only cost signal is therefore **in-band**:
346
+
347
+ - `usage.points_consumed` on the final SSE chunk (streaming) or the `usage` block
348
+ (non-streaming) — the authoritative per-turn cost;
349
+ - `dailyConsumed` / `dailyBalance` on `first-login` — the running daily total;
350
+ - the `(xN.N)` multiplier in the model's display `name` — advisory, from the
351
+ catalog, not from the response.
352
+
353
+ Because there is no rate-limit header to react to, the plugin meters itself:
354
+ it tracks `points_consumed` per turn and refuses to spend when
355
+ `dailyBalance + permanentBalance` is exhausted rather than discovering it via 4xx.
356
+
357
+ ---
358
+
359
+ ## 10. Session lifetime
360
+
361
+ The desktop session is a 32-character opaque string with no expiry encoded in it
362
+ and no refresh token in the observed flows. The desktop client is re-authenticated
363
+ by the user logging in again. `auth-session.json` on disk carries
364
+ `{session, userid, phone, updatedAt}` and is what the plugin's "import desktop
365
+ login" button adopts.
366
+
367
+ A dead session is detected by envelope `code` `100002`/`020002`, never by HTTP
368
+ status. There is **no documented TTL** — see Unknowns.
369
+
370
+ ---
371
+
372
+ ## Unknowns
373
+
374
+ Honest list of what this survey could not establish, with why:
375
+
376
+ 1. **Whether the daily pot refreshes without any call.** The test account was
377
+ created on the observation day (`dailyCycleDate` = 2026-11-11, first ledger
378
+ entry same day), so no date rollover ever occurred during testing. I cannot
379
+ distinguish "the `first-login` call performs the reset" from "the server resets
380
+ lazily at rollover and `first-login` merely reports it". The plugin calls it
381
+ anyway: the official client does it on login, it is idempotent, and it is free.
382
+ 2. **Session TTL / absolute expiry.** No expiry field in the session or any
383
+ response. Only the failure mode (`100002`) is known.
384
+ 3. **Rate limits.** None observed and no headers advertise them. Whether limits
385
+ exist at higher request rates is untested — I deliberately did not hammer the
386
+ API with the user's credentials.
387
+ 4. **Exact points formula.** How `prompt_tokens`/`completion_tokens` translate to
388
+ `points_consumed` under a given `(xN.N)` multiplier is not derivable from the
389
+ responses. Observed samples: 140 tokens on `spark-x2.6` → 0 points; one short
390
+ turn on `GLM-5.3-Flash` → 1 point. Too few points to model.
391
+ 5. **Behaviour of image models via the chat gateway.** `Hy-Image-3.5-preview` and
392
+ friends declare `type: "image"` and `output_modalities: ["image"]`. Whether
393
+ `/chat/completions` services them at all, or they require a separate
394
+ images endpoint I did not find, is untested — testing would have spent the
395
+ user's points on image generation.
396
+ 6. **Multi-account rate interaction.** Whether several sessions on one machine or
397
+ IP share a limit is untested.
398
+ 7. **`reasoning_effort` semantics.** Every effort value was accepted in the request
399
+ shape, but whether `none` truly disables reasoning (vs. still emitting
400
+ `reasoning_content`) was not verified per-value on every model.
401
+ 8. **Server-side model retirement.** Because an unknown id silently falls back
402
+ instead of erroring, there is no way to detect that a model was retired except
403
+ by comparing the live catalog — which is exactly why the plugin does.