dsh-llm-adapters 1.2.1 → 1.2.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/src/raccoon.ts CHANGED
@@ -9,8 +9,9 @@
9
9
  */
10
10
 
11
11
  import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
12
+ import { createHash } from 'node:crypto'
12
13
  import type { AccountStore } from './account-store.ts'
13
- import { RaccoonAuth } from './auth.ts'
14
+ import { RaccoonAuth, decodeJwtFacts } from './auth.ts'
14
15
  import { RaccoonQrLogin } from './raccoon-login.ts'
15
16
  import type { LoginStatus } from './login-page.ts'
16
17
  import {
@@ -47,6 +48,8 @@ export interface RaccoonProviderOptions {
47
48
  fetchImpl?: typeof fetch
48
49
  /** Injectable OpenRouter lookup for tests; defaults to the live catalog. */
49
50
  openRouter?: OpenRouterLookup
51
+ /** Injectable session-id bookkeeping for tests; defaults to the real one. */
52
+ sessionIds?: RaccoonSessionIds
50
53
  }
51
54
 
52
55
  function numeric(value: unknown): number | undefined {
@@ -98,6 +101,118 @@ function raccoonHeaders(token: string): Record<string, string> {
98
101
  }
99
102
  }
100
103
 
104
+ /**
105
+ * The upstream code for the account's token-throughput wall.
106
+ *
107
+ * It reads like a request-rate complaint (`您的模型请求频率超限`) and is not
108
+ * one: measured on a live account, ten large requests in ten seconds hit it
109
+ * while fifty small ones did not, and the window refills by throughput rather
110
+ * than by request count. The gateway sends no `Retry-After` and no
111
+ * `x-ratelimit-*` header, so nothing here can compute a wait either.
112
+ */
113
+ export const RACCOON_RATE_LIMIT_CODE = 'user_model_rate_limited'
114
+
115
+ /** Wording the gateway has used alongside that code. */
116
+ const RATE_LIMIT_WORDS = ['user_model_rate_limited', 'rate_limit', '频率超限']
117
+
118
+ /**
119
+ * Whether a rejection is the token wall rather than a generic failure.
120
+ *
121
+ * Matched on the status, the code and the wording, because the same condition
122
+ * arrives as an HTTP 429 and as an error frame inside a 200 stream.
123
+ */
124
+ export function isRaccoonRateLimited(status: number | undefined, text: string): boolean {
125
+ if (status === 429) return true
126
+ const lower = text.toLowerCase()
127
+ return RATE_LIMIT_WORDS.some(word => lower.includes(word.toLowerCase()))
128
+ }
129
+
130
+ /**
131
+ * The wall, as an error the caller can tell apart from a generic 429.
132
+ *
133
+ * A subclass rather than a flag: the in-band path and the HTTP path would
134
+ * otherwise both be caught by the same handler, and the second pass would wrap
135
+ * the message the first pass had already rewritten.
136
+ */
137
+ export class RaccoonRateLimitedError extends ProviderHttpError {
138
+ constructor(message: string) {
139
+ super(message, 429)
140
+ this.name = 'RaccoonRateLimitedError'
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The message a user sees once the wall is hit.
146
+ *
147
+ * The raw text says "retry later" and gives no reason, which is why this reads
148
+ * as the plugin being broken. The honest answer names the cause and the one
149
+ * lever the user actually has on this side.
150
+ */
151
+ function raccoonRateLimited(detail: string): RaccoonRateLimitedError {
152
+ return new RaccoonRateLimitedError(
153
+ '小浣熊账号的模型吞吐已达上限(不是每分钟请求数,而是按账号的 token 速率)。'
154
+ + '一轮对话带的上下文越大越快撞到,撞到后额度按吞吐缓慢回血,上游也没有给恢复时间。'
155
+ + '稍等一会儿再发;把该模型的上下文窗口调小,同一会话能撑得更久。'
156
+ + `上游原文:${detail}`,
157
+ )
158
+ }
159
+
160
+ /** The `error` object a compatible gateway puts in an SSE frame. */
161
+ function frameFailure(frame: Record<string, unknown>): { code?: string, message?: string } | undefined {
162
+ const error = frame.error
163
+ if (error === undefined) return undefined
164
+ if (typeof error === 'string') return { message: error }
165
+ if (error === null || typeof error !== 'object') return undefined
166
+ const record = error as Record<string, unknown>
167
+ const code = typeof record.code === 'string' ? record.code : undefined
168
+ const message = typeof record.message === 'string' ? record.message : undefined
169
+ return {
170
+ ...code === undefined ? {} : { code },
171
+ ...message === undefined ? {} : { message },
172
+ }
173
+ }
174
+
175
+ /**
176
+ * One conversation's session id, stable across the turns of that conversation.
177
+ *
178
+ * The official client never opens a session per request — measured on its own
179
+ * logs, 125 calls over 26 minutes all carried one `sess-0-53927d74` — while an
180
+ * implementation that mints a fresh id per request tells the gateway that one
181
+ * account opened hundreds of sessions within minutes.
182
+ *
183
+ * The id is a pure function of (account, model) rather than a random value, so a
184
+ * restarted or hot-reloaded process continues the same conversation instead of
185
+ * presenting a new session, and two models stay two conversations. The principal
186
+ * itself never leaves the process — only a truncated digest of it does.
187
+ */
188
+ export class RaccoonSessionIds {
189
+ /**
190
+ * @param principal - a value that is stable for one account and identifies it
191
+ * without being the token itself (the JWT's `sub`).
192
+ */
193
+ constructor(private readonly principal: () => Promise<string | undefined>) {}
194
+
195
+ async for(model: string): Promise<string | undefined> {
196
+ const principal = await this.principal()
197
+ if (principal === undefined) return undefined
198
+ return raccoonSessionId(principal, model)
199
+ }
200
+ }
201
+
202
+ /**
203
+ * `sess-<sequence>-<8 hex>`, the shape the official client sends.
204
+ *
205
+ * The sequence is the official client's process-local counter and is pinned to 0
206
+ * here on purpose: an incrementing value would make one conversation look like a
207
+ * different session after a restart, which is the one thing this id exists to
208
+ * prevent. Identity lives in the digest, which is truncated on purpose — the id
209
+ * must separate accounts without naming them on the wire.
210
+ */
211
+ export function raccoonSessionId(principal: string, model: string): string {
212
+ const digest = createHash('sha256').update(`${principal}\0${model}`).digest('hex').slice(0, 8)
213
+ return `sess-0-${digest}`
214
+ }
215
+
101
216
  /** Whether the gateway accepts a token harvested from the browser. */
102
217
  export async function raccoonAccessTokenValid(
103
218
  token: string,
@@ -156,12 +271,16 @@ export class RaccoonProvider {
156
271
  readonly catalog: ModelCatalog
157
272
  private quotaCache: { at: number, value: QuotaSnapshot } | undefined
158
273
  private quotaInflight: Promise<QuotaSnapshot> | undefined
159
- private checkinInflight: Promise<CheckinStatus> | undefined
160
274
  private loginFlow: RaccoonQrLogin | undefined
161
275
  private readonly openRouter: OpenRouterLookup
276
+ private readonly sessionIds: RaccoonSessionIds
162
277
 
163
278
  constructor(private readonly options: RaccoonProviderOptions) {
164
279
  this.openRouter = options.openRouter ?? new OpenRouterCatalog(options.fetchImpl ?? fetch)
280
+ this.sessionIds = options.sessionIds ?? new RaccoonSessionIds(async () => {
281
+ const stored = await options.store.raccoon()
282
+ return stored === undefined ? undefined : decodeJwtFacts(stored.accessToken).subject
283
+ })
165
284
  this.catalog = new ModelCatalog(
166
285
  () => this.fetchModels(),
167
286
  options.ttlMs,
@@ -225,6 +344,19 @@ export class RaccoonProvider {
225
344
  return raccoonHeaders(token)
226
345
  }
227
346
 
347
+ /**
348
+ * The chat headers, including the session id the gateway counts sessions by.
349
+ *
350
+ * Only chat carries it: the catalogue, quota and check-in routes are plain
351
+ * business calls the official client sends outside any session, so leaving
352
+ * them alone keeps this change to the one route it was measured on.
353
+ */
354
+ private async chatHeaders(token: string, model: string): Promise<Record<string, string>> {
355
+ const base = raccoonHeaders(token)
356
+ const sessionId = await this.sessionIds.for(model).catch(() => undefined)
357
+ return sessionId === undefined ? base : { ...base, 'x-raccoon-session-id': sessionId }
358
+ }
359
+
228
360
  /** Run one JSON call; a rejected token means this login needs a new scan. */
229
361
  private async authorizedJson<T>(
230
362
  path: string,
@@ -371,6 +503,15 @@ export class RaccoonProvider {
371
503
  return undefined
372
504
  }
373
505
 
506
+ /**
507
+ * Read today's grant. **Read-only on purpose.**
508
+ *
509
+ * The gateway has no claim endpoint: points are issued on the day's first
510
+ * website visit. Replaying that visit (`setting_info`) can trigger the grant
511
+ * from here, but doing so takes a state-changing action on the account without
512
+ * the user asking for it in that moment, so the plugin only reports. Claiming
513
+ * is the website's job, and the card links there.
514
+ */
374
515
  async checkinStatus(): Promise<CheckinStatus> {
375
516
  const grant = await this.dailyGrant()
376
517
  if (grant !== undefined) {
@@ -393,53 +534,6 @@ export class RaccoonProvider {
393
534
  }
394
535
  }
395
536
 
396
- async claimCheckin(): Promise<CheckinStatus> {
397
- // `setting_info` is the call the website makes on load; it is also the one
398
- // that carries the point-grant popups. Ask as the web client so the server
399
- // treats it as a normal page visit rather than a desktop API call.
400
- await this.authorizedJson('/office/v3/setting_info', {}, {
401
- 'x-client-platform': 'web',
402
- 'x-client-version': '131.0.0.0',
403
- })
404
- const grant = await this.dailyGrant()
405
- if (grant === undefined) {
406
- return {
407
- available: true,
408
- claimed: false,
409
- deviceClaimed: false,
410
- message: '每日积分尚未到账;请打开网页签到',
411
- url: RACCOON_CHECKIN_URL,
412
- }
413
- }
414
- this.invalidateQuota()
415
- return {
416
- available: true,
417
- claimed: true,
418
- deviceClaimed: false,
419
- ...grant.points === undefined ? {} : { reward: grant.points },
420
- ...grant.grantedAt === undefined ? {} : { grantedAt: grant.grantedAt },
421
- message: '每日积分已到账',
422
- url: RACCOON_CHECKIN_URL,
423
- }
424
- }
425
-
426
- /**
427
- * One automatic check-in pass: report today's grant, or ask the website path
428
- * to issue it. Shared so overlapping timers cannot spend two call slots.
429
- */
430
- async ensureDailyCheckin(): Promise<CheckinStatus> {
431
- if (this.checkinInflight !== undefined) return this.checkinInflight
432
- this.checkinInflight = (async () => {
433
- const status = await this.checkinStatus()
434
- return status.claimed ? status : this.claimCheckin()
435
- })()
436
- try {
437
- return await this.checkinInflight
438
- } finally {
439
- this.checkinInflight = undefined
440
- }
441
- }
442
-
443
537
  async *stream(
444
538
  options: GenerateOptions,
445
539
  model: string,
@@ -465,7 +559,7 @@ export class RaccoonProvider {
465
559
  try {
466
560
  yield* openAiStream({
467
561
  url: `${RACCOON_BASE}/llm/v2/chat/completions`,
468
- headers: this.headers(token),
562
+ headers: await this.chatHeaders(token, model),
469
563
  body,
470
564
  signal: options.signal,
471
565
  fetchImpl: this.options.fetchImpl,
@@ -474,11 +568,30 @@ export class RaccoonProvider {
474
568
  this.invalidateQuota()
475
569
  onUnauthorized?.()
476
570
  },
571
+ // The same wall arrives inside a 200 stream as an error frame. Without
572
+ // this hook it would surface as a generic 502 and read as a plugin bug.
573
+ onFrame: (frame) => {
574
+ const failure = frameFailure(frame)
575
+ if (failure === undefined) return
576
+ const detail = failure.message ?? failure.code ?? '上游返回错误'
577
+ throw isRaccoonRateLimited(undefined, `${failure.code ?? ''} ${detail}`)
578
+ ? raccoonRateLimited(detail)
579
+ : new ProviderHttpError(detail, 502)
580
+ },
477
581
  })
478
582
  } catch (error) {
479
583
  if (error instanceof ProviderHttpError && (error.status === 401 || error.status === 403)) {
480
584
  throw new Error('小浣熊登录已失效,请在设置页点「打开扫码登录」。')
481
585
  }
586
+ // The HTTP path: `openAiStream` throws the status before any frame is read.
587
+ // The in-band path already rewrote its own error, so it is left alone.
588
+ // Only the status is trusted here: matching on the wording would re-wrap a
589
+ // 502 that the in-band hook deliberately classified as a gateway failure.
590
+ if (error instanceof ProviderHttpError
591
+ && !(error instanceof RaccoonRateLimitedError)
592
+ && error.status === 429) {
593
+ throw raccoonRateLimited(error.message)
594
+ }
482
595
  throw error
483
596
  }
484
597
  }
@@ -1,65 +0,0 @@
1
- /**
2
- * The trial plan's chat budget.
3
- *
4
- * The gateway opens one "session" per chat request and allows **three per
5
- * rolling minute** for the whole account. The window is shared by every model,
6
- * it is not keyed by `session-id` (re-sending the same id does not buy a slot),
7
- * and a request rejected with `InferHub.4004.200` spends a slot just like a
8
- * successful one. Measured behaviour, three slots then a lockout:
9
- *
10
- * ```
11
- * t+0s ok ok ok cap cap cap burst of six
12
- * t+10s cap …
13
- * t+51s cap
14
- * t+61s ok ok ok the burst ages out of the window
15
- * ```
16
- *
17
- * So a turn cannot be made to "go faster" by retrying: the only way a request
18
- * stops failing is to spend the budget deliberately — wait for the oldest start
19
- * to age out instead of firing into a wall. This class owns that bookkeeping and
20
- * is shared by every provider instance in the process, because a hot reload
21
- * must not reset the count the gateway still remembers.
22
- *
23
- * @module dsh-llm-adapters/codearts-window
24
- */
25
- /** Sessions the trial plan allows inside one window. */
26
- export declare const CODEARTS_SESSION_LIMIT = 3;
27
- /** How long a start keeps its slot. Measured: the burst frees at ~60 s. */
28
- export declare const CODEARTS_SESSION_WINDOW_MS = 60000;
29
- /** The claim a caller holds while its request is in flight. */
30
- export interface CodeArtsSessionSlot {
31
- /**
32
- * Give the slot back.
33
- *
34
- * The gateway does not charge for a call it turned away: a burst rejected
35
- * with `TM.00001041` at t+0 s lifts at the same moment as one that was never
36
- * sent. Keeping the slot after a refusal would therefore throttle the next
37
- * minute to one request per twenty seconds for no reason.
38
- */
39
- release(): void;
40
- }
41
- export declare class CodeArtsSessionWindow {
42
- private readonly limit;
43
- private readonly windowMs;
44
- private readonly now;
45
- private readonly starts;
46
- /** Serialises waiters so two callers cannot both take the last slot. */
47
- private tail;
48
- constructor(limit?: number, windowMs?: number, now?: () => number);
49
- /** Starts recorded inside the current window. */
50
- get size(): number;
51
- /** Milliseconds until another start fits; `0` when the window has room. */
52
- waitMs(): number;
53
- /**
54
- * Claim a slot, waiting for the window to roll over when it is full.
55
- *
56
- * The slot is recorded on return, not on completion: the gateway counts the
57
- * start, and holds it for a fixed time whether the answer was short or long.
58
- */
59
- open(signal?: AbortSignal): Promise<CodeArtsSessionSlot>;
60
- /** Forget every start (tests, and a fresh login on another account). */
61
- reset(): void;
62
- private trim;
63
- }
64
- /** The process-wide window; a plugin reload reuses it on purpose. */
65
- export declare function sharedSessionWindow(): CodeArtsSessionWindow;
@@ -1,136 +0,0 @@
1
- /**
2
- * The trial plan's chat budget.
3
- *
4
- * The gateway opens one "session" per chat request and allows **three per
5
- * rolling minute** for the whole account. The window is shared by every model,
6
- * it is not keyed by `session-id` (re-sending the same id does not buy a slot),
7
- * and a request rejected with `InferHub.4004.200` spends a slot just like a
8
- * successful one. Measured behaviour, three slots then a lockout:
9
- *
10
- * ```
11
- * t+0s ok ok ok cap cap cap burst of six
12
- * t+10s cap …
13
- * t+51s cap
14
- * t+61s ok ok ok the burst ages out of the window
15
- * ```
16
- *
17
- * So a turn cannot be made to "go faster" by retrying: the only way a request
18
- * stops failing is to spend the budget deliberately — wait for the oldest start
19
- * to age out instead of firing into a wall. This class owns that bookkeeping and
20
- * is shared by every provider instance in the process, because a hot reload
21
- * must not reset the count the gateway still remembers.
22
- *
23
- * @module dsh-llm-adapters/codearts-window
24
- */
25
-
26
- /** Sessions the trial plan allows inside one window. */
27
- export const CODEARTS_SESSION_LIMIT = 3
28
-
29
- /** How long a start keeps its slot. Measured: the burst frees at ~60 s. */
30
- export const CODEARTS_SESSION_WINDOW_MS = 60_000
31
-
32
- /** A little slack so a request never lands a few milliseconds early. */
33
- const MARGIN_MS = 1_500
34
-
35
- /** The claim a caller holds while its request is in flight. */
36
- export interface CodeArtsSessionSlot {
37
- /**
38
- * Give the slot back.
39
- *
40
- * The gateway does not charge for a call it turned away: a burst rejected
41
- * with `TM.00001041` at t+0 s lifts at the same moment as one that was never
42
- * sent. Keeping the slot after a refusal would therefore throttle the next
43
- * minute to one request per twenty seconds for no reason.
44
- */
45
- release(): void
46
- }
47
-
48
- function sleep(ms: number, signal?: AbortSignal): Promise<void> {
49
- return new Promise((resolve, reject) => {
50
- if (signal?.aborted === true) {
51
- reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'))
52
- return
53
- }
54
- const onAbort = (): void => {
55
- clearTimeout(timer)
56
- reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted'))
57
- }
58
- const timer = setTimeout(() => {
59
- signal?.removeEventListener('abort', onAbort)
60
- resolve()
61
- }, ms)
62
- signal?.addEventListener('abort', onAbort, { once: true })
63
- })
64
- }
65
-
66
- export class CodeArtsSessionWindow {
67
- private readonly starts: number[] = []
68
- /** Serialises waiters so two callers cannot both take the last slot. */
69
- private tail: Promise<void> = Promise.resolve()
70
-
71
- constructor(
72
- private readonly limit: number = CODEARTS_SESSION_LIMIT,
73
- private readonly windowMs: number = CODEARTS_SESSION_WINDOW_MS,
74
- private readonly now: () => number = Date.now,
75
- ) {}
76
-
77
- /** Starts recorded inside the current window. */
78
- get size(): number {
79
- this.trim()
80
- return this.starts.length
81
- }
82
-
83
- /** Milliseconds until another start fits; `0` when the window has room. */
84
- waitMs(): number {
85
- this.trim()
86
- if (this.starts.length < this.limit) return 0
87
- const oldest = this.starts[0] ?? 0
88
- return Math.max(0, this.windowMs - (this.now() - oldest))
89
- }
90
-
91
- /**
92
- * Claim a slot, waiting for the window to roll over when it is full.
93
- *
94
- * The slot is recorded on return, not on completion: the gateway counts the
95
- * start, and holds it for a fixed time whether the answer was short or long.
96
- */
97
- async open(signal?: AbortSignal): Promise<CodeArtsSessionSlot> {
98
- const run = this.tail.then(async (): Promise<CodeArtsSessionSlot> => {
99
- for (;;) {
100
- const wait = this.waitMs()
101
- if (wait === 0) {
102
- const start = this.now()
103
- this.starts.push(start)
104
- return {
105
- release: () => {
106
- const index = this.starts.indexOf(start)
107
- if (index >= 0) this.starts.splice(index, 1)
108
- },
109
- }
110
- }
111
- await sleep(wait + MARGIN_MS, signal)
112
- }
113
- })
114
- // A failed waiter must not poison the queue for the next one.
115
- this.tail = run.then(() => undefined, () => undefined)
116
- return await run
117
- }
118
-
119
- /** Forget every start (tests, and a fresh login on another account). */
120
- reset(): void {
121
- this.starts.length = 0
122
- }
123
-
124
- private trim(): void {
125
- const now = this.now()
126
- while (this.starts.length > 0 && now - (this.starts[0] ?? 0) >= this.windowMs) this.starts.shift()
127
- }
128
- }
129
-
130
- let shared: CodeArtsSessionWindow | undefined
131
-
132
- /** The process-wide window; a plugin reload reuses it on purpose. */
133
- export function sharedSessionWindow(): CodeArtsSessionWindow {
134
- shared ??= new CodeArtsSessionWindow()
135
- return shared
136
- }