@lobehub/chat 1.134.6 → 1.135.0

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/changelog/v1.json +18 -0
  3. package/docs/development/basic/feature-development-frontend.zh-CN.mdx +1 -1
  4. package/docs/development/basic/folder-structure.mdx +67 -16
  5. package/docs/development/basic/folder-structure.zh-CN.mdx +67 -16
  6. package/locales/ar/modelProvider.json +15 -0
  7. package/locales/ar/models.json +3 -0
  8. package/locales/bg-BG/modelProvider.json +15 -0
  9. package/locales/bg-BG/models.json +3 -0
  10. package/locales/de-DE/modelProvider.json +15 -0
  11. package/locales/de-DE/models.json +3 -0
  12. package/locales/en-US/modelProvider.json +15 -0
  13. package/locales/en-US/models.json +3 -0
  14. package/locales/es-ES/modelProvider.json +15 -0
  15. package/locales/es-ES/models.json +3 -0
  16. package/locales/fa-IR/modelProvider.json +15 -0
  17. package/locales/fa-IR/models.json +3 -0
  18. package/locales/fr-FR/modelProvider.json +15 -0
  19. package/locales/fr-FR/models.json +3 -0
  20. package/locales/it-IT/modelProvider.json +15 -0
  21. package/locales/it-IT/models.json +3 -0
  22. package/locales/ja-JP/modelProvider.json +15 -0
  23. package/locales/ja-JP/models.json +3 -0
  24. package/locales/ko-KR/modelProvider.json +15 -0
  25. package/locales/ko-KR/models.json +3 -0
  26. package/locales/nl-NL/modelProvider.json +15 -0
  27. package/locales/nl-NL/models.json +3 -0
  28. package/locales/pl-PL/modelProvider.json +15 -0
  29. package/locales/pl-PL/models.json +3 -0
  30. package/locales/pt-BR/modelProvider.json +15 -0
  31. package/locales/pt-BR/models.json +3 -0
  32. package/locales/ru-RU/modelProvider.json +15 -0
  33. package/locales/ru-RU/models.json +3 -0
  34. package/locales/tr-TR/modelProvider.json +15 -0
  35. package/locales/tr-TR/models.json +3 -0
  36. package/locales/vi-VN/modelProvider.json +15 -0
  37. package/locales/vi-VN/models.json +3 -0
  38. package/locales/zh-CN/modelProvider.json +15 -0
  39. package/locales/zh-CN/models.json +3 -0
  40. package/locales/zh-TW/modelProvider.json +15 -0
  41. package/locales/zh-TW/models.json +3 -0
  42. package/package.json +1 -1
  43. package/packages/model-bank/src/aiModels/fal.ts +28 -0
  44. package/packages/model-runtime/src/core/openaiCompatibleFactory/createImage.ts +16 -27
  45. package/packages/model-runtime/src/core/openaiCompatibleFactory/index.test.ts +51 -11
  46. package/packages/model-runtime/src/core/streams/protocol.ts +2 -15
  47. package/packages/model-runtime/src/providers/azureOpenai/index.ts +5 -1
  48. package/packages/model-runtime/src/providers/azureai/index.ts +5 -1
  49. package/packages/model-runtime/src/providers/fal/index.ts +12 -7
  50. package/packages/model-runtime/src/providers/newapi/index.test.ts +28 -3
  51. package/packages/model-runtime/src/providers/newapi/index.ts +34 -88
  52. package/packages/model-runtime/src/types/index.ts +0 -1
  53. package/packages/model-runtime/src/utils/sanitizeError.test.ts +109 -0
  54. package/packages/model-runtime/src/utils/sanitizeError.ts +59 -0
  55. package/packages/types/src/message/base.ts +1 -0
  56. package/packages/utils/package.json +2 -1
  57. package/src/app/[variants]/(main)/image/@menu/components/SizeSelect/index.tsx +24 -1
  58. package/src/server/modules/EdgeConfig/index.ts +15 -33
  59. package/src/server/modules/EdgeConfig/types.ts +13 -0
  60. package/packages/model-runtime/src/types/usage.ts +0 -27
