豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 7b70532

Browse files
authored
Add a new fetchedValue concept (#4943)
* Implement fetchedValue * Implement a compostible fetcher * Keep cache hot * Create a capi client fetched value * Revert capi client change * Fix tests * Cleanup the middlewares to be a bit smarter * cleanup stream cloning * Update parse * Fix window active cloning * Fix tests * Fix docs * Update docs
1 parent ac5a66a commit 7b70532

15 files changed

+1517
-124
lines changed

eslint.config.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,10 @@ export default tseslint.config(
245245
target: './src/',
246246
from: './test/'
247247
},
248+
{
249+
target: './src/shared-fetch-utils',
250+
from: ['./src/extension', './src/platform', './src/util', './src/lib']
251+
},
248252
{
249253
target: './src/util',
250254
from: ['./src/platform', './src/extension']

src/platform/endpoint/node/automodeService.ts

Lines changed: 45 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@
55

66
import { RequestType } from '@vscode/copilot-api';
77
import type { ChatRequest } from 'vscode';
8+
import { FetchedValue } from '../../../shared-fetch-utils/common/fetchedValue';
89
import { createServiceIdentifier } from '../../../util/common/services';
9-
import { TimeoutTimer } from '../../../util/vs/base/common/async';
1010
import { Disposable, DisposableMap } from '../../../util/vs/base/common/lifecycle';
1111
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
1212
import { ChatLocation } from '../../../vscodeTypes';
1313
import { IAuthenticationService } from '../../authentication/common/authentication';
1414
import { ConfigKey, IConfigurationService } from '../../configuration/common/configurationService';
1515
import { IEnvService } from '../../env/common/envService';
1616
import { ILogService } from '../../log/common/logService';
17+
import { createCapiClientFetchedValue } from '../../networking/common/capiClientFetchedValue';
1718
import { isAbortError } from '../../networking/common/fetcherService';
1819
import { IChatEndpoint } from '../../networking/common/networking';
1920
import { IRequestLogger } from '../../requestLogger/node/requestLogger';
@@ -41,108 +42,60 @@ interface AutoModelCacheEntry {
4142
}
4243

4344
class AutoModeTokenBank extends Disposable {
44-
private _token: AutoModeAPIResponse | undefined;
45-
private _fetchTokenPromise: Promise<void> | undefined;
46-
private _refreshTimer: TimeoutTimer;
45+
private readonly _fetchedValue: FetchedValue<AutoModeAPIResponse>;
4746
private _usedSinceLastFetch = false;
4847

4948
constructor(
5049
public debugName: string,
51-
private readonly _location: ChatLocation,
52-
private readonly _capiClientService: ICAPIClientService,
53-
private readonly _authService: IAuthenticationService,
54-
private readonly _logService: ILogService,
55-
private readonly _expService: IExperimentationService,
56-
private readonly _envService: IEnvService
50+
location: ChatLocation,
51+
capiClientService: ICAPIClientService,
52+
authService: IAuthenticationService,
53+
_logService: ILogService,
54+
expService: IExperimentationService,
55+
envService: IEnvService,
5756
) {
5857
super();
59-
this._refreshTimer = this._register(new TimeoutTimer());
60-
this._register(this._envService.onDidChangeWindowState((state) => {
61-
if (state.active && this._usedSinceLastFetch && (!this._token || this._token.expires_at * 1000 - Date.now() < 5 * 60 * 1000)) {
62-
// Window is active again, fetch a new token if it's expiring soon or we don't have one
63-
this._fetchTokenPromise = this._fetchToken();
64-
}
58+
59+
const expName = location === ChatLocation.Editor
60+
? 'copilotchat.autoModelHint.editor'
61+
: 'copilotchat.autoModelHint';
62+
63+
this._fetchedValue = this._register(createCapiClientFetchedValue<AutoModeAPIResponse>(capiClientService, envService, {
64+
request: async () => {
65+
const authToken = (await authService.getCopilotToken()).token;
66+
const autoModeHint = expService.getTreatmentVariable<string>(expName) || 'auto';
67+
return {
68+
headers: {
69+
'Content-Type': 'application/json',
70+
'Authorization': `Bearer ${authToken}`,
71+
},
72+
method: 'POST' as const,
73+
json: { auto_mode: { model_hints: [autoModeHint] } },
74+
};
75+
},
76+
requestMetadata: { type: RequestType.AutoModels },
77+
parseResponse: async (res) => {
78+
if (res.status < 200 || res.status >= 300) {
79+
const text = await res.text().catch(() => '');
80+
throw new Error(`AutoMode token response status: ${res.status}${text ? `, body: ${text}` : ''}`);
81+
}
82+
const data = await res.json() as AutoModeAPIResponse;
83+
this._usedSinceLastFetch = false;
84+
return data;
85+
},
86+
isStale: (token) => {
87+
if (!this._usedSinceLastFetch) {
88+
return false;
89+
}
90+
return token.expires_at * 1000 - Date.now() < 5 * 60 * 1000;
91+
},
92+
keepCacheHot: true,
6593
}));
66-
this._fetchTokenPromise = this._fetchToken();
6794
}
6895

6996
async getToken(): Promise<AutoModeAPIResponse> {
70-
if (!this._token) {
71-
if (this._fetchTokenPromise) {
72-
await this._fetchTokenPromise;
73-
}
74-
// If we still don't have a token (e.g., the awaited promise returned nothing), force a new fetch
75-
if (!this._token) {
76-
this._fetchTokenPromise = this._fetchToken(true);
77-
await this._fetchTokenPromise;
78-
}
79-
}
80-
if (!this._token) {
81-
throw new Error(`[${this.debugName}] Failed to fetch AutoMode token: token is undefined after fetch attempt.`);
82-
}
8397
this._usedSinceLastFetch = true;
84-
return this._token;
85-
}
86-
87-
88-
private async _fetchToken(force?: boolean): Promise<void> {
89-
// If the window isn't active we will skip fetching to save network calls
90-
// We will fetch again when the window becomes active
91-
if (!this._envService.isActive && !force) {
92-
return;
93-
}
94-
const startTime = Date.now();
95-
96-
try {
97-
const authToken = (await this._authService.getCopilotToken()).token;
98-
const headers: Record<string, string> = {
99-
'Content-Type': 'application/json',
100-
'Authorization': `Bearer ${authToken}`
101-
};
102-
103-
const expName = this._location === ChatLocation.Editor
104-
? 'copilotchat.autoModelHint.editor'
105-
: 'copilotchat.autoModelHint';
106-
107-
const autoModeHint = this._expService.getTreatmentVariable<string>(expName) || 'auto';
108-
109-
const response = await this._capiClientService.makeRequest<Response>({
110-
json: {
111-
'auto_mode': { 'model_hints': [autoModeHint] }
112-
},
113-
headers,
114-
method: 'POST'
115-
}, { type: RequestType.AutoModels });
116-
if (!response.ok) {
117-
throw new Error(`Response status: ${response.status}, status text: ${response.statusText}`);
118-
}
119-
const data: AutoModeAPIResponse = await response.json() as AutoModeAPIResponse;
120-
// HACK: Boost the autoModeHint model to the front of the list until CAPI fixes their bug
121-
const hintIndex = data.available_models.indexOf(autoModeHint);
122-
if (hintIndex > 0) {
123-
data.available_models.splice(hintIndex, 1);
124-
data.available_models.unshift(autoModeHint);
125-
}
126-
this._logService.trace(`Fetched auto model for ${this.debugName} in ${Date.now() - startTime}ms.`);
127-
this._token = data;
128-
this._usedSinceLastFetch = false;
129-
// Trigger a refresh 5 minutes before expiration
130-
if (!this._store.isDisposed) {
131-
this._refreshTimer.cancelAndSet(() => {
132-
if (!this._usedSinceLastFetch) {
133-
this._logService.trace(`[${this.debugName}] Skipping auto mode token refresh because it was not used since last fetch.`);
134-
this._token = undefined;
135-
return;
136-
}
137-
this._fetchToken();
138-
}, (data.expires_at * 1000) - Date.now() - 5 * 60 * 1000);
139-
}
140-
} catch (err) {
141-
this._logService.error(`[${this.debugName}] Failed to fetch AutoMode token:`, err);
142-
this._token = undefined;
143-
} finally {
144-
this._fetchTokenPromise = undefined;
145-
}
98+
return this._fetchedValue.resolve();
14699
}
147100
}
148101

src/platform/endpoint/node/test/automodeService.spec.ts

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,35 @@ import { NullTelemetryService } from '../../../telemetry/common/nullTelemetrySer
2121
import { ICAPIClientService } from '../../common/capiClient';
2222
import { AutomodeService } from '../automodeService';
2323

24+
function createMockHeaders(entries: Record<string, string> = {}): { get(name: string): string | null } {
25+
const lower: Record<string, string> = {};
26+
for (const [k, v] of Object.entries(entries)) {
27+
lower[k.toLowerCase()] = v;
28+
}
29+
return { get: (name: string) => lower[name.toLowerCase()] ?? null };
30+
}
31+
32+
/**
33+
* Creates a mock response with a real stream-backed body so that middleware
34+
* cloning (tee) works correctly. Token responses go through the middleware
35+
* pipeline where {@link cloneResponse} reads the body stream.
36+
*/
37+
function makeMockTokenResponse(body: { available_models: string[]; expires_at: number; session_token: string }) {
38+
const serialized = JSON.stringify(body);
39+
return {
40+
status: 200,
41+
headers: createMockHeaders(),
42+
body: new ReadableStream<Uint8Array>({
43+
start(controller) {
44+
controller.enqueue(new TextEncoder().encode(serialized));
45+
controller.close();
46+
},
47+
}),
48+
async text() { return serialized; },
49+
async json() { return JSON.parse(serialized); },
50+
};
51+
}
52+
2453
describe('AutomodeService', () => {
2554
let automodeService: AutomodeService;
2655
let mockCAPIClientService: ICAPIClientService;
@@ -64,14 +93,13 @@ describe('AutomodeService', () => {
6493
}
6594

6695
function mockApiResponse(available_models: string[], session_token = 'test-token', expiresInSeconds = 3600): void {
67-
(mockCAPIClientService.makeRequest as ReturnType<typeof vi.fn>).mockResolvedValue({
68-
ok: true,
69-
json: vi.fn().mockResolvedValue({
96+
(mockCAPIClientService.makeRequest as ReturnType<typeof vi.fn>).mockResolvedValue(
97+
makeMockTokenResponse({
7098
available_models,
7199
expires_at: Math.floor(Date.now() / 1000) + expiresInSeconds,
72100
session_token,
73101
})
74-
});
102+
);
75103
}
76104

77105
function enableRouter(): void {
@@ -85,14 +113,13 @@ describe('AutomodeService', () => {
85113
mockChatEndpoint = createEndpoint('gpt-4o-mini', 'OpenAI');
86114

87115
mockCAPIClientService = {
88-
makeRequest: vi.fn().mockResolvedValue({
89-
ok: true,
90-
json: vi.fn().mockResolvedValue({
116+
makeRequest: vi.fn().mockResolvedValue(
117+
makeMockTokenResponse({
91118
available_models: ['gpt-4o', 'gpt-4o-mini'],
92119
expires_at: Math.floor(Date.now() / 1000) + 3600,
93120
session_token: 'test-token'
94121
})
95-
})
122+
)
96123
} as unknown as ICAPIClientService;
97124

98125
mockAuthService = {
@@ -154,6 +181,8 @@ describe('AutomodeService', () => {
154181
if (opts?.type === RequestType.ModelRouter) {
155182
return Promise.resolve({
156183
ok: true,
184+
status: 200,
185+
headers: createMockHeaders(),
157186
text: vi.fn().mockResolvedValue(JSON.stringify({
158187
predicted_label: 'needs_reasoning',
159188
confidence: 0.85,
@@ -165,14 +194,13 @@ describe('AutomodeService', () => {
165194
}))
166195
});
167196
}
168-
return Promise.resolve({
169-
ok: true,
170-
json: vi.fn().mockResolvedValue({
197+
return Promise.resolve(
198+
makeMockTokenResponse({
171199
available_models: ['gpt-4o', 'gpt-4o-mini'],
172200
expires_at: Math.floor(Date.now() / 1000) + 3600,
173201
session_token: 'test-token'
174202
})
175-
});
203+
);
176204
});
177205

178206
automodeService = createService();
@@ -205,6 +233,8 @@ describe('AutomodeService', () => {
205233
capturedBody = req.body;
206234
return Promise.resolve({
207235
ok: true,
236+
status: 200,
237+
headers: createMockHeaders(),
208238
text: vi.fn().mockResolvedValue(JSON.stringify({
209239
predicted_label: 'needs_reasoning',
210240
confidence: 0.85,
@@ -216,14 +246,13 @@ describe('AutomodeService', () => {
216246
}))
217247
});
218248
}
219-
return Promise.resolve({
220-
ok: true,
221-
json: vi.fn().mockResolvedValue({
249+
return Promise.resolve(
250+
makeMockTokenResponse({
222251
available_models: ['gpt-4o', 'gpt-4o-mini'],
223252
expires_at: Math.floor(Date.now() / 1000) + 3600,
224253
session_token: 'test-token'
225254
})
226-
});
255+
);
227256
});
228257

229258
automodeService = createService();
@@ -416,6 +445,8 @@ describe('AutomodeService', () => {
416445
if (opts?.type === RequestType.ModelRouter) {
417446
return Promise.resolve({
418447
ok: true,
448+
status: 200,
449+
headers: createMockHeaders(),
419450
text: vi.fn().mockResolvedValue(JSON.stringify({
420451
predicted_label: 'needs_reasoning',
421452
confidence: 0.9,
@@ -427,14 +458,13 @@ describe('AutomodeService', () => {
427458
}))
428459
});
429460
}
430-
return Promise.resolve({
431-
ok: true,
432-
json: vi.fn().mockResolvedValue({
461+
return Promise.resolve(
462+
makeMockTokenResponse({
433463
available_models,
434464
expires_at: Math.floor(Date.now() / 1000) + 3600,
435465
session_token,
436466
})
437-
});
467+
);
438468
});
439469
}
440470

@@ -447,14 +477,13 @@ describe('AutomodeService', () => {
447477
if (opts?.type === RequestType.ModelRouter) {
448478
return Promise.reject(new Error('Network error'));
449479
}
450-
return Promise.resolve({
451-
ok: true,
452-
json: vi.fn().mockResolvedValue({
480+
return Promise.resolve(
481+
makeMockTokenResponse({
453482
available_models: ['claude-sonnet', 'gpt-4o'],
454483
expires_at: Math.floor(Date.now() / 1000) + 3600,
455484
session_token: 'test-token',
456485
})
457-
});
486+
);
458487
});
459488

460489
automodeService = createService();
@@ -498,14 +527,13 @@ describe('AutomodeService', () => {
498527
});
499528
});
500529
}
501-
return Promise.resolve({
502-
ok: true,
503-
json: vi.fn().mockResolvedValue({
530+
return Promise.resolve(
531+
makeMockTokenResponse({
504532
available_models: ['claude-sonnet', 'gpt-4o'],
505533
expires_at: Math.floor(Date.now() / 1000) + 3600,
506534
session_token: 'test-token',
507535
})
508-
});
536+
);
509537
});
510538

511539
automodeService = createService();
@@ -669,6 +697,8 @@ describe('AutomodeService', () => {
669697
if (opts?.type === RequestType.ModelRouter) {
670698
return Promise.resolve({
671699
ok: true,
700+
status: 200,
701+
headers: createMockHeaders(),
672702
text: vi.fn().mockResolvedValue(JSON.stringify({
673703
predicted_label: 'needs_reasoning',
674704
confidence: 0.9,
@@ -680,14 +710,13 @@ describe('AutomodeService', () => {
680710
}))
681711
});
682712
}
683-
return Promise.resolve({
684-
ok: true,
685-
json: vi.fn().mockResolvedValue({
713+
return Promise.resolve(
714+
makeMockTokenResponse({
686715
available_models: ['claude-sonnet', 'gpt-4o'],
687716
expires_at: Math.floor(Date.now() / 1000) + 3600,
688717
session_token: 'test-token',
689718
})
690-
});
719+
);
691720
});
692721

693722
automodeService = createService();

0 commit comments

Comments
 (0)