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.
@@ -44,6 +44,13 @@ export interface JwtFacts {
44
44
  phone?: string;
45
45
  ownerType?: string;
46
46
  sessionId?: string;
47
+ /**
48
+ * The `sub` claim, which is what an account is called on the Raccoon gateway.
49
+ *
50
+ * Unlike the token itself it survives a re-sign, so it is the seed a stable
51
+ * per-account session id is derived from.
52
+ */
53
+ subject?: string;
47
54
  }
48
55
  /** Read non-secret JWT claims; malformed tokens simply yield no facts. */
49
56
  export declare function decodeJwtFacts(token: string): JwtFacts;
@@ -28,6 +28,17 @@ export interface CodeArtsCredential {
28
28
  dpopPrivateJwk?: DpopPrivateJwk;
29
29
  /** Display only, from the refresh token's profile; never used for signing. */
30
30
  userName?: string;
31
+ /**
32
+ * The id the gateway counts concurrent sessions against (`x-ot-session-id`).
33
+ *
34
+ * Measured on a real account: the three-session cap is keyed by this header,
35
+ * not by `Chat-Id` / `Session-Id`. A per-request value makes every call a new
36
+ * session, so the account spends its own budget on its own history and the
37
+ * fourth call of the minute comes back `TM.00001041`. Derived from the account
38
+ * identity instead of stored at random: the same value in every process, and
39
+ * one that survives a token renewal.
40
+ */
41
+ otSessionId?: string;
31
42
  }
32
43
  /** Who the credential belongs to, as the portal encoded it in the token. */
