@lobehub/chat 1.29.6 → 1.30.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/.env.example +5 -0
- package/CHANGELOG.md +25 -0
- package/locales/ar/modelProvider.json +12 -0
- package/locales/bg-BG/modelProvider.json +12 -0
- package/locales/de-DE/modelProvider.json +12 -0
- package/locales/en-US/modelProvider.json +12 -0
- package/locales/es-ES/modelProvider.json +12 -0
- package/locales/fr-FR/modelProvider.json +12 -0
- package/locales/it-IT/modelProvider.json +12 -0
- package/locales/ja-JP/modelProvider.json +12 -0
- package/locales/ko-KR/modelProvider.json +12 -0
- package/locales/nl-NL/modelProvider.json +12 -0
- package/locales/pl-PL/modelProvider.json +12 -0
- package/locales/pt-BR/modelProvider.json +12 -0
- package/locales/ru-RU/modelProvider.json +12 -0
- package/locales/tr-TR/modelProvider.json +12 -0
- package/locales/vi-VN/modelProvider.json +12 -0
- package/locales/zh-CN/modelProvider.json +12 -0
- package/locales/zh-TW/modelProvider.json +12 -0
- package/package.json +2 -2
- package/src/app/(main)/settings/llm/ProviderList/Cloudflare/index.tsx +43 -0
- package/src/app/(main)/settings/llm/ProviderList/providers.tsx +4 -0
- package/src/config/llm.ts +9 -0
- package/src/config/modelProviders/cloudflare.ts +89 -0
- package/src/config/modelProviders/index.ts +4 -0
- package/src/const/auth.ts +2 -0
- package/src/const/settings/llm.ts +5 -0
- package/src/libs/agent-runtime/AgentRuntime.ts +7 -1
- package/src/libs/agent-runtime/cloudflare/index.test.ts +648 -0
- package/src/libs/agent-runtime/cloudflare/index.ts +123 -0
- package/src/libs/agent-runtime/types/type.ts +1 -0
- package/src/libs/agent-runtime/utils/cloudflareHelpers.test.ts +339 -0
- package/src/libs/agent-runtime/utils/cloudflareHelpers.ts +134 -0
- package/src/locales/default/modelProvider.ts +13 -1
- package/src/server/globalConfig/index.ts +4 -0
- package/src/server/modules/AgentRuntime/index.ts +11 -0
- package/src/services/_auth.ts +9 -0
- package/src/services/chat.ts +7 -0
- package/src/store/user/slices/modelList/selectors/keyVaults.ts +2 -0
- package/src/store/user/slices/modelList/selectors/modelConfig.ts +2 -0
- package/src/types/user/settings/keyVaults.ts +6 -0
@@ -0,0 +1,123 @@
|
|
1
|
+
import { ChatModelCard } from '@/types/llm';
|
2
|
+
|
3
|
+
import { LobeRuntimeAI } from '../BaseAI';
|
4
|
+
import { AgentRuntimeErrorType } from '../error';
|
5
|
+
import { ChatCompetitionOptions, ChatStreamPayload, ModelProvider } from '../types';
|
6
|
+
import {
|
7
|
+
CloudflareStreamTransformer,
|
8
|
+
DEFAULT_BASE_URL_PREFIX,
|
9
|
+
convertModelManifest,
|
10
|
+
desensitizeCloudflareUrl,
|
11
|
+
fillUrl,
|
12
|
+
} from '../utils/cloudflareHelpers';
|
13
|
+
import { AgentRuntimeError } from '../utils/createError';
|
14
|
+
import { debugStream } from '../utils/debugStream';
|
15
|
+
import { StreamingResponse } from '../utils/response';
|
16
|
+
import { createCallbacksTransformer } from '../utils/streams';
|
17
|
+
|
18
|
+
export interface LobeCloudflareParams {
|
19
|
+
apiKey?: string;
|
20
|
+
baseURLOrAccountID?: string;
|
21
|
+
}
|
22
|
+
|
23
|
+
export class LobeCloudflareAI implements LobeRuntimeAI {
|
24
|
+
baseURL: string;
|
25
|
+
accountID: string;
|
26
|
+
apiKey?: string;
|
27
|
+
|
28
|
+
constructor({ apiKey, baseURLOrAccountID }: LobeCloudflareParams) {
|
29
|
+
if (!baseURLOrAccountID) {
|
30
|
+
throw AgentRuntimeError.createError(AgentRuntimeErrorType.InvalidProviderAPIKey);
|
31
|
+
}
|
32
|
+
if (baseURLOrAccountID.startsWith('http')) {
|
33
|
+
this.baseURL = baseURLOrAccountID.endsWith('/')
|
34
|
+
? baseURLOrAccountID
|
35
|
+
: baseURLOrAccountID + '/';
|
36
|
+
// Try get accountID from baseURL
|
37
|
+
this.accountID = baseURLOrAccountID.replaceAll(/^.*\/([\dA-Fa-f]{32})\/.*$/g, '$1');
|
38
|
+
} else {
|
39
|
+
if (!apiKey) {
|
40
|
+
throw AgentRuntimeError.createError(AgentRuntimeErrorType.InvalidProviderAPIKey);
|
41
|
+
}
|
42
|
+
this.accountID = baseURLOrAccountID;
|
43
|
+
this.baseURL = fillUrl(baseURLOrAccountID);
|
44
|
+
}
|
45
|
+
this.apiKey = apiKey;
|
46
|
+
}
|
47
|
+
|
48
|
+
async chat(payload: ChatStreamPayload, options?: ChatCompetitionOptions): Promise<Response> {
|
49
|
+
try {
|
50
|
+
const { model, tools, ...restPayload } = payload;
|
51
|
+
const functions = tools?.map((tool) => tool.function);
|
52
|
+
const headers = options?.headers || {};
|
53
|
+
if (this.apiKey) {
|
54
|
+
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
55
|
+
}
|
56
|
+
const url = new URL(model, this.baseURL);
|
57
|
+
const response = await fetch(url, {
|
58
|
+
body: JSON.stringify({ tools: functions, ...restPayload }),
|
59
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
60
|
+
method: 'POST',
|
61
|
+
signal: options?.signal,
|
62
|
+
});
|
63
|
+
|
64
|
+
const desensitizedEndpoint = desensitizeCloudflareUrl(url.toString());
|
65
|
+
|
66
|
+
switch (response.status) {
|
67
|
+
case 400: {
|
68
|
+
throw AgentRuntimeError.chat({
|
69
|
+
endpoint: desensitizedEndpoint,
|
70
|
+
error: response,
|
71
|
+
errorType: AgentRuntimeErrorType.ProviderBizError,
|
72
|
+
provider: ModelProvider.Cloudflare,
|
73
|
+
});
|
74
|
+
}
|
75
|
+
}
|
76
|
+
|
77
|
+
// Only tee when debugging
|
78
|
+
let responseBody: ReadableStream;
|
79
|
+
if (process.env.DEBUG_CLOUDFLARE_CHAT_COMPLETION === '1') {
|
80
|
+
const [prod, useForDebug] = response.body!.tee();
|
81
|
+
debugStream(useForDebug).catch();
|
82
|
+
responseBody = prod;
|
83
|
+
} else {
|
84
|
+
responseBody = response.body!;
|
85
|
+
}
|
86
|
+
|
87
|
+
return StreamingResponse(
|
88
|
+
responseBody
|
89
|
+
.pipeThrough(new TransformStream(new CloudflareStreamTransformer()))
|
90
|
+
.pipeThrough(createCallbacksTransformer(options?.callback)),
|
91
|
+
{ headers: options?.headers },
|
92
|
+
);
|
93
|
+
} catch (error) {
|
94
|
+
const desensitizedEndpoint = desensitizeCloudflareUrl(this.baseURL);
|
95
|
+
|
96
|
+
throw AgentRuntimeError.chat({
|
97
|
+
endpoint: desensitizedEndpoint,
|
98
|
+
error: error as any,
|
99
|
+
errorType: AgentRuntimeErrorType.ProviderBizError,
|
100
|
+
provider: ModelProvider.Cloudflare,
|
101
|
+
});
|
102
|
+
}
|
103
|
+
}
|
104
|
+
|
105
|
+
async models(): Promise<ChatModelCard[]> {
|
106
|
+
const url = `${DEFAULT_BASE_URL_PREFIX}/client/v4/accounts/${this.accountID}/ai/models/search`;
|
107
|
+
const response = await fetch(url, {
|
108
|
+
headers: {
|
109
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
110
|
+
'Content-Type': 'application/json',
|
111
|
+
},
|
112
|
+
method: 'GET',
|
113
|
+
});
|
114
|
+
const j = await response.json();
|
115
|
+
const models: any[] = j['result'].filter(
|
116
|
+
(model: any) => model['task']['name'] === 'Text Generation',
|
117
|
+
);
|
118
|
+
const chatModels: ChatModelCard[] = models
|
119
|
+
.map((model) => convertModelManifest(model))
|
120
|
+
.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
121
|
+
return chatModels;
|
122
|
+
}
|
123
|
+
}
|
@@ -0,0 +1,339 @@
|
|
1
|
+
// @vitest-environment node
|
2
|
+
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
3
|
+
|
4
|
+
import * as desensitizeTool from '../utils/desensitizeUrl';
|
5
|
+
import {
|
6
|
+
CloudflareStreamTransformer,
|
7
|
+
desensitizeCloudflareUrl,
|
8
|
+
fillUrl,
|
9
|
+
getModelBeta,
|
10
|
+
getModelDisplayName,
|
11
|
+
getModelFunctionCalling,
|
12
|
+
getModelTokens,
|
13
|
+
} from './cloudflareHelpers';
|
14
|
+
|
15
|
+
//const {
|
16
|
+
// getModelBeta,
|
17
|
+
// getModelDisplayName,
|
18
|
+
// getModelFunctionCalling,
|
19
|
+
// getModelTokens,
|
20
|
+
//} = require('./cloudflareHelpers');
|
21
|
+
|
22
|
+
//const cloudflareHelpers = require('./cloudflareHelpers');
|
23
|
+
//const getModelBeta = cloudflareHelpers.__get__('getModelBeta');
|
24
|
+
//const getModelDisplayName = cloudflareHelpers.__get__('getModelDisplayName');
|
25
|
+
//const getModelFunctionCalling = cloudflareHelpers.__get__('getModelFunctionCalling');
|
26
|
+
//const getModelTokens = cloudflareHelpers.__get__('getModelTokens');
|
27
|
+
|
28
|
+
afterEach(() => {
|
29
|
+
vi.restoreAllMocks();
|
30
|
+
});
|
31
|
+
|
32
|
+
describe('cloudflareHelpers', () => {
|
33
|
+
describe('CloudflareStreamTransformer', () => {
|
34
|
+
let transformer: CloudflareStreamTransformer;
|
35
|
+
beforeEach(() => {
|
36
|
+
transformer = new CloudflareStreamTransformer();
|
37
|
+
});
|
38
|
+
|
39
|
+
describe('parseChunk', () => {
|
40
|
+
let chunks: string[];
|
41
|
+
let controller: TransformStreamDefaultController;
|
42
|
+
|
43
|
+
beforeEach(() => {
|
44
|
+
chunks = [];
|
45
|
+
controller = Object.create(TransformStreamDefaultController.prototype);
|
46
|
+
vi.spyOn(controller, 'enqueue').mockImplementation((chunk) => {
|
47
|
+
chunks.push(chunk);
|
48
|
+
});
|
49
|
+
});
|
50
|
+
|
51
|
+
it('should parse chunk', () => {
|
52
|
+
// Arrange
|
53
|
+
const chunk = 'data: {"key": "value", "response": "response1"}';
|
54
|
+
const textDecoder = new TextDecoder();
|
55
|
+
|
56
|
+
// Act
|
57
|
+
transformer['parseChunk'](chunk, controller);
|
58
|
+
|
59
|
+
// Assert
|
60
|
+
expect(chunks.length).toBe(2);
|
61
|
+
expect(chunks[0]).toBe('event: text\n');
|
62
|
+
expect(chunks[1]).toBe('data: "response1"\n\n');
|
63
|
+
});
|
64
|
+
|
65
|
+
it('should not replace `data` in text', () => {
|
66
|
+
// Arrange
|
67
|
+
const chunk = 'data: {"key": "value", "response": "data: a"}';
|
68
|
+
const textDecoder = new TextDecoder();
|
69
|
+
|
70
|
+
// Act
|
71
|
+
transformer['parseChunk'](chunk, controller);
|
72
|
+
|
73
|
+
// Assert
|
74
|
+
expect(chunks.length).toBe(2);
|
75
|
+
expect(chunks[0]).toBe('event: text\n');
|
76
|
+
expect(chunks[1]).toBe('data: "data: a"\n\n');
|
77
|
+
});
|
78
|
+
});
|
79
|
+
|
80
|
+
describe('transform', () => {
|
81
|
+
const textDecoder = new TextDecoder();
|
82
|
+
const textEncoder = new TextEncoder();
|
83
|
+
let chunks: string[];
|
84
|
+
|
85
|
+
beforeEach(() => {
|
86
|
+
chunks = [];
|
87
|
+
vi.spyOn(
|
88
|
+
transformer as any as {
|
89
|
+
parseChunk: (chunk: string, controller: TransformStreamDefaultController) => void;
|
90
|
+
},
|
91
|
+
'parseChunk',
|
92
|
+
).mockImplementation((chunk: string, _) => {
|
93
|
+
chunks.push(chunk);
|
94
|
+
});
|
95
|
+
});
|
96
|
+
|
97
|
+
it('should split single chunk', async () => {
|
98
|
+
// Arrange
|
99
|
+
const chunk = textEncoder.encode('data: {"key": "value", "response": "response1"}\n\n');
|
100
|
+
|
101
|
+
// Act
|
102
|
+
await transformer.transform(chunk, undefined!);
|
103
|
+
|
104
|
+
// Assert
|
105
|
+
expect(chunks.length).toBe(1);
|
106
|
+
expect(chunks[0]).toBe('data: {"key": "value", "response": "response1"}');
|
107
|
+
});
|
108
|
+
|
109
|
+
it('should split multiple chunks', async () => {
|
110
|
+
// Arrange
|
111
|
+
const chunk = textEncoder.encode(
|
112
|
+
'data: {"key": "value", "response": "response1"}\n\n' +
|
113
|
+
'data: {"key": "value", "response": "response2"}\n\n',
|
114
|
+
);
|
115
|
+
|
116
|
+
// Act
|
117
|
+
await transformer.transform(chunk, undefined!);
|
118
|
+
|
119
|
+
// Assert
|
120
|
+
expect(chunks.length).toBe(2);
|
121
|
+
expect(chunks[0]).toBe('data: {"key": "value", "response": "response1"}');
|
122
|
+
expect(chunks[1]).toBe('data: {"key": "value", "response": "response2"}');
|
123
|
+
});
|
124
|
+
|
125
|
+
it('should ignore empty chunk', async () => {
|
126
|
+
// Arrange
|
127
|
+
const chunk = textEncoder.encode('\n\n');
|
128
|
+
|
129
|
+
// Act
|
130
|
+
await transformer.transform(chunk, undefined!);
|
131
|
+
|
132
|
+
// Assert
|
133
|
+
expect(chunks.join()).toBe('');
|
134
|
+
});
|
135
|
+
|
136
|
+
it('should split and concat delayed chunks', async () => {
|
137
|
+
// Arrange
|
138
|
+
const chunk1 = textEncoder.encode('data: {"key": "value", "respo');
|
139
|
+
const chunk2 = textEncoder.encode('nse": "response1"}\n\ndata: {"key": "val');
|
140
|
+
const chunk3 = textEncoder.encode('ue", "response": "response2"}\n\n');
|
141
|
+
|
142
|
+
// Act & Assert
|
143
|
+
await transformer.transform(chunk1, undefined!);
|
144
|
+
expect(transformer['parseChunk']).not.toHaveBeenCalled();
|
145
|
+
expect(chunks.length).toBe(0);
|
146
|
+
expect(transformer['buffer']).toBe('data: {"key": "value", "respo');
|
147
|
+
|
148
|
+
await transformer.transform(chunk2, undefined!);
|
149
|
+
expect(chunks.length).toBe(1);
|
150
|
+
expect(chunks[0]).toBe('data: {"key": "value", "response": "response1"}');
|
151
|
+
expect(transformer['buffer']).toBe('data: {"key": "val');
|
152
|
+
|
153
|
+
await transformer.transform(chunk3, undefined!);
|
154
|
+
expect(chunks.length).toBe(2);
|
155
|
+
expect(chunks[1]).toBe('data: {"key": "value", "response": "response2"}');
|
156
|
+
expect(transformer['buffer']).toBe('');
|
157
|
+
});
|
158
|
+
|
159
|
+
it('should ignore standalone [DONE]', async () => {
|
160
|
+
// Arrange
|
161
|
+
const chunk = textEncoder.encode('data: [DONE]\n\n');
|
162
|
+
|
163
|
+
// Act
|
164
|
+
await transformer.transform(chunk, undefined!);
|
165
|
+
|
166
|
+
// Assert
|
167
|
+
expect(transformer['parseChunk']).not.toHaveBeenCalled();
|
168
|
+
expect(chunks.length).toBe(0);
|
169
|
+
expect(transformer['buffer']).toBe('');
|
170
|
+
});
|
171
|
+
|
172
|
+
it('should ignore [DONE] in chunk', async () => {
|
173
|
+
// Arrange
|
174
|
+
const chunk = textEncoder.encode(
|
175
|
+
'data: {"key": "value", "response": "response1"}\n\ndata: [DONE]\n\n',
|
176
|
+
);
|
177
|
+
|
178
|
+
// Act
|
179
|
+
await transformer.transform(chunk, undefined!);
|
180
|
+
|
181
|
+
// Assert
|
182
|
+
expect(chunks.length).toBe(1);
|
183
|
+
expect(chunks[0]).toBe('data: {"key": "value", "response": "response1"}');
|
184
|
+
expect(transformer['buffer']).toBe('');
|
185
|
+
});
|
186
|
+
});
|
187
|
+
});
|
188
|
+
|
189
|
+
describe('fillUrl', () => {
|
190
|
+
it('should return URL with account id', () => {
|
191
|
+
const url = fillUrl('80009000a000b000c000d000e000f000');
|
192
|
+
expect(url).toBe(
|
193
|
+
'https://api.cloudflare.com/client/v4/accounts/80009000a000b000c000d000e000f000/ai/run/',
|
194
|
+
);
|
195
|
+
});
|
196
|
+
});
|
197
|
+
|
198
|
+
describe('maskAccountId', () => {
|
199
|
+
describe('desensitizeAccountId', () => {
|
200
|
+
it('should replace account id with **** in official API endpoint', () => {
|
201
|
+
const url =
|
202
|
+
'https://api.cloudflare.com/client/v4/accounts/80009000a000b000c000d000e000f000/ai/run/';
|
203
|
+
const maskedUrl = desensitizeCloudflareUrl(url);
|
204
|
+
expect(maskedUrl).toBe('https://api.cloudflare.com/client/v4/accounts/****/ai/run/');
|
205
|
+
});
|
206
|
+
|
207
|
+
it('should replace account id with **** in custom API endpoint', () => {
|
208
|
+
const url =
|
209
|
+
'https://api.cloudflare.com/custom/prefix/80009000a000b000c000d000e000f000/custom/suffix/';
|
210
|
+
const maskedUrl = desensitizeCloudflareUrl(url);
|
211
|
+
expect(maskedUrl).toBe('https://api.cloudflare.com/custom/prefix/****/custom/suffix/');
|
212
|
+
});
|
213
|
+
});
|
214
|
+
|
215
|
+
describe('desensitizeCloudflareUrl', () => {
|
216
|
+
it('should mask account id in official API endpoint', () => {
|
217
|
+
const url =
|
218
|
+
'https://api.cloudflare.com/client/v4/accounts/80009000a000b000c000d000e000f000/ai/run/';
|
219
|
+
const maskedUrl = desensitizeCloudflareUrl(url);
|
220
|
+
expect(maskedUrl).toBe('https://api.cloudflare.com/client/v4/accounts/****/ai/run/');
|
221
|
+
});
|
222
|
+
|
223
|
+
it('should call desensitizeUrl for custom API endpoint', () => {
|
224
|
+
const url = 'https://custom.url/path';
|
225
|
+
vi.spyOn(desensitizeTool, 'desensitizeUrl').mockImplementation(
|
226
|
+
(_) => 'https://custom.mocked.url',
|
227
|
+
);
|
228
|
+
const maskedUrl = desensitizeCloudflareUrl(url);
|
229
|
+
expect(desensitizeTool.desensitizeUrl).toHaveBeenCalledWith('https://custom.url');
|
230
|
+
expect(maskedUrl).toBe('https://custom.mocked.url/path');
|
231
|
+
});
|
232
|
+
|
233
|
+
it('should mask account id in custom API endpoint', () => {
|
234
|
+
const url =
|
235
|
+
'https://custom.url/custom/prefix/80009000a000b000c000d000e000f000/custom/suffix/';
|
236
|
+
const maskedUrl = desensitizeCloudflareUrl(url);
|
237
|
+
expect(maskedUrl).toBe('https://cu****om.url/custom/prefix/****/custom/suffix/');
|
238
|
+
});
|
239
|
+
|
240
|
+
it('should mask account id in custom API endpoint with query params', () => {
|
241
|
+
const url =
|
242
|
+
'https://custom.url/custom/prefix/80009000a000b000c000d000e000f000/custom/suffix/?query=param';
|
243
|
+
const maskedUrl = desensitizeCloudflareUrl(url);
|
244
|
+
expect(maskedUrl).toBe(
|
245
|
+
'https://cu****om.url/custom/prefix/****/custom/suffix/?query=param',
|
246
|
+
);
|
247
|
+
});
|
248
|
+
|
249
|
+
it('should mask account id in custom API endpoint with port', () => {
|
250
|
+
const url =
|
251
|
+
'https://custom.url:8080/custom/prefix/80009000a000b000c000d000e000f000/custom/suffix/';
|
252
|
+
const maskedUrl = desensitizeCloudflareUrl(url);
|
253
|
+
expect(maskedUrl).toBe('https://cu****om.url:****/custom/prefix/****/custom/suffix/');
|
254
|
+
});
|
255
|
+
});
|
256
|
+
});
|
257
|
+
|
258
|
+
describe('modelManifest', () => {
|
259
|
+
describe('getModelBeta', () => {
|
260
|
+
it('should get beta property', () => {
|
261
|
+
const model = { properties: [{ property_id: 'beta', value: 'true' }] };
|
262
|
+
const beta = getModelBeta(model);
|
263
|
+
expect(beta).toBe(true);
|
264
|
+
});
|
265
|
+
|
266
|
+
it('should return false if beta property is false', () => {
|
267
|
+
const model = { properties: [{ property_id: 'beta', value: 'false' }] };
|
268
|
+
const beta = getModelBeta(model);
|
269
|
+
expect(beta).toBe(false);
|
270
|
+
});
|
271
|
+
|
272
|
+
it('should return false if beta property is not present', () => {
|
273
|
+
const model = { properties: [] };
|
274
|
+
const beta = getModelBeta(model);
|
275
|
+
expect(beta).toBe(false);
|
276
|
+
});
|
277
|
+
});
|
278
|
+
|
279
|
+
describe('getModelDisplayName', () => {
|
280
|
+
it('should return display name with beta suffix', () => {
|
281
|
+
const model = { name: 'model', properties: [{ property_id: 'beta', value: 'true' }] };
|
282
|
+
const name = getModelDisplayName(model, true);
|
283
|
+
expect(name).toBe('model (Beta)');
|
284
|
+
});
|
285
|
+
|
286
|
+
it('should return display name without beta suffix', () => {
|
287
|
+
const model = { name: 'model', properties: [] };
|
288
|
+
const name = getModelDisplayName(model, false);
|
289
|
+
expect(name).toBe('model');
|
290
|
+
});
|
291
|
+
|
292
|
+
it('should return model["name"]', () => {
|
293
|
+
const model = { id: 'modelID', name: 'modelName' };
|
294
|
+
const name = getModelDisplayName(model, false);
|
295
|
+
expect(name).toBe('modelName');
|
296
|
+
});
|
297
|
+
|
298
|
+
it('should return last part of model["name"]', () => {
|
299
|
+
const model = { name: '@provider/modelFamily/modelName' };
|
300
|
+
const name = getModelDisplayName(model, false);
|
301
|
+
expect(name).toBe('modelName');
|
302
|
+
});
|
303
|
+
});
|
304
|
+
|
305
|
+
describe('getModelFunctionCalling', () => {
|
306
|
+
it('should return true if function_calling property is true', () => {
|
307
|
+
const model = { properties: [{ property_id: 'function_calling', value: 'true' }] };
|
308
|
+
const functionCalling = getModelFunctionCalling(model);
|
309
|
+
expect(functionCalling).toBe(true);
|
310
|
+
});
|
311
|
+
|
312
|
+
it('should return false if function_calling property is false', () => {
|
313
|
+
const model = { properties: [{ property_id: 'function_calling', value: 'false' }] };
|
314
|
+
const functionCalling = getModelFunctionCalling(model);
|
315
|
+
expect(functionCalling).toBe(false);
|
316
|
+
});
|
317
|
+
|
318
|
+
it('should return false if function_calling property is not set', () => {
|
319
|
+
const model = { properties: [] };
|
320
|
+
const functionCalling = getModelFunctionCalling(model);
|
321
|
+
expect(functionCalling).toBe(false);
|
322
|
+
});
|
323
|
+
});
|
324
|
+
|
325
|
+
describe('getModelTokens', () => {
|
326
|
+
it('should return tokens property value', () => {
|
327
|
+
const model = { properties: [{ property_id: 'max_total_tokens', value: '100' }] };
|
328
|
+
const tokens = getModelTokens(model);
|
329
|
+
expect(tokens).toBe(100);
|
330
|
+
});
|
331
|
+
|
332
|
+
it('should return undefined if tokens property is not present', () => {
|
333
|
+
const model = { properties: [] };
|
334
|
+
const tokens = getModelTokens(model);
|
335
|
+
expect(tokens).toBeUndefined();
|
336
|
+
});
|
337
|
+
});
|
338
|
+
});
|
339
|
+
});
|
@@ -0,0 +1,134 @@
|
|
1
|
+
import { desensitizeUrl } from '../utils/desensitizeUrl';
|
2
|
+
|
3
|
+
class CloudflareStreamTransformer {
|
4
|
+
private textDecoder = new TextDecoder();
|
5
|
+
private buffer: string = '';
|
6
|
+
|
7
|
+
private parseChunk(chunk: string, controller: TransformStreamDefaultController) {
|
8
|
+
const dataPrefix = /^data: /;
|
9
|
+
const json = chunk.replace(dataPrefix, '');
|
10
|
+
const parsedChunk = JSON.parse(json);
|
11
|
+
controller.enqueue(`event: text\n`);
|
12
|
+
controller.enqueue(`data: ${JSON.stringify(parsedChunk.response)}\n\n`);
|
13
|
+
}
|
14
|
+
|
15
|
+
public async transform(chunk: Uint8Array, controller: TransformStreamDefaultController) {
|
16
|
+
let textChunk = this.textDecoder.decode(chunk);
|
17
|
+
if (this.buffer.trim() !== '') {
|
18
|
+
textChunk = this.buffer + textChunk;
|
19
|
+
this.buffer = '';
|
20
|
+
}
|
21
|
+
const splits = textChunk.split('\n\n');
|
22
|
+
for (let i = 0; i < splits.length - 1; i++) {
|
23
|
+
if (/\[DONE]/.test(splits[i].trim())) {
|
24
|
+
return;
|
25
|
+
}
|
26
|
+
this.parseChunk(splits[i], controller);
|
27
|
+
}
|
28
|
+
const lastChunk = splits.at(-1)!;
|
29
|
+
if (lastChunk.trim() !== '') {
|
30
|
+
this.buffer += lastChunk; // does not need to be trimmed.
|
31
|
+
} // else drop.
|
32
|
+
}
|
33
|
+
}
|
34
|
+
|
35
|
+
const CF_PROPERTY_NAME = 'property_id';
|
36
|
+
const DEFAULT_BASE_URL_PREFIX = 'https://api.cloudflare.com';
|
37
|
+
|
38
|
+
function fillUrl(accountID: string): string {
|
39
|
+
return `${DEFAULT_BASE_URL_PREFIX}/client/v4/accounts/${accountID}/ai/run/`;
|
40
|
+
}
|
41
|
+
|
42
|
+
function desensitizeAccountId(path: string): string {
|
43
|
+
return path.replace(/\/[\dA-Fa-f]{32}\//, '/****/');
|
44
|
+
}
|
45
|
+
|
46
|
+
function desensitizeCloudflareUrl(url: string): string {
|
47
|
+
const urlObj = new URL(url);
|
48
|
+
let { protocol, hostname, port, pathname, search } = urlObj;
|
49
|
+
if (url.startsWith(DEFAULT_BASE_URL_PREFIX)) {
|
50
|
+
return `${protocol}//${hostname}${port ? `:${port}` : ''}${desensitizeAccountId(pathname)}${search}`;
|
51
|
+
} else {
|
52
|
+
const desensitizedUrl = desensitizeUrl(`${protocol}//${hostname}${port ? `:${port}` : ''}`);
|
53
|
+
if (desensitizedUrl.endsWith('/') && pathname.startsWith('/')) {
|
54
|
+
pathname = pathname.slice(1);
|
55
|
+
}
|
56
|
+
return `${desensitizedUrl}${desensitizeAccountId(pathname)}${search}`;
|
57
|
+
}
|
58
|
+
}
|
59
|
+
|
60
|
+
function getModelBeta(model: any): boolean {
|
61
|
+
try {
|
62
|
+
const betaProperty = model['properties'].filter(
|
63
|
+
(property: any) => property[CF_PROPERTY_NAME] === 'beta',
|
64
|
+
);
|
65
|
+
if (betaProperty.length === 1) {
|
66
|
+
return betaProperty[0]['value'] === 'true'; // This is a string now.
|
67
|
+
}
|
68
|
+
return false;
|
69
|
+
} catch {
|
70
|
+
return false;
|
71
|
+
}
|
72
|
+
}
|
73
|
+
|
74
|
+
function getModelDisplayName(model: any, beta: boolean): string {
|
75
|
+
const modelId = model['name'];
|
76
|
+
let name = modelId.split('/').at(-1)!;
|
77
|
+
if (beta) {
|
78
|
+
name += ' (Beta)';
|
79
|
+
}
|
80
|
+
return name;
|
81
|
+
}
|
82
|
+
|
83
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars, unused-imports/no-unused-vars
|
84
|
+
function getModelFunctionCalling(model: any): boolean {
|
85
|
+
try {
|
86
|
+
const fcProperty = model['properties'].filter(
|
87
|
+
(property: any) => property[CF_PROPERTY_NAME] === 'function_calling',
|
88
|
+
);
|
89
|
+
if (fcProperty.length === 1) {
|
90
|
+
return fcProperty[0]['value'] === 'true';
|
91
|
+
}
|
92
|
+
return false;
|
93
|
+
} catch {
|
94
|
+
return false;
|
95
|
+
}
|
96
|
+
}
|
97
|
+
|
98
|
+
function getModelTokens(model: any): number | undefined {
|
99
|
+
try {
|
100
|
+
const tokensProperty = model['properties'].filter(
|
101
|
+
(property: any) => property[CF_PROPERTY_NAME] === 'max_total_tokens',
|
102
|
+
);
|
103
|
+
if (tokensProperty.length === 1) {
|
104
|
+
return parseInt(tokensProperty[0]['value']);
|
105
|
+
}
|
106
|
+
return undefined;
|
107
|
+
} catch {
|
108
|
+
return undefined;
|
109
|
+
}
|
110
|
+
}
|
111
|
+
|
112
|
+
function convertModelManifest(model: any) {
|
113
|
+
const modelBeta = getModelBeta(model);
|
114
|
+
return {
|
115
|
+
description: model['description'],
|
116
|
+
displayName: getModelDisplayName(model, modelBeta),
|
117
|
+
enabled: !modelBeta,
|
118
|
+
functionCall: false, //getModelFunctionCalling(model),
|
119
|
+
id: model['name'],
|
120
|
+
tokens: getModelTokens(model),
|
121
|
+
};
|
122
|
+
}
|
123
|
+
|
124
|
+
export {
|
125
|
+
CloudflareStreamTransformer,
|
126
|
+
convertModelManifest,
|
127
|
+
DEFAULT_BASE_URL_PREFIX,
|
128
|
+
desensitizeCloudflareUrl,
|
129
|
+
fillUrl,
|
130
|
+
getModelBeta,
|
131
|
+
getModelDisplayName,
|
132
|
+
getModelFunctionCalling,
|
133
|
+
getModelTokens,
|
134
|
+
};
|
@@ -21,7 +21,7 @@ export default {
|
|
21
21
|
},
|
22
22
|
bedrock: {
|
23
23
|
accessKeyId: {
|
24
|
-
desc: '填入AWS Access Key Id',
|
24
|
+
desc: '填入 AWS Access Key Id',
|
25
25
|
placeholder: 'AWS Access Key Id',
|
26
26
|
title: 'AWS Access Key Id',
|
27
27
|
},
|
@@ -52,6 +52,18 @@ export default {
|
|
52
52
|
title: '使用自定义 Bedrock 鉴权信息',
|
53
53
|
},
|
54
54
|
},
|
55
|
+
cloudflare: {
|
56
|
+
apiKey: {
|
57
|
+
desc: '请填写 Cloudflare API Key',
|
58
|
+
placeholder: 'Cloudflare API Key',
|
59
|
+
title: 'Cloudflare API Key',
|
60
|
+
},
|
61
|
+
baseURLOrAccountID: {
|
62
|
+
desc: '填入 Cloudflare 账户 ID 或 自定义 API 地址',
|
63
|
+
placeholder: 'Cloudflare Account ID / custom API URL',
|
64
|
+
title: 'Cloudflare 账户 ID / API 地址',
|
65
|
+
}
|
66
|
+
},
|
55
67
|
github: {
|
56
68
|
personalAccessToken: {
|
57
69
|
desc: '填入你的 Github PAT,点击 [这里](https://github.com/settings/tokens) 创建',
|
@@ -99,6 +99,9 @@ export const getServerGlobalConfig = () => {
|
|
99
99
|
BAICHUAN_MODEL_LIST,
|
100
100
|
|
101
101
|
ENABLED_TAICHU,
|
102
|
+
|
103
|
+
ENABLED_CLOUDFLARE,
|
104
|
+
|
102
105
|
TAICHU_MODEL_LIST,
|
103
106
|
|
104
107
|
ENABLED_AI21,
|
@@ -202,6 +205,7 @@ export const getServerGlobalConfig = () => {
|
|
202
205
|
modelString: AWS_BEDROCK_MODEL_LIST,
|
203
206
|
}),
|
204
207
|
},
|
208
|
+
cloudflare: { enabled: ENABLED_CLOUDFLARE },
|
205
209
|
deepseek: {
|
206
210
|
enabled: ENABLED_DEEPSEEK,
|
207
211
|
enabledModels: extractEnabledModels(DEEPSEEK_MODEL_LIST),
|
@@ -210,6 +210,17 @@ const getLlmOptionsFromPayload = (provider: string, payload: JWTPayload) => {
|
|
210
210
|
|
211
211
|
return { apiKey };
|
212
212
|
}
|
213
|
+
case ModelProvider.Cloudflare: {
|
214
|
+
const { CLOUDFLARE_API_KEY, CLOUDFLARE_BASE_URL_OR_ACCOUNT_ID } = getLLMConfig();
|
215
|
+
|
216
|
+
const apiKey = apiKeyManager.pick(payload?.apiKey || CLOUDFLARE_API_KEY);
|
217
|
+
const baseURLOrAccountID =
|
218
|
+
payload.apiKey && payload.cloudflareBaseURLOrAccountID
|
219
|
+
? payload.cloudflareBaseURLOrAccountID
|
220
|
+
: CLOUDFLARE_BASE_URL_OR_ACCOUNT_ID;
|
221
|
+
|
222
|
+
return { apiKey, baseURLOrAccountID };
|
223
|
+
}
|
213
224
|
case ModelProvider.Ai360: {
|
214
225
|
const { AI360_API_KEY } = getLLMConfig();
|
215
226
|
|
package/src/services/_auth.ts
CHANGED
@@ -69,6 +69,15 @@ export const getProviderAuthPayload = (provider: string) => {
|
|
69
69
|
return { endpoint: config?.baseURL };
|
70
70
|
}
|
71
71
|
|
72
|
+
case ModelProvider.Cloudflare: {
|
73
|
+
const config = keyVaultsConfigSelectors.cloudflareConfig(useUserStore.getState());
|
74
|
+
|
75
|
+
return {
|
76
|
+
apiKey: config?.apiKey,
|
77
|
+
cloudflareBaseURLOrAccountID: config?.baseURLOrAccountID,
|
78
|
+
};
|
79
|
+
}
|
80
|
+
|
72
81
|
default: {
|
73
82
|
const config = keyVaultsConfigSelectors.getVaultByProvider(provider as GlobalLLMProviderKey)(
|
74
83
|
useUserStore.getState(),
|