Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/prompt/node/test/chatMLFetcherRetry.spec.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Raw } from '@vscode/prompt-tsx';
7
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
8
import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';
9
import { CopilotToken } from '../../../../platform/authentication/common/copilotToken';
10
import { IFetchMLOptions } from '../../../../platform/chat/common/chatMLFetcher';
11
import { IChatQuotaService } from '../../../../platform/chat/common/chatQuotaService';
12
import { ChatFetchResponseType, ChatLocation } from '../../../../platform/chat/common/commonTypes';
13
import { IInteractionService } from '../../../../platform/chat/common/interactionService';
14
import { ConfigKey } from '../../../../platform/configuration/common/configurationService';
15
import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService';
16
import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';
17
import { ICAPIClientService } from '../../../../platform/endpoint/common/capiClient';
18
import { MockAuthenticationService } from '../../../../platform/ignore/node/test/mockAuthenticationService';
19
import { MockCAPIClientService } from '../../../../platform/ignore/node/test/mockCAPIClientService';
20
import { ElectronFetchErrorChromiumDetails, ILogService } from '../../../../platform/log/common/logService';
21
import { FinishedCallback } from '../../../../platform/networking/common/fetch';
22
import { IFetcherService, IHeaders, Response } from '../../../../platform/networking/common/fetcherService';
23
import { IChatEndpoint } from '../../../../platform/networking/common/networking';
24
import { NullChatWebSocketManager } from '../../../../platform/networking/node/chatWebSocketManager';
25
import { NoopOTelService } from '../../../../platform/otel/common/noopOtelService';
26
import { resolveOTelConfig } from '../../../../platform/otel/common/otelConfig';
27
import { NullRequestLogger } from '../../../../platform/requestLogger/node/nullRequestLogger';
28
import { NullExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';
29
import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService';
30
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';
31
import { TelemetryData } from '../../../../platform/telemetry/common/telemetryData';
32
import { TestLogService } from '../../../../platform/testing/common/testLogService';
33
import { InstantiationServiceBuilder } from '../../../../util/common/services';
34
import { CancellationToken, CancellationTokenSource } from '../../../../util/vs/base/common/cancellation';
35
import { Event } from '../../../../util/vs/base/common/event';
36
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
37
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
38
import { IPowerService, NullPowerService } from '../../../power/common/powerService';
39
import { ChatMLFetcherImpl } from '../chatMLFetcher';
40
41
describe('ChatMLFetcherImpl retry logic', () => {
42
let disposables: DisposableStore;
43
let fetcher: ChatMLFetcherImpl;
44
let mockFetcherService: MockFetcherService;
45
let configurationService: InMemoryConfigurationService;
46
let cancellationTokenSource: CancellationTokenSource;
47
let endpoint: IChatEndpoint;
48
49
beforeEach(() => {
50
disposables = new DisposableStore();
51
cancellationTokenSource = disposables.add(new CancellationTokenSource());
52
53
mockFetcherService = new MockFetcherService();
54
configurationService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());
55
configurationService.setConfig(ConfigKey.TeamInternal.RetryServerErrorStatusCodes, '500,502');
56
configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, true);
57
58
const logService = new TestLogService();
59
const telemetryService = new NullTelemetryService();
60
const experimentationService = new NullExperimentationService();
61
62
endpoint = createMockEndpoint();
63
64
fetcher = new ChatMLFetcherImpl(
65
mockFetcherService as unknown as IFetcherService,
66
telemetryService,
67
new NullRequestLogger(),
68
logService,
69
new TestAuthenticationService() as unknown as IAuthenticationService,
70
createMockInteractionService(),
71
createMockChatQuotaService(),
72
new TestCAPIClientService() as unknown as ICAPIClientService,
73
createMockConversationOptions(),
74
configurationService,
75
experimentationService,
76
createMockPowerService(),
77
new InstantiationServiceBuilder([
78
[IFetcherService, mockFetcherService as unknown as IFetcherService],
79
[ITelemetryService, telemetryService],
80
[ICAPIClientService, new TestCAPIClientService() as unknown as ICAPIClientService],
81
]).seal() as unknown as IInstantiationService,
82
new NullChatWebSocketManager(),
83
new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })),
84
);
85
86
// Skip delays in tests for faster execution
87
fetcher.connectivityCheckDelays = [0, 0, 0];
88
});
89
90
afterEach(() => {
91
disposables.dispose();
92
});
93
94
function createBaseOpts(): IFetchMLOptions {
95
return {
96
debugName: 'test',
97
messages: [{ role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] }],
98
endpoint,
99
location: ChatLocation.Panel,
100
enableRetryOnError: true,
101
requestOptions: {},
102
finishedCb: undefined,
103
};
104
}
105
106
describe('server error retry with configured status codes', () => {
107
it('retries on 500 status code when configured', async () => {
108
// Order: 1) initial fetch → 500, 2) connectivity check → 200, 3) retry → success
109
mockFetcherService.queueResponse(createErrorResponse(500, 'Internal Server Error'));
110
mockFetcherService.queueResponse(createSuccessResponse('{}')); // connectivity check
111
mockFetcherService.queueResponse(createSuccessResponse('Hello!')); // retry
112
113
const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);
114
115
expect(result.type).toBe(ChatFetchResponseType.Success);
116
expect(mockFetcherService.fetchCallCount).toBeGreaterThanOrEqual(2);
117
});
118
119
it('retries on 502 status code when configured', async () => {
120
// Order: 1) initial fetch → 502, 2) connectivity check → 200, 3) retry → success
121
mockFetcherService.queueResponse(createErrorResponse(502, 'Bad Gateway'));
122
mockFetcherService.queueResponse(createSuccessResponse('{}')); // connectivity check
123
mockFetcherService.queueResponse(createSuccessResponse('Success!')); // retry
124
125
const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);
126
127
expect(result.type).toBe(ChatFetchResponseType.Success);
128
});
129
130
it('does not retry on 404 status code', async () => {
131
mockFetcherService.queueResponse(createErrorResponse(404, 'Not Found'));
132
133
const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);
134
135
expect(result.type).toBe(ChatFetchResponseType.NotFound);
136
expect(mockFetcherService.fetchCallCount).toBe(1);
137
});
138
139
it('does not retry when enableRetryOnError is false', async () => {
140
mockFetcherService.queueResponse(createErrorResponse(500, 'Internal Server Error'));
141
142
const opts = createBaseOpts();
143
opts.enableRetryOnError = false;
144
const result = await fetcher.fetchMany(opts, cancellationTokenSource.token);
145
146
expect(result.type).toBe(ChatFetchResponseType.Failed);
147
expect(mockFetcherService.fetchCallCount).toBe(1);
148
});
149
150
it('respects custom status codes from configuration', async () => {
151
// Configure to only retry on 503
152
configurationService.setConfig(ConfigKey.TeamInternal.RetryServerErrorStatusCodes, '503');
153
154
mockFetcherService.queueResponse(createErrorResponse(500, 'Internal Server Error'));
155
156
const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);
157
158
// Should NOT retry because 500 is not in the configured list
159
expect(result.type).toBe(ChatFetchResponseType.Failed);
160
expect(mockFetcherService.fetchCallCount).toBe(1);
161
});
162
});
163
164
describe('network error retry', () => {
165
it('retries after connectivity check succeeds', async () => {
166
configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, true);
167
168
// Use ENOTFOUND instead of ECONNRESET - ECONNRESET triggers auto-retry in networking.ts
169
// Order: 1) initial fetch → error, 2) connectivity check → 200, 3) retry → success
170
mockFetcherService.queueError(createNetworkError('ENOTFOUND'));
171
mockFetcherService.queueResponse(createSuccessResponse('{}')); // connectivity check
172
mockFetcherService.queueResponse(createSuccessResponse('Success!')); // retry
173
174
const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);
175
176
expect(result.type).toBe(ChatFetchResponseType.Success);
177
});
178
179
it('does not retry when RetryNetworkErrors is disabled', async () => {
180
configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, false);
181
182
// Use ENOTFOUND instead of ECONNRESET - ECONNRESET triggers auto-retry in networking.ts
183
mockFetcherService.queueError(createNetworkError('ENOTFOUND'));
184
185
const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);
186
187
expect(result.type).toBe(ChatFetchResponseType.NetworkError);
188
expect(mockFetcherService.fetchCallCount).toBe(1);
189
});
190
});
191
192
describe('status code parsing', () => {
193
it('handles comma-separated status codes with spaces', async () => {
194
configurationService.setConfig(ConfigKey.TeamInternal.RetryServerErrorStatusCodes, '500, 502 , 503');
195
196
mockFetcherService.queueResponse(createErrorResponse(502, 'Bad Gateway'));
197
mockFetcherService.queueResponse(createSuccessResponse('{}')); // connectivity check
198
mockFetcherService.queueResponse(createSuccessResponse('Success!')); // retry
199
200
const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);
201
202
expect(result.type).toBe(ChatFetchResponseType.Success);
203
});
204
205
it('handles invalid status codes gracefully', async () => {
206
configurationService.setConfig(ConfigKey.TeamInternal.RetryServerErrorStatusCodes, '500,invalid,502');
207
208
mockFetcherService.queueResponse(createErrorResponse(500, 'Internal Server Error'));
209
mockFetcherService.queueResponse(createSuccessResponse('{}')); // connectivity check
210
mockFetcherService.queueResponse(createSuccessResponse('Success!')); // retry
211
212
// Should still retry on 500 even with invalid entry in config
213
const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);
214
215
expect(result.type).toBe(ChatFetchResponseType.Success);
216
});
217
218
it('does not retry when configuration is empty string', async () => {
219
configurationService.setConfig(ConfigKey.TeamInternal.RetryServerErrorStatusCodes, '');
220
221
mockFetcherService.queueResponse(createErrorResponse(500, 'Internal Server Error'));
222
223
const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);
224
225
// Empty config means no status codes to retry - should fail without retry
226
expect(result.type).toBe(ChatFetchResponseType.Failed);
227
expect(mockFetcherService.fetchCallCount).toBe(1);
228
});
229
230
it('does not retry when configuration contains only invalid values', async () => {
231
configurationService.setConfig(ConfigKey.TeamInternal.RetryServerErrorStatusCodes, 'invalid,abc,xyz');
232
233
mockFetcherService.queueResponse(createErrorResponse(500, 'Internal Server Error'));
234
235
const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);
236
237
// All invalid means no valid status codes - should fail without retry
238
expect(result.type).toBe(ChatFetchResponseType.Failed);
239
expect(mockFetcherService.fetchCallCount).toBe(1);
240
});
241
});
242
243
describe('connectivity check failure', () => {
244
it('does not retry server error when connectivity check fails', async () => {
245
configurationService.setConfig(ConfigKey.TeamInternal.RetryServerErrorStatusCodes, '500,502');
246
247
// Order: 1) initial fetch → 500, 2) connectivity checks fail (3 attempts)
248
mockFetcherService.queueResponse(createErrorResponse(500, 'Internal Server Error'));
249
// Connectivity check retries 3 times (with 0ms delays in tests)
250
mockFetcherService.queueError(createNetworkError('ENOTFOUND')); // 1st connectivity check
251
mockFetcherService.queueError(createNetworkError('ENOTFOUND')); // 2nd connectivity check
252
mockFetcherService.queueError(createNetworkError('ENOTFOUND')); // 3rd connectivity check
253
254
const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);
255
256
// Should fail because connectivity check never succeeded
257
expect(result.type).toBe(ChatFetchResponseType.Failed);
258
});
259
});
260
261
describe('network process crash fallback to node-fetch', () => {
262
it('falls back to node-fetch and retries when network process crashed and flag is enabled', async () => {
263
configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, true);
264
configurationService.setConfig(ConfigKey.TeamInternal.FallbackNodeFetchOnNetworkProcessCrash, true);
265
266
// 1) initial fetch → network process crash error
267
// 2) connectivity check via node-fetch → success
268
// 3) retry via node-fetch → success
269
mockFetcherService.queueError(createNetworkProcessCrashedError());
270
mockFetcherService.queueResponse(createSuccessResponse('{}')); // connectivity check
271
mockFetcherService.queueResponse(createSuccessResponse('Recovered!')); // retry
272
273
const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);
274
275
expect(result.type).toBe(ChatFetchResponseType.Success);
276
// Verify that connectivity check and retry used node-fetch
277
const fetcherIds = mockFetcherService.fetcherIdsUsed;
278
// fetcherIds[0] = initial request (default fetcher)
279
// fetcherIds[1] = connectivity check (should be node-fetch)
280
// fetcherIds[2] = retry request (should be node-fetch)
281
expect(fetcherIds[1]).toBe('node-fetch');
282
expect(fetcherIds[2]).toBe('node-fetch');
283
});
284
285
it('does NOT fall back to node-fetch when flag is disabled', async () => {
286
configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, true);
287
configurationService.setConfig(ConfigKey.TeamInternal.FallbackNodeFetchOnNetworkProcessCrash, false);
288
289
// 1) initial fetch → network process crash error
290
// 2-4) connectivity checks via default fetcher → all fail (dead network process)
291
mockFetcherService.queueError(createNetworkProcessCrashedError());
292
mockFetcherService.queueError(createNetworkError('ENOTFOUND')); // 1st connectivity check
293
mockFetcherService.queueError(createNetworkError('ENOTFOUND')); // 2nd connectivity check
294
mockFetcherService.queueError(createNetworkError('ENOTFOUND')); // 3rd connectivity check
295
296
const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);
297
298
// Should fail: the connectivity checks used the dead default fetcher
299
expect(result.type).toBe(ChatFetchResponseType.NetworkError);
300
// Verify that connectivity checks did NOT use node-fetch
301
const fetcherIds = mockFetcherService.fetcherIdsUsed;
302
expect(fetcherIds[1]).toBeUndefined(); // default fetcher, not node-fetch
303
});
304
305
it('does NOT fall back to node-fetch for non-crash network errors', async () => {
306
configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, true);
307
configurationService.setConfig(ConfigKey.TeamInternal.FallbackNodeFetchOnNetworkProcessCrash, true);
308
309
// Regular network error (not a crash) — should NOT trigger node-fetch fallback
310
mockFetcherService.queueError(createNetworkError('ENOTFOUND'));
311
mockFetcherService.queueResponse(createSuccessResponse('{}')); // connectivity check
312
mockFetcherService.queueResponse(createSuccessResponse('Success!')); // retry
313
314
const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);
315
316
expect(result.type).toBe(ChatFetchResponseType.Success);
317
// Verify that connectivity check used the default fetcher, not node-fetch
318
const fetcherIds = mockFetcherService.fetcherIdsUsed;
319
expect(fetcherIds[1]).toBeUndefined(); // default fetcher
320
});
321
322
it('does NOT fall back when RetryNetworkErrors is disabled even if crash flag is enabled', async () => {
323
configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, false);
324
configurationService.setConfig(ConfigKey.TeamInternal.FallbackNodeFetchOnNetworkProcessCrash, true);
325
326
mockFetcherService.queueError(createNetworkProcessCrashedError());
327
328
const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);
329
330
// Should fail without retry: the general retry-on-network-error flag is off
331
expect(result.type).toBe(ChatFetchResponseType.NetworkError);
332
expect(mockFetcherService.fetchCallCount).toBe(1);
333
});
334
335
it('sets isNetworkProcessCrash flag on the error result', async () => {
336
configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, false);
337
configurationService.setConfig(ConfigKey.TeamInternal.FallbackNodeFetchOnNetworkProcessCrash, false);
338
339
mockFetcherService.queueError(createNetworkProcessCrashedError());
340
341
const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);
342
343
expect(result.type).toBe(ChatFetchResponseType.NetworkError);
344
if (result.type === ChatFetchResponseType.NetworkError) {
345
expect(result.isNetworkProcessCrash).toBe(true);
346
}
347
});
348
349
it('does not set isNetworkProcessCrash flag for regular network errors', async () => {
350
configurationService.setConfig(ConfigKey.TeamInternal.RetryNetworkErrors, false);
351
352
mockFetcherService.queueError(createNetworkError('ENOTFOUND'));
353
354
const result = await fetcher.fetchMany(createBaseOpts(), cancellationTokenSource.token);
355
356
expect(result.type).toBe(ChatFetchResponseType.NetworkError);
357
if (result.type === ChatFetchResponseType.NetworkError) {
358
expect(result.isNetworkProcessCrash).toBeUndefined();
359
}
360
});
361
});
362
});
363
364
// --- Test Helpers ---
365
366
/**
367
* Mock fetcher service that queues responses for testing retry logic.
368
*/
369
class MockFetcherService {
370
private _responseQueue: (Response | Error)[] = [];
371
private _fetchCallCount = 0;
372
373
get fetchCallCount(): number {
374
return this._fetchCallCount;
375
}
376
377
queueResponse(response: Response): void {
378
this._responseQueue.push(response);
379
}
380
381
queueError(error: Error): void {
382
this._responseQueue.push(error);
383
}
384
385
/**
386
* The `useFetcher` values passed to each `fetch` call, in order.
387
* Used to verify that the retry logic correctly switches fetchers.
388
*/
389
private _fetcherIdsUsed: (string | undefined)[] = [];
390
391
get fetcherIdsUsed(): (string | undefined)[] {
392
return this._fetcherIdsUsed;
393
}
394
395
async fetch(_url: string, options?: any): Promise<Response> {
396
this._fetchCallCount++;
397
this._fetcherIdsUsed.push(options?.useFetcher);
398
const next = this._responseQueue.shift();
399
if (!next) {
400
throw new Error('No more queued responses');
401
}
402
if (next instanceof Error) {
403
throw next;
404
}
405
return next;
406
}
407
408
fetchWithPagination<T>(): Promise<T[]> {
409
throw new Error('Method not implemented.');
410
}
411
412
disconnectAll(): Promise<void> {
413
return Promise.resolve();
414
}
415
416
makeAbortController(): AbortController {
417
return new AbortController();
418
}
419
420
isAbortError(_err: unknown): boolean {
421
return false;
422
}
423
424
isInternetDisconnectedError(_err: unknown): boolean {
425
return false;
426
}
427
428
isFetcherError(err: unknown): boolean {
429
return err instanceof Error && 'code' in err;
430
}
431
432
isNetworkProcessCrashedError(err: unknown): boolean {
433
return !!(err && typeof err === 'object' && 'chromiumDetails' in err &&
434
(err as { chromiumDetails?: ElectronFetchErrorChromiumDetails }).chromiumDetails?.network_process_crashed === true);
435
}
436
437
getUserMessageForFetcherError(_err: unknown): string {
438
return 'Network error occurred';
439
}
440
441
getUserAgentLibrary(): string {
442
return 'test-agent';
443
}
444
}
445
446
/**
447
* Extended mock authentication service that returns a valid token.
448
*/
449
class TestAuthenticationService extends MockAuthenticationService {
450
override getCopilotToken(_force?: boolean): Promise<CopilotToken> {
451
return Promise.resolve({
452
token: 'test-token',
453
username: 'test-user',
454
} as CopilotToken);
455
}
456
}
457
458
/**
459
* Extended mock CAPI client service that provides the ping URL.
460
*/
461
class TestCAPIClientService extends MockCAPIClientService {
462
get capiPingURL(): string {
463
return 'https://api.github.com/copilot_internal/ping';
464
}
465
}
466
467
function createMockInteractionService(): IInteractionService {
468
return {
469
_serviceBrand: undefined,
470
onInteractionStateChanged: Event.None,
471
sendChatInteraction: () => { },
472
getInteractionState: () => undefined,
473
} as unknown as IInteractionService;
474
}
475
476
function createMockEndpoint(): IChatEndpoint {
477
return {
478
url: 'https://api.github.com/copilot/chat/completions',
479
urlOrRequestMetadata: 'https://api.github.com/copilot/chat/completions',
480
model: 'test-model',
481
modelMaxPromptTokens: 8192,
482
maxOutputTokens: 4096,
483
supportsToolCalls: true,
484
supportsVision: false,
485
supportsPrediction: false,
486
showInModelPicker: true,
487
isDefault: true,
488
isFallback: false,
489
policy: 'enabled',
490
getHeaders: async () => ({}),
491
createRequestBody: () => ({
492
model: 'test-model',
493
messages: [],
494
stream: true
495
}),
496
acquireTokenizer: () => ({
497
countMessagesTokens: async () => 100,
498
countTokens: async () => 100,
499
tokenize: async () => [],
500
}),
501
processResponseFromChatEndpoint: async (_telemetryService: ITelemetryService, _logService: ILogService, response: Response, _expectedNumChoices: number, finishedCb: FinishedCallback, telemetryData: TelemetryData, _cancellationToken?: CancellationToken) => {
502
// Stream the response text through the callback
503
const text = await response.text();
504
if (finishedCb) {
505
await finishedCb(text, 0, { text });
506
}
507
// Return an async iterable of ChatCompletion objects
508
return {
509
[Symbol.asyncIterator]: async function* () {
510
yield {
511
message: { role: Raw.ChatRole.Assistant, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text }] },
512
choiceIndex: 0,
513
requestId: {
514
headerRequestId: response.headers.get('x-request-id') || 'test-request-id',
515
gitHubRequestId: response.headers.get('x-github-request-id') || '',
516
completionId: '',
517
created: 0,
518
serverExperiments: '',
519
deploymentId: '',
520
},
521
tokens: [],
522
usage: undefined,
523
model: 'test-model',
524
blockFinished: true,
525
finishReason: 'stop',
526
telemetryData: telemetryData,
527
};
528
}
529
};
530
},
531
acceptChatPolicy: async () => true,
532
doRequest: async () => {
533
throw new Error('Not implemented');
534
},
535
} as unknown as IChatEndpoint;
536
}
537
538
function createMockChatQuotaService(): IChatQuotaService {
539
return {
540
_serviceBrand: undefined,
541
processQuotaHeaders: () => { },
542
} as unknown as IChatQuotaService;
543
}
544
545
function createMockConversationOptions() {
546
return {
547
_serviceBrand: undefined,
548
maxResponseTokens: 4096,
549
temperature: 0.5,
550
topP: 1,
551
rejectionMessage: 'rejected',
552
};
553
}
554
555
function createMockPowerService(): IPowerService {
556
return new NullPowerService();
557
}
558
559
/**
560
* Simple FakeHeaders implementation that accepts initial headers.
561
*/
562
class FakeHeaders implements IHeaders {
563
constructor(private readonly headers = new Map<string, string>()) { }
564
get(name: string): string | null {
565
return this.headers.get(name.toLowerCase()) ?? null;
566
}
567
*[Symbol.iterator](): Iterator<[string, string]> {
568
yield* this.headers.entries();
569
}
570
}
571
572
function createSuccessResponse(content: string): Response {
573
const streamContent = `data: {"choices":[{"delta":{"content":"${content}"},"index":0}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop","index":0}]}\n\ndata: [DONE]\n\n`;
574
return Response.fromText(
575
200,
576
'OK',
577
new FakeHeaders(new Map([
578
['content-type', 'text/event-stream'],
579
])),
580
streamContent,
581
'node-fetch'
582
);
583
}
584
585
function createErrorResponse(status: number, statusText: string): Response {
586
return Response.fromText(
587
status,
588
statusText,
589
new FakeHeaders(),
590
JSON.stringify({ error: { message: statusText } }),
591
'node-fetch'
592
);
593
}
594
595
function createNetworkError(code: string): Error & { code: string } {
596
const error = new Error(`Network error: ${code}`) as Error & { code: string };
597
error.code = code;
598
return error;
599
}
600
601
/**
602
* Creates an error that simulates Electron's network process crashing.
603
* Electron attaches `chromiumDetails` with structured error info to the error object.
604
*/
605
function createNetworkProcessCrashedError(): Error & { code: string; chromiumDetails: ElectronFetchErrorChromiumDetails } {
606
const error = new Error('net::ERR_FAILED') as any;
607
error.code = 'ERR_FAILED';
608
error.chromiumDetails = { is_request_error: true, network_process_crashed: true } satisfies ElectronFetchErrorChromiumDetails;
609
return error;
610
}
611
612