@lobehub/chat 1.136.13 → 1.137.1
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/.cursor/rules/add-setting-env.mdc +175 -0
- package/.cursor/rules/db-migrations.mdc +25 -0
- package/.env.example +7 -0
- package/CHANGELOG.md +50 -0
- package/Dockerfile +3 -2
- package/Dockerfile.database +15 -3
- package/Dockerfile.pglite +3 -2
- package/changelog/v1.json +18 -0
- package/docs/development/database-schema.dbml +1 -0
- package/docs/self-hosting/advanced/feature-flags.mdx +25 -15
- package/docs/self-hosting/advanced/feature-flags.zh-CN.mdx +25 -15
- package/docs/self-hosting/environment-variables/basic.mdx +12 -0
- package/docs/self-hosting/environment-variables/basic.zh-CN.mdx +12 -0
- package/locales/ar/setting.json +8 -0
- package/locales/bg-BG/setting.json +8 -0
- package/locales/de-DE/setting.json +8 -0
- package/locales/en-US/setting.json +8 -0
- package/locales/es-ES/setting.json +8 -0
- package/locales/fa-IR/setting.json +8 -0
- package/locales/fr-FR/setting.json +8 -0
- package/locales/it-IT/setting.json +8 -0
- package/locales/ja-JP/setting.json +8 -0
- package/locales/ko-KR/setting.json +8 -0
- package/locales/nl-NL/setting.json +8 -0
- package/locales/pl-PL/setting.json +8 -0
- package/locales/pt-BR/setting.json +8 -0
- package/locales/ru-RU/setting.json +8 -0
- package/locales/tr-TR/setting.json +8 -0
- package/locales/vi-VN/setting.json +8 -0
- package/locales/zh-CN/setting.json +8 -0
- package/locales/zh-TW/setting.json +8 -0
- package/package.json +1 -1
- package/packages/agent-runtime/examples/tools-calling.ts +4 -3
- package/packages/agent-runtime/src/core/__tests__/runtime.test.ts +559 -29
- package/packages/agent-runtime/src/core/runtime.ts +171 -43
- package/packages/agent-runtime/src/types/instruction.ts +32 -6
- package/packages/agent-runtime/src/types/runtime.ts +2 -2
- package/packages/agent-runtime/src/types/state.ts +1 -8
- package/packages/agent-runtime/vitest.config.mts +14 -0
- package/packages/const/src/settings/image.ts +8 -0
- package/packages/const/src/settings/index.ts +3 -0
- package/packages/context-engine/src/__tests__/pipeline.test.ts +485 -0
- package/packages/context-engine/src/base/__tests__/BaseProcessor.test.ts +381 -0
- package/packages/context-engine/src/base/__tests__/BaseProvider.test.ts +392 -0
- package/packages/context-engine/src/processors/__tests__/MessageCleanup.test.ts +346 -0
- package/packages/context-engine/src/processors/__tests__/ToolCall.test.ts +552 -0
- package/packages/database/migrations/0038_add_image_user_settings.sql +1 -0
- package/packages/database/migrations/meta/0038_snapshot.json +7580 -0
- package/packages/database/migrations/meta/_journal.json +7 -0
- package/packages/database/src/core/migrations.json +6 -0
- package/packages/database/src/models/user.ts +3 -1
- package/packages/database/src/schemas/user.ts +1 -0
- package/packages/file-loaders/src/loaders/docx/index.test.ts +0 -1
- package/packages/file-loaders/src/loaders/excel/__snapshots__/index.test.ts.snap +30 -0
- package/packages/file-loaders/src/loaders/excel/index.test.ts +8 -0
- package/packages/file-loaders/src/loaders/pptx/index.test.ts +25 -0
- package/packages/file-loaders/src/utils/parser-utils.test.ts +155 -0
- package/packages/file-loaders/vitest.config.mts +8 -0
- package/packages/model-runtime/CLAUDE.md +5 -0
- package/packages/model-runtime/docs/test-coverage.md +706 -0
- package/packages/model-runtime/src/core/ModelRuntime.test.ts +231 -0
- package/packages/model-runtime/src/core/RouterRuntime/createRuntime.ts +1 -1
- package/packages/model-runtime/src/core/openaiCompatibleFactory/createImage.test.ts +799 -0
- package/packages/model-runtime/src/core/openaiCompatibleFactory/index.test.ts +188 -4
- package/packages/model-runtime/src/core/openaiCompatibleFactory/index.ts +41 -10
- package/packages/model-runtime/src/core/streams/openai/__snapshots__/responsesStream.test.ts.snap +439 -0
- package/packages/model-runtime/src/core/streams/openai/openai.test.ts +789 -0
- package/packages/model-runtime/src/core/streams/openai/responsesStream.test.ts +551 -0
- package/packages/model-runtime/src/core/usageConverters/utils/computeChatCost.test.ts +230 -0
- package/packages/model-runtime/src/core/usageConverters/utils/computeImageCost.test.ts +334 -37
- package/packages/model-runtime/src/providerTestUtils.ts +148 -145
- package/packages/model-runtime/src/providers/ai302/index.test.ts +60 -0
- package/packages/model-runtime/src/providers/ai302/index.ts +9 -4
- package/packages/model-runtime/src/providers/ai360/index.test.ts +1213 -1
- package/packages/model-runtime/src/providers/ai360/index.ts +9 -4
- package/packages/model-runtime/src/providers/aihubmix/index.test.ts +73 -0
- package/packages/model-runtime/src/providers/aihubmix/index.ts +6 -9
- package/packages/model-runtime/src/providers/akashchat/index.test.ts +433 -3
- package/packages/model-runtime/src/providers/akashchat/index.ts +12 -7
- package/packages/model-runtime/src/providers/anthropic/generateObject.test.ts +183 -29
- package/packages/model-runtime/src/providers/anthropic/generateObject.ts +40 -24
- package/packages/model-runtime/src/providers/azureai/index.test.ts +102 -0
- package/packages/model-runtime/src/providers/baichuan/index.test.ts +416 -26
- package/packages/model-runtime/src/providers/baichuan/index.ts +23 -20
- package/packages/model-runtime/src/providers/bedrock/index.test.ts +420 -2
- package/packages/model-runtime/src/providers/cerebras/index.test.ts +465 -0
- package/packages/model-runtime/src/providers/cerebras/index.ts +8 -3
- package/packages/model-runtime/src/providers/cohere/index.test.ts +1074 -1
- package/packages/model-runtime/src/providers/cohere/index.ts +8 -3
- package/packages/model-runtime/src/providers/cometapi/index.test.ts +439 -3
- package/packages/model-runtime/src/providers/cometapi/index.ts +8 -3
- package/packages/model-runtime/src/providers/deepseek/index.test.ts +116 -1
- package/packages/model-runtime/src/providers/deepseek/index.ts +8 -3
- package/packages/model-runtime/src/providers/fireworksai/index.test.ts +264 -3
- package/packages/model-runtime/src/providers/fireworksai/index.ts +8 -3
- package/packages/model-runtime/src/providers/giteeai/index.test.ts +325 -3
- package/packages/model-runtime/src/providers/giteeai/index.ts +23 -6
- package/packages/model-runtime/src/providers/github/index.test.ts +532 -3
- package/packages/model-runtime/src/providers/github/index.ts +8 -3
- package/packages/model-runtime/src/providers/groq/index.test.ts +344 -31
- package/packages/model-runtime/src/providers/groq/index.ts +8 -3
- package/packages/model-runtime/src/providers/higress/index.test.ts +142 -0
- package/packages/model-runtime/src/providers/higress/index.ts +8 -3
- package/packages/model-runtime/src/providers/huggingface/index.test.ts +612 -1
- package/packages/model-runtime/src/providers/huggingface/index.ts +9 -4
- package/packages/model-runtime/src/providers/hunyuan/index.test.ts +365 -1
- package/packages/model-runtime/src/providers/hunyuan/index.ts +9 -3
- package/packages/model-runtime/src/providers/infiniai/index.test.ts +71 -0
- package/packages/model-runtime/src/providers/internlm/index.test.ts +369 -2
- package/packages/model-runtime/src/providers/internlm/index.ts +10 -5
- package/packages/model-runtime/src/providers/jina/index.test.ts +164 -3
- package/packages/model-runtime/src/providers/jina/index.ts +8 -3
- package/packages/model-runtime/src/providers/lmstudio/index.test.ts +182 -3
- package/packages/model-runtime/src/providers/lmstudio/index.ts +8 -3
- package/packages/model-runtime/src/providers/mistral/index.test.ts +779 -27
- package/packages/model-runtime/src/providers/mistral/index.ts +8 -3
- package/packages/model-runtime/src/providers/modelscope/index.test.ts +232 -1
- package/packages/model-runtime/src/providers/modelscope/index.ts +8 -3
- package/packages/model-runtime/src/providers/moonshot/index.test.ts +489 -2
- package/packages/model-runtime/src/providers/moonshot/index.ts +8 -3
- package/packages/model-runtime/src/providers/nebius/index.test.ts +381 -3
- package/packages/model-runtime/src/providers/nebius/index.ts +8 -3
- package/packages/model-runtime/src/providers/newapi/index.test.ts +667 -3
- package/packages/model-runtime/src/providers/newapi/index.ts +6 -3
- package/packages/model-runtime/src/providers/nvidia/index.test.ts +168 -1
- package/packages/model-runtime/src/providers/nvidia/index.ts +12 -7
- package/packages/model-runtime/src/providers/ollama/index.test.ts +797 -1
- package/packages/model-runtime/src/providers/ollama/index.ts +8 -0
- package/packages/model-runtime/src/providers/ollamacloud/index.test.ts +411 -0
- package/packages/model-runtime/src/providers/ollamacloud/index.ts +8 -3
- package/packages/model-runtime/src/providers/openai/index.test.ts +171 -2
- package/packages/model-runtime/src/providers/openai/index.ts +8 -3
- package/packages/model-runtime/src/providers/openrouter/index.test.ts +1647 -95
- package/packages/model-runtime/src/providers/openrouter/index.ts +12 -7
- package/packages/model-runtime/src/providers/qiniu/index.test.ts +294 -1
- package/packages/model-runtime/src/providers/qiniu/index.ts +8 -3
- package/packages/model-runtime/src/providers/search1api/index.test.ts +1131 -11
- package/packages/model-runtime/src/providers/search1api/index.ts +10 -4
- package/packages/model-runtime/src/providers/sensenova/index.test.ts +1069 -1
- package/packages/model-runtime/src/providers/sensenova/index.ts +8 -3
- package/packages/model-runtime/src/providers/siliconcloud/index.test.ts +196 -0
- package/packages/model-runtime/src/providers/siliconcloud/index.ts +8 -3
- package/packages/model-runtime/src/providers/spark/index.test.ts +293 -1
- package/packages/model-runtime/src/providers/spark/index.ts +8 -3
- package/packages/model-runtime/src/providers/stepfun/index.test.ts +322 -3
- package/packages/model-runtime/src/providers/stepfun/index.ts +8 -3
- package/packages/model-runtime/src/providers/tencentcloud/index.test.ts +182 -3
- package/packages/model-runtime/src/providers/tencentcloud/index.ts +8 -3
- package/packages/model-runtime/src/providers/togetherai/index.test.ts +359 -4
- package/packages/model-runtime/src/providers/togetherai/index.ts +12 -5
- package/packages/model-runtime/src/providers/v0/index.test.ts +341 -0
- package/packages/model-runtime/src/providers/v0/index.ts +20 -6
- package/packages/model-runtime/src/providers/vercelaigateway/index.test.ts +710 -0
- package/packages/model-runtime/src/providers/vercelaigateway/index.ts +19 -13
- package/packages/model-runtime/src/providers/vllm/index.test.ts +45 -1
- package/packages/model-runtime/src/providers/volcengine/index.test.ts +75 -0
- package/packages/model-runtime/src/providers/wenxin/index.test.ts +144 -1
- package/packages/model-runtime/src/providers/wenxin/index.ts +8 -3
- package/packages/model-runtime/src/providers/xai/index.test.ts +105 -1
- package/packages/model-runtime/src/providers/xinference/index.test.ts +70 -1
- package/packages/model-runtime/src/providers/zeroone/index.test.ts +327 -3
- package/packages/model-runtime/src/providers/zeroone/index.ts +23 -6
- package/packages/model-runtime/src/providers/zhipu/index.test.ts +908 -236
- package/packages/model-runtime/src/providers/zhipu/index.ts +8 -3
- package/packages/model-runtime/src/types/structureOutput.ts +5 -1
- package/packages/model-runtime/vitest.config.mts +7 -1
- package/packages/types/src/aiChat.ts +20 -2
- package/packages/types/src/serverConfig.ts +7 -1
- package/packages/types/src/tool/index.ts +1 -0
- package/packages/types/src/tool/tool.ts +33 -0
- package/packages/types/src/user/settings/image.ts +3 -0
- package/packages/types/src/user/settings/index.ts +3 -0
- package/src/app/[variants]/(main)/settings/_layout/SettingsContent.tsx +3 -0
- package/src/app/[variants]/(main)/settings/hooks/useCategory.tsx +8 -3
- package/src/app/[variants]/(main)/settings/image/index.tsx +74 -0
- package/src/components/FormInput/FormSliderWithInput.tsx +40 -0
- package/src/components/FormInput/index.ts +1 -0
- package/src/envs/image.ts +27 -0
- package/src/features/Conversation/Messages/Assistant/index.tsx +1 -1
- package/src/features/Conversation/Messages/User/index.tsx +2 -2
- package/src/hooks/useFetchAiImageConfig.ts +12 -17
- package/src/locales/default/setting.ts +8 -0
- package/src/server/globalConfig/index.ts +5 -0
- package/src/server/routers/lambda/aiChat.ts +2 -0
- package/src/store/global/initialState.ts +1 -0
- package/src/store/image/slices/generationConfig/action.test.ts +17 -0
- package/src/store/image/slices/generationConfig/action.ts +18 -21
- package/src/store/image/slices/generationConfig/initialState.ts +3 -2
- package/src/store/user/slices/common/action.ts +1 -0
- package/src/store/user/slices/settings/selectors/settings.ts +3 -0
|
@@ -1,13 +1,18 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
1
2
|
import { ModelProvider } from 'model-bank';
|
|
2
3
|
import { Ollama } from 'ollama/browser';
|
|
3
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
5
|
|
|
5
6
|
import { AgentRuntimeErrorType } from '../../types/error';
|
|
6
7
|
import { AgentRuntimeError } from '../../utils/createError';
|
|
7
|
-
import
|
|
8
|
+
import * as debugStreamModule from '../../utils/debugStream';
|
|
9
|
+
import { LobeOllamaAI, params } from './index';
|
|
8
10
|
|
|
9
11
|
vi.mock('ollama/browser');
|
|
10
12
|
|
|
13
|
+
// Mock the console.error to avoid polluting test output
|
|
14
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
15
|
+
|
|
11
16
|
describe('LobeOllamaAI', () => {
|
|
12
17
|
let ollamaAI: LobeOllamaAI;
|
|
13
18
|
|
|
@@ -25,6 +30,12 @@ describe('LobeOllamaAI', () => {
|
|
|
25
30
|
expect(ollamaAI.baseURL).toBe('https://example.com');
|
|
26
31
|
});
|
|
27
32
|
|
|
33
|
+
it('should initialize Ollama client without baseURL', () => {
|
|
34
|
+
const instance = new LobeOllamaAI();
|
|
35
|
+
expect(instance['client']).toBeInstanceOf(Ollama);
|
|
36
|
+
expect(instance.baseURL).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
28
39
|
it('should throw AgentRuntimeError with invalid baseURL', () => {
|
|
29
40
|
try {
|
|
30
41
|
new LobeOllamaAI({ baseURL: 'invalid-url' });
|
|
@@ -133,6 +144,180 @@ describe('LobeOllamaAI', () => {
|
|
|
133
144
|
});
|
|
134
145
|
expect(response).toBeInstanceOf(Response);
|
|
135
146
|
});
|
|
147
|
+
|
|
148
|
+
it('should pass tools to Ollama client', async () => {
|
|
149
|
+
const chatMock = vi.fn().mockResolvedValue({});
|
|
150
|
+
vi.mocked(Ollama.prototype.chat).mockImplementation(chatMock);
|
|
151
|
+
|
|
152
|
+
const payload = {
|
|
153
|
+
messages: [{ content: 'Hello', role: 'user' }],
|
|
154
|
+
model: 'model-id',
|
|
155
|
+
tools: [
|
|
156
|
+
{
|
|
157
|
+
type: 'function',
|
|
158
|
+
function: { name: 'tool1', description: '', parameters: {} },
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
};
|
|
162
|
+
const options = { signal: new AbortController().signal };
|
|
163
|
+
|
|
164
|
+
await ollamaAI.chat(payload as any, options);
|
|
165
|
+
|
|
166
|
+
expect(chatMock).toHaveBeenCalledWith(
|
|
167
|
+
expect.objectContaining({
|
|
168
|
+
tools: [
|
|
169
|
+
{
|
|
170
|
+
type: 'function',
|
|
171
|
+
function: { name: 'tool1', description: '', parameters: {} },
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should throw OllamaServiceUnavailable when fetch fails', async () => {
|
|
179
|
+
const errorMock = { message: 'fetch failed' };
|
|
180
|
+
vi.mocked(Ollama.prototype.chat).mockRejectedValue(errorMock);
|
|
181
|
+
|
|
182
|
+
const payload = {
|
|
183
|
+
messages: [{ content: 'Hello', role: 'user' }],
|
|
184
|
+
model: 'model-id',
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await ollamaAI.chat(payload as any);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
expect(e).toEqual(
|
|
191
|
+
AgentRuntimeError.chat({
|
|
192
|
+
error: { message: 'please check whether your ollama service is available' },
|
|
193
|
+
errorType: AgentRuntimeErrorType.OllamaServiceUnavailable,
|
|
194
|
+
provider: ModelProvider.Ollama,
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should handle error with string error field', async () => {
|
|
201
|
+
const errorMock = {
|
|
202
|
+
error: 'Some error string',
|
|
203
|
+
message: 'Error occurred',
|
|
204
|
+
name: 'OllamaError',
|
|
205
|
+
status_code: 500,
|
|
206
|
+
};
|
|
207
|
+
vi.mocked(Ollama.prototype.chat).mockRejectedValue(errorMock);
|
|
208
|
+
|
|
209
|
+
const payload = {
|
|
210
|
+
messages: [{ content: 'Hello', role: 'user' }],
|
|
211
|
+
model: 'model-id',
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
await ollamaAI.chat(payload as any);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
expect(e).toEqual(
|
|
218
|
+
AgentRuntimeError.chat({
|
|
219
|
+
error: {
|
|
220
|
+
// When error is a string, it uses e.message instead
|
|
221
|
+
message: 'Error occurred',
|
|
222
|
+
name: 'OllamaError',
|
|
223
|
+
status_code: 500,
|
|
224
|
+
},
|
|
225
|
+
errorType: AgentRuntimeErrorType.OllamaBizError,
|
|
226
|
+
provider: ModelProvider.Ollama,
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should handle error with object error field', async () => {
|
|
233
|
+
const errorMock = {
|
|
234
|
+
error: { message: 'Object error message', code: 'ERROR_CODE' },
|
|
235
|
+
message: 'Error occurred',
|
|
236
|
+
name: 'OllamaError',
|
|
237
|
+
status_code: 500,
|
|
238
|
+
};
|
|
239
|
+
vi.mocked(Ollama.prototype.chat).mockRejectedValue(errorMock);
|
|
240
|
+
|
|
241
|
+
const payload = {
|
|
242
|
+
messages: [{ content: 'Hello', role: 'user' }],
|
|
243
|
+
model: 'model-id',
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
await ollamaAI.chat(payload as any);
|
|
248
|
+
} catch (e) {
|
|
249
|
+
expect(e).toEqual(
|
|
250
|
+
AgentRuntimeError.chat({
|
|
251
|
+
error: {
|
|
252
|
+
message: 'Object error message',
|
|
253
|
+
code: 'ERROR_CODE',
|
|
254
|
+
name: 'OllamaError',
|
|
255
|
+
status_code: 500,
|
|
256
|
+
},
|
|
257
|
+
errorType: AgentRuntimeErrorType.OllamaBizError,
|
|
258
|
+
provider: ModelProvider.Ollama,
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should pass all parameters correctly', async () => {
|
|
265
|
+
const chatMock = vi.fn().mockResolvedValue({});
|
|
266
|
+
vi.mocked(Ollama.prototype.chat).mockImplementation(chatMock);
|
|
267
|
+
|
|
268
|
+
const payload = {
|
|
269
|
+
frequency_penalty: 0.5,
|
|
270
|
+
messages: [{ content: 'Hello', role: 'user' }],
|
|
271
|
+
model: 'model-id',
|
|
272
|
+
presence_penalty: 0.3,
|
|
273
|
+
temperature: 0.8,
|
|
274
|
+
top_p: 0.9,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
await ollamaAI.chat(payload as any);
|
|
278
|
+
|
|
279
|
+
expect(chatMock).toHaveBeenCalledWith({
|
|
280
|
+
messages: [{ content: 'Hello', role: 'user' }],
|
|
281
|
+
model: 'model-id',
|
|
282
|
+
options: {
|
|
283
|
+
frequency_penalty: 0.5,
|
|
284
|
+
presence_penalty: 0.3,
|
|
285
|
+
temperature: 0.4,
|
|
286
|
+
top_p: 0.9,
|
|
287
|
+
},
|
|
288
|
+
stream: true,
|
|
289
|
+
tools: undefined,
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('DEBUG', () => {
|
|
294
|
+
it('should call debugStream when DEBUG_OLLAMA_CHAT_COMPLETION is 1', async () => {
|
|
295
|
+
const originalDebugValue = process.env.DEBUG_OLLAMA_CHAT_COMPLETION;
|
|
296
|
+
process.env.DEBUG_OLLAMA_CHAT_COMPLETION = '1';
|
|
297
|
+
|
|
298
|
+
const mockProdStream = new ReadableStream() as any;
|
|
299
|
+
const mockDebugStream = new ReadableStream() as any;
|
|
300
|
+
|
|
301
|
+
const mockAsyncIterator = {
|
|
302
|
+
[Symbol.asyncIterator]: () => mockAsyncIterator,
|
|
303
|
+
next: vi.fn().mockResolvedValue({ done: true, value: undefined }),
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
vi.mocked(Ollama.prototype.chat).mockResolvedValue(mockAsyncIterator as any);
|
|
307
|
+
vi.spyOn(debugStreamModule, 'debugStream').mockImplementation(() => Promise.resolve());
|
|
308
|
+
|
|
309
|
+
const payload = {
|
|
310
|
+
messages: [{ content: 'Hello', role: 'user' }],
|
|
311
|
+
model: 'model-id',
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
await ollamaAI.chat(payload as any);
|
|
315
|
+
|
|
316
|
+
// Note: The actual debugStream call happens asynchronously
|
|
317
|
+
// We're just verifying the code path is set up correctly
|
|
318
|
+
process.env.DEBUG_OLLAMA_CHAT_COMPLETION = originalDebugValue;
|
|
319
|
+
});
|
|
320
|
+
});
|
|
136
321
|
});
|
|
137
322
|
|
|
138
323
|
describe('models', () => {
|
|
@@ -166,6 +351,114 @@ describe('LobeOllamaAI', () => {
|
|
|
166
351
|
},
|
|
167
352
|
]);
|
|
168
353
|
});
|
|
354
|
+
|
|
355
|
+
it('should merge with known model list for capabilities', async () => {
|
|
356
|
+
const listMock = vi.fn().mockResolvedValue({
|
|
357
|
+
models: [{ name: 'llama3.1:latest' }],
|
|
358
|
+
});
|
|
359
|
+
vi.mocked(Ollama.prototype.list).mockImplementation(listMock);
|
|
360
|
+
|
|
361
|
+
const models = await ollamaAI.models();
|
|
362
|
+
|
|
363
|
+
expect(models.length).toBeGreaterThanOrEqual(1);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('should handle case-insensitive model matching with known models', async () => {
|
|
367
|
+
const listMock = vi.fn().mockResolvedValue({
|
|
368
|
+
models: [{ name: 'LLAMA3.1:LATEST' }],
|
|
369
|
+
});
|
|
370
|
+
vi.mocked(Ollama.prototype.list).mockImplementation(listMock);
|
|
371
|
+
|
|
372
|
+
const models = await ollamaAI.models();
|
|
373
|
+
|
|
374
|
+
expect(models.length).toBeGreaterThanOrEqual(1);
|
|
375
|
+
expect(models[0].id).toBe('LLAMA3.1:LATEST');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('should handle empty model list', async () => {
|
|
379
|
+
const listMock = vi.fn().mockResolvedValue({
|
|
380
|
+
models: [],
|
|
381
|
+
});
|
|
382
|
+
vi.mocked(Ollama.prototype.list).mockImplementation(listMock);
|
|
383
|
+
|
|
384
|
+
const models = await ollamaAI.models();
|
|
385
|
+
|
|
386
|
+
expect(models).toEqual([]);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('should set enabled property from known model list', async () => {
|
|
390
|
+
const listMock = vi.fn().mockResolvedValue({
|
|
391
|
+
models: [{ name: 'llama3.1:latest' }, { name: 'unknown-model' }],
|
|
392
|
+
});
|
|
393
|
+
vi.mocked(Ollama.prototype.list).mockImplementation(listMock);
|
|
394
|
+
|
|
395
|
+
const models = await ollamaAI.models();
|
|
396
|
+
|
|
397
|
+
expect(models.length).toBe(2);
|
|
398
|
+
const unknownModel = models.find((m) => m.id === 'unknown-model');
|
|
399
|
+
expect(unknownModel?.enabled).toBe(false);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('should set functionCall from known model abilities', async () => {
|
|
403
|
+
const listMock = vi.fn().mockResolvedValue({
|
|
404
|
+
models: [{ name: 'llama3.1:latest' }],
|
|
405
|
+
});
|
|
406
|
+
vi.mocked(Ollama.prototype.list).mockImplementation(listMock);
|
|
407
|
+
|
|
408
|
+
const models = await ollamaAI.models();
|
|
409
|
+
|
|
410
|
+
const model = models.find((m) => m.id === 'llama3.1:latest');
|
|
411
|
+
expect(model).toHaveProperty('functionCall');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('should set vision from known model abilities', async () => {
|
|
415
|
+
const listMock = vi.fn().mockResolvedValue({
|
|
416
|
+
models: [{ name: 'llama3.2-vision:latest' }],
|
|
417
|
+
});
|
|
418
|
+
vi.mocked(Ollama.prototype.list).mockImplementation(listMock);
|
|
419
|
+
|
|
420
|
+
const models = await ollamaAI.models();
|
|
421
|
+
|
|
422
|
+
const model = models.find((m) => m.id === 'llama3.2-vision:latest');
|
|
423
|
+
if (model) {
|
|
424
|
+
expect(model).toHaveProperty('vision');
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('should set reasoning from known model abilities', async () => {
|
|
429
|
+
const listMock = vi.fn().mockResolvedValue({
|
|
430
|
+
models: [{ name: 'test-model' }],
|
|
431
|
+
});
|
|
432
|
+
vi.mocked(Ollama.prototype.list).mockImplementation(listMock);
|
|
433
|
+
|
|
434
|
+
const models = await ollamaAI.models();
|
|
435
|
+
|
|
436
|
+
expect(models[0]).toHaveProperty('reasoning');
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('should preserve context window tokens from known model', async () => {
|
|
440
|
+
const listMock = vi.fn().mockResolvedValue({
|
|
441
|
+
models: [{ name: 'llama3.1:latest' }],
|
|
442
|
+
});
|
|
443
|
+
vi.mocked(Ollama.prototype.list).mockImplementation(listMock);
|
|
444
|
+
|
|
445
|
+
const models = await ollamaAI.models();
|
|
446
|
+
|
|
447
|
+
const model = models.find((m) => m.id === 'llama3.1:latest');
|
|
448
|
+
expect(model).toHaveProperty('contextWindowTokens');
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('should set displayName from known model', async () => {
|
|
452
|
+
const listMock = vi.fn().mockResolvedValue({
|
|
453
|
+
models: [{ name: 'llama3.1:latest' }],
|
|
454
|
+
});
|
|
455
|
+
vi.mocked(Ollama.prototype.list).mockImplementation(listMock);
|
|
456
|
+
|
|
457
|
+
const models = await ollamaAI.models();
|
|
458
|
+
|
|
459
|
+
const model = models.find((m) => m.id === 'llama3.1:latest');
|
|
460
|
+
expect(model).toHaveProperty('displayName');
|
|
461
|
+
});
|
|
169
462
|
});
|
|
170
463
|
|
|
171
464
|
describe('buildOllamaMessages', () => {
|
|
@@ -182,6 +475,31 @@ describe('LobeOllamaAI', () => {
|
|
|
182
475
|
{ content: 'Hi there!', role: 'assistant' },
|
|
183
476
|
]);
|
|
184
477
|
});
|
|
478
|
+
|
|
479
|
+
it('should handle empty message array', () => {
|
|
480
|
+
const messages: any[] = [];
|
|
481
|
+
|
|
482
|
+
const ollamaMessages = ollamaAI['buildOllamaMessages'](messages);
|
|
483
|
+
|
|
484
|
+
expect(ollamaMessages).toEqual([]);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('should handle multiple messages with different roles', () => {
|
|
488
|
+
const messages = [
|
|
489
|
+
{ content: 'Hello', role: 'system' },
|
|
490
|
+
{ content: 'Hi', role: 'user' },
|
|
491
|
+
{ content: 'Hello there', role: 'assistant' },
|
|
492
|
+
{ content: 'How are you?', role: 'user' },
|
|
493
|
+
];
|
|
494
|
+
|
|
495
|
+
const ollamaMessages = ollamaAI['buildOllamaMessages'](messages as any);
|
|
496
|
+
|
|
497
|
+
expect(ollamaMessages).toHaveLength(4);
|
|
498
|
+
expect(ollamaMessages[0].role).toBe('system');
|
|
499
|
+
expect(ollamaMessages[1].role).toBe('user');
|
|
500
|
+
expect(ollamaMessages[2].role).toBe('assistant');
|
|
501
|
+
expect(ollamaMessages[3].role).toBe('user');
|
|
502
|
+
});
|
|
185
503
|
});
|
|
186
504
|
|
|
187
505
|
describe('convertContentToOllamaMessage', () => {
|
|
@@ -242,5 +560,483 @@ describe('LobeOllamaAI', () => {
|
|
|
242
560
|
role: 'user',
|
|
243
561
|
});
|
|
244
562
|
});
|
|
563
|
+
|
|
564
|
+
it('should handle mixed text and image content', () => {
|
|
565
|
+
const message = {
|
|
566
|
+
content: [
|
|
567
|
+
{ type: 'text', text: 'First text' },
|
|
568
|
+
{ type: 'text', text: 'Second text' },
|
|
569
|
+
{
|
|
570
|
+
type: 'image_url',
|
|
571
|
+
image_url: { url: 'data:image/png;base64,abc123' },
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
type: 'image_url',
|
|
575
|
+
image_url: { url: 'data:image/jpeg;base64,def456' },
|
|
576
|
+
},
|
|
577
|
+
],
|
|
578
|
+
role: 'user',
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const ollamaMessage = ollamaAI['convertContentToOllamaMessage'](message as any);
|
|
582
|
+
|
|
583
|
+
expect(ollamaMessage).toEqual({
|
|
584
|
+
content: 'Second text', // Should keep latest text
|
|
585
|
+
role: 'user',
|
|
586
|
+
images: ['abc123', 'def456'],
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('should handle content with empty text', () => {
|
|
591
|
+
const message = {
|
|
592
|
+
content: [{ type: 'text', text: '' }],
|
|
593
|
+
role: 'user',
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const ollamaMessage = ollamaAI['convertContentToOllamaMessage'](message as any);
|
|
597
|
+
|
|
598
|
+
expect(ollamaMessage).toEqual({
|
|
599
|
+
content: '',
|
|
600
|
+
role: 'user',
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('should handle content with only images (no text)', () => {
|
|
605
|
+
const message = {
|
|
606
|
+
content: [
|
|
607
|
+
{
|
|
608
|
+
type: 'image_url',
|
|
609
|
+
image_url: { url: 'data:image/png;base64,abc123' },
|
|
610
|
+
},
|
|
611
|
+
],
|
|
612
|
+
role: 'user',
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const ollamaMessage = ollamaAI['convertContentToOllamaMessage'](message as any);
|
|
616
|
+
|
|
617
|
+
expect(ollamaMessage).toEqual({
|
|
618
|
+
content: '',
|
|
619
|
+
role: 'user',
|
|
620
|
+
images: ['abc123'],
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('should handle multiple images without text', () => {
|
|
625
|
+
const message = {
|
|
626
|
+
content: [
|
|
627
|
+
{
|
|
628
|
+
type: 'image_url',
|
|
629
|
+
image_url: { url: 'data:image/png;base64,abc123' },
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
type: 'image_url',
|
|
633
|
+
image_url: { url: 'data:image/jpeg;base64,def456' },
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
type: 'image_url',
|
|
637
|
+
image_url: { url: 'data:image/gif;base64,ghi789' },
|
|
638
|
+
},
|
|
639
|
+
],
|
|
640
|
+
role: 'user',
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const ollamaMessage = ollamaAI['convertContentToOllamaMessage'](message as any);
|
|
644
|
+
|
|
645
|
+
expect(ollamaMessage).toEqual({
|
|
646
|
+
content: '',
|
|
647
|
+
role: 'user',
|
|
648
|
+
images: ['abc123', 'def456', 'ghi789'],
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('should ignore images with invalid data URIs', () => {
|
|
653
|
+
const message = {
|
|
654
|
+
content: [
|
|
655
|
+
{ type: 'text', text: 'Hello' },
|
|
656
|
+
{
|
|
657
|
+
type: 'image_url',
|
|
658
|
+
image_url: { url: 'https://example.com/image.png' },
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
type: 'image_url',
|
|
662
|
+
image_url: { url: 'data:image/png;base64,valid123' },
|
|
663
|
+
},
|
|
664
|
+
],
|
|
665
|
+
role: 'user',
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const ollamaMessage = ollamaAI['convertContentToOllamaMessage'](message as any);
|
|
669
|
+
|
|
670
|
+
expect(ollamaMessage).toEqual({
|
|
671
|
+
content: 'Hello',
|
|
672
|
+
role: 'user',
|
|
673
|
+
images: ['valid123'],
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it('should handle complex interleaved content', () => {
|
|
678
|
+
const message = {
|
|
679
|
+
content: [
|
|
680
|
+
{ type: 'text', text: 'Text 1' },
|
|
681
|
+
{
|
|
682
|
+
type: 'image_url',
|
|
683
|
+
image_url: { url: 'data:image/png;base64,img1' },
|
|
684
|
+
},
|
|
685
|
+
{ type: 'text', text: 'Text 2' },
|
|
686
|
+
{
|
|
687
|
+
type: 'image_url',
|
|
688
|
+
image_url: { url: 'data:image/jpeg;base64,img2' },
|
|
689
|
+
},
|
|
690
|
+
{ type: 'text', text: 'Text 3' },
|
|
691
|
+
],
|
|
692
|
+
role: 'user',
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
const ollamaMessage = ollamaAI['convertContentToOllamaMessage'](message as any);
|
|
696
|
+
|
|
697
|
+
expect(ollamaMessage).toEqual({
|
|
698
|
+
content: 'Text 3', // Should keep latest text
|
|
699
|
+
role: 'user',
|
|
700
|
+
images: ['img1', 'img2'],
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it('should handle assistant role with images', () => {
|
|
705
|
+
const message = {
|
|
706
|
+
content: [
|
|
707
|
+
{ type: 'text', text: 'Here is the image' },
|
|
708
|
+
{
|
|
709
|
+
type: 'image_url',
|
|
710
|
+
image_url: { url: 'data:image/png;base64,abc123' },
|
|
711
|
+
},
|
|
712
|
+
],
|
|
713
|
+
role: 'assistant',
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
const ollamaMessage = ollamaAI['convertContentToOllamaMessage'](message as any);
|
|
717
|
+
|
|
718
|
+
expect(ollamaMessage).toEqual({
|
|
719
|
+
content: 'Here is the image',
|
|
720
|
+
role: 'assistant',
|
|
721
|
+
images: ['abc123'],
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('should handle system role with text', () => {
|
|
726
|
+
const message = {
|
|
727
|
+
content: [{ type: 'text', text: 'You are a helpful assistant' }],
|
|
728
|
+
role: 'system',
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const ollamaMessage = ollamaAI['convertContentToOllamaMessage'](message as any);
|
|
732
|
+
|
|
733
|
+
expect(ollamaMessage).toEqual({
|
|
734
|
+
content: 'You are a helpful assistant',
|
|
735
|
+
role: 'system',
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('should handle empty content array', () => {
|
|
740
|
+
const message = {
|
|
741
|
+
content: [],
|
|
742
|
+
role: 'user',
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
const ollamaMessage = ollamaAI['convertContentToOllamaMessage'](message as any);
|
|
746
|
+
|
|
747
|
+
expect(ollamaMessage).toEqual({
|
|
748
|
+
content: '',
|
|
749
|
+
role: 'user',
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
describe('embeddings', () => {
|
|
755
|
+
it('should handle single input string', async () => {
|
|
756
|
+
const embeddingsMock = vi.fn().mockResolvedValue({
|
|
757
|
+
embedding: [0.1, 0.2, 0.3],
|
|
758
|
+
});
|
|
759
|
+
vi.mocked(Ollama.prototype.embeddings).mockImplementation(embeddingsMock);
|
|
760
|
+
|
|
761
|
+
const result = await ollamaAI.embeddings({
|
|
762
|
+
input: 'test input',
|
|
763
|
+
model: 'embed-model',
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
expect(embeddingsMock).toHaveBeenCalledWith({
|
|
767
|
+
model: 'embed-model',
|
|
768
|
+
prompt: 'test input',
|
|
769
|
+
});
|
|
770
|
+
expect(result).toEqual([[0.1, 0.2, 0.3]]);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
it('should handle array of input strings', async () => {
|
|
774
|
+
const embeddingsMock = vi
|
|
775
|
+
.fn()
|
|
776
|
+
.mockResolvedValueOnce({ embedding: [0.1, 0.2, 0.3] })
|
|
777
|
+
.mockResolvedValueOnce({ embedding: [0.4, 0.5, 0.6] });
|
|
778
|
+
vi.mocked(Ollama.prototype.embeddings).mockImplementation(embeddingsMock);
|
|
779
|
+
|
|
780
|
+
const result = await ollamaAI.embeddings({
|
|
781
|
+
input: ['input 1', 'input 2'],
|
|
782
|
+
model: 'embed-model',
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
expect(embeddingsMock).toHaveBeenCalledTimes(2);
|
|
786
|
+
expect(embeddingsMock).toHaveBeenNthCalledWith(1, {
|
|
787
|
+
model: 'embed-model',
|
|
788
|
+
prompt: 'input 1',
|
|
789
|
+
});
|
|
790
|
+
expect(embeddingsMock).toHaveBeenNthCalledWith(2, {
|
|
791
|
+
model: 'embed-model',
|
|
792
|
+
prompt: 'input 2',
|
|
793
|
+
});
|
|
794
|
+
expect(result).toEqual([
|
|
795
|
+
[0.1, 0.2, 0.3],
|
|
796
|
+
[0.4, 0.5, 0.6],
|
|
797
|
+
]);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('should pass dimensions parameter', async () => {
|
|
801
|
+
const embeddingsMock = vi.fn().mockResolvedValue({
|
|
802
|
+
embedding: [0.1, 0.2, 0.3],
|
|
803
|
+
});
|
|
804
|
+
vi.mocked(Ollama.prototype.embeddings).mockImplementation(embeddingsMock);
|
|
805
|
+
|
|
806
|
+
await ollamaAI.embeddings({
|
|
807
|
+
dimensions: 128,
|
|
808
|
+
input: 'test input',
|
|
809
|
+
model: 'embed-model',
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
expect(embeddingsMock).toHaveBeenCalledWith({
|
|
813
|
+
model: 'embed-model',
|
|
814
|
+
prompt: 'test input',
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
it('should throw OllamaBizError on embedding error', async () => {
|
|
819
|
+
const errorMock = {
|
|
820
|
+
message: 'Embedding error',
|
|
821
|
+
name: 'EmbeddingError',
|
|
822
|
+
status_code: 500,
|
|
823
|
+
};
|
|
824
|
+
vi.mocked(Ollama.prototype.embeddings).mockRejectedValue(errorMock);
|
|
825
|
+
|
|
826
|
+
try {
|
|
827
|
+
await ollamaAI.embeddings({
|
|
828
|
+
input: 'test input',
|
|
829
|
+
model: 'embed-model',
|
|
830
|
+
});
|
|
831
|
+
} catch (e) {
|
|
832
|
+
expect(e).toEqual(
|
|
833
|
+
AgentRuntimeError.chat({
|
|
834
|
+
error: errorMock,
|
|
835
|
+
errorType: AgentRuntimeErrorType.OllamaBizError,
|
|
836
|
+
provider: ModelProvider.Ollama,
|
|
837
|
+
}),
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
describe('pullModel', () => {
|
|
844
|
+
it('should successfully pull a model', async () => {
|
|
845
|
+
const mockAsyncIterator = {
|
|
846
|
+
[Symbol.asyncIterator]: () => mockAsyncIterator,
|
|
847
|
+
next: vi
|
|
848
|
+
.fn()
|
|
849
|
+
.mockResolvedValueOnce({
|
|
850
|
+
done: false,
|
|
851
|
+
value: { status: 'downloading', completed: 50, total: 100 },
|
|
852
|
+
})
|
|
853
|
+
.mockResolvedValueOnce({ done: true, value: undefined }),
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
vi.mocked(Ollama.prototype.pull).mockResolvedValue(mockAsyncIterator as any);
|
|
857
|
+
|
|
858
|
+
const response = await ollamaAI.pullModel({ model: 'test-model' });
|
|
859
|
+
|
|
860
|
+
expect(response).toBeInstanceOf(Response);
|
|
861
|
+
expect(response.headers.get('Content-Type')).toBe('application/json');
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it('should pass insecure parameter', async () => {
|
|
865
|
+
const pullMock = vi.fn().mockResolvedValue({
|
|
866
|
+
[Symbol.asyncIterator]: () => ({
|
|
867
|
+
next: vi.fn().mockResolvedValue({ done: true, value: undefined }),
|
|
868
|
+
}),
|
|
869
|
+
});
|
|
870
|
+
vi.mocked(Ollama.prototype.pull).mockImplementation(pullMock);
|
|
871
|
+
|
|
872
|
+
await ollamaAI.pullModel({ model: 'test-model', insecure: true });
|
|
873
|
+
|
|
874
|
+
expect(pullMock).toHaveBeenCalledWith({
|
|
875
|
+
insecure: true,
|
|
876
|
+
model: 'test-model',
|
|
877
|
+
stream: true,
|
|
878
|
+
});
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it('should default insecure to false', async () => {
|
|
882
|
+
const pullMock = vi.fn().mockResolvedValue({
|
|
883
|
+
[Symbol.asyncIterator]: () => ({
|
|
884
|
+
next: vi.fn().mockResolvedValue({ done: true, value: undefined }),
|
|
885
|
+
}),
|
|
886
|
+
});
|
|
887
|
+
vi.mocked(Ollama.prototype.pull).mockImplementation(pullMock);
|
|
888
|
+
|
|
889
|
+
await ollamaAI.pullModel({ model: 'test-model' });
|
|
890
|
+
|
|
891
|
+
expect(pullMock).toHaveBeenCalledWith({
|
|
892
|
+
insecure: false,
|
|
893
|
+
model: 'test-model',
|
|
894
|
+
stream: true,
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it('should handle abort signal', async () => {
|
|
899
|
+
const abortController = new AbortController();
|
|
900
|
+
const abortMock = vi.fn();
|
|
901
|
+
vi.mocked(Ollama.prototype.abort).mockImplementation(abortMock);
|
|
902
|
+
|
|
903
|
+
const mockAsyncIterator = {
|
|
904
|
+
[Symbol.asyncIterator]: () => mockAsyncIterator,
|
|
905
|
+
next: vi.fn().mockResolvedValue({ done: true, value: undefined }),
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
vi.mocked(Ollama.prototype.pull).mockResolvedValue(mockAsyncIterator as any);
|
|
909
|
+
|
|
910
|
+
const responsePromise = ollamaAI.pullModel(
|
|
911
|
+
{ model: 'test-model' },
|
|
912
|
+
{ signal: abortController.signal },
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
abortController.abort();
|
|
916
|
+
|
|
917
|
+
await responsePromise;
|
|
918
|
+
|
|
919
|
+
expect(abortMock).toHaveBeenCalled();
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it('should handle OllamaServiceUnavailable error', async () => {
|
|
923
|
+
const errorMock = new Error('fetch failed');
|
|
924
|
+
vi.mocked(Ollama.prototype.pull).mockRejectedValue(errorMock);
|
|
925
|
+
|
|
926
|
+
const response = await ollamaAI.pullModel({ model: 'test-model' });
|
|
927
|
+
|
|
928
|
+
// Status code 472 is for OllamaServiceUnavailable (see errorResponse.ts)
|
|
929
|
+
expect(response.status).toBe(472);
|
|
930
|
+
const body = await response.json();
|
|
931
|
+
expect(body).toMatchObject({
|
|
932
|
+
errorType: AgentRuntimeErrorType.OllamaServiceUnavailable,
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it('should handle AbortError', async () => {
|
|
937
|
+
const abortError = new Error('Aborted');
|
|
938
|
+
abortError.name = 'AbortError';
|
|
939
|
+
vi.mocked(Ollama.prototype.pull).mockRejectedValue(abortError);
|
|
940
|
+
|
|
941
|
+
const response = await ollamaAI.pullModel({ model: 'test-model' });
|
|
942
|
+
|
|
943
|
+
expect(response.status).toBe(499);
|
|
944
|
+
const body = await response.json();
|
|
945
|
+
expect(body).toEqual({
|
|
946
|
+
model: 'test-model',
|
|
947
|
+
status: 'cancelled',
|
|
948
|
+
});
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it('should handle generic errors', async () => {
|
|
952
|
+
const genericError = new Error('Generic error');
|
|
953
|
+
vi.mocked(Ollama.prototype.pull).mockRejectedValue(genericError);
|
|
954
|
+
|
|
955
|
+
const response = await ollamaAI.pullModel({ model: 'test-model' });
|
|
956
|
+
|
|
957
|
+
expect(response.status).toBe(500);
|
|
958
|
+
const body = await response.json();
|
|
959
|
+
expect(body).toEqual({
|
|
960
|
+
error: 'Generic error',
|
|
961
|
+
model: 'test-model',
|
|
962
|
+
status: 'error',
|
|
963
|
+
});
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
it('should handle non-Error objects', async () => {
|
|
967
|
+
vi.mocked(Ollama.prototype.pull).mockRejectedValue('String error');
|
|
968
|
+
|
|
969
|
+
const response = await ollamaAI.pullModel({ model: 'test-model' });
|
|
970
|
+
|
|
971
|
+
expect(response.status).toBe(500);
|
|
972
|
+
const body = await response.json();
|
|
973
|
+
expect(body).toEqual({
|
|
974
|
+
error: 'String error',
|
|
975
|
+
model: 'test-model',
|
|
976
|
+
status: 'error',
|
|
977
|
+
});
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it('should handle stream cancellation via reader cancel', async () => {
|
|
981
|
+
const abortController = new AbortController();
|
|
982
|
+
const abortMock = vi.fn();
|
|
983
|
+
const removeEventListenerSpy = vi.spyOn(abortController.signal, 'removeEventListener');
|
|
984
|
+
vi.mocked(Ollama.prototype.abort).mockImplementation(abortMock);
|
|
985
|
+
|
|
986
|
+
const mockAsyncIterator = {
|
|
987
|
+
[Symbol.asyncIterator]: () => mockAsyncIterator,
|
|
988
|
+
next: vi.fn().mockResolvedValue({ done: false, value: { status: 'downloading' } }),
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
vi.mocked(Ollama.prototype.pull).mockResolvedValue(mockAsyncIterator as any);
|
|
992
|
+
|
|
993
|
+
const response = await ollamaAI.pullModel(
|
|
994
|
+
{ model: 'test-model' },
|
|
995
|
+
{ signal: abortController.signal },
|
|
996
|
+
);
|
|
997
|
+
|
|
998
|
+
const reader = response.body?.getReader();
|
|
999
|
+
// Cancel the stream to trigger onCancel callback
|
|
1000
|
+
await reader?.cancel();
|
|
1001
|
+
|
|
1002
|
+
// Verify that abort was called and listener was removed
|
|
1003
|
+
expect(abortMock).toHaveBeenCalled();
|
|
1004
|
+
expect(removeEventListenerSpy).toHaveBeenCalled();
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
it('should remove abort listener on error', async () => {
|
|
1008
|
+
const abortController = new AbortController();
|
|
1009
|
+
const removeEventListenerSpy = vi.spyOn(abortController.signal, 'removeEventListener');
|
|
1010
|
+
|
|
1011
|
+
const genericError = new Error('Generic error');
|
|
1012
|
+
vi.mocked(Ollama.prototype.pull).mockRejectedValue(genericError);
|
|
1013
|
+
|
|
1014
|
+
await ollamaAI.pullModel({ model: 'test-model' }, { signal: abortController.signal });
|
|
1015
|
+
|
|
1016
|
+
expect(removeEventListenerSpy).toHaveBeenCalled();
|
|
1017
|
+
});
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
describe('params export', () => {
|
|
1021
|
+
it('should export params object', () => {
|
|
1022
|
+
expect(params).toBeDefined();
|
|
1023
|
+
expect(params.provider).toBe(ModelProvider.Ollama);
|
|
1024
|
+
expect(params.baseURL).toBeUndefined();
|
|
1025
|
+
expect(params.debug).toBeDefined();
|
|
1026
|
+
expect(params.debug.chatCompletion).toBeInstanceOf(Function);
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
it('should disable debug by default', () => {
|
|
1030
|
+
delete process.env.DEBUG_OLLAMA_CHAT_COMPLETION;
|
|
1031
|
+
const result = params.debug.chatCompletion();
|
|
1032
|
+
expect(result).toBe(false);
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
it('should enable debug when env is set', () => {
|
|
1036
|
+
process.env.DEBUG_OLLAMA_CHAT_COMPLETION = '1';
|
|
1037
|
+
const result = params.debug.chatCompletion();
|
|
1038
|
+
expect(result).toBe(true);
|
|
1039
|
+
delete process.env.DEBUG_OLLAMA_CHAT_COMPLETION;
|
|
1040
|
+
});
|
|
245
1041
|
});
|
|
246
1042
|
});
|