@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
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Live protocol check for the Loomy plugin.
|
|
4
|
+
*
|
|
5
|
+
* Re-asserts every claim in docs/protocol.md against the real service and prints
|
|
6
|
+
* one PASS/FAIL line per assertion. Exit code is 1 if any assertion failed, so CI
|
|
7
|
+
* can gate on it.
|
|
8
|
+
*
|
|
9
|
+
* node docs/verify/live-check.mjs # full run
|
|
10
|
+
* node docs/verify/live-check.mjs --offline # skip the one paid call
|
|
11
|
+
* node docs/verify/live-check.mjs --session <path>
|
|
12
|
+
*
|
|
13
|
+
* Auth: reads the Loomy desktop session file by default. Set LOOMY_SESSION to a
|
|
14
|
+
* session string instead.
|
|
15
|
+
*
|
|
16
|
+
* SECRETS: the session is never printed. Values are redacted to
|
|
17
|
+
* "<first2>…<last2> (len N)" wherever a message might carry one. Do not add
|
|
18
|
+
* logging that bypasses redact().
|
|
19
|
+
*
|
|
20
|
+
* Points: every call here is a free read except the single chat probe, which
|
|
21
|
+
* targets the free model (spark-x2.6) so it costs 0 points. --offline drops it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash } from 'node:crypto'
|
|
25
|
+
import fs from 'node:fs'
|
|
26
|
+
import os from 'node:os'
|
|
27
|
+
import path from 'node:path'
|
|
28
|
+
|
|
29
|
+
const API = 'https://loomyad.xunfei.cn'
|
|
30
|
+
const GATEWAY = API + '/api/v1/chat/completions'
|
|
31
|
+
const VERSION = '0.9.39'
|
|
32
|
+
|
|
33
|
+
const args = process.argv.slice(2)
|
|
34
|
+
const offline = args.includes('--offline')
|
|
35
|
+
const sessionArgIndex = args.indexOf('--session')
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where this user's Loomy desktop session may live.
|
|
39
|
+
*
|
|
40
|
+
* On Windows the desktop client relocates its state to
|
|
41
|
+
* `C:\\Users\\Public\\Loomy\\<sha256(username)[:12]>\\userData\`, so the directory is
|
|
42
|
+
* derived rather than hard-coded — a literal hash would only work on the machine it was
|
|
43
|
+
* copied from.
|
|
44
|
+
*
|
|
45
|
+
* Only this user's directory is probed. `C:\\Users\\Public\` is shared
|
|
46
|
+
* by every local account, so listing that root would pick up whoever ran Loomy last —
|
|
47
|
+
* exactly the behaviour the plugin was changed to stop doing.
|
|
48
|
+
*/
|
|
49
|
+
function windowsSessionPaths() {
|
|
50
|
+
const candidates = []
|
|
51
|
+
try {
|
|
52
|
+
const hash = createHash('sha256').update(os.userInfo().username).digest('hex').slice(0, 12)
|
|
53
|
+
candidates.push(path.join('C:\\Users\\Public\\Loomy', hash, 'userData', 'auth-session.json'))
|
|
54
|
+
} catch {
|
|
55
|
+
// userInfo can throw in a container; APPDATA below still applies
|
|
56
|
+
}
|
|
57
|
+
const roaming = process.env.APPDATA
|
|
58
|
+
if (typeof roaming === 'string' && roaming.length > 0) {
|
|
59
|
+
candidates.push(path.join(roaming, 'Loomy', 'auth-session.json'))
|
|
60
|
+
}
|
|
61
|
+
return candidates
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const DEFAULT_SESSION_PATHS = process.platform === 'win32'
|
|
65
|
+
? windowsSessionPaths()
|
|
66
|
+
: [
|
|
67
|
+
path.join(os.homedir(), 'Library', 'Application Support', 'Loomy', 'auth-session.json'),
|
|
68
|
+
path.join(os.homedir(), '.config', 'loomy', 'auth-session.json')
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
let session = ''
|
|
72
|
+
let userid = ''
|
|
73
|
+
let accountSource = ''
|
|
74
|
+
|
|
75
|
+
/** Never let a session string reach stdout. */
|
|
76
|
+
function redact(value) {
|
|
77
|
+
const text = String(value ?? '')
|
|
78
|
+
if (session.length > 4 && text.includes(session)) {
|
|
79
|
+
return text.split(session).join(`<${mask(session)}>`)
|
|
80
|
+
}
|
|
81
|
+
return text
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function mask(value) {
|
|
85
|
+
const text = String(value ?? '')
|
|
86
|
+
if (text.length <= 4) return '*'.repeat(text.length)
|
|
87
|
+
return `${text.slice(0, 2)}…${text.slice(-2)} (len ${text.length})`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const results = []
|
|
91
|
+
function record(ok, name, detail = '') {
|
|
92
|
+
results.push({ ok, name, detail })
|
|
93
|
+
const tag = ok ? 'PASS' : 'FAIL'
|
|
94
|
+
console.log(`${tag} ${name}${detail ? ' — ' + redact(detail) : ''}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sleep(ms) {
|
|
98
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Load the session, preferring an explicit path, then env, then known locations. */
|
|
102
|
+
function loadSession() {
|
|
103
|
+
if (process.env.LOOMY_SESSION) {
|
|
104
|
+
session = process.env.LOOMY_SESSION.trim()
|
|
105
|
+
accountSource = 'env LOOMY_SESSION'
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
const explicit = sessionArgIndex !== -1 ? args[sessionArgIndex + 1] : undefined
|
|
109
|
+
const candidates = explicit ? [explicit] : DEFAULT_SESSION_PATHS
|
|
110
|
+
for (const candidate of candidates) {
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8'))
|
|
113
|
+
if (typeof parsed?.session === 'string' && parsed.session.length > 0) {
|
|
114
|
+
session = parsed.session
|
|
115
|
+
userid = String(parsed.userid ?? '')
|
|
116
|
+
accountSource = candidate
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// try the next candidate
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** JSON GET against the points/business API (token header, envelope expected). */
|
|
127
|
+
async function apiGet(pathname, { token } = {}) {
|
|
128
|
+
const headers = { Accept: 'application/json', 'loomy-version': VERSION }
|
|
129
|
+
if (token !== null) headers.token = token ?? session
|
|
130
|
+
const response = await fetch(API + pathname, { headers, signal: AbortSignal.timeout(30_000) })
|
|
131
|
+
const text = await response.text()
|
|
132
|
+
let json = null
|
|
133
|
+
try { json = JSON.parse(text) } catch { json = null }
|
|
134
|
+
return { status: response.status, headers: response.headers, text, json }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function apiPost(pathname, body, { token } = {}) {
|
|
138
|
+
const headers = {
|
|
139
|
+
Accept: 'application/json',
|
|
140
|
+
'Content-Type': 'application/json',
|
|
141
|
+
'loomy-version': VERSION
|
|
142
|
+
}
|
|
143
|
+
if (token !== null) headers.token = token ?? session
|
|
144
|
+
const response = await fetch(API + pathname, {
|
|
145
|
+
method: 'POST', headers, body: JSON.stringify(body ?? {}),
|
|
146
|
+
signal: AbortSignal.timeout(30_000)
|
|
147
|
+
})
|
|
148
|
+
const text = await response.text()
|
|
149
|
+
let json = null
|
|
150
|
+
try { json = JSON.parse(text) } catch { json = null }
|
|
151
|
+
return { status: response.status, headers: response.headers, text, json }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function hex(count) {
|
|
155
|
+
let out = ''
|
|
156
|
+
while (out.length < count) out += Math.floor(Math.random() * 16).toString(16)
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The full header set the desktop client sends for one chat turn. */
|
|
161
|
+
function chatHeaders() {
|
|
162
|
+
return {
|
|
163
|
+
'Content-Type': 'application/json',
|
|
164
|
+
Accept: 'text/event-stream',
|
|
165
|
+
Authorization: 'Bearer ' + session,
|
|
166
|
+
token: session,
|
|
167
|
+
'loomy-version': VERSION,
|
|
168
|
+
'X-Loomy-Request-Purpose': 'chat.message',
|
|
169
|
+
traceparent: `00-${hex(32)}-${hex(16)}-01`
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function chatProbe(body, { headers } = {}) {
|
|
174
|
+
const response = await fetch(GATEWAY, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: headers ?? chatHeaders(),
|
|
177
|
+
body: JSON.stringify(body),
|
|
178
|
+
signal: AbortSignal.timeout(60_000)
|
|
179
|
+
})
|
|
180
|
+
const text = await response.text()
|
|
181
|
+
return { status: response.status, contentType: response.headers.get('content-type') || '', text }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Split an SSE body into its `data:` payloads, preserving the [DONE] sentinel. */
|
|
185
|
+
function sseEvents(text) {
|
|
186
|
+
const out = []
|
|
187
|
+
for (const line of text.split('\n')) {
|
|
188
|
+
const trimmed = line.trim()
|
|
189
|
+
if (!trimmed.startsWith('data:')) continue
|
|
190
|
+
const payload = trimmed.slice(5).trim()
|
|
191
|
+
if (payload === '[DONE]') { out.push({ done: true }); continue }
|
|
192
|
+
try { out.push({ json: JSON.parse(payload) }) } catch { out.push({ raw: payload }) }
|
|
193
|
+
}
|
|
194
|
+
return out
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function main() {
|
|
198
|
+
console.log('Loomy protocol live check')
|
|
199
|
+
console.log('='.repeat(60))
|
|
200
|
+
|
|
201
|
+
/* ── 0. credentials ─────────────────────────────────────────────── */
|
|
202
|
+
if (!loadSession()) {
|
|
203
|
+
console.error('FAIL no session found. Set LOOMY_SESSION or pass --session <path>.')
|
|
204
|
+
process.exit(1)
|
|
205
|
+
}
|
|
206
|
+
record(true, 'session loaded', `${accountSource}; userid=${userid ? mask(userid) : '(absent)'}; session=${mask(session)}`)
|
|
207
|
+
|
|
208
|
+
/* ── 1. model catalog ───────────────────────────────────────────── */
|
|
209
|
+
const models = await apiGet('/api/v1/models')
|
|
210
|
+
record(models.status === 200, 'GET /api/v1/models -> 200', `status ${models.status}`)
|
|
211
|
+
const isBareList = models.json !== null && Array.isArray(models.json?.data) && models.json?.code === undefined
|
|
212
|
+
record(isBareList, 'model list is a BARE list (no envelope, no code field)',
|
|
213
|
+
isBareList ? `keys: ${Object.keys(models.json).join(',')}` : `body: ${models.text.slice(0, 160)}`)
|
|
214
|
+
const catalog = Array.isArray(models.json?.data) ? models.json.data : []
|
|
215
|
+
record(catalog.length > 0, 'catalog is non-empty', `${catalog.length} models`)
|
|
216
|
+
record(typeof models.json?.reasoning_catalog_version === 'string',
|
|
217
|
+
'reasoning_catalog_version present', String(models.json?.reasoning_catalog_version))
|
|
218
|
+
const defaultModel = catalog.find((entry) => entry?.is_default_model === true)
|
|
219
|
+
record(defaultModel !== undefined, 'exactly one is_default_model',
|
|
220
|
+
defaultModel ? defaultModel.id : 'none flagged')
|
|
221
|
+
record(catalog.every((entry) => typeof entry?.id === 'string' && entry.id.length > 0),
|
|
222
|
+
'every catalog entry has a string id')
|
|
223
|
+
for (const entry of catalog.slice(0, 3)) {
|
|
224
|
+
record(typeof entry.protocol === 'string' && entry.protocol === 'openai_chat',
|
|
225
|
+
`${entry.id}: protocol openai_chat`, String(entry.protocol))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/* ── 2. auth header matrix ──────────────────────────────────────── */
|
|
229
|
+
const tokenOnly = await apiGet('/api/v1/models')
|
|
230
|
+
const authOnly = await apiGet('/api/v1/models', { token: null })
|
|
231
|
+
record(Array.isArray(tokenOnly.json?.data), 'models accepts token header only')
|
|
232
|
+
record(authOnly.json?.code === '100002', 'models REJECTS Authorization-only with code 100002',
|
|
233
|
+
`code=${authOnly.json?.code} desc=${authOnly.json?.desc}`)
|
|
234
|
+
|
|
235
|
+
const chatTokenOnly = await chatProbe(
|
|
236
|
+
{ model: defaultModel?.id ?? 'spark-x2.6', messages: [{ role: 'user', content: 'hi' }], stream: true },
|
|
237
|
+
{ headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', token: session, 'loomy-version': VERSION } }
|
|
238
|
+
)
|
|
239
|
+
record(chatTokenOnly.contentType.includes('application/json') && chatTokenOnly.text.includes('100002'),
|
|
240
|
+
'chat REJECTS token-only with code 100002 (misleading "缺少 token")',
|
|
241
|
+
chatTokenOnly.text.slice(0, 120))
|
|
242
|
+
|
|
243
|
+
/* ── 3. points / daily grant ────────────────────────────────────── */
|
|
244
|
+
const first = await apiPost('/api/v1/points/first-login', {})
|
|
245
|
+
record(first.json?.code === '000000', 'POST points/first-login -> code 000000',
|
|
246
|
+
`code=${first.json?.code} desc=${first.json?.desc}`)
|
|
247
|
+
const balance = first.json?.data ?? {}
|
|
248
|
+
record(typeof balance.dailyQuota === 'number' && balance.dailyQuota > 0,
|
|
249
|
+
'dailyQuota reported', String(balance.dailyQuota))
|
|
250
|
+
record(typeof balance.dailyCycleDate === 'string', 'dailyCycleDate is a date string',
|
|
251
|
+
String(balance.dailyCycleDate))
|
|
252
|
+
const identityHolds = balance.currentBalance === (balance.permanentBalance ?? 0) + (balance.dailyBalance ?? 0)
|
|
253
|
+
record(identityHolds, 'currentBalance === permanentBalance + dailyBalance',
|
|
254
|
+
`${balance.currentBalance} === ${balance.permanentBalance} + ${balance.dailyBalance}`)
|
|
255
|
+
|
|
256
|
+
// Idempotency: a second call in the same dailyCycleDate must not change the balance.
|
|
257
|
+
const second = await apiPost('/api/v1/points/first-login', {})
|
|
258
|
+
const secondBalance = second.json?.data ?? {}
|
|
259
|
+
record(second.json?.code === '000000' && secondBalance.currentBalance === balance.currentBalance,
|
|
260
|
+
'first-login is idempotent within the same dailyCycleDate',
|
|
261
|
+
`alreadyProcessed=${secondBalance.alreadyProcessed} balance ${balance.currentBalance} -> ${secondBalance.currentBalance}`)
|
|
262
|
+
record(secondBalance.dailyCycleDate === balance.dailyCycleDate,
|
|
263
|
+
'dailyCycleDate stable across repeated calls', String(secondBalance.dailyCycleDate))
|
|
264
|
+
|
|
265
|
+
/* ── 4. records (v1 vs v2 shape) ────────────────────────────────── */
|
|
266
|
+
const recordsV1 = await apiGet('/api/v1/points/records?pageNo=1&pageSize=5&recordType=all')
|
|
267
|
+
record(recordsV1.json?.code === '000000', 'GET v1 points/records -> 000000')
|
|
268
|
+
const v1List = recordsV1.json?.data?.list
|
|
269
|
+
record(Array.isArray(v1List), 'v1 records expose a list array')
|
|
270
|
+
if (Array.isArray(v1List) && v1List.length > 0) {
|
|
271
|
+
record('pointsActual' in v1List[0], 'v1 record carries pointsActual (not points)',
|
|
272
|
+
Object.keys(v1List[0]).join(','))
|
|
273
|
+
record('dailyCycleDate' in v1List[0], 'v1 record carries dailyCycleDate')
|
|
274
|
+
}
|
|
275
|
+
const recordsV2 = await apiGet('/api/v2/points/records?pageNo=1&pageSize=5&direction=credit')
|
|
276
|
+
record(recordsV2.json?.code === '000000', 'GET v2 points/records -> 000000')
|
|
277
|
+
const v2List = recordsV2.json?.data?.list
|
|
278
|
+
if (Array.isArray(v2List) && v2List.length > 0) {
|
|
279
|
+
record('points' in v2List[0] && !('pointsActual' in v2List[0]),
|
|
280
|
+
'v2 record carries points (not pointsActual)', Object.keys(v2List[0]).join(','))
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/* ── 5. read-only business endpoints ────────────────────────────── */
|
|
284
|
+
for (const [label, pathname] of [
|
|
285
|
+
['user/identity', '/api/v1/user/identity'],
|
|
286
|
+
['onboarding/tasks', '/api/v1/onboarding/tasks'],
|
|
287
|
+
['pet-work', '/api/v1/pet-work']
|
|
288
|
+
]) {
|
|
289
|
+
const res = await apiGet(pathname)
|
|
290
|
+
record(res.json?.code === '000000', `GET ${label} -> 000000`, String(res.json?.desc ?? ''))
|
|
291
|
+
}
|
|
292
|
+
const tasks = await apiGet('/api/v1/onboarding/tasks')
|
|
293
|
+
record(tasks.json?.data?.total === 10000, 'onboarding total pool is 10000',
|
|
294
|
+
String(tasks.json?.data?.total))
|
|
295
|
+
|
|
296
|
+
/* ── 6. error shapes ────────────────────────────────────────────── */
|
|
297
|
+
const badToken = await apiGet('/api/v1/models', { token: '0'.repeat(32) })
|
|
298
|
+
record(badToken.status === 200 && badToken.json?.code === '100002',
|
|
299
|
+
'bad token -> HTTP 200 with code 100002 (status is not the failure signal)',
|
|
300
|
+
`status=${badToken.status} code=${badToken.json?.code}`)
|
|
301
|
+
record(typeof badToken.json?.desc === 'string', 'error envelope carries a human desc',
|
|
302
|
+
String(badToken.json?.desc))
|
|
303
|
+
|
|
304
|
+
const emptyMessages = await chatProbe({ model: defaultModel?.id ?? 'spark-x2.6', messages: [], stream: true })
|
|
305
|
+
record(emptyMessages.status === 400 && emptyMessages.text.includes('messages 不能为空'),
|
|
306
|
+
'empty messages -> 400 "messages 不能为空" (no free probe via empty array)',
|
|
307
|
+
`status=${emptyMessages.status}`)
|
|
308
|
+
|
|
309
|
+
/* ── 7. rate-limit headers ──────────────────────────────────────── */
|
|
310
|
+
const headerNames = [...models.headers.keys()]
|
|
311
|
+
const rateLimits = headerNames.filter((name) => /ratelimit|retry-after|x-rate/i.test(name))
|
|
312
|
+
record(rateLimits.length === 0, 'no rate-limit headers on responses',
|
|
313
|
+
rateLimits.length ? rateLimits.join(',') : `headers: ${headerNames.join(',')}`)
|
|
314
|
+
|
|
315
|
+
/* ── 8. the one chat probe (free model, costs 0 points) ─────────── */
|
|
316
|
+
if (offline) {
|
|
317
|
+
console.log('SKIP chat/SSE probe (--offline)')
|
|
318
|
+
} else {
|
|
319
|
+
const freeModel = catalog.find((entry) => /x0\.0/.test(String(entry.name ?? '')))?.id ?? 'spark-x2.6'
|
|
320
|
+
const stream = await chatProbe({
|
|
321
|
+
model: freeModel,
|
|
322
|
+
messages: [{ role: 'user', content: 'Reply with the single word: ok' }],
|
|
323
|
+
stream: true,
|
|
324
|
+
tools: [{
|
|
325
|
+
type: 'function',
|
|
326
|
+
function: {
|
|
327
|
+
name: 'noop_tool',
|
|
328
|
+
description: 'Does nothing',
|
|
329
|
+
parameters: { type: 'object', properties: { value: { type: 'string' } }, required: ['value'] }
|
|
330
|
+
}
|
|
331
|
+
}]
|
|
332
|
+
})
|
|
333
|
+
record(stream.contentType.includes('text/event-stream'),
|
|
334
|
+
'chat with Authorization -> text/event-stream', stream.contentType)
|
|
335
|
+
|
|
336
|
+
const events = sseEvents(stream.text)
|
|
337
|
+
record(events.length > 0, 'SSE stream carries data: events', `${events.length} events`)
|
|
338
|
+
record(events.some((event) => event.done === true), 'SSE stream ends with data: [DONE]')
|
|
339
|
+
const firstChunk = events.find((event) => event.json)?.json
|
|
340
|
+
record(firstChunk?.object === 'chat.completion.chunk', 'first event is a chat.completion.chunk',
|
|
341
|
+
String(firstChunk?.object))
|
|
342
|
+
record(typeof firstChunk?.model === 'string' && firstChunk.model === freeModel,
|
|
343
|
+
'stream reports the model actually used', String(firstChunk?.model))
|
|
344
|
+
|
|
345
|
+
const usageEvent = events.find((event) => event.json?.usage !== undefined)
|
|
346
|
+
record(usageEvent !== undefined, 'stream carries a usage block',
|
|
347
|
+
usageEvent ? JSON.stringify(usageEvent.json.usage) : 'absent')
|
|
348
|
+
if (usageEvent) {
|
|
349
|
+
const usage = usageEvent.json.usage
|
|
350
|
+
record(typeof usage.points_consumed === 'number', 'usage.points_consumed is present and numeric',
|
|
351
|
+
`points_consumed=${usage.points_consumed}`)
|
|
352
|
+
record(Array.isArray(usageEvent.json.choices) && usageEvent.json.choices.length === 0,
|
|
353
|
+
'usage chunk has an EMPTY choices array (adapter must guard choices[0])')
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Reasoning and/or content must arrive as deltas.
|
|
357
|
+
const hasDelta = events.some((event) => {
|
|
358
|
+
const delta = event.json?.choices?.[0]?.delta
|
|
359
|
+
return delta && (typeof delta.content === 'string' || typeof delta.reasoning_content === 'string')
|
|
360
|
+
})
|
|
361
|
+
record(hasDelta, 'stream carries delta.content and/or delta.reasoning_content')
|
|
362
|
+
|
|
363
|
+
const finish = events.find((event) => typeof event.json?.choices?.[0]?.finish_reason === 'string' && event.json.choices[0].finish_reason !== null)
|
|
364
|
+
record(finish !== undefined, 'stream carries a finish_reason',
|
|
365
|
+
finish ? String(finish.json.choices[0].finish_reason) : 'absent')
|
|
366
|
+
|
|
367
|
+
// Non-streaming must be accepted.
|
|
368
|
+
const plain = await chatProbe({ model: freeModel, messages: [{ role: 'user', content: 'Reply with: ok' }], stream: false })
|
|
369
|
+
let plainJson = null
|
|
370
|
+
try { plainJson = JSON.parse(plain.text) } catch { plainJson = null }
|
|
371
|
+
record(plain.contentType.includes('application/json') && plainJson?.object === 'chat.completion',
|
|
372
|
+
'stream:false is accepted and answers a chat.completion object', plain.contentType)
|
|
373
|
+
record(Array.isArray(plainJson?.choices) && typeof plainJson?.choices?.[0]?.message?.content === 'string',
|
|
374
|
+
'non-streaming answer carries choices[0].message.content')
|
|
375
|
+
|
|
376
|
+
// An unknown model must NOT error — it silently falls back. Documented trap.
|
|
377
|
+
const unknown = await chatProbe({ model: 'definitely-not-a-real-model-xyz', messages: [{ role: 'user', content: 'hi' }], stream: true })
|
|
378
|
+
const unknownFirst = sseEvents(unknown.text).find((event) => event.json)?.json
|
|
379
|
+
record(unknown.status === 200 && unknownFirst?.model !== 'definitely-not-a-real-model-xyz',
|
|
380
|
+
'unknown model id silently falls back instead of erroring (trap)',
|
|
381
|
+
`requested=definitely-not-a-real-model-xyz served=${unknownFirst?.model}`)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/* ── 9. balance did not move ────────────────────────────────────── */
|
|
385
|
+
await sleep(400)
|
|
386
|
+
const after = await apiPost('/api/v1/points/first-login', {})
|
|
387
|
+
const afterBalance = after.json?.data ?? {}
|
|
388
|
+
record(afterBalance.currentBalance === balance.currentBalance,
|
|
389
|
+
'checks spent no points (balance unchanged)',
|
|
390
|
+
`${balance.currentBalance} -> ${afterBalance.currentBalance}`)
|
|
391
|
+
|
|
392
|
+
/* ── summary ────────────────────────────────────────────────────── */
|
|
393
|
+
const failed = results.filter((entry) => !entry.ok)
|
|
394
|
+
console.log('='.repeat(60))
|
|
395
|
+
console.log(`${results.length - failed.length}/${results.length} PASS, ${failed.length} FAIL`)
|
|
396
|
+
if (failed.length > 0) {
|
|
397
|
+
console.log('\nFailed assertions:')
|
|
398
|
+
for (const entry of failed) console.log(` - ${entry.name}${entry.detail ? ': ' + redact(entry.detail) : ''}`)
|
|
399
|
+
}
|
|
400
|
+
process.exit(failed.length === 0 ? 0 : 1)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
main().catch((error) => {
|
|
404
|
+
console.error('FAIL uncaught: ' + redact(error instanceof Error ? error.message : String(error)))
|
|
405
|
+
process.exit(1)
|
|
406
|
+
})
|
package/icon-mask.png
ADDED
|
Binary file
|
package/icon.png
ADDED
|
Binary file
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kukonce/dsh-loomy",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "Loomy (讯飞 Loomy) model provider for DeepSeek Harness — session login, live model catalog, daily 5000-credit renewal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./client": "./client/index.js",
|
|
10
|
+
"./package.json": "./package.json",
|
|
11
|
+
"./locale/*.json": "./locale/*.json",
|
|
12
|
+
"./icon": "./icon.png"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"client",
|
|
17
|
+
"docs",
|
|
18
|
+
"locale/*.json",
|
|
19
|
+
"cordis.patch.yml",
|
|
20
|
+
"icon.png",
|
|
21
|
+
"icon-mask.png",
|
|
22
|
+
"README.md",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"keywords": [
|
|
27
|
+
"dsh",
|
|
28
|
+
"dsh-plugin",
|
|
29
|
+
"llm-provider",
|
|
30
|
+
"loomy",
|
|
31
|
+
"xfyun"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20.11"
|
|
35
|
+
},
|
|
36
|
+
"dsh": {
|
|
37
|
+
"bundle": {
|
|
38
|
+
"patch": "./cordis.patch.yml"
|
|
39
|
+
},
|
|
40
|
+
"client": {
|
|
41
|
+
"platform": "web",
|
|
42
|
+
"immediately": true,
|
|
43
|
+
"inject": []
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"license": "MIT",
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/kukonce/dsh-loomy.git"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/kukonce/dsh-loomy#readme",
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/kukonce/dsh-loomy/issues"
|
|
54
|
+
},
|
|
55
|
+
"author": "kukonce",
|
|
56
|
+
"scripts": {
|
|
57
|
+
"verify": "node docs/verify/live-check.mjs",
|
|
58
|
+
"verify:offline": "node docs/verify/live-check.mjs --offline"
|
|
59
|
+
}
|
|
6
60
|
}
|