@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.
- package/CHANGELOG.md +58 -0
- package/changelog/v1.json +18 -0
- package/docs/development/basic/feature-development-frontend.zh-CN.mdx +1 -1
- package/docs/development/basic/folder-structure.mdx +67 -16
- package/docs/development/basic/folder-structure.zh-CN.mdx +67 -16
- package/locales/ar/modelProvider.json +15 -0
- package/locales/ar/models.json +3 -0
- package/locales/bg-BG/modelProvider.json +15 -0
- package/locales/bg-BG/models.json +3 -0
- package/locales/de-DE/modelProvider.json +15 -0
- package/locales/de-DE/models.json +3 -0
- package/locales/en-US/modelProvider.json +15 -0
- package/locales/en-US/models.json +3 -0
- package/locales/es-ES/modelProvider.json +15 -0
- package/locales/es-ES/models.json +3 -0
- package/locales/fa-IR/modelProvider.json +15 -0
- package/locales/fa-IR/models.json +3 -0
- package/locales/fr-FR/modelProvider.json +15 -0
- package/locales/fr-FR/models.json +3 -0
- package/locales/it-IT/modelProvider.json +15 -0
- package/locales/it-IT/models.json +3 -0
- package/locales/ja-JP/modelProvider.json +15 -0
- package/locales/ja-JP/models.json +3 -0
- package/locales/ko-KR/modelProvider.json +15 -0
- package/locales/ko-KR/models.json +3 -0
- package/locales/nl-NL/modelProvider.json +15 -0
- package/locales/nl-NL/models.json +3 -0
- package/locales/pl-PL/modelProvider.json +15 -0
- package/locales/pl-PL/models.json +3 -0
- package/locales/pt-BR/modelProvider.json +15 -0
- package/locales/pt-BR/models.json +3 -0
- package/locales/ru-RU/modelProvider.json +15 -0
- package/locales/ru-RU/models.json +3 -0
- package/locales/tr-TR/modelProvider.json +15 -0
- package/locales/tr-TR/models.json +3 -0
- package/locales/vi-VN/modelProvider.json +15 -0
- package/locales/vi-VN/models.json +3 -0
- package/locales/zh-CN/modelProvider.json +15 -0
- package/locales/zh-CN/models.json +3 -0
- package/locales/zh-TW/modelProvider.json +15 -0
- package/locales/zh-TW/models.json +3 -0
- package/package.json +1 -1
- package/packages/model-bank/src/aiModels/fal.ts +28 -0
- package/packages/model-runtime/src/core/openaiCompatibleFactory/createImage.ts +16 -27
- package/packages/model-runtime/src/core/openaiCompatibleFactory/index.test.ts +51 -11
- package/packages/model-runtime/src/core/streams/protocol.ts +2 -15
- package/packages/model-runtime/src/providers/azureOpenai/index.ts +5 -1
- package/packages/model-runtime/src/providers/azureai/index.ts +5 -1
- package/packages/model-runtime/src/providers/fal/index.ts +12 -7
- package/packages/model-runtime/src/providers/newapi/index.test.ts +28 -3
- package/packages/model-runtime/src/providers/newapi/index.ts +34 -88
- package/packages/model-runtime/src/types/index.ts +0 -1
- package/packages/model-runtime/src/utils/sanitizeError.test.ts +109 -0
- package/packages/model-runtime/src/utils/sanitizeError.ts +59 -0
- package/packages/types/src/message/base.ts +1 -0
- package/packages/utils/package.json +2 -1
- package/src/app/[variants]/(main)/image/@menu/components/SizeSelect/index.tsx +24 -1
- package/src/server/modules/EdgeConfig/index.ts +15 -33
- package/src/server/modules/EdgeConfig/types.ts +13 -0
- 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.
|
|
54
|
-
height
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 (
|
|
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 = {
|
|
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({
|
|
586
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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' }
|
|
35
|
+
return { ...payload, apiMode: 'responses' };
|
|
35
36
|
}
|
|
36
|
-
return payload
|
|
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
|
-
//
|
|
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
|
-
//
|
|
55
|
+
// Try to get pricing information to enrich model details
|
|
76
56
|
let pricingMap: Map<string, NewAPIPricing> = new Map();
|
|
77
57
|
try {
|
|
78
|
-
//
|
|
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
|
-
//
|
|
82
|
+
// add pricing info
|
|
103
83
|
const pricing = pricingMap.get(model.id);
|
|
104
84
|
if (pricing) {
|
|
105
|
-
// NewAPI
|
|
106
|
-
// - quota_type: 0
|
|
107
|
-
// - model_ratio:
|
|
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
|
|
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; //
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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: {
|
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
6
|
+
import { EdgeConfigData, EdgeConfigKeys } from './types';
|
|
7
7
|
|
|
8
|
-
const
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
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.
|
|
44
|
+
const featureFlags = await this.getValue('feature_flags');
|
|
61
45
|
debug('Feature flags retrieved: %O', featureFlags);
|
|
62
|
-
return featureFlags
|
|
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;
|