@@ -20,6 +20,7 @@ import { AgentRuntimeError } from '../../utils/createError';
20
20
  import { debugStream } from '../../utils/debugStream';
21
21
  import { convertImageUrlToFile, convertOpenAIMessages } from '../../utils/openaiHelpers';
22
22
  import { StreamingResponse } from '../../utils/response';
23
+ import { sanitizeError } from '../../utils/sanitizeError';
23
24
 
24
25
  const azureImageLogger = debug('lobe-image:azure');
25
26
  export class LobeAzureOpenAI implements LobeRuntimeAI {
@@ -253,9 +254,12 @@ export class LobeAzureOpenAI implements LobeRuntimeAI {
253
254
  ? AgentRuntimeErrorType.ProviderBizError
254
255
  : AgentRuntimeErrorType.AgentRuntimeError;
255
256
 
257
+ // Sanitize error to remove sensitive information like API keys from headers
258
+ const sanitizedError = sanitizeError(error);
259
+
256
260
  throw AgentRuntimeError.chat({
257
261
  endpoint: this.maskSensitiveUrl(this.baseURL),
258
- error,
262
+ error: sanitizedError,
259
263
  errorType,
260
264
  provider: ModelProvider.Azure,
261
265
  });
@@ -12,6 +12,7 @@ import { AgentRuntimeErrorType } from '../../types/error';
12
12
  import { AgentRuntimeError } from '../../utils/createError';
13
13
  import { debugStream } from '../../utils/debugStream';
14
14
  import { StreamingResponse } from '../../utils/response';
15
+ import { sanitizeError } from '../../utils/sanitizeError';
15
16
 
16
17
  interface AzureAIParams {
17
18
  apiKey?: string;
@@ -112,9 +113,12 @@ export class LobeAzureAI implements LobeRuntimeAI {
112
113
  ? AgentRuntimeErrorType.ProviderBizError
113
114
  : AgentRuntimeErrorType.AgentRuntimeError;
114
115
 
116
+ // Sanitize error to remove sensitive information like API keys from headers
117
+ const sanitizedError = sanitizeError(error);
118
+
115
119
  throw AgentRuntimeError.chat({
116
120
  endpoint: this.maskSensitiveUrl(this.baseURL),
117
- error,
121
+ error: sanitizedError,
118
122
  errorType,
119
123
  provider: ModelProvider.Azure,
120
124
  });
@@ -33,6 +33,7 @@ export class LobeFalAI implements LobeRuntimeAI {
33
33
  ['cfg', 'guidance_scale'],
34
34
  ['imageUrl', 'image_url'],
35
35
  ['imageUrls', 'image_urls'],
36
+ ['size', 'image_size'],
36
37
  ]);
37
38
 
38
39
  const defaultInput: Record<string, unknown> = {
@@ -50,12 +51,16 @@ export class LobeFalAI implements LobeRuntimeAI {
50
51
  );
51
52
 
52
53
  if ('width' in userInput && 'height' in userInput) {
53
- userInput.image_size = {
54
- height: userInput.height,
55
- width: userInput.width,
56
- };
57
- delete userInput.width;
58
- delete userInput.height;
54
+ if (userInput.size) {
55
+ throw new Error('width/height and size are not supported at the same time');
56
+ } else {
57
+ userInput.image_size = {
58
+ height: userInput.height,
59
+ width: userInput.width,
60
+ };
61
+ delete userInput.width;
62
+ delete userInput.height;
63
+ }
59
64
  }
60
65
 
61
66
  const modelsAcceleratedByDefault = new Set<string>(['flux/krea']);
@@ -66,7 +71,7 @@ export class LobeFalAI implements LobeRuntimeAI {
66
71
  // Ensure model has fal-ai/ prefix
67
72
  let endpoint = model.startsWith('fal-ai/') ? model : `fal-ai/${model}`;
68
73
  const hasImageUrls = (params.imageUrls?.length ?? 0) > 0;
69
- if (endpoint === 'fal-ai/bytedance/seedream/v4') {
74
+ if (['fal-ai/bytedance/seedream/v4', 'fal-ai/hunyuan-image/v3'].includes(endpoint)) {
70
75
  endpoint += hasImageUrls ? '/edit' : '/text-to-image';
71
76
  } else if (endpoint === 'fal-ai/nano-banana' && hasImageUrls) {
72
77
  endpoint += '/edit';
@@ -563,7 +563,22 @@ describe('NewAPI Runtime - 100% Branch Coverage', () => {
563
563
 
564
564
  if (inputPrice !== undefined) {
565
565
  const outputPrice = inputPrice * (pricing.completion_ratio || 1);
566
- enhancedModel.pricing = { input: inputPrice, output: outputPrice };
566
+ enhancedModel.pricing = {
567
+ units: [
568
+ {
569
+ name: 'textInput',
570
+ unit: 'millionTokens',
571
+ strategy: 'fixed',
572
+ rate: inputPrice,
573
+ },
574
+ {
575
+ name: 'textOutput',
576
+ unit: 'millionTokens',
577
+ strategy: 'fixed',
578
+ rate: outputPrice,
579
+ },
580
+ ],
581
+ };
567
582
  }
568
583
  }
569
584
 
@@ -582,8 +597,18 @@ describe('NewAPI Runtime - 100% Branch Coverage', () => {
582
597
  });
583
598
 
584
599
  // Verify pricing results
585
- expect(enrichedModels[0].pricing).toEqual({ input: 40, output: 120 }); // model_price * 2, input * completion_ratio
586
- expect(enrichedModels[1].pricing).toEqual({ input: 10, output: 10 }); // model_ratio * 2, input * 1 (default)
600
+ expect(enrichedModels[0].pricing).toEqual({
601
+ units: [
602
+ { name: 'textInput', unit: 'millionTokens', strategy: 'fixed', rate: 40 },
603
+ { name: 'textOutput', unit: 'millionTokens', strategy: 'fixed', rate: 120 },
604
+ ],
605
+ }); // model_price * 2, input * completion_ratio
606
+ expect(enrichedModels[1].pricing).toEqual({
607
+ units: [
608
+ { name: 'textInput', unit: 'millionTokens', strategy: 'fixed', rate: 10 },
609
+ { name: 'textOutput', unit: 'millionTokens', strategy: 'fixed', rate: 10 },
610
+ ],
611
+ }); // model_ratio * 2, input * 1 (default)
587
612
  expect(enrichedModels[2].pricing).toBeUndefined(); // quota_type = 1, skipped
588
613
 
589
614
  // Verify provider detection
@@ -20,41 +20,21 @@ export interface NewAPIPricing {
20
20
  model_name: string;
21
21
  model_price?: number;
22
22
  model_ratio?: number;
23
- quota_type: number; // 0: 按量计费, 1: 按次计费
23
+ /** 0: Pay-per-token, 1: Pay-per-call */
24
+ quota_type: number;
24
25
  supported_endpoint_types?: string[];
25
26
  }
26
27
 
27
28
  const handlePayload = (payload: ChatStreamPayload) => {
28
- // 处理 OpenAI responses API 模式
29
+ // Handle OpenAI responses API mode
29
30
  if (
30
31
  responsesAPIModels.has(payload.model) ||
31
32
  payload.model.includes('gpt-') ||
32
33
  /^o\d/.test(payload.model)
33
34
  ) {
34
- return { ...payload, apiMode: 'responses' } as any;
35
+ return { ...payload, apiMode: 'responses' };
35
36
  }
36
- return payload as any;
37
- };
38
-
39
- // 根据 owned_by 字段判断提供商(基于 NewAPI 的 channel name)
40
- const getProviderFromOwnedBy = (ownedBy: string): string => {
41
- const normalizedOwnedBy = ownedBy.toLowerCase();
42
-
43
- if (normalizedOwnedBy.includes('claude') || normalizedOwnedBy.includes('anthropic')) {
44
- return 'anthropic';
45
- }
46
- if (normalizedOwnedBy.includes('google') || normalizedOwnedBy.includes('gemini')) {
47
- return 'google';
48
- }
49
- if (normalizedOwnedBy.includes('xai') || normalizedOwnedBy.includes('grok')) {
50
- return 'xai';
51
- }
52
- if (normalizedOwnedBy.includes('ali') || normalizedOwnedBy.includes('qwen')) {
53
- return 'qwen';
54
- }
55
-
56
- // 默认为 openai
57
- return 'openai';
37
+ return payload;
58
38
  };
59
39
 
60
40
  export const LobeNewAPIAI = createRouterRuntime({
@@ -66,16 +46,16 @@ export const LobeNewAPIAI = createRouterRuntime({
66
46
  },
67
47
  id: ModelProvider.NewAPI,
68
48
  models: async ({ client: openAIClient }) => {
69
- // 获取基础 URL(移除末尾的 API 版本路径如 /v1、/v1beta 等)
49
+ // Get base URL (remove trailing API version paths like /v1, /v1beta, etc.)
70
50
  const baseURL = openAIClient.baseURL.replace(/\/v\d+[a-z]*\/?$/, '');
71
51
 
72
52
  const modelsPage = (await openAIClient.models.list()) as any;
73
53
  const modelList: NewAPIModelCard[] = modelsPage.data || [];
74
54
 
75
- // 尝试获取 pricing 信息以补充模型详细信息
55
+ // Try to get pricing information to enrich model details
76
56
  let pricingMap: Map<string, NewAPIPricing> = new Map();
77
57
  try {
78
- // 使用保存的 baseURL
58
+ // Use saved baseURL
79
59
  const pricingResponse = await fetch(`${baseURL}/api/pricing`, {
80
60
  headers: {
81
61
  Authorization: `Bearer ${openAIClient.apiKey}`,
@@ -99,22 +79,22 @@ export const LobeNewAPIAI = createRouterRuntime({
99
79
  const enrichedModelList = modelList.map((model) => {
100
80
  let enhancedModel: any = { ...model };
101
81
 
102
- // 1. 添加 pricing 信息
82
+ // add pricing info
103
83
  const pricing = pricingMap.get(model.id);
104
84
  if (pricing) {
105
- // NewAPI 的价格计算逻辑:
106
- // - quota_type: 0 表示按量计费(按 token),1 表示按次计费
107
- // - model_ratio: 相对于基础价格的倍率(基础价格 = $0.002/1K tokens
108
- // - model_price: 直接指定的价格(优先使用)
109
- // - completion_ratio: 输出价格相对于输入价格的倍率
85
+ // NewAPI pricing calculation logic:
86
+ // - quota_type: 0 means pay-per-token, 1 means pay-per-call
87
+ // - model_ratio: multiplier relative to base price (base price = $0.002/1K tokens)
88
+ // - model_price: directly specified price (takes priority)
89
+ // - completion_ratio: output price multiplier relative to input price
110
90
  //
111
- // LobeChat 需要的格式:美元/百万 token
91
+ // LobeChat required format: USD per million tokens
112
92
 
113
93
  let inputPrice: number | undefined;
114
94
  let outputPrice: number | undefined;
115
95
 
116
96
  if (pricing.quota_type === 0) {
117
- // 按量计费
97
+ // Pay-per-token
118
98
  if (pricing.model_price && pricing.model_price > 0) {
119
99
  // model_price is a direct price value; need to confirm its unit.
120
100
  // Assumption: model_price is the price per 1,000 tokens (i.e., $/1K tokens).
@@ -124,62 +104,38 @@ export const LobeNewAPIAI = createRouterRuntime({
124
104
  inputPrice = pricing.model_price * 2;
125
105
  } else if (pricing.model_ratio) {
126
106
  // model_ratio × $0.002/1K = model_ratio × $2/1M
127
- inputPrice = pricing.model_ratio * 2; // 转换为 $/1M tokens
107
+ inputPrice = pricing.model_ratio * 2; // Convert to $/1M tokens
128
108
  }
129
109
 
130
110
  if (inputPrice !== undefined) {
131
- // 计算输出价格
111
+ // Calculate output price
132
112
  outputPrice = inputPrice * (pricing.completion_ratio || 1);
133
113
 
134
114
  enhancedModel.pricing = {
135
- input: inputPrice,
136
- output: outputPrice,
115
+ units: [
116
+ {
117
+ name: 'textInput',
118
+ rate: inputPrice,
119
+ strategy: 'fixed',
120
+ unit: 'millionTokens',
121
+ },
122
+ {
123
+ name: 'textOutput',
124
+ rate: outputPrice,
125
+ strategy: 'fixed',
126
+ unit: 'millionTokens',
127
+ },
128
+ ],
137
129
  };
138
130
  }
139
131
  }
140
- // quota_type === 1 按次计费暂不支持
141
- }
142
-
143
- // 2. 根据优先级处理 provider 信息并缓存路由
144
- let detectedProvider = 'openai'; // 默认
145
-
146
- // 优先级1:使用 supported_endpoint_types
147
- if (model.supported_endpoint_types && model.supported_endpoint_types.length > 0) {
148
- if (model.supported_endpoint_types.includes('anthropic')) {
149
- detectedProvider = 'anthropic';
150
- } else if (model.supported_endpoint_types.includes('gemini')) {
151
- detectedProvider = 'google';
152
- } else if (model.supported_endpoint_types.includes('xai')) {
153
- detectedProvider = 'xai';
154
- } else if (model.supported_endpoint_types.includes('qwen')) {
155
- detectedProvider = 'qwen';
156
- }
157
- }
158
- // 优先级2:使用 owned_by 字段
159
- else if (model.owned_by) {
160
- detectedProvider = getProviderFromOwnedBy(model.owned_by);
161
- }
162
- // 优先级3:基于模型名称检测
163
- else {
164
- detectedProvider = detectModelProvider(model.id);
132
+ // quota_type === 1 pay-per-call is not currently supported
165
133
  }
166
134
 
167
- // 将检测到的 provider 信息附加到模型上
168
- enhancedModel._detectedProvider = detectedProvider;
169
-
170
135
  return enhancedModel;
171
136
  });
172
137
 
173
- // 使用 processMultiProviderModelList 处理模型能力
174
- const processedModels = await processMultiProviderModelList(enrichedModelList, 'newapi');
175
-
176
- // 清理临时字段
177
- return processedModels.map((model: any) => {
178
- if (model._detectedProvider) {
179
- delete model._detectedProvider;
180
- }
181
- return model;
182
- });
138
+ return processMultiProviderModelList(enrichedModelList, 'newapi');
183
139
  },
184
140
  routers: (options) => {
185
141
  const userBaseURL = options.baseURL?.replace(/\/v\d+[a-z]*\/?$/, '') || '';
@@ -215,16 +171,6 @@ export const LobeNewAPIAI = createRouterRuntime({
215
171
  baseURL: urlJoin(userBaseURL, '/v1'),
216
172
  },
217
173
  },
218
- {
219
- apiType: 'qwen',
220
- models: LOBE_DEFAULT_MODEL_LIST.map((m) => m.id).filter(
221
- (id) => detectModelProvider(id) === 'qwen',
222
- ),
223
- options: {
224
- ...options,
225
- baseURL: urlJoin(userBaseURL, '/v1'),
226
- },
227
- },
228
174
  {
229
175
  apiType: 'openai',
230
176
  options: {
@@ -8,4 +8,3 @@ export * from './textToImage';
8
8
  export * from './toolsCalling';
9
9
  export * from './tts';
10
10
  export * from './type';
11
- export * from './usage';
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { sanitizeError } from './sanitizeError';
4
+
5
+ describe('sanitizeError', () => {
6
+ it('should remove sensitive request headers', () => {
7
+ const errorWithHeaders = {
8
+ message: 'API Error',
9
+ code: 401,
10
+ request: {
11
+ headers: {
12
+ authorization: 'Bearer sk-1234567890',
13
+ 'content-type': 'application/json',
14
+ 'ocp-apim-subscription-key': 'azure-key-123',
15
+ },
16
+ url: 'https://api.example.com',
17
+ },
18
+ };
19
+
20
+ const sanitized = sanitizeError(errorWithHeaders);
21
+
22
+ expect(sanitized).toEqual({
23
+ message: 'API Error',
24
+ code: 401,
25
+ });
26
+ expect(sanitized.request).toBeUndefined();
27
+ });
28
+
29
+ it('should remove sensitive fields at any level', () => {
30
+ const errorWithNestedSensitive = {
31
+ message: 'Error',
32
+ data: {
33
+ config: {
34
+ apikey: 'secret-key',
35
+ headers: {
36
+ authorization: 'Bearer token',
37
+ },
38
+ },
39
+ response: {
40
+ status: 401,
41
+ data: 'Unauthorized',
42
+ },
43
+ },
44
+ };
45
+
46
+ const sanitized = sanitizeError(errorWithNestedSensitive);
47
+
48
+ expect(sanitized).toEqual({
49
+ message: 'Error',
50
+ data: {
51
+ response: {
52
+ status: 401,
53
+ data: 'Unauthorized',
54
+ },
55
+ },
56
+ });
57
+ expect(sanitized.data.config).toBeUndefined();
58
+ });
59
+
60
+ it('should handle primitive values', () => {
61
+ expect(sanitizeError('string')).toBe('string');
62
+ expect(sanitizeError(123)).toBe(123);
63
+ expect(sanitizeError(true)).toBe(true);
64
+ expect(sanitizeError(null)).toBe(null);
65
+ expect(sanitizeError(undefined)).toBe(undefined);
66
+ });
67
+
68
+ it('should handle arrays', () => {
69
+ const errorArray = [
70
+ { message: 'Error 1', apikey: 'secret' },
71
+ { message: 'Error 2', status: 500 },
72
+ ];
73
+
74
+ const sanitized = sanitizeError(errorArray);
75
+
76
+ expect(sanitized).toEqual([{ message: 'Error 1' }, { message: 'Error 2', status: 500 }]);
77
+ });
78
+
79
+ it('should be case insensitive for sensitive field detection', () => {
80
+ const errorWithMixedCase = {
81
+ message: 'Error',
82
+ Authorization: 'Bearer token',
83
+ 'API-KEY': 'secret',
84
+ Headers: { token: 'secret' },
85
+ };
86
+
87
+ const sanitized = sanitizeError(errorWithMixedCase);
88
+
89
+ expect(sanitized).toEqual({
90
+ message: 'Error',
91
+ });
92
+ });
93
+
94
+ it('should preserve safe nested structures', () => {
95
+ const errorWithSafeNested = {
96
+ message: 'Error occurred',
97
+ status: 401,
98
+ details: {
99
+ code: 'UNAUTHORIZED',
100
+ timestamp: '2024-01-01T00:00:00Z',
101
+ path: '/api/chat',
102
+ },
103
+ };
104
+
105
+ const sanitized = sanitizeError(errorWithSafeNested);
106
+
107
+ expect(sanitized).toEqual(errorWithSafeNested);
108
+ });
109
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Sanitizes error objects by removing sensitive information that could expose API keys or other credentials.
3
+ * This is particularly important for errors from Azure/OpenAI SDKs that may include request headers.
4
+ */
5
+ export function sanitizeError(error: any): any {
6
+ if (!error || typeof error !== 'object') {
7
+ return error;
8
+ }
9
+
10
+ // Handle array of errors
11
+ if (Array.isArray(error)) {
12
+ return error.map(sanitizeError);
13
+ }
14
+
15
+ // Create a sanitized copy
16
+ const sanitized: any = {};
17
+
18
+ // List of sensitive fields that should be removed or masked
19
+ const sensitiveFields = [
20
+ 'request',
21
+ 'headers',
22
+ 'authorization',
23
+ 'apikey',
24
+ 'api-key',
25
+ 'ocp-apim-subscription-key',
26
+ 'x-api-key',
27
+ 'bearer',
28
+ 'token',
29
+ 'auth',
30
+ 'credential',
31
+ 'key',
32
+ 'secret',
33
+ 'password',
34
+ 'config',
35
+ 'options',
36
+ ];
37
+
38
+ // Copy safe fields and recursively sanitize nested objects
39
+ for (const key in error) {
40
+ if (error.hasOwnProperty(key)) {
41
+ const value = error[key];
42
+ const lowerKey = key.toLowerCase();
43
+
44
+ // Skip sensitive fields entirely
45
+ if (sensitiveFields.indexOf(lowerKey) !== -1) {
46
+ continue;
47
+ }
48
+
49
+ // Recursively sanitize nested objects
50
+ if (value && typeof value === 'object') {
51
+ sanitized[key] = sanitizeError(value);
52
+ } else {
53
+ sanitized[key] = value;
54
+ }
55
+ }
56
+ }
57
+
58
+ return sanitized;
59
+ }
@@ -61,6 +61,7 @@ export interface ModelTokensUsage {
61
61
  rejectedPredictionTokens?: number;
62
62
 
63
63
  // Total tokens
64
+ // TODO: make all following fields required
64
65
  totalInputTokens?: number;
65
66
  totalOutputTokens?: number;
66
67
  totalTokens?: number;
@@ -5,7 +5,8 @@
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
7
7
  "./server": "./src/server/index.ts",
8
- "./client": "./src/client/index.ts"
8
+ "./client": "./src/client/index.ts",
9
+ "./object": "./src/object.ts"
9
10
  },
10
11
  "scripts": {
11
12
  "test": "vitest",
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { Block, Grid, GridProps, Text } from '@lobehub/ui';
3
+ import { Block, Grid, GridProps, Select, Text } from '@lobehub/ui';
4
4
  import { useTheme } from 'antd-style';
5
5
  import { ReactNode, memo } from 'react';
6
6
  import { Center } from 'react-layout-kit';
@@ -13,6 +13,19 @@ export interface SizeSelectProps extends Omit<GridProps, 'children' | 'onChange'
13
13
  value?: 'auto' | string;
14
14
  }
15
15
 
16
+ /**
17
+ * Check if a size value can be parsed as valid aspect ratio
18
+ */
19
+ const canParseAsRatio = (value: string): boolean => {
20
+ if (value === 'auto') return true;
21
+
22
+ const parts = value.split('x');
23
+ if (parts.length !== 2) return false;
24
+
25
+ const [width, height] = parts.map(Number);
26
+ return !isNaN(width) && !isNaN(height) && width > 0 && height > 0;
27
+ };
28
+
16
29
  const SizeSelect = memo<SizeSelectProps>(({ options, onChange, value, defaultValue, ...rest }) => {
17
30
  const theme = useTheme();
18
31
  const [active, setActive] = useMergeState('auto', {
@@ -20,6 +33,16 @@ const SizeSelect = memo<SizeSelectProps>(({ options, onChange, value, defaultVal
20
33
  onChange,
21
34
  value,
22
35
  });
36
+
37
+ // Check if all options can be parsed as valid ratios
38
+ const hasInvalidRatio = options?.some((item) => !canParseAsRatio(item.value));
39
+
40
+ // If any option cannot be parsed as ratio, fallback to regular Select
41
+ if (hasInvalidRatio) {
42
+ return (
43
+ <Select onChange={onChange} options={options} style={{ width: '100%' }} value={active} />
44
+ );
45
+ }
23
46
  return (
24
47
  <Block padding={4} variant={'filled'} {...rest}>
25
48
  <Grid gap={4} maxItemWidth={72} rows={16}>
@@ -3,22 +3,9 @@ import createDebug from 'debug';
3
3
 
4
4
  import { appEnv } from '@/envs/app';
5
5
 
6
- const debug = createDebug('lobe-server:edge-config');
6
+ import { EdgeConfigData, EdgeConfigKeys } from './types';
7
7
 
8
- const EdgeConfigKeys = {
9
- /**
10
- * Assistant whitelist
11
- */
12
- AssistantBlacklist: 'assistant_blacklist',
13
- /**
14
- * Assistant whitelist
15
- */
16
- AssistantWhitelist: 'assistant_whitelist',
17
- /**
18
- * Feature flags configuration
19
- */
20
- FeatureFlags: 'feature_flags',
21
- };
8
+ const debug = createDebug('lobe-server:edge-config');
22
9
 
23
10
  export class EdgeConfig {
24
11
  get client(): EdgeConfigClient {
@@ -38,29 +25,24 @@ export class EdgeConfig {
38
25
  return isEnabled;
39
26
  }
40
27
 
41
- getAgentRestrictions = async () => {
42
- const { assistant_blacklist: blacklist, assistant_whitelist: whitelist } =
43
- await this.client.getAll([
44
- EdgeConfigKeys.AssistantWhitelist,
45
- EdgeConfigKeys.AssistantBlacklist,
46
- ]);
28
+ private async getValue<K extends EdgeConfigKeys>(key: K) {
29
+ return this.client.get<EdgeConfigData[K]>(key);
30
+ }
47
31
 
48
- return { blacklist, whitelist } as {
49
- blacklist: string[] | undefined;
50
- whitelist: string[] | undefined;
51
- };
52
- };
32
+ private async getValues<const K extends EdgeConfigKeys>(keys: K[]) {
33
+ return this.client.getAll<Pick<EdgeConfigData, K>>(keys);
34
+ }
53
35
 
54
- getFlagByKey = async (key: string) => {
55
- const value = await this.client.get(key);
56
- return value;
36
+ getAgentRestrictions = async () => {
37
+ const { assistant_blacklist: blacklist, assistant_whitelist: whitelist } = await this.getValues(
38
+ ['assistant_blacklist', 'assistant_whitelist'],
39
+ );
40
+ return { blacklist, whitelist };
57
41
  };
58
42
 
59
43
  getFeatureFlags = async () => {
60
- const featureFlags = await this.client.get(EdgeConfigKeys.FeatureFlags);
44
+ const featureFlags = await this.getValue('feature_flags');
61
45
  debug('Feature flags retrieved: %O', featureFlags);
62
- return featureFlags as Record<string, boolean | string[]> | undefined;
46
+ return featureFlags;
63
47
  };
64
48
  }
65
-
66
- export { EdgeConfigKeys };
@@ -4,6 +4,19 @@
4
4
  * EdgeConfig 完整配置类型
5
5
  */
6
6
  export interface EdgeConfigData {
7
+ /**
8
+ * Assistant blacklist
9
+ */
7
10
  assistant_blacklist?: string[];
11
+ /**
12
+ * Assistant whitelist
13
+ */
8
14
  assistant_whitelist?: string[];
15
+
16
+ /**
17
+ * Feature flags configuration
18
+ */
19
+ feature_flags?: Record<string, boolean | string[]>;
9
20
  }
21
+
22
+ export type EdgeConfigKeys = keyof EdgeConfigData;