@lobehub/lobehub 2.0.0-next.147 → 2.0.0-next.149

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 CHANGED
@@ -2,6 +2,57 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## [Version 2.0.0-next.149](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.148...v2.0.0-next.149)
6
+
7
+ <sup>Released on **2025-12-03**</sup>
8
+
9
+ #### 🐛 Bug Fixes
10
+
11
+ - **desktop**: Add token refresh retry mechanism.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### What's fixed
19
+
20
+ - **desktop**: Add token refresh retry mechanism, closes [#10575](https://github.com/lobehub/lobe-chat/issues/10575) ([83fc2e8](https://github.com/lobehub/lobe-chat/commit/83fc2e8))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
30
+ ## [Version 2.0.0-next.148](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.147...v2.0.0-next.148)
31
+
32
+ <sup>Released on **2025-12-03**</sup>
33
+
34
+ #### 🐛 Bug Fixes
35
+
36
+ - **misc**: Remove apiMode param from Azure and Cloudflare provider requests, when desktop use contextMenu not work.
37
+
38
+ <br/>
39
+
40
+ <details>
41
+ <summary><kbd>Improvements and Fixes</kbd></summary>
42
+
43
+ #### What's fixed
44
+
45
+ - **misc**: Remove apiMode param from Azure and Cloudflare provider requests, closes [#10571](https://github.com/lobehub/lobe-chat/issues/10571) ([7e44faa](https://github.com/lobehub/lobe-chat/commit/7e44faa))
46
+ - **misc**: When desktop use contextMenu not work, closes [#10545](https://github.com/lobehub/lobe-chat/issues/10545) ([43c4db7](https://github.com/lobehub/lobe-chat/commit/43c4db7))
47
+
48
+ </details>
49
+
50
+ <div align="right">
51
+
52
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
53
+
54
+ </div>
55
+
5
56
  ## [Version 2.0.0-next.147](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.146...v2.0.0-next.147)
6
57
 
7
58
  <sup>Released on **2025-12-02**</sup>
package/CLAUDE.md CHANGED
@@ -81,17 +81,21 @@ When working with Linear issues:
81
81
  1. Complete the implementation for this specific issue
82
82
  2. Run type check: `bun run type-check`
83
83
  3. Run related tests if applicable
84
- 4. **IMMEDIATELY** update issue status to "Done": `mcp__linear-server__update_issue`
85
- 5. **IMMEDIATELY** add completion comment: `mcp__linear-server__create_comment`
86
- 6. Only then move on to the next issue
84
+ 4. Create PR if needed
85
+ 5. **IMMEDIATELY** update issue status to **"In Review"** (NOT "Done"): `mcp__linear-server__update_issue`
86
+ 6. **IMMEDIATELY** add completion comment: `mcp__linear-server__create_comment`
87
+ 7. Only then move on to the next issue
88
+
89
+ **Note:** Issue status should be set to **"In Review"** when PR is created. The status will be updated to **"Done"** only after the PR is merged (usually handled by Linear-GitHub integration or manually).
87
90
 
88
91
  **❌ Wrong approach:**
89
92
 
90
93
  - Complete Issue A → Complete Issue B → Complete Issue C → Update all statuses → Add all comments
94
+ - Mark issue as "Done" immediately after creating PR
91
95
 
92
96
  **✅ Correct approach:**
93
97
 
94
- - Complete Issue A → Update A status → Add A comment → Complete Issue B → Update B status → Add B comment → ...
98
+ - Complete Issue A → Create PR → Update A status to "In Review" → Add A comment → Complete Issue B → ...
95
99
 
96
100
  ## Rules Index
97
101
 
@@ -45,11 +45,13 @@
45
45
  "@lobechat/electron-server-ipc": "workspace:*",
46
46
  "@lobechat/file-loaders": "workspace:*",
47
47
  "@lobehub/i18n-cli": "^1.25.1",
48
+ "@types/async-retry": "^1.4.9",
48
49
  "@types/lodash": "^4.17.21",
49
50
  "@types/resolve": "^1.20.6",
50
51
  "@types/semver": "^7.7.1",
51
52
  "@types/set-cookie-parser": "^2.4.10",
52
53
  "@typescript/native-preview": "7.0.0-dev.20250711.1",
54
+ "async-retry": "^1.3.3",
53
55
  "consola": "^3.4.2",
54
56
  "cookie": "^1.1.1",
55
57
  "diff": "^8.0.2",
@@ -246,12 +246,23 @@ export default class AuthCtr extends ControllerModule {
246
246
  logger.info('Auto-refresh successful');
247
247
  this.broadcastTokenRefreshed();
248
248
  } else {
249
- logger.error(`Auto-refresh failed: ${result.error}`);
250
- // If auto-refresh fails, stop timer and clear token
251
- this.stopAutoRefresh();
252
- await this.remoteServerConfigCtr.clearTokens();
253
- await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
254
- this.broadcastAuthorizationRequired();
249
+ logger.error(`Auto-refresh failed after retries: ${result.error}`);
250
+
251
+ // Only clear tokens for non-retryable errors (e.g., invalid_grant)
252
+ // The retry mechanism in RemoteServerConfigCtr already handles transient errors
253
+ if (this.remoteServerConfigCtr.isNonRetryableError(result.error)) {
254
+ logger.warn(
255
+ 'Non-retryable error detected, clearing tokens and requiring re-authorization',
256
+ );
257
+ this.stopAutoRefresh();
258
+ await this.remoteServerConfigCtr.clearTokens();
259
+ await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
260
+ this.broadcastAuthorizationRequired();
261
+ } else {
262
+ // For other errors (after retries exhausted), log but don't clear tokens immediately
263
+ // The next refresh cycle will retry
264
+ logger.warn('Refresh failed but error may be transient, will retry on next cycle');
265
+ }
255
266
  }
256
267
  }
257
268
  } catch (error) {
@@ -335,11 +346,12 @@ export default class AuthCtr extends ControllerModule {
335
346
 
336
347
  /**
337
348
  * Refresh access token
349
+ * This method includes retry mechanism via RemoteServerConfigCtr.refreshAccessToken()
338
350
  */
339
351
  async refreshAccessToken() {
340
352
  logger.info('Starting to refresh access token');
341
353
  try {
342
- // Call the centralized refresh logic in RemoteServerConfigCtr
354
+ // Call the centralized refresh logic in RemoteServerConfigCtr (includes retry)
343
355
  const result = await this.remoteServerConfigCtr.refreshAccessToken();
344
356
 
345
357
  if (result.success) {
@@ -350,25 +362,38 @@ export default class AuthCtr extends ControllerModule {
350
362
  this.startAutoRefresh();
351
363
  return { success: true };
352
364
  } else {
353
- // Throw an error to be caught by the catch block below
354
- // This maintains the existing behavior of clearing tokens on failure
355
365
  logger.error(`Token refresh failed via AuthCtr call: ${result.error}`);
356
- throw new Error(result.error || 'Token refresh failed');
357
- }
358
- } catch (error) {
359
- // Keep the existing logic to clear tokens and require re-auth on failure
360
- logger.error('Token refresh operation failed via AuthCtr, initiating cleanup:', error);
361
366
 
362
- // Refresh failed, clear tokens and disable remote server
363
- logger.warn('Refresh failed, clearing tokens and disabling remote server');
364
- this.stopAutoRefresh();
365
- await this.remoteServerConfigCtr.clearTokens();
366
- await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
367
+ // Only clear tokens for non-retryable errors (e.g., invalid_grant)
368
+ if (this.remoteServerConfigCtr.isNonRetryableError(result.error)) {
369
+ logger.warn(
370
+ 'Non-retryable error detected, clearing tokens and requiring re-authorization',
371
+ );
372
+ this.stopAutoRefresh();
373
+ await this.remoteServerConfigCtr.clearTokens();
374
+ await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
375
+ this.broadcastAuthorizationRequired();
376
+ } else {
377
+ // For transient errors, don't clear tokens - allow manual retry
378
+ logger.warn('Refresh failed but error may be transient, tokens preserved for retry');
379
+ }
367
380
 
368
- // Notify render process that re-authorization is required
369
- this.broadcastAuthorizationRequired();
381
+ return { error: result.error, success: false };
382
+ }
383
+ } catch (error) {
384
+ const errorMessage = error instanceof Error ? error.message : String(error);
385
+ logger.error('Token refresh operation failed via AuthCtr:', errorMessage);
386
+
387
+ // Only clear tokens for non-retryable errors
388
+ if (this.remoteServerConfigCtr.isNonRetryableError(errorMessage)) {
389
+ logger.warn('Non-retryable error in catch block, clearing tokens');
390
+ this.stopAutoRefresh();
391
+ await this.remoteServerConfigCtr.clearTokens();
392
+ await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
393
+ this.broadcastAuthorizationRequired();
394
+ }
370
395
 
371
- return { error: error.message, success: false };
396
+ return { error: errorMessage, success: false };
372
397
  }
373
398
  }
374
399
 
@@ -601,7 +626,7 @@ export default class AuthCtr extends ControllerModule {
601
626
  if (currentTime >= expiresAt) {
602
627
  logger.info('Token has expired, attempting to refresh it');
603
628
 
604
- // Attempt to refresh token
629
+ // Attempt to refresh token (includes retry mechanism)
605
630
  const refreshResult = await this.remoteServerConfigCtr.refreshAccessToken();
606
631
  if (refreshResult.success) {
607
632
  logger.info('Token refresh successful during initialization');
@@ -611,10 +636,18 @@ export default class AuthCtr extends ControllerModule {
611
636
  return;
612
637
  } else {
613
638
  logger.error(`Token refresh failed during initialization: ${refreshResult.error}`);
614
- // Clear token and require re-authorization only on refresh failure
615
- await this.remoteServerConfigCtr.clearTokens();
616
- await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
617
- this.broadcastAuthorizationRequired();
639
+
640
+ // Only clear token for non-retryable errors
641
+ if (this.remoteServerConfigCtr.isNonRetryableError(refreshResult.error)) {
642
+ logger.warn('Non-retryable error during initialization, clearing tokens');
643
+ await this.remoteServerConfigCtr.clearTokens();
644
+ await this.remoteServerConfigCtr.setRemoteServerConfig({ active: false });
645
+ this.broadcastAuthorizationRequired();
646
+ } else {
647
+ // For transient errors, still start auto-refresh timer to retry later
648
+ logger.warn('Transient error during initialization, will retry via auto-refresh');
649
+ this.startAutoRefresh();
650
+ }
618
651
  return;
619
652
  }
620
653
  }
@@ -1,4 +1,5 @@
1
1
  import { DataSyncConfig } from '@lobechat/electron-client-ipc';
2
+ import retry from 'async-retry';
2
3
  import { safeStorage } from 'electron';
3
4
  import querystring from 'node:querystring';
4
5
  import { URL } from 'node:url';
@@ -8,6 +9,28 @@ import { createLogger } from '@/utils/logger';
8
9
 
9
10
  import { ControllerModule, ipcClientEvent } from './index';
10
11
 
12
+ /**
13
+ * Non-retryable OIDC error codes
14
+ * These errors indicate the refresh token is invalid and retry won't help
15
+ */
16
+ const NON_RETRYABLE_OIDC_ERRORS = [
17
+ 'invalid_grant', // refresh token is invalid, expired, or revoked
18
+ 'invalid_client', // client configuration error
19
+ 'unauthorized_client', // client not authorized
20
+ 'access_denied', // user denied access
21
+ 'invalid_scope', // requested scope is invalid
22
+ ];
23
+
24
+ /**
25
+ * Deterministic failures that will never succeed on retry
26
+ * These are permanent state issues that require user intervention
27
+ */
28
+ const DETERMINISTIC_FAILURES = [
29
+ 'no refresh token available', // refresh token is missing from storage
30
+ 'remote server is not active or configured', // config is invalid or disabled
31
+ 'missing tokens in refresh response', // server returned incomplete response
32
+ ];
33
+
11
34
  // Create logger
12
35
  const logger = createLogger('controllers:RemoteServerConfigCtr');
13
36
 
@@ -246,9 +269,34 @@ export default class RemoteServerConfigCtr extends ControllerModule {
246
269
  }
247
270
 
248
271
  /**
249
- * Refresh access token
272
+ * Check if an error is non-retryable
273
+ * Includes OIDC errors (e.g., invalid_grant) and deterministic failures
274
+ * (e.g., missing refresh token, invalid config)
275
+ * @param error Error message to check
276
+ * @returns true if the error should not be retried
277
+ */
278
+ isNonRetryableError(error?: string): boolean {
279
+ if (!error) return false;
280
+ const lowerError = error.toLowerCase();
281
+
282
+ // Check OIDC error codes
283
+ if (NON_RETRYABLE_OIDC_ERRORS.some((code) => lowerError.includes(code))) {
284
+ return true;
285
+ }
286
+
287
+ // Check deterministic failures that require user intervention
288
+ if (DETERMINISTIC_FAILURES.some((msg) => lowerError.includes(msg))) {
289
+ return true;
290
+ }
291
+
292
+ return false;
293
+ }
294
+
295
+ /**
296
+ * Refresh access token with retry mechanism
250
297
  * Use stored refresh token to obtain a new access token
251
298
  * Handles concurrent requests by returning the existing refresh promise if one is in progress.
299
+ * Retries up to 3 times with exponential backoff for transient errors.
252
300
  */
253
301
  async refreshAccessToken(): Promise<{ error?: string; success: boolean }> {
254
302
  // If a refresh is already in progress, return the existing promise
@@ -257,14 +305,62 @@ export default class RemoteServerConfigCtr extends ControllerModule {
257
305
  return this.refreshPromise;
258
306
  }
259
307
 
260
- // Start a new refresh operation
261
- logger.info('Initiating new token refresh operation.');
262
- this.refreshPromise = this.performTokenRefresh();
308
+ // Start a new refresh operation with retry
309
+ logger.info('Initiating new token refresh operation with retry.');
310
+ this.refreshPromise = this.performTokenRefreshWithRetry();
263
311
 
264
312
  // Return the promise so callers can wait
265
313
  return this.refreshPromise;
266
314
  }
267
315
 
316
+ /**
317
+ * Performs token refresh with retry mechanism
318
+ * Uses exponential backoff: 1s, 2s, 4s
319
+ */
320
+ private async performTokenRefreshWithRetry(): Promise<{ error?: string; success: boolean }> {
321
+ try {
322
+ return await retry(
323
+ async (bail, attemptNumber) => {
324
+ logger.debug(`Token refresh attempt ${attemptNumber}/3`);
325
+
326
+ const result = await this.performTokenRefresh();
327
+
328
+ if (result.success) {
329
+ return result;
330
+ }
331
+
332
+ // Check if error is non-retryable
333
+ if (this.isNonRetryableError(result.error)) {
334
+ logger.warn(`Non-retryable error encountered: ${result.error}`);
335
+ // Use bail to stop retrying immediately
336
+ bail(new Error(result.error));
337
+ return result; // This won't be reached, but TypeScript needs it
338
+ }
339
+
340
+ // Throw error to trigger retry for transient errors
341
+ throw new Error(result.error);
342
+ },
343
+ {
344
+ factor: 2, // Exponential backoff factor
345
+ maxTimeout: 4000, // Max wait time between retries: 4s
346
+ minTimeout: 1000, // Min wait time between retries: 1s
347
+ onRetry: (err: Error, attempt: number) => {
348
+ logger.info(`Token refresh retry ${attempt}/3: ${err.message}`);
349
+ },
350
+ retries: 3, // Total retry attempts
351
+ },
352
+ );
353
+ } catch (error) {
354
+ const errorMessage = error instanceof Error ? error.message : String(error);
355
+ logger.error('Token refresh failed after all retries:', errorMessage);
356
+ return { error: errorMessage, success: false };
357
+ } finally {
358
+ // Ensure the promise reference is cleared once the operation completes
359
+ logger.debug('Clearing the refresh promise reference.');
360
+ this.refreshPromise = null;
361
+ }
362
+ }
363
+
268
364
  /**
269
365
  * Performs the actual token refresh logic.
270
366
  * This method is called by refreshAccessToken and wrapped in a promise.
@@ -337,10 +433,6 @@ export default class RemoteServerConfigCtr extends ControllerModule {
337
433
  const errorMessage = error instanceof Error ? error.message : String(error);
338
434
  logger.error('Exception during token refresh operation:', errorMessage, error);
339
435
  return { error: `Exception occurred during token refresh: ${errorMessage}`, success: false };
340
- } finally {
341
- // Ensure the promise reference is cleared once the operation completes
342
- logger.debug('Clearing the refresh promise reference.');
343
- this.refreshPromise = null;
344
436
  }
345
437
  }
346
438
 
@@ -355,6 +355,41 @@ describe('RemoteServerConfigCtr', () => {
355
355
  });
356
356
  });
357
357
 
358
+ describe('isNonRetryableError', () => {
359
+ it('should return false for null/undefined error', () => {
360
+ expect(controller.isNonRetryableError(undefined)).toBe(false);
361
+ expect(controller.isNonRetryableError('')).toBe(false);
362
+ });
363
+
364
+ it('should return true for OIDC error codes', () => {
365
+ expect(controller.isNonRetryableError('invalid_grant')).toBe(true);
366
+ expect(controller.isNonRetryableError('Token refresh failed: invalid_client')).toBe(true);
367
+ expect(controller.isNonRetryableError('unauthorized_client error')).toBe(true);
368
+ expect(controller.isNonRetryableError('access_denied by user')).toBe(true);
369
+ expect(controller.isNonRetryableError('invalid_scope requested')).toBe(true);
370
+ });
371
+
372
+ it('should return true for deterministic failures', () => {
373
+ expect(controller.isNonRetryableError('No refresh token available')).toBe(true);
374
+ expect(controller.isNonRetryableError('Remote server is not active or configured')).toBe(
375
+ true,
376
+ );
377
+ expect(controller.isNonRetryableError('Missing tokens in refresh response')).toBe(true);
378
+ });
379
+
380
+ it('should return false for transient/network errors', () => {
381
+ expect(controller.isNonRetryableError('Network error')).toBe(false);
382
+ expect(controller.isNonRetryableError('fetch failed')).toBe(false);
383
+ expect(controller.isNonRetryableError('ETIMEDOUT')).toBe(false);
384
+ expect(controller.isNonRetryableError('Connection refused')).toBe(false);
385
+ });
386
+
387
+ it('should be case insensitive', () => {
388
+ expect(controller.isNonRetryableError('INVALID_GRANT')).toBe(true);
389
+ expect(controller.isNonRetryableError('NO REFRESH TOKEN AVAILABLE')).toBe(true);
390
+ });
391
+ });
392
+
358
393
  describe('refreshAccessToken', () => {
359
394
  let mockFetch: ReturnType<typeof vi.fn>;
360
395
 
@@ -556,7 +591,7 @@ describe('RemoteServerConfigCtr', () => {
556
591
  expect(mockFetch).toHaveBeenCalledTimes(1);
557
592
  });
558
593
 
559
- it('should handle network errors', async () => {
594
+ it('should handle network errors with retry', async () => {
560
595
  const { safeStorage } = await import('electron');
561
596
  vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
562
597
  vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
@@ -582,7 +617,9 @@ describe('RemoteServerConfigCtr', () => {
582
617
 
583
618
  expect(result.success).toBe(false);
584
619
  expect(result.error).toContain('Network error');
585
- });
620
+ // With retry mechanism, fetch should be called 4 times (1 initial + 3 retries)
621
+ expect(mockFetch).toHaveBeenCalledTimes(4);
622
+ }, 15000);
586
623
  });
587
624
 
588
625
  describe('afterAppReady', () => {
package/changelog/v1.json CHANGED
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "children": {},
4
+ "date": "2025-12-03",
5
+ "version": "2.0.0-next.149"
6
+ },
7
+ {
8
+ "children": {
9
+ "fixes": [
10
+ "Remove apiMode param from Azure and Cloudflare provider requests, when desktop use contextMenu not work."
11
+ ]
12
+ },
13
+ "date": "2025-12-03",
14
+ "version": "2.0.0-next.148"
15
+ },
2
16
  {
3
17
  "children": {
4
18
  "features": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/lobehub",
3
- "version": "2.0.0-next.147",
3
+ "version": "2.0.0-next.149",
4
4
  "description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
@@ -16,20 +16,20 @@ import {
16
16
 
17
17
  let serverDB = await getTestDBInstance();
18
18
 
19
- // 测试数据
19
+ // Test data
20
20
  const testModelName = 'Session';
21
21
  const testId = 'test-id';
22
22
  const testUserId = 'test-user-id';
23
23
  const testClientId = 'test-client-id';
24
24
  const testGrantId = 'test-grant-id';
25
25
  const testUserCode = 'test-user-code';
26
- const testExpires = new Date(Date.now() + 3600 * 1000); // 1小时后过期
26
+ const testExpires = new Date(Date.now() + 3600 * 1000); // Expires in 1 hour
27
27
 
28
28
  beforeEach(async () => {
29
29
  await serverDB.insert(users).values({ id: testUserId }).onConflictDoNothing();
30
30
  });
31
31
 
32
- // 每次测试后清理数据
32
+ // Clean up data after each test
33
33
  afterEach(async () => {
34
34
  await serverDB.delete(users);
35
35
  await serverDB.delete(oidcClients);
@@ -39,14 +39,14 @@ afterEach(async () => {
39
39
 
40
40
  describe('DrizzleAdapter', () => {
41
41
  describe('constructor', () => {
42
- it('应该正确创建适配器实例', () => {
42
+ it('should create adapter instance correctly', () => {
43
43
  const adapter = new DrizzleAdapter(testModelName, serverDB);
44
44
  expect(adapter).toBeDefined();
45
45
  });
46
46
  });
47
47
 
48
48
  describe('upsert', () => {
49
- it('应该为Session模型创建新记录', async () => {
49
+ it('should create new record for Session model', async () => {
50
50
  const adapter = new DrizzleAdapter('Session', serverDB);
51
51
  const payload = {
52
52
  accountId: testUserId,
@@ -66,7 +66,7 @@ describe('DrizzleAdapter', () => {
66
66
  expect(result?.data).toEqual(payload);
67
67
  });
68
68
 
69
- it('应该为Client模型创建新记录', async () => {
69
+ it('should create new record for Client model', async () => {
70
70
  const adapter = new DrizzleAdapter('Client', serverDB);
71
71
  const payload = {
72
72
  client_id: testClientId,
@@ -94,7 +94,7 @@ describe('DrizzleAdapter', () => {
94
94
  expect(result?.scopes).toEqual(['openid', 'profile', 'email']);
95
95
  });
96
96
 
97
- it('应该为AccessToken模型创建新记录', async () => {
97
+ it('should create new record for AccessToken model', async () => {
98
98
  const adapter = new DrizzleAdapter('AccessToken', serverDB);
99
99
  const payload = {
100
100
  accountId: testUserId,
@@ -118,7 +118,7 @@ describe('DrizzleAdapter', () => {
118
118
  expect(result?.data).toEqual(payload);
119
119
  });
120
120
 
121
- it('应该为DeviceCode模型创建新记录并包含userCode', async () => {
121
+ it('should create new record for DeviceCode model with userCode', async () => {
122
122
  const adapter = new DrizzleAdapter('DeviceCode', serverDB);
123
123
  const payload = {
124
124
  clientId: testClientId,
@@ -139,30 +139,30 @@ describe('DrizzleAdapter', () => {
139
139
  expect(result?.data).toEqual(payload);
140
140
  });
141
141
 
142
- it('应该更新现有的Session记录', async () => {
142
+ it('should update existing Session record', async () => {
143
143
  const adapter = new DrizzleAdapter('Session', serverDB);
144
144
  const initialPayload = { accountId: testUserId, cookie: 'initial-cookie' };
145
145
  const updatedPayload = { accountId: testUserId, cookie: 'updated-cookie' };
146
146
 
147
- // 初始插入
147
+ // Initial insert
148
148
  await adapter.upsert(testId, initialPayload, 3600);
149
149
  let result = await serverDB.query.oidcSessions.findFirst({
150
150
  where: eq(oidcSessions.id, testId),
151
151
  });
152
152
  expect(result?.data).toEqual(initialPayload);
153
153
 
154
- // 更新
155
- await adapter.upsert(testId, updatedPayload, 7200); // 新的过期时间
154
+ // Update
155
+ await adapter.upsert(testId, updatedPayload, 7200); // New expiration time
156
156
  result = await serverDB.query.oidcSessions.findFirst({ where: eq(oidcSessions.id, testId) });
157
157
  expect(result?.data).toEqual(updatedPayload);
158
- // 验证 expiresAt 是否也更新了 (大约 2 小时后)
158
+ // Verify expiresAt is also updated (approximately 2 hours later)
159
159
  expect(result?.expiresAt).toBeInstanceOf(Date);
160
160
  const expectedExpires = Date.now() + 7200 * 1000;
161
- expect(result!.expiresAt!.getTime()).toBeGreaterThan(expectedExpires - 5000); // 允许 5 秒误差
161
+ expect(result!.expiresAt!.getTime()).toBeGreaterThan(expectedExpires - 5000); // Allow 5 second tolerance
162
162
  expect(result!.expiresAt!.getTime()).toBeLessThan(expectedExpires + 5000);
163
163
  });
164
164
 
165
- it('应该更新现有的Client记录', async () => {
165
+ it('should update existing Client record', async () => {
166
166
  const adapter = new DrizzleAdapter('Client', serverDB);
167
167
  const initialPayload = {
168
168
  client_id: testClientId,
@@ -175,12 +175,12 @@ describe('DrizzleAdapter', () => {
175
175
  ...initialPayload,
176
176
  client_uri: 'https://updated.com',
177
177
  name: 'Updated Client',
178
- scopes: ['openid', 'profile'], // 假设 scope 格式是空格分隔字符串
178
+ scopes: ['openid', 'profile'],
179
179
  scope: 'openid profile',
180
180
  redirectUris: ['https://updated.com/callback'],
181
181
  };
182
182
 
183
- // 初始插入
183
+ // Initial insert
184
184
  await adapter.upsert(testClientId, initialPayload, 0);
185
185
  let result = await serverDB.query.oidcClients.findFirst({
186
186
  where: eq(oidcClients.id, testClientId),
@@ -190,21 +190,20 @@ describe('DrizzleAdapter', () => {
190
190
  expect(result?.clientUri).toBe('https://initial.com');
191
191
  expect(result?.scopes).toEqual(['openid']);
192
192
 
193
- // 更新
193
+ // Update
194
194
  await adapter.upsert(testClientId, updatedPayload, 0);
195
195
  result = await serverDB.query.oidcClients.findFirst({
196
196
  where: eq(oidcClients.id, testClientId),
197
197
  });
198
198
  expect(result?.name).toBe('Updated Client');
199
199
  expect(result?.clientUri).toBe('https://updated.com');
200
- expect(result?.scopes).toEqual(['openid', 'profile']); // 验证数据库中存储的是数组
200
+ expect(result?.scopes).toEqual(['openid', 'profile']);
201
201
  expect(result?.redirectUris).toEqual(['https://updated.com/callback']);
202
202
  });
203
203
  });
204
204
 
205
205
  describe('find', () => {
206
- it('应该找到存在的记录', async () => {
207
- // 先创建一个记录
206
+ it('should find existing record', async () => {
208
207
  const adapter = new DrizzleAdapter('Session', serverDB);
209
208
  const payload = {
210
209
  accountId: testUserId,
@@ -214,15 +213,13 @@ describe('DrizzleAdapter', () => {
214
213
 
215
214
  await adapter.upsert(testId, payload, 3600);
216
215
 
217
- // 然后查找它
218
216
  const result = await adapter.find(testId);
219
217
 
220
218
  expect(result).toBeDefined();
221
219
  expect(result).toEqual(payload);
222
220
  });
223
221
 
224
- it('应该为Client模型返回正确的格式', async () => {
225
- // 先创建一个Client记录
222
+ it('should return correct format for Client model', async () => {
226
223
  const adapter = new DrizzleAdapter('Client', serverDB);
227
224
  const payload = {
228
225
  client_id: testClientId,
@@ -239,7 +236,6 @@ describe('DrizzleAdapter', () => {
239
236
 
240
237
  await adapter.upsert(testClientId, payload, 0);
241
238
 
242
- // 然后查找它
243
239
  const result = await adapter.find(testClientId);
244
240
 
245
241
  expect(result).toBeDefined();
@@ -249,50 +245,87 @@ describe('DrizzleAdapter', () => {
249
245
  expect(result.scope).toBe(payload.scope);
250
246
  });
251
247
 
252
- it('应该返回undefined如果记录不存在', async () => {
248
+ it('should return undefined if record does not exist', async () => {
253
249
  const adapter = new DrizzleAdapter('Session', serverDB);
254
250
  const result = await adapter.find('non-existent-id');
255
251
  expect(result).toBeUndefined();
256
252
  });
257
253
 
258
- it('应该返回undefined如果记录已过期', async () => {
259
- // 创建一个过期的记录(过期时间设为过去)
254
+ it('should return undefined if record is expired', async () => {
260
255
  const adapter = new DrizzleAdapter('Session', serverDB);
261
256
  const payload = {
262
257
  accountId: testUserId,
263
258
  cookie: 'cookie-value',
264
- exp: Math.floor(Date.now() / 1000) - 3600, // 1小时前
259
+ exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
265
260
  };
266
261
 
267
- // 负的过期时间表示立即过期
262
+ // Negative expiration time means immediate expiration
268
263
  await adapter.upsert(testId, payload, -1);
269
264
 
270
- // 等待一小段时间确保过期
265
+ // Wait briefly to ensure expiration
271
266
  await new Promise((resolve) => setTimeout(resolve, 10));
272
267
 
273
- // 然后查找它
274
268
  const result = await adapter.find(testId);
275
269
 
276
270
  expect(result).toBeUndefined();
277
271
  });
278
272
 
279
- it('应该返回undefined如果记录已被消费', async () => {
273
+ it('should return undefined if record is consumed', async () => {
280
274
  const adapter = new DrizzleAdapter('AccessToken', serverDB);
281
275
  const payload = { accountId: testUserId, clientId: testClientId };
282
276
  await adapter.upsert(testId, payload, 3600);
283
277
 
284
- // 消费记录
278
+ // Consume the record
285
279
  await adapter.consume(testId);
286
280
 
287
- // 查找已消费记录
281
+ // Find consumed record
282
+ const result = await adapter.find(testId);
283
+ expect(result).toBeUndefined();
284
+ });
285
+
286
+ it('should allow RefreshToken reuse within grace period', async () => {
287
+ const adapter = new DrizzleAdapter('RefreshToken', serverDB);
288
+ const payload = {
289
+ accountId: testUserId,
290
+ clientId: testClientId,
291
+ grantId: testGrantId,
292
+ };
293
+ await adapter.upsert(testId, payload, 3600);
294
+
295
+ // Consume the record
296
+ await adapter.consume(testId);
297
+
298
+ // Within grace period (180 seconds), should still find the record
299
+ const result = await adapter.find(testId);
300
+ expect(result).toBeDefined();
301
+ expect(result).toEqual(payload);
302
+ });
303
+
304
+ it('should reject RefreshToken reuse after grace period expires', async () => {
305
+ const adapter = new DrizzleAdapter('RefreshToken', serverDB);
306
+ const payload = {
307
+ accountId: testUserId,
308
+ clientId: testClientId,
309
+ grantId: testGrantId,
310
+ };
311
+ await adapter.upsert(testId, payload, 3600);
312
+
313
+ // Directly update consumedAt to a past time (beyond grace period)
314
+ // Grace period is 180 seconds, set to 200 seconds ago
315
+ const pastConsumedAt = new Date(Date.now() - 200 * 1000);
316
+ await serverDB
317
+ .update(oidcRefreshTokens)
318
+ .set({ consumedAt: pastConsumedAt })
319
+ .where(eq(oidcRefreshTokens.id, testId));
320
+
321
+ // Grace period expired, should return undefined
288
322
  const result = await adapter.find(testId);
289
323
  expect(result).toBeUndefined();
290
324
  });
291
325
  });
292
326
 
293
327
  describe('findByUserCode', () => {
294
- it('应该通过userCode找到DeviceCode记录', async () => {
295
- // 先创建一个DeviceCode记录
328
+ it('should find DeviceCode record by userCode', async () => {
296
329
  const adapter = new DrizzleAdapter('DeviceCode', serverDB);
297
330
  const payload = {
298
331
  clientId: testClientId,
@@ -302,46 +335,44 @@ describe('DrizzleAdapter', () => {
302
335
 
303
336
  await adapter.upsert(testId, payload, 3600);
304
337
 
305
- // 然后通过userCode查找它
306
338
  const result = await adapter.findByUserCode(testUserCode);
307
339
 
308
340
  expect(result).toBeDefined();
309
341
  expect(result).toEqual(payload);
310
342
  });
311
343
 
312
- it('应该返回undefined如果DeviceCode记录已过期', async () => {
344
+ it('should return undefined if DeviceCode record is expired', async () => {
313
345
  const adapter = new DrizzleAdapter('DeviceCode', serverDB);
314
346
  const payload = { clientId: testClientId, userCode: testUserCode };
315
- // 使用负数 expiresIn 使其立即过期
347
+ // Use negative expiresIn to make it expire immediately
316
348
  await adapter.upsert(testId, payload, -1);
317
- await new Promise((resolve) => setTimeout(resolve, 10)); // 短暂等待确保过期
349
+ await new Promise((resolve) => setTimeout(resolve, 10)); // Brief wait to ensure expiration
318
350
 
319
351
  const result = await adapter.findByUserCode(testUserCode);
320
352
  expect(result).toBeUndefined();
321
353
  });
322
354
 
323
- it('应该返回undefined如果DeviceCode记录已被消费', async () => {
355
+ it('should return undefined if DeviceCode record is consumed', async () => {
324
356
  const adapter = new DrizzleAdapter('DeviceCode', serverDB);
325
357
  const payload = { clientId: testClientId, userCode: testUserCode };
326
358
  await adapter.upsert(testId, payload, 3600);
327
359
 
328
- // 消费记录
360
+ // Consume the record
329
361
  await adapter.consume(testId);
330
362
 
331
- // 查找已消费记录
363
+ // Find consumed record
332
364
  const result = await adapter.findByUserCode(testUserCode);
333
365
  expect(result).toBeUndefined();
334
366
  });
335
367
 
336
- it('应该在非DeviceCode模型上抛出错误', async () => {
368
+ it('should throw error on non-DeviceCode model', async () => {
337
369
  const adapter = new DrizzleAdapter('Session', serverDB);
338
370
  await expect(adapter.findByUserCode(testUserCode)).rejects.toThrow();
339
371
  });
340
372
  });
341
373
 
342
374
  describe('findSessionByUserId', () => {
343
- it('应该通过userId找到Session记录', async () => {
344
- // 先创建一个Session记录
375
+ it('should find Session record by userId', async () => {
345
376
  const adapter = new DrizzleAdapter('Session', serverDB);
346
377
  const payload = {
347
378
  accountId: testUserId,
@@ -351,14 +382,13 @@ describe('DrizzleAdapter', () => {
351
382
 
352
383
  await adapter.upsert(testId, payload, 3600);
353
384
 
354
- // 然后通过userId查找它
355
385
  const result = await adapter.findSessionByUserId(testUserId);
356
386
 
357
387
  expect(result).toBeDefined();
358
388
  expect(result).toEqual(payload);
359
389
  });
360
390
 
361
- it('应该在非Session模型上返回undefined', async () => {
391
+ it('should return undefined on non-Session model', async () => {
362
392
  const adapter = new DrizzleAdapter('AccessToken', serverDB);
363
393
  const result = await adapter.findSessionByUserId(testUserId);
364
394
  expect(result).toBeUndefined();
@@ -366,8 +396,7 @@ describe('DrizzleAdapter', () => {
366
396
  });
367
397
 
368
398
  describe('destroy', () => {
369
- it('应该删除存在的记录', async () => {
370
- // 先创建一个记录
399
+ it('should delete existing record', async () => {
371
400
  const adapter = new DrizzleAdapter('Session', serverDB);
372
401
  const payload = {
373
402
  accountId: testUserId,
@@ -377,16 +406,16 @@ describe('DrizzleAdapter', () => {
377
406
 
378
407
  await adapter.upsert(testId, payload, 3600);
379
408
 
380
- // 确认记录存在
409
+ // Confirm record exists
381
410
  let result = await serverDB.query.oidcSessions.findFirst({
382
411
  where: eq(oidcSessions.id, testId),
383
412
  });
384
413
  expect(result).toBeDefined();
385
414
 
386
- // 删除记录
415
+ // Delete record
387
416
  await adapter.destroy(testId);
388
417
 
389
- // 验证记录已被删除
418
+ // Verify record is deleted
390
419
  result = await serverDB.query.oidcSessions.findFirst({
391
420
  where: eq(oidcSessions.id, testId),
392
421
  });
@@ -395,8 +424,7 @@ describe('DrizzleAdapter', () => {
395
424
  });
396
425
 
397
426
  describe('consume', () => {
398
- it('应该标记记录为已消费', async () => {
399
- // 先创建一个记录
427
+ it('should mark record as consumed', async () => {
400
428
  const adapter = new DrizzleAdapter('AccessToken', serverDB);
401
429
  const payload = {
402
430
  accountId: testUserId,
@@ -406,10 +434,10 @@ describe('DrizzleAdapter', () => {
406
434
 
407
435
  await adapter.upsert(testId, payload, 3600);
408
436
 
409
- // 消费记录
437
+ // Consume the record
410
438
  await adapter.consume(testId);
411
439
 
412
- // 验证记录已被标记为已消费
440
+ // Verify record is marked as consumed
413
441
  const result = await serverDB.query.oidcAccessTokens.findFirst({
414
442
  where: eq(oidcAccessTokens.id, testId),
415
443
  });
@@ -420,8 +448,8 @@ describe('DrizzleAdapter', () => {
420
448
  });
421
449
 
422
450
  describe('revokeByGrantId', () => {
423
- it('应该撤销与指定 grantId 相关的所有记录', async () => {
424
- // 创建AccessToken记录
451
+ it('should revoke all records associated with specified grantId', async () => {
452
+ // Create AccessToken record
425
453
  const accessTokenAdapter = new DrizzleAdapter('AccessToken', serverDB);
426
454
  const accessTokenPayload = {
427
455
  accountId: testUserId,
@@ -431,7 +459,7 @@ describe('DrizzleAdapter', () => {
431
459
  };
432
460
  await accessTokenAdapter.upsert(testId, accessTokenPayload, 3600);
433
461
 
434
- // 创建RefreshToken记录
462
+ // Create RefreshToken record
435
463
  const refreshTokenAdapter = new DrizzleAdapter('RefreshToken', serverDB);
436
464
  const refreshTokenPayload = {
437
465
  accountId: testUserId,
@@ -441,10 +469,10 @@ describe('DrizzleAdapter', () => {
441
469
  };
442
470
  await refreshTokenAdapter.upsert('refresh-' + testId, refreshTokenPayload, 3600);
443
471
 
444
- // 撤销与testGrantId相关的所有记录
472
+ // Revoke all records associated with testGrantId
445
473
  await accessTokenAdapter.revokeByGrantId(testGrantId);
446
474
 
447
- // 验证记录已被删除
475
+ // Verify records are deleted
448
476
  const accessTokenResult = await serverDB.query.oidcAccessTokens.findFirst({
449
477
  where: eq(oidcAccessTokens.id, testId),
450
478
  });
@@ -458,16 +486,16 @@ describe('DrizzleAdapter', () => {
458
486
  expect(refreshTokenResult).toBeUndefined();
459
487
  });
460
488
 
461
- it('应该在Grant模型上直接返回', async () => {
462
- // Grant模型不需要通过grantId来撤销
489
+ it('should return directly on Grant model', async () => {
490
+ // Grant model does not need to be revoked by grantId
463
491
  const adapter = new DrizzleAdapter('Grant', serverDB);
464
492
  await adapter.revokeByGrantId(testGrantId);
465
- // 如果没有抛出错误,测试通过
493
+ // Test passes if no error is thrown
466
494
  });
467
495
  });
468
496
 
469
497
  describe('createAdapterFactory', () => {
470
- it('应该创建一个适配器工厂函数', () => {
498
+ it('should create an adapter factory function', () => {
471
499
  const factory = DrizzleAdapter.createAdapterFactory(serverDB as any);
472
500
  expect(factory).toBeDefined();
473
501
  expect(typeof factory).toBe('function');
@@ -479,9 +507,9 @@ describe('DrizzleAdapter', () => {
479
507
  });
480
508
 
481
509
  describe('getTable (indirectly via public methods)', () => {
482
- it('当使用不支持的模型名称时应该抛出错误', async () => {
510
+ it('should throw error when using unsupported model name', async () => {
483
511
  const invalidAdapter = new DrizzleAdapter('InvalidModelName', serverDB);
484
- // 调用一个会触发 getTable 的方法
512
+ // Call a method that triggers getTable
485
513
  await expect(invalidAdapter.find('any-id')).rejects.toThrow('不支持的模型: InvalidModelName');
486
514
  await expect(invalidAdapter.upsert('any-id', {}, 3600)).rejects.toThrow(
487
515
  '不支持的模型: InvalidModelName',
@@ -863,17 +863,18 @@ const aihubmixModels: AIChatModelCard[] = [
863
863
  },
864
864
  contextWindowTokens: 131_072,
865
865
  description:
866
- 'DeepSeek V3.2 DeepSeek 最新发布的通用大模型,支持混合推理架构,具备更强的 Agent 能力。',
867
- displayName: 'DeepSeek V3.2 Exp',
868
- id: 'DeepSeek-V3.2-Exp',
866
+ 'DeepSeek-V3.2 是一款高效的大语言模型,具备 DSA 稀疏注意力与强化推理能力,其核心亮点在于强大的 Agent 能力——通过大规模任务合成,将推理与真实工具调用深度融合,实现更稳健、合规、可泛化的智能体表现。',
867
+ displayName: 'DeepSeek V3.2',
868
+ id: 'deepseek-chat',
869
869
  maxOutput: 8192,
870
870
  pricing: {
871
871
  units: [
872
- { name: 'textInput', rate: 0.28, strategy: 'fixed', unit: 'millionTokens' },
873
- { name: 'textOutput', rate: 0.42, strategy: 'fixed', unit: 'millionTokens' },
872
+ { name: 'textInput', rate: 0.3, strategy: 'fixed', unit: 'millionTokens' },
873
+ { name: 'textOutput', rate: 0.45, strategy: 'fixed', unit: 'millionTokens' },
874
+ { name: 'textInput_cacheRead', rate: 0.03, strategy: 'fixed', unit: 'millionTokens' },
874
875
  ],
875
876
  },
876
- releasedAt: '2025-09-29',
877
+ releasedAt: '2025-12-01',
877
878
  type: 'chat',
878
879
  },
879
880
  {
@@ -884,34 +885,18 @@ const aihubmixModels: AIChatModelCard[] = [
884
885
  contextWindowTokens: 131_072,
885
886
  description:
886
887
  'DeepSeek V3.2 思考模式。在输出最终回答之前,模型会先输出一段思维链内容,以提升最终答案的准确性。',
887
- displayName: 'DeepSeek V3.2 Exp Thinking',
888
+ displayName: 'DeepSeek V3.2 Thinking',
888
889
  enabled: true,
889
- id: 'DeepSeek-V3.2-Exp-Think',
890
+ id: 'deepseek-reasoner',
890
891
  maxOutput: 65_536,
891
892
  pricing: {
892
893
  units: [
893
- { name: 'textInput', rate: 0.28, strategy: 'fixed', unit: 'millionTokens' },
894
- { name: 'textOutput', rate: 0.42, strategy: 'fixed', unit: 'millionTokens' },
895
- ],
896
- },
897
- releasedAt: '2025-09-29',
898
- type: 'chat',
899
- },
900
- {
901
- abilities: {
902
- functionCall: true,
903
- },
904
- contextWindowTokens: 131_072,
905
- description:
906
- 'DeepSeek-V3.1-非思考模式;DeepSeek-V3.1 是深度求索全新推出的混合推理模型,支持思考与非思考2种推理模式,较 DeepSeek-R1-0528 思考效率更高。经 Post-Training 优化,Agent 工具使用与智能体任务表现大幅提升。',
907
- displayName: 'DeepSeek V3.1 (non-Think)',
908
- id: 'DeepSeek-V3.1',
909
- pricing: {
910
- units: [
911
- { name: 'textInput', rate: 0.56, strategy: 'fixed', unit: 'millionTokens' },
912
- { name: 'textOutput', rate: 1.68, strategy: 'fixed', unit: 'millionTokens' },
894
+ { name: 'textInput', rate: 0.3, strategy: 'fixed', unit: 'millionTokens' },
895
+ { name: 'textOutput', rate: 0.45, strategy: 'fixed', unit: 'millionTokens' },
896
+ { name: 'textInput_cacheRead', rate: 0.03, strategy: 'fixed', unit: 'millionTokens' },
913
897
  ],
914
898
  },
899
+ releasedAt: '2025-12-01',
915
900
  type: 'chat',
916
901
  },
917
902
  {
@@ -963,8 +948,8 @@ const aihubmixModels: AIChatModelCard[] = [
963
948
  id: 'DeepSeek-R1',
964
949
  pricing: {
965
950
  units: [
966
- { name: 'textInput', rate: 0.546, strategy: 'fixed', unit: 'millionTokens' },
967
- { name: 'textOutput', rate: 2.184, strategy: 'fixed', unit: 'millionTokens' },
951
+ { name: 'textInput', rate: 0.4, strategy: 'fixed', unit: 'millionTokens' },
952
+ { name: 'textOutput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
968
953
  ],
969
954
  },
970
955
  type: 'chat',
@@ -22,7 +22,7 @@ const deepseekChatModels: AIChatModelCard[] = [
22
22
  { name: 'textOutput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
23
23
  ],
24
24
  },
25
- releasedAt: '2025-09-29',
25
+ releasedAt: '2025-12-01',
26
26
  type: 'chat',
27
27
  },
28
28
  {
@@ -45,7 +45,7 @@ const deepseekChatModels: AIChatModelCard[] = [
45
45
  { name: 'textOutput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
46
46
  ],
47
47
  },
48
- releasedAt: '2025-09-29',
48
+ releasedAt: '2025-12-01',
49
49
  type: 'chat',
50
50
  },
51
51
  ];
@@ -1012,7 +1012,7 @@ const qwenChatModels: AIChatModelCard[] = [
1012
1012
  search: true,
1013
1013
  },
1014
1014
  config: {
1015
- deploymentName: 'qwen-plus-2025-09-11',
1015
+ deploymentName: 'qwen-plus-2025-12-01',
1016
1016
  },
1017
1017
  contextWindowTokens: 1_000_000,
1018
1018
  description: '通义千问超大规模语言模型增强版,支持中文、英文等不同语言输入。',
@@ -1,6 +1,7 @@
1
1
  import { LobeAnthropicAI } from '../../providers/anthropic';
2
2
  import { LobeAzureAI } from '../../providers/azureai';
3
3
  import { LobeCloudflareAI } from '../../providers/cloudflare';
4
+ import { LobeDeepSeekAI } from '../../providers/deepseek';
4
5
  import { LobeFalAI } from '../../providers/fal';
5
6
  import { LobeGoogleAI } from '../../providers/google';
6
7
  import { LobeOpenAI } from '../../providers/openai';
@@ -11,6 +12,7 @@ export const baseRuntimeMap = {
11
12
  anthropic: LobeAnthropicAI,
12
13
  azure: LobeAzureAI,
13
14
  cloudflare: LobeCloudflareAI,
15
+ deepseek: LobeDeepSeekAI,
14
16
  fal: LobeFalAI,
15
17
  google: LobeGoogleAI,
16
18
  openai: LobeOpenAI,
@@ -59,6 +59,11 @@ export const params: CreateRouterRuntimeOptions = {
59
59
  ),
60
60
  options: { baseURL: urlJoin(baseURL, '/v1') },
61
61
  },
62
+ {
63
+ apiType: 'deepseek',
64
+ models: ['deepseek-chat', 'deepseek-reasoner'],
65
+ options: { baseURL: urlJoin(baseURL, '/v1') },
66
+ },
62
67
  {
63
68
  apiType: 'openai',
64
69
  options: {
@@ -43,7 +43,9 @@ export class LobeAzureOpenAI implements LobeRuntimeAI {
43
43
  baseURL: string;
44
44
 
45
45
  async chat(payload: ChatStreamPayload, options?: ChatMethodOptions) {
46
- const { messages, model, ...params } = payload;
46
+ // Remove internal apiMode parameter to prevent sending to Azure OpenAI API
47
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
48
+ const { messages, model, apiMode: _, ...params } = payload;
47
49
  // o1 series models on Azure OpenAI does not support streaming currently
48
50
  const enableStreaming = model.includes('o1') ? false : (params.stream ?? true);
49
51
 
@@ -36,7 +36,9 @@ export class LobeAzureAI implements LobeRuntimeAI {
36
36
  baseURL: string;
37
37
 
38
38
  async chat(payload: ChatStreamPayload, options?: ChatMethodOptions) {
39
- const { messages, model, temperature, top_p, ...params } = payload;
39
+ // Remove internal apiMode parameter to prevent sending to Azure AI API
40
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
41
+ const { messages, model, temperature, top_p, apiMode: _, ...params } = payload;
40
42
  // o1 series models on Azure OpenAI does not support streaming currently
41
43
  const enableStreaming = model.includes('o1') ? false : (params.stream ?? true);
42
44
 
@@ -57,7 +57,9 @@ export class LobeCloudflareAI implements LobeRuntimeAI {
57
57
 
58
58
  async chat(payload: ChatStreamPayload, options?: ChatMethodOptions): Promise<Response> {
59
59
  try {
60
- const { model, tools, ...restPayload } = payload;
60
+ // Remove internal apiMode parameter to prevent sending to Cloudflare API
61
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
62
+ const { model, tools, apiMode: _, ...restPayload } = payload;
61
63
  const functions = tools?.map((tool) => tool.function);
62
64
  const headers = options?.headers || {};
63
65
  if (this.apiKey) {
@@ -7,7 +7,7 @@ import { Flexbox } from 'react-layout-kit';
7
7
  import { useChatStore } from '@/store/chat';
8
8
  import { aiChatSelectors, messageStateSelectors } from '@/store/chat/selectors';
9
9
 
10
- import { DefaultMessage } from '../Default';
10
+ import { DefaultMessage, MessageContentClassName } from '../Default';
11
11
  import ImageFileListViewer from '../User/ImageFileListViewer';
12
12
  import { CollapsedMessage } from './CollapsedMessage';
13
13
  import MessageContent from './DisplayContent';
@@ -71,7 +71,7 @@ export const AssistantMessageBody = memo<
71
71
  if (isCollapsed) return <CollapsedMessage content={content} id={id} />;
72
72
 
73
73
  return (
74
- <Flexbox gap={8} id={id}>
74
+ <Flexbox className={MessageContentClassName} gap={8} id={id}>
75
75
  {showSearch && (
76
76
  <SearchGrounding citations={search?.citations} searchQueries={search?.searchQueries} />
77
77
  )}
@@ -80,6 +80,7 @@ const Item = memo<ChatListItemProps>(
80
80
  contextMenuState,
81
81
  handleContextMenu,
82
82
  hideContextMenu,
83
+ contextMenuMode,
83
84
  } = useChatItemContextMenu({
84
85
  editing,
85
86
  onActionClick: () => {},
@@ -140,6 +141,10 @@ const Item = memo<ChatListItemProps>(
140
141
  if (!item) return;
141
142
 
142
143
  if (isDesktop) {
144
+ if (contextMenuMode !== 'disabled') {
145
+ handleContextMenu(event);
146
+ return;
147
+ }
143
148
  const { electronSystemService } = await import('@/services/electron/system');
144
149
 
145
150
  electronSystemService.showContextMenu('chat', {
@@ -127,6 +127,7 @@ export const useChatItemContextMenu = ({
127
127
 
128
128
  return {
129
129
  containerRef,
130
+ contextMenuMode,
130
131
  contextMenuState,
131
132
  handleContextMenu,
132
133
  handleMenuClick,
@@ -15,6 +15,20 @@ import { eq, sql } from 'drizzle-orm';
15
15
  // 创建 adapter 日志命名空间
16
16
  const log = debug('lobe-oidc:adapter');
17
17
 
18
+ /**
19
+ * Grace period for consumed RefreshToken (in seconds)
20
+ *
21
+ * When rotateRefreshToken is enabled, the old refresh token is consumed
22
+ * when a new one is issued. However, if the client fails to receive/save
23
+ * the new token (network issues, crashes), the old token becomes unusable.
24
+ *
25
+ * This grace period allows the consumed refresh token to be reused within
26
+ * a short window, giving clients a chance to retry the refresh operation.
27
+ *
28
+ * Default: 180 seconds (3 minutes)
29
+ */
30
+ const REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 180;
31
+
18
32
  class OIDCAdapter {
19
33
  private db: LobeChatDatabase;
20
34
  private name: string;
@@ -278,8 +292,35 @@ class OIDCAdapter {
278
292
  return undefined;
279
293
  }
280
294
 
281
- // 如果记录已被消费,返回 undefined
295
+ // 如果记录已被消费,检查是否在宽限期内
282
296
  if (model.consumedAt) {
297
+ // For RefreshToken, allow reuse within grace period
298
+ if (this.name === 'RefreshToken') {
299
+ const consumedAt = new Date(model.consumedAt);
300
+ const gracePeriodEnd = new Date(
301
+ consumedAt.getTime() + REFRESH_TOKEN_GRACE_PERIOD_SECONDS * 1000,
302
+ );
303
+ const now = new Date();
304
+
305
+ if (now <= gracePeriodEnd) {
306
+ // Within grace period, allow reuse for retry scenarios
307
+ log(
308
+ '[RefreshToken] Token consumed at %s but within grace period (ends %s), allowing reuse',
309
+ consumedAt.toISOString(),
310
+ gracePeriodEnd.toISOString(),
311
+ );
312
+ return model.data;
313
+ }
314
+
315
+ log(
316
+ '[RefreshToken] Token consumed at %s, grace period expired at %s, returning undefined',
317
+ consumedAt.toISOString(),
318
+ gracePeriodEnd.toISOString(),
319
+ );
320
+ return undefined;
321
+ }
322
+
323
+ // For other token types, consumed means invalid
283
324
  log(
284
325
  '[%s] Record already consumed (consumedAt: %s), returning undefined',
285
326
  this.name,