33
44
  export interface CodeArtsUserProfile {
@@ -75,6 +86,14 @@ export declare function readJwtExpiry(token: string): number | undefined;
75
86
  * request; without it the field stays empty and the page says so.
76
87
  */
77
88
  export declare function readUserProfile(token: string | undefined): CodeArtsUserProfile | undefined;
89
+ /**
90
+ * The session id this account should chat under, derived from who the account is.
91
+ *
92
+ * 32 lowercase hex, the shape the IDE's own kernel sends. Nothing but stability
93
+ * matters here — the gateway uses the value as a key, not as an identity — so it
94
+ * is hashed from the principal id rather than kept in a file.
95
+ */
96
+ export declare function otSessionIdOf(profile: CodeArtsUserProfile | undefined): string | undefined;
78
97
  /** The portal URL the browser opens; the callback is on loopback. */
79
98
  export declare function buildAuthorizeUrl(port: number, pkce: PkcePair, ticketId: string): string;
80
99
  /** Where the browser lands after a successful or failed exchange. */
@@ -19,7 +19,6 @@ import type { AccountStore } from './account-store.ts';
19
19
  import { CodeArtsAuth } from './auth.ts';
20
20
  import type { AuthStatus } from './auth.ts';
21
21
  import type { ProviderModel } from './catalog.ts';
22
- import { CodeArtsSessionWindow } from './codearts-window.ts';
23
22
  import type { LoginStatus } from './login-page.ts';
24
23
  import type { OpenRouterLookup } from './openrouter.ts';
25
24
  import type { CheckinStatus, QuotaPack, QuotaSnapshot } from './types.ts';
@@ -72,8 +71,6 @@ export interface CodeArtsProviderOptions {
72
71
  loginTimeoutMs?: number;
73
72
  /** Injectable OpenRouter lookup for tests; defaults to the live catalog. */
74
73
  openRouter?: OpenRouterLookup;
75
- /** Chat budget; defaults to the process-wide window (survives hot reloads). */
76
- window?: CodeArtsSessionWindow;
77
74
  }
78
75
  /** One roster entry before it becomes a model row. */
79
76
  export interface CodeArtsModelRow {
@@ -110,7 +107,6 @@ export declare class CodeArtsProvider {
110
107
  /** Models that answered `InferHub.4004.200`: no allowance pack to bill. */
111
108
  private readonly noBenefit;
112
109
  private noBenefitLoaded;
113
- private readonly window;
114
110
  private readonly openRouter;
115
111
  private readonly sessionId;
116
112
  /**
@@ -159,7 +155,7 @@ export declare class CodeArtsProvider {
159
155
  * so a conversation pays for the description once.
160
156
  */
161
157
  private relayImages;
162
- /** Ask the vision model to describe one image, within the same chat budget. */
158
+ /** Ask the vision model to describe one image; another turn on the same session. */
163
159
  private describeImage;
164
160
  stream(options: GenerateOptions, model: string, readImage: ReadImage | undefined, onInvalidate: () => void): AsyncGenerator<StreamChunk>;
165
161
  private send;
@@ -45,8 +45,6 @@ export interface Config {
45
45
  catalogFetchTimeoutSeconds?: number;
46
46
  /** Minutes between automatic OpenRouter syncs; 0 disables the schedule. */
47
47
  openRouterSyncMinutes?: number;
48
- /** Minutes between Raccoon daily-point checks; 0 disables the monitor. */
49
- raccoonCheckinMinutes?: number;
50
48
  }
51
49
  export declare const Config: z<Schemastery.ObjectS<NoInfer<{
52
50
  loomyEnabled: z<boolean, boolean, "volatile-defined">;
@@ -82,7 +80,6 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
82
80
  modelCacheTtlSeconds: z<number, number, "volatile-defined">;
83
81
  catalogFetchTimeoutSeconds: z<number, number, "volatile-defined">;
84
82
  openRouterSyncMinutes: z<number, number, "volatile-defined">;
85
- raccoonCheckinMinutes: z<number, number, "volatile-defined">;
86
83
  }>>, Schemastery.ObjectT<NoInfer<{
87
84
  loomyEnabled: z<boolean, boolean, "volatile-defined">;
88
85
  raccoonEnabled: z<boolean, boolean, "volatile-defined">;
@@ -117,7 +114,6 @@ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
117
114
  modelCacheTtlSeconds: z<number, number, "volatile-defined">;
118
115
  catalogFetchTimeoutSeconds: z<number, number, "volatile-defined">;
119
116
  openRouterSyncMinutes: z<number, number, "volatile-defined">;
120
- raccoonCheckinMinutes: z<number, number, "volatile-defined">;
121
117
  }>>, "plain">;
122
118
  export interface ResolvedConfig {
123
119
  loomyEnabled: boolean;
@@ -153,7 +149,6 @@ export interface ResolvedConfig {
153
149
  modelCacheTtlMs: number;
154
150
  catalogFetchTimeoutMs: number;
155
151
  openRouterSyncMinutes: number;
156
- raccoonCheckinMinutes: number;
157
152
  }
158
153
  export type ParsedConfig = {
159
154
  readonly [K in keyof Config]?: unknown;
@@ -71,11 +71,10 @@ export { CodeArtsProvider, CODEARTS_DEFAULT_EFFORT, CODEARTS_EFFORTS, clampMaxTo
71
71
  export type { CodeArtsActivity, CodeArtsModelRow, CodeArtsPackageInfo, CodeArtsProviderOptions, } from './codearts.ts';
72
72
  export { CODEARTS_BENEFIT_FALLBACK, CODEARTS_CHAT_URL, CODEARTS_CONTEXT_WINDOWS, CODEARTS_EFFORT_LEVELS, CODEARTS_FALLBACK_CONTEXT_WINDOW, CODEARTS_MAX_OUTPUT_TOKENS, CODEARTS_SNAP_BASE, CODEARTS_VISION_MODELS, codeartsContextWindow, } from './codearts-endpoints.ts';
73
73
  export { headersForFetch, signRequestHuawei } from './codearts-sign.ts';
74
- export { RefreshTokenExpiredError, buildAuthorizeUrl, exchangeAuthorizationCode, exchangeRefreshToken, generateDpopKeyPair, generatePkcePair, readJwtExpiry, signDpopJws, } from './codearts-oauth.ts';
74
+ export { RefreshTokenExpiredError, buildAuthorizeUrl, exchangeAuthorizationCode, exchangeRefreshToken, generateDpopKeyPair, generatePkcePair, otSessionIdOf, readJwtExpiry, signDpopJws, } from './codearts-oauth.ts';
75
75
  export type { CodeArtsCredential, DpopKeyPair, DpopPrivateJwk, PkcePair } from './codearts-oauth.ts';
76
76
  export { CodeArtsLogin } from './codearts-login.ts';
77
77
  export type { CodeArtsLoginAccount, CodeArtsLoginOptions } from './codearts-login.ts';
78
- export { CODEARTS_SESSION_LIMIT, CODEARTS_SESSION_WINDOW_MS, CodeArtsSessionWindow, sharedSessionWindow, } from './codearts-window.ts';
79
78
  export { ProviderHttpError, buildMessages, buildTools, consumeOpenAiStream, openAiStream, sseEvents } from './wire.ts';
80
79
  export type { WireMessage } from './wire.ts';
81
80
  export { API_PREFIX, buildRows } from './api.ts';
@@ -118,7 +117,6 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
118
117
  modelCacheTtlSeconds: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
119
118
  catalogFetchTimeoutSeconds: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
120
119
  openRouterSyncMinutes: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
121
- raccoonCheckinMinutes: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
122
120
  }>>, Schemastery.ObjectT<NoInfer<{
123
121
  loomyEnabled: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
124
122
  raccoonEnabled: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
@@ -153,6 +151,5 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
153
151
  modelCacheTtlSeconds: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
154
152
  catalogFetchTimeoutSeconds: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
155
153
  openRouterSyncMinutes: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
156
- raccoonCheckinMinutes: import("@deepseek-ai/schemastery").default<number, number, "volatile-defined">;
157
154
  }>>, "plain">;
158
155
  export declare function apply(ctx: Context, config: ParsedConfig): void;
@@ -15,6 +15,7 @@ import { ModelCatalog } from './catalog.ts';
15
15
  import type { ProviderModel } from './catalog.ts';
16
16
  import type { OpenRouterLookup } from './openrouter.ts';
17
17
  import type { CheckinStatus, QuotaSnapshot } from './types.ts';
18
+ import { ProviderHttpError } from './wire.ts';
18
19
  import type { ReadImage } from './wire.ts';
19
20
  export declare const RACCOON_BASE = "https://xiaohuanxiong.com/api/web";
20
21
  export declare const RACCOON_CLIENT_VERSION = "v1.0.35";
@@ -30,7 +31,68 @@ export interface RaccoonProviderOptions {
30
31
  fetchImpl?: typeof fetch;
31
32
  /** Injectable OpenRouter lookup for tests; defaults to the live catalog. */
32
33
  openRouter?: OpenRouterLookup;
34
+ /** Injectable session-id bookkeeping for tests; defaults to the real one. */
35
+ sessionIds?: RaccoonSessionIds;
33
36
  }
37
+ /**
38
+ * The upstream code for the account's token-throughput wall.
39
+ *
40
+ * It reads like a request-rate complaint (`您的模型请求频率超限`) and is not
41
+ * one: measured on a live account, ten large requests in ten seconds hit it
42
+ * while fifty small ones did not, and the window refills by throughput rather
43
+ * than by request count. The gateway sends no `Retry-After` and no
44
+ * `x-ratelimit-*` header, so nothing here can compute a wait either.
45
+ */
46
+ export declare const RACCOON_RATE_LIMIT_CODE = "user_model_rate_limited";
47
+ /**
48
+ * Whether a rejection is the token wall rather than a generic failure.
49
+ *
50
+ * Matched on the status, the code and the wording, because the same condition
51
+ * arrives as an HTTP 429 and as an error frame inside a 200 stream.
52
+ */
53
+ export declare function isRaccoonRateLimited(status: number | undefined, text: string): boolean;
54
+ /**
55
+ * The wall, as an error the caller can tell apart from a generic 429.
56
+ *
57
+ * A subclass rather than a flag: the in-band path and the HTTP path would
58
+ * otherwise both be caught by the same handler, and the second pass would wrap
59
+ * the message the first pass had already rewritten.
60
+ */
61
+ export declare class RaccoonRateLimitedError extends ProviderHttpError {
62
+ constructor(message: string);
63
+ }
64
+ /**
65
+ * One conversation's session id, stable across the turns of that conversation.
66
+ *
67
+ * The official client never opens a session per request — measured on its own
68
+ * logs, 125 calls over 26 minutes all carried one `sess-0-53927d74` — while an
69
+ * implementation that mints a fresh id per request tells the gateway that one
70
+ * account opened hundreds of sessions within minutes.
71
+ *
72
+ * The id is a pure function of (account, model) rather than a random value, so a
73
+ * restarted or hot-reloaded process continues the same conversation instead of
74
+ * presenting a new session, and two models stay two conversations. The principal
75
+ * itself never leaves the process — only a truncated digest of it does.
76
+ */
77
+ export declare class RaccoonSessionIds {
78
+ private readonly principal;
79
+ /**
80
+ * @param principal - a value that is stable for one account and identifies it
81
+ * without being the token itself (the JWT's `sub`).
82
+ */
83
+ constructor(principal: () => Promise<string | undefined>);
84
+ for(model: string): Promise<string | undefined>;
85
+ }
86
+ /**
87
+ * `sess-<sequence>-<8 hex>`, the shape the official client sends.
88
+ *
89
+ * The sequence is the official client's process-local counter and is pinned to 0
90
+ * here on purpose: an incrementing value would make one conversation look like a
91
+ * different session after a restart, which is the one thing this id exists to
92
+ * prevent. Identity lives in the digest, which is truncated on purpose — the id
93
+ * must separate accounts without naming them on the wire.
94
+ */
95
+ export declare function raccoonSessionId(principal: string, model: string): string;
34
96
  /** Whether the gateway accepts a token harvested from the browser. */
35
97
  export declare function raccoonAccessTokenValid(token: string, fetchImpl?: typeof fetch): Promise<boolean>;
36
98
  /**
@@ -43,9 +105,9 @@ export declare class RaccoonProvider {
43
105
  readonly catalog: ModelCatalog;
44
106
  private quotaCache;
45
107
  private quotaInflight;
46
- private checkinInflight;
47
108
  private loginFlow;
48
109
  private readonly openRouter;
110
+ private readonly sessionIds;
49
111
  constructor(options: RaccoonProviderOptions);
50
112
  status(): Promise<import("./auth.ts").AuthStatus>;
51
113
  signedIn(): Promise<boolean>;
@@ -58,6 +120,14 @@ export declare class RaccoonProvider {
58
120
  private login;
59
121
  logoutAccount(): Promise<void>;
60
122
  private headers;
123
+ /**
124
+ * The chat headers, including the session id the gateway counts sessions by.
125
+ *
126
+ * Only chat carries it: the catalogue, quota and check-in routes are plain
127
+ * business calls the official client sends outside any session, so leaving
128
+ * them alone keeps this change to the one route it was measured on.
129
+ */
130
+ private chatHeaders;
61
131
  /** Run one JSON call; a rejected token means this login needs a new scan. */
62
132
  private authorizedJson;
63
133
  private fetchModels;
@@ -76,12 +146,15 @@ export declare class RaccoonProvider {
76
146
  * `point_grant_popups` on `setting_info`; the bills list is the durable proof.
77
147
  */
78
148
  private dailyGrant;
79
- checkinStatus(): Promise<CheckinStatus>;
80
- claimCheckin(): Promise<CheckinStatus>;
81
149
  /**
82
- * One automatic check-in pass: report today's grant, or ask the website path
83
- * to issue it. Shared so overlapping timers cannot spend two call slots.
150
+ * Read today's grant. **Read-only on purpose.**
151
+ *
152
+ * The gateway has no claim endpoint: points are issued on the day's first
153
+ * website visit. Replaying that visit (`setting_info`) can trigger the grant
154
+ * from here, but doing so takes a state-changing action on the account without
155
+ * the user asking for it in that moment, so the plugin only reports. Claiming
156
+ * is the website's job, and the card links there.
84
157
  */
85
- ensureDailyCheckin(): Promise<CheckinStatus>;
158
+ checkinStatus(): Promise<CheckinStatus>;
86
159
  stream(options: GenerateOptions, model: string, readImage: ReadImage | undefined, onUnauthorized?: () => void): AsyncGenerator<StreamChunk>;
87
160
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-llm-adapters",
3
3
  "displayName": "Local LLM Adapters",
4
4
  "description": "DeepSeek Harness adapters for Loomy, SenseTime Raccoon and Trae. Sign in through the provider’s own web page (WeChat QR for Loomy and Raccoon, a dedicated browser window for Trae), or reuse a signed-in local Trae client for the mainland check-in, then use the account’s models with quota, thinking levels and per-model settings pages.",
5
- "version": "1.2.1",
5
+ "version": "1.2.2",
6
6
  "type": "module",
7
7
  "license": "MIT",
8
8
  "author": "k1-h2",
@@ -93,12 +93,19 @@ export interface CodeArtsAccountRecord {
93
93
  d: string
94
94
  }
95
95
  userName?: string
96
+ /**
97
+ * The `x-ot-session-id` this account chats under: one stable value instead of
98
+ * one per request, because the gateway counts concurrent sessions by it.
99
+ *
100
+ * A record written before the field existed derives the same value from the
101
+ * refresh token's profile, so an existing sign-in needs no new login.
102
+ */
103
+ otSessionId?: string
96
104
  /**
97
105
  * Models the account answered `InferHub.4004.200` for.
98
106
  *
99
- * The verdict is worth persisting: asking with `maas_type: benefit` spends
100
- * one of the three chat slots the plan allows per minute, even though the
101
- * call cannot succeed.
107
+ * Worth persisting: the account has no allowance pack for them, so without the
108
+ * verdict every call would pay for a request that cannot succeed.
102
109
  */
103
110
  noBenefit?: string[]
104
111
  loginAt: number
@@ -181,6 +188,7 @@ function codeartsRecord(value: unknown): CodeArtsAccountRecord | undefined {
181
188
  ...text(raw.codeVerifier) === undefined ? {} : { codeVerifier: text(raw.codeVerifier)! },
182
189
  ...key === undefined ? {} : { dpopPrivateJwk: key },
183
190
  ...text(raw.userName) === undefined ? {} : { userName: text(raw.userName)! },
191
+ ...text(raw.otSessionId) === undefined ? {} : { otSessionId: text(raw.otSessionId)! },
184
192
  ...noBenefit.length === 0 ? {} : { noBenefit },
185
193
  }
186
194
  }
package/src/auth.ts CHANGED
@@ -14,7 +14,7 @@ import type {
14
14
  AccountStore, CodeArtsAccountRecord, LoomyAccountRecord, RaccoonAccountRecord, TraeAccountRecord,
15
15
  } from './account-store.ts'
16
16
  import type { TraeRegion } from './brand.ts'
17
- import { RefreshTokenExpiredError, exchangeRefreshToken, keyPairFromStoredJwk, readUserProfile } from './codearts-oauth.ts'
17
+ import { RefreshTokenExpiredError, exchangeRefreshToken, keyPairFromStoredJwk, otSessionIdOf, readUserProfile } from './codearts-oauth.ts'
18
18
  import type { CodeArtsCredential } from './codearts-oauth.ts'
19
19
  import { responseError } from './http.ts'
20
20
  import { TRAE_REGION_PROFILES, TRAE_WEB_USER_AGENT } from './trae-endpoints.ts'
@@ -114,6 +114,13 @@ export interface JwtFacts {
114
114
  phone?: string
115
115
  ownerType?: string
116
116
  sessionId?: string
117
+ /**
118
+ * The `sub` claim, which is what an account is called on the Raccoon gateway.
119
+ *
120
+ * Unlike the token itself it survives a re-sign, so it is the seed a stable
121
+ * per-account session id is derived from.
122
+ */
123
+ subject?: string
117
124
  }
118
125
 
119
126
  /** Normalize seconds, milliseconds, and ISO strings to epoch milliseconds. */
@@ -145,6 +152,7 @@ export function decodeJwtFacts(token: string): JwtFacts {
145
152
  ...typeof record.phone === 'string' ? { phone: record.phone } : {},
146
153
  ...typeof record.owner_type === 'string' ? { ownerType: record.owner_type } : {},
147
154
  ...typeof record.sid === 'string' ? { sessionId: record.sid } : {},
155
+ ...typeof record.sub === 'string' ? { subject: record.sub } : {},
148
156
  }
149
157
  } catch {
150
158
  return {}
@@ -546,6 +554,7 @@ export class CodeArtsAuth {
546
554
  ...next.expiresAt === undefined ? {} : { expiresAt: next.expiresAt },
547
555
  ...next.refreshToken === undefined ? {} : { refreshToken: next.refreshToken },
548
556
  ...next.userName === undefined ? {} : { userName: next.userName },
557
+ ...next.otSessionId === undefined ? {} : { otSessionId: next.otSessionId },
549
558
  })
550
559
  this.lastError = undefined
551
560
  return next
@@ -570,6 +579,10 @@ export class CodeArtsAuth {
570
579
  }
571
580
 
572
581
  function credentialOf(record: CodeArtsAccountRecord): CodeArtsCredential {
582
+ // A record written before `otSessionId` existed derives the very value the
583
+ // login would have stored, so an account that is already signed in gets its
584
+ // single session without a new login.
585
+ const otSessionId = record.otSessionId ?? otSessionIdOf(readUserProfile(record.refreshToken))
573
586
  return {
574
587
  accessKeyId: record.accessKeyId,
575
588
  secretAccessKey: record.secretAccessKey,
@@ -578,5 +591,6 @@ function credentialOf(record: CodeArtsAccountRecord): CodeArtsCredential {
578
591
  ...record.refreshToken === undefined ? {} : { refreshToken: record.refreshToken },
579
592
  ...record.codeVerifier === undefined ? {} : { codeVerifier: record.codeVerifier },
580
593
  ...record.dpopPrivateJwk === undefined ? {} : { dpopPrivateJwk: record.dpopPrivateJwk },
594
+ ...otSessionId === undefined ? {} : { otSessionId },
581
595
  }
582
596
  }
@@ -35,6 +35,17 @@ export interface CodeArtsCredential {
35
35
  dpopPrivateJwk?: DpopPrivateJwk
36
36
  /** Display only, from the refresh token's profile; never used for signing. */
37
37
  userName?: string
38
+ /**
39
+ * The id the gateway counts concurrent sessions against (`x-ot-session-id`).
40
+ *
41
+ * Measured on a real account: the three-session cap is keyed by this header,
42
+ * not by `Chat-Id` / `Session-Id`. A per-request value makes every call a new
43
+ * session, so the account spends its own budget on its own history and the
44
+ * fourth call of the minute comes back `TM.00001041`. Derived from the account
45
+ * identity instead of stored at random: the same value in every process, and
46
+ * one that survives a token renewal.
47
+ */
48
+ otSessionId?: string
38
49
  }
39
50
 
40
51
  /** Who the credential belongs to, as the portal encoded it in the token. */
@@ -180,6 +191,19 @@ export function readUserProfile(token: string | undefined): CodeArtsUserProfile
180
191
  }
181
192
  }
182
193
 
194
+ /**
195
+ * The session id this account should chat under, derived from who the account is.
196
+ *
197
+ * 32 lowercase hex, the shape the IDE's own kernel sends. Nothing but stability
198
+ * matters here — the gateway uses the value as a key, not as an identity — so it
199
+ * is hashed from the principal id rather than kept in a file.
200
+ */
201
+ export function otSessionIdOf(profile: CodeArtsUserProfile | undefined): string | undefined {
202
+ const seed = profile?.userId ?? profile?.userName
203
+ if (seed === undefined) return undefined
204
+ return createHash('sha256').update(`codearts-ot-session:${seed}`).digest('hex').slice(0, 32)
205
+ }
206
+
183
207
  /** The portal URL the browser opens; the callback is on loopback. */
184
208
  export function buildAuthorizeUrl(port: number, pkce: PkcePair, ticketId: string): string {
185
209
  return `${CODEARTS_PORTAL_AUTHORIZE}?theme=2&locale=zh-cn`
@@ -263,13 +287,18 @@ export async function exchangeRefreshToken(
263
287
  }, keyPair, fetcher)
264
288
  const credential = credentialFrom(token, codeVerifier, keyPair)
265
289
  // A refresh response may omit the rotated refresh token; keep the old one so
266
- // the next refresh is still possible. The name rides on that token, so it has
267
- // to be read from whichever one this credential ends up carrying.
268
- const userName = credential.userName ?? readUserProfile(refreshToken)?.userName
290
+ // the next refresh is still possible. The name and the account identity ride on
291
+ // that token, so both are read from whichever one this credential ends up
292
+ // carrying: a response without a profile must not hand the account a new
293
+ // session id, or every renewal would cost it a slot again.
294
+ const previous = readUserProfile(refreshToken)
295
+ const userName = credential.userName ?? previous?.userName
296
+ const otSessionId = credential.otSessionId ?? otSessionIdOf(previous)
269
297
  return {
270
298
  ...credential,
271
299
  refreshToken: credential.refreshToken ?? refreshToken,
272
300
  ...userName === undefined ? {} : { userName },
301
+ ...otSessionId === undefined ? {} : { otSessionId },
273
302
  }
274
303
  }
275
304
 
@@ -277,14 +306,16 @@ function credentialFrom(token: TokenResponse, codeVerifier: string, keyPair: Dpo
277
306
  const credentials = token.credentials ?? {}
278
307
  const expiration = credentials.expiration
279
308
  const expiresAt = expiration === undefined ? undefined : Date.parse(expiration)
280
- const userName = readUserProfile(token.refresh_token)?.userName
309
+ const profile = readUserProfile(token.refresh_token)
310
+ const otSessionId = otSessionIdOf(profile)
281
311
  return {
282
312
  accessKeyId: credentials.access_key_id ?? '',
283
313
  secretAccessKey: credentials.secret_access_key ?? '',
284
314
  securityToken: credentials.security_token ?? '',
285
315
  ...expiresAt === undefined || Number.isNaN(expiresAt) ? {} : { expiresAt },
286
316
  ...token.refresh_token === undefined ? {} : { refreshToken: token.refresh_token },
287
- ...userName === undefined ? {} : { userName },
317
+ ...profile?.userName === undefined ? {} : { userName: profile.userName },
318
+ ...otSessionId === undefined ? {} : { otSessionId },
288
319
  codeVerifier,
289
320
  dpopPrivateJwk: keyPair.privateKeyJwk,
290
321
  }
package/src/codearts.ts CHANGED
@@ -46,7 +46,6 @@ import {
46
46
  } from './codearts-endpoints.ts'
47
47
  import { CodeArtsLogin } from './codearts-login.ts'
48
48
  import { headersForFetch, sha256Hex, signRequestHuawei } from './codearts-sign.ts'
49
- import { CodeArtsSessionWindow, sharedSessionWindow } from './codearts-window.ts'
50
49
  import type { CodeArtsCredential } from './codearts-oauth.ts'
51
50
  import type { LoginStatus } from './login-page.ts'
52
51
  import { parseModelName } from './model-name.ts'
@@ -115,8 +114,6 @@ export interface CodeArtsProviderOptions {
115
114
  loginTimeoutMs?: number
116
115
  /** Injectable OpenRouter lookup for tests; defaults to the live catalog. */
117
116
  openRouter?: OpenRouterLookup
118
- /** Chat budget; defaults to the process-wide window (survives hot reloads). */
119
- window?: CodeArtsSessionWindow
120
117
  }
121
118
 
122
119
  /** One roster entry before it becomes a model row. */
@@ -306,20 +303,22 @@ const sleep = (ms: number, signal?: AbortSignal): Promise<void> => new Promise((
306
303
  signal?.addEventListener('abort', onAbort, { once: true })
307
304
  })
308
305
 
309
- /** Retries for a gateway that is busy for reasons the window cannot predict. */
306
+ /** Retries for a gateway that is busy for reasons the caller cannot predict. */
310
307
  const QUEUE_ATTEMPTS = 3
311
- /** Minimum pause before retrying a rejected call; the window adds its own. */
308
+ /** Pause before retrying a rejected call. */
312
309
  const QUEUE_BACKOFF_MS = 20_000
313
310
 
314
311
  /**
315
312
  * The message a user sees once waiting is hopeless.
316
313
  *
317
- * The raw gateway text (`TM.00001041`) reads like a bug; the limit is the
318
- * trial plan's, and the only fix on this side is to say so.
314
+ * The raw gateway text (`TM.00001041`) reads like a bug. The three concurrent
315
+ * sessions are the trial plan's, and one plugin chatting under a single session
316
+ * id is not meant to reach them: the slots are being held by something else on
317
+ * the same account.
319
318
  */
320
319
  function queueExhausted(): ProviderHttpError {
321
320
  return new ProviderHttpError(
322
- 'CodeArts 体验版每分钟只允许 3 个会话,当前已被占满。请稍等片刻再发,或关闭其它 CodeArts 会话。',
321
+ 'CodeArts 这个账号的 3 个并发会话已被占满(通常是码道 IDE 或另一台设备),请稍后再发,或关闭其它 CodeArts 会话。',
323
322
  429,
324
323
  )
325
324
  }
@@ -356,7 +355,6 @@ export class CodeArtsProvider {
356
355
  /** Models that answered `InferHub.4004.200`: no allowance pack to bill. */
357
356
  private readonly noBenefit = new Set<string>()
358
357
  private noBenefitLoaded = false
359
- private readonly window: CodeArtsSessionWindow
360
358
  private readonly openRouter: OpenRouterLookup
361
359
  private readonly sessionId = randomUUID()
362
360
  /**
@@ -371,7 +369,6 @@ export class CodeArtsProvider {
371
369
  private imageCacheWrite: Promise<void> = Promise.resolve()
372
370
 
373
371
  constructor(private readonly options: CodeArtsProviderOptions) {
374
- this.window = options.window ?? sharedSessionWindow()
375
372
  this.openRouter = options.openRouter ?? new OpenRouterCatalog(options.fetchImpl ?? fetch)
376
373
  this.catalog = new ModelCatalog(() => this.loadModels(), options.ttlMs)
377
374
  this.login = new CodeArtsLogin({
@@ -380,9 +377,7 @@ export class CodeArtsProvider {
380
377
  ...options.logger === undefined ? {} : { logger: options.logger },
381
378
  save: async (account) => {
382
379
  await options.store.saveCodearts(account as CodeArtsAccountRecord)
383
- // A different account starts with a clean budget: the slots the gateway
384
- // remembers belong to the old one.
385
- this.window.reset()
380
+ // A different account has different verdicts about what it may bill.
386
381
  this.noBenefit.clear()
387
382
  this.invalidateModels()
388
383
  this.invalidateQuota()
@@ -784,7 +779,7 @@ export class CodeArtsProvider {
784
779
  }
785
780
  }
786
781
 
787
- /** Ask the vision model to describe one image, within the same chat budget. */
782
+ /** Ask the vision model to describe one image; another turn on the same session. */
788
783
  private async describeImage(dataUrl: string, signal?: AbortSignal): Promise<string> {
789
784
  const payload = JSON.stringify({
790
785
  model: CODEARTS_VISION_MODEL,
@@ -802,8 +797,6 @@ export class CodeArtsProvider {
802
797
  let authRefreshed = false
803
798
  let queueAttempts = 0
804
799
  for (;;) {
805
- // The vision model is another chat session, so it spends the same budget.
806
- const slot = await this.window.open(signal)
807
800
  let response: Response
808
801
  try {
809
802
  response = await this.send(chatId, payload, CODEARTS_VISION_MODEL, false, signal)
@@ -814,10 +807,9 @@ export class CodeArtsProvider {
814
807
  continue
815
808
  }
816
809
  if (error instanceof CodeArtsRetry && error.kind === 'queue') {
817
- slot.release()
818
810
  if (queueAttempts < QUEUE_ATTEMPTS) {
819
811
  queueAttempts++
820
- await sleep(Math.max(this.window.waitMs(), QUEUE_BACKOFF_MS), signal)
812
+ await sleep(QUEUE_BACKOFF_MS, signal)
821
813
  continue
822
814
  }
823
815
  throw queueExhausted()
@@ -842,10 +834,9 @@ export class CodeArtsProvider {
842
834
  }
843
835
  } catch (error) {
844
836
  if (error instanceof CodeArtsRetry && error.kind === 'queue') {
845
- slot.release()
846
837
  if (queueAttempts < QUEUE_ATTEMPTS) {
847
838
  queueAttempts++
848
- await sleep(Math.max(this.window.waitMs(), QUEUE_BACKOFF_MS), signal)
839
+ await sleep(QUEUE_BACKOFF_MS, signal)
849
840
  continue
850
841
  }
851
842
  throw queueExhausted()
@@ -906,10 +897,6 @@ export class CodeArtsProvider {
906
897
  let benefitDropped = false
907
898
  let queueAttempts = 0
908
899
  for (;;) {
909
- // The plan allows three sessions a minute for the whole account, and a
910
- // rejected call spends one just like a served one. Wait for a slot here
911
- // instead of firing into `TM.00001041` and hoping a retry lands later.
912
- const slot = await this.window.open(options.signal)
913
900
  let response: Response
914
901
  try {
915
902
  response = await this.send(chatId, payload, model, benefitDropped, options.signal)
@@ -933,8 +920,7 @@ export class CodeArtsProvider {
933
920
  }
934
921
  if (error.kind === 'queue' && queueAttempts < QUEUE_ATTEMPTS) {
935
922
  queueAttempts++
936
- slot.release()
937
- await sleep(Math.max(this.window.waitMs(), QUEUE_BACKOFF_MS), options.signal)
923
+ await sleep(QUEUE_BACKOFF_MS, options.signal)
938
924
  continue
939
925
  }
940
926
  if (error.kind === 'queue') throw queueExhausted()
@@ -966,8 +952,7 @@ export class CodeArtsProvider {
966
952
  // safe while nothing has been emitted, or the answer would repeat.
967
953
  if (!produced && error instanceof CodeArtsRetry && error.kind === 'queue' && queueAttempts < QUEUE_ATTEMPTS) {
968
954
  queueAttempts++
969
- slot.release()
970
- await sleep(Math.max(this.window.waitMs(), QUEUE_BACKOFF_MS), options.signal)
955
+ await sleep(QUEUE_BACKOFF_MS, options.signal)
971
956
  continue
972
957
  }
973
958
  if (error instanceof CodeArtsRetry && error.kind === 'queue') throw queueExhausted()
@@ -997,6 +982,9 @@ export class CodeArtsProvider {
997
982
  ): Promise<Response> {
998
983
  const credential = await this.credential()
999
984
  const benefit = !benefitDropped && this.isBenefitModel(model)
985
+ // A credential whose token carries no identity falls back to this process's
986
+ // own id: still one session for the account, never one per request.
987
+ const otSessionId = credential.otSessionId ?? this.sessionId.replace(/-/g, '')
1000
988
  const signed = await signRequestHuawei(
1001
989
  credential.accessKeyId,
1002
990
  credential.secretAccessKey,
@@ -1015,6 +1003,14 @@ export class CodeArtsProvider {
1015
1003
  'chat-id': chatId,
1016
1004
  'session-id': this.sessionId,
1017
1005
  lang: 'en',
1006
+ // Measured on a real account: the gateway counts the account's concurrent
1007
+ // sessions by `x-ot-session-id`, not by `chat-id` / `session-id`. One stable
1008
+ // value is why the trial plan stops charging its own history for every
1009
+ // turn; the trace id stays per request, as the IDE sends it. These join the
1010
+ // unsigned set — `chat-id` and `session-id` already do.
1011
+ 'x-ot-session-id': otSessionId,
1012
+ 'user-session-id': otSessionId,
1013
+ 'x-ot-trace-id': randomUUID().replace(/-/g, ''),
1018
1014
  }
1019
1015
  const timeout = AbortSignal.timeout(Math.max(this.options.timeoutMs(), 60_000))
1020
1016
  const response = await this.fetcher(CODEARTS_CHAT_URL, {
@@ -1052,7 +1048,6 @@ export class CodeArtsProvider {
1052
1048
 
1053
1049
  async logoutAccount(): Promise<void> {
1054
1050
  await this.options.auth.logout()
1055
- this.window.reset()
1056
1051
  this.invalidateModels()
1057
1052
  this.invalidateQuota()
1058
1053
  }