@kukonce/dsh-loomy 0.0.0-stage → 0.3.2
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/CHANGELOG.md +125 -0
- package/LICENSE +26 -0
- package/README.md +223 -2
- package/client/index.js +1092 -0
- package/cordis.patch.yml +28 -0
- package/docs/PUBLISHING.md +87 -0
- package/docs/community-entry.yml +21 -0
- package/docs/models.md +74 -0
- package/docs/pill-composer.png +0 -0
- package/docs/protocol.md +403 -0
- package/docs/verify/live-check.mjs +406 -0
- package/icon-mask.png +0 -0
- package/icon.png +0 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +57 -3
- package/src/account/auth.js +297 -0
- package/src/account/credits.js +373 -0
- package/src/account/desktop-session.js +127 -0
- package/src/account/models.js +216 -0
- package/src/account/session.js +115 -0
- package/src/core/endpoints.js +78 -0
- package/src/core/http.js +124 -0
- package/src/core/paths.js +71 -0
- package/src/core/sign.js +166 -0
- package/src/index.js +429 -0
- package/src/llm/adapter.js +698 -0
- package/src/llm/endings.js +116 -0
- package/src/llm/sse.js +80 -0
- package/src/llm/wire.js +272 -0
- package/src/panel/api.js +661 -0
package/cordis.patch.yml
ADDED
|
@@ -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.
|
|
Binary file
|
package/docs/protocol.md
ADDED
|
@@ -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.
|