Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/byok/node/openAIEndpoint.ts
13399 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
import type { CancellationToken } from 'vscode';
6
import { IChatMLFetcher } from '../../../platform/chat/common/chatMLFetcher';
7
import { ChatFetchResponseType, ChatResponse } from '../../../platform/chat/common/commonTypes';
8
import { IConfigurationService } from '../../../platform/configuration/common/configurationService';
9
import { IDomainService } from '../../../platform/endpoint/common/domainService';
10
import { IChatModelInformation } from '../../../platform/endpoint/common/endpointProvider';
11
import { ChatEndpoint } from '../../../platform/endpoint/node/chatEndpoint';
12
import { ILogService } from '../../../platform/log/common/logService';
13
import { isOpenAiFunctionTool } from '../../../platform/networking/common/fetch';
14
import { createCapiRequestBody, IChatEndpoint, ICreateEndpointBodyOptions, IEndpointBody, IMakeChatRequestOptions } from '../../../platform/networking/common/networking';
15
import { RawMessageConversionCallback } from '../../../platform/networking/common/openai';
16
import { IChatWebSocketManager } from '../../../platform/networking/node/chatWebSocketManager';
17
import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';
18
import { ITokenizerProvider } from '../../../platform/tokenizer/node/tokenizer';
19
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
20
21
function hydrateBYOKErrorMessages(response: ChatResponse): ChatResponse {
22
if (response.type === ChatFetchResponseType.Failed && response.streamError) {
23
return {
24
type: response.type,
25
requestId: response.requestId,
26
serverRequestId: response.serverRequestId,
27
reason: JSON.stringify(response.streamError),
28
};
29
} else if (response.type === ChatFetchResponseType.RateLimited) {
30
return {
31
type: response.type,
32
requestId: response.requestId,
33
serverRequestId: response.serverRequestId,
34
reason: response.capiError ? 'Rate limit exceeded\n\n' + JSON.stringify(response.capiError) : 'Rate limit exceeded',
35
rateLimitKey: '',
36
retryAfter: undefined,
37
isAuto: false,
38
capiError: response.capiError
39
};
40
}
41
return response;
42
}
43
44
/**
45
* Checks to see if a given endpoint is a BYOK model.
46
* @param endpoint The endpoint to check if it's a BYOK model
47
* @returns 1 if client side byok, 2 if server side byok, -1 if not a byok model
48
*/
49
export function isBYOKModel(endpoint: IChatEndpoint | undefined): number {
50
if (!endpoint) {
51
return -1;
52
}
53
return (endpoint instanceof OpenAIEndpoint || endpoint.isExtensionContributed) ? 1 : (endpoint.customModel ? 2 : -1);
54
}
55
56
export class OpenAIEndpoint extends ChatEndpoint {
57
// Reserved headers that cannot be overridden for security and functionality reasons
58
// Including forbidden request headers: https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header
59
private static readonly _reservedHeaders: ReadonlySet<string> = new Set([
60
// Forbidden Request Headers
61
'accept-charset',
62
'accept-encoding',
63
'access-control-request-headers',
64
'access-control-request-method',
65
'connection',
66
'content-length',
67
'cookie',
68
'date',
69
'dnt',
70
'expect',
71
'host',
72
'keep-alive',
73
'origin',
74
'permissions-policy',
75
'referer',
76
'te',
77
'trailer',
78
'transfer-encoding',
79
'upgrade',
80
'user-agent',
81
'via',
82
// Forwarding & Routing
83
'forwarded',
84
'x-forwarded-for',
85
'x-forwarded-host',
86
'x-forwarded-proto',
87
// Others
88
'api-key',
89
'authorization',
90
'content-type',
91
'openai-intent',
92
'x-github-api-version',
93
'x-initiator',
94
'x-interaction-id',
95
'x-interaction-type',
96
'x-onbehalf-extension-id',
97
'x-request-id',
98
'x-vscode-user-agent-library-version',
99
// Pattern-based forbidden headers are checked separately:
100
// - 'proxy-*' headers (handled in sanitization logic)
101
// - 'sec-*' headers (handled in sanitization logic)
102
// - 'x-http-method*' with forbidden methods CONNECT, TRACE, TRACK (handled in sanitization logic)
103
]);
104
105
// RFC 7230 compliant header name pattern: token characters only
106
private static readonly _validHeaderNamePattern = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/;
107
108
// Maximum limits to prevent abuse
109
private static readonly _maxHeaderNameLength = 256;
110
private static readonly _maxHeaderValueLength = 8192;
111
private static readonly _maxCustomHeaderCount = 20;
112
113
private readonly _customHeaders: Record<string, string>;
114
constructor(
115
_modelMetadata: IChatModelInformation,
116
protected readonly _apiKey: string,
117
protected readonly _modelUrl: string,
118
@IDomainService domainService: IDomainService,
119
@IChatMLFetcher chatMLFetcher: IChatMLFetcher,
120
@ITokenizerProvider tokenizerProvider: ITokenizerProvider,
121
@IInstantiationService protected instantiationService: IInstantiationService,
122
@IConfigurationService configurationService: IConfigurationService,
123
@IExperimentationService expService: IExperimentationService,
124
@IChatWebSocketManager chatWebSocketService: IChatWebSocketManager,
125
@ILogService protected logService: ILogService
126
) {
127
super(
128
_modelMetadata,
129
domainService,
130
chatMLFetcher,
131
tokenizerProvider,
132
instantiationService,
133
configurationService,
134
expService,
135
chatWebSocketService,
136
logService
137
);
138
this._customHeaders = this._sanitizeCustomHeaders(_modelMetadata.requestHeaders);
139
}
140
141
private _sanitizeCustomHeaders(headers: Readonly<Record<string, string>> | undefined): Record<string, string> {
142
if (!headers) {
143
return {};
144
}
145
146
const entries = Object.entries(headers);
147
148
if (entries.length > OpenAIEndpoint._maxCustomHeaderCount) {
149
this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has ${entries.length} custom headers, exceeding limit of ${OpenAIEndpoint._maxCustomHeaderCount}. Only first ${OpenAIEndpoint._maxCustomHeaderCount} will be processed.`);
150
}
151
152
const sanitized: Record<string, string> = {};
153
let processedCount = 0;
154
155
for (const [rawKey, rawValue] of entries) {
156
if (processedCount >= OpenAIEndpoint._maxCustomHeaderCount) {
157
break;
158
}
159
160
const key = rawKey.trim();
161
if (!key) {
162
this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has empty header name, skipping.`);
163
continue;
164
}
165
166
if (key.length > OpenAIEndpoint._maxHeaderNameLength) {
167
this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has header name exceeding ${OpenAIEndpoint._maxHeaderNameLength} characters, skipping.`);
168
continue;
169
}
170
171
if (!OpenAIEndpoint._validHeaderNamePattern.test(key)) {
172
this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has invalid header name format: '${key}', Skipping.`);
173
continue;
174
}
175
176
const lowerKey = key.toLowerCase();
177
if (OpenAIEndpoint._reservedHeaders.has(lowerKey)) {
178
this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' attempted to override reserved header '${key}', skipping.`);
179
continue;
180
}
181
182
// Check for pattern-based forbidden headers
183
if (lowerKey.startsWith('proxy-') || lowerKey.startsWith('sec-')) {
184
this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' attempted to set forbidden header pattern '${key}', skipping.`);
185
continue;
186
}
187
188
// Check for X-HTTP-Method* headers with forbidden methods
189
if ((lowerKey === 'x-http-method' || lowerKey === 'x-http-method-override' || lowerKey === 'x-method-override')) {
190
const forbiddenMethods = ['connect', 'trace', 'track'];
191
const methodValue = String(rawValue).toLowerCase().trim();
192
if (forbiddenMethods.includes(methodValue)) {
193
this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' attempted to set forbidden method '${methodValue}' in header '${key}', skipping.`);
194
continue;
195
}
196
}
197
198
const sanitizedValue = this._sanitizeHeaderValue(rawValue);
199
if (sanitizedValue === undefined) {
200
this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has invalid value for header '${key}': '${rawValue}', skipping.`);
201
continue;
202
}
203
204
sanitized[key] = sanitizedValue;
205
processedCount++;
206
}
207
208
return sanitized;
209
}
210
211
private _sanitizeHeaderValue(value: unknown): string | undefined {
212
if (typeof value !== 'string') {
213
return undefined;
214
}
215
216
const trimmed = value.trim();
217
218
if (trimmed.length > OpenAIEndpoint._maxHeaderValueLength) {
219
return undefined;
220
}
221
222
// Disallow control characters including CR, LF, and others (0x00-0x1F, 0x7F)
223
// This prevents HTTP header injection and response splitting attacks
224
if (/[\x00-\x1F\x7F]/.test(trimmed)) {
225
return undefined;
226
}
227
228
// Additional check for potential Unicode issues
229
// Reject headers with bidirectional override characters or zero-width characters
230
if (/[\u200B-\u200D\u202A-\u202E\uFEFF]/.test(trimmed)) {
231
return undefined;
232
}
233
234
return trimmed;
235
}
236
237
override createRequestBody(options: ICreateEndpointBodyOptions): IEndpointBody {
238
if (this.useResponsesApi) {
239
// Handle Responses API: customize the body directly
240
options.ignoreStatefulMarker = false;
241
const body = super.createRequestBody(options);
242
body.store = true;
243
body.n = undefined;
244
body.stream_options = undefined;
245
if (!this.modelMetadata.capabilities.supports.thinking) {
246
body.reasoning = undefined;
247
body.include = undefined;
248
}
249
if (body.previous_response_id && (!body.previous_response_id.startsWith('resp_') || this.modelMetadata.zeroDataRetentionEnabled)) {
250
// Don't use a response ID from CAPI or when zero data retention is enabled
251
body.previous_response_id = undefined;
252
}
253
return body;
254
} else {
255
// Handle CAPI: provide callback for thinking data processing
256
const callback: RawMessageConversionCallback = (out, data) => {
257
if (data && data.id) {
258
out.cot_id = data.id;
259
out.cot_summary = Array.isArray(data.text) ? data.text.join('') : data.text;
260
}
261
};
262
const body = createCapiRequestBody(options, this.model, callback);
263
return body;
264
}
265
}
266
267
override interceptBody(body: IEndpointBody | undefined): void {
268
super.interceptBody(body);
269
// TODO @lramos15 - We should do this for all models and not just here
270
if (body?.tools?.length === 0) {
271
delete body.tools;
272
}
273
274
if (body?.tools) {
275
body.tools = body.tools.map(tool => {
276
if (isOpenAiFunctionTool(tool) && tool.function.parameters === undefined) {
277
tool.function.parameters = { type: 'object', properties: {} };
278
}
279
return tool;
280
});
281
}
282
283
if (body) {
284
if (this.modelMetadata.capabilities.supports.thinking) {
285
delete body.temperature;
286
body['max_completion_tokens'] = body.max_tokens;
287
delete body.max_tokens;
288
}
289
// Removing max tokens defaults to the maximum which is what we want for BYOK
290
delete body.max_tokens;
291
if (!this.useResponsesApi && body.stream) {
292
body['stream_options'] = { 'include_usage': true };
293
}
294
}
295
}
296
297
override get urlOrRequestMetadata(): string {
298
return this._modelUrl;
299
}
300
301
public override getExtraHeaders(): Record<string, string> {
302
const headers: Record<string, string> = {
303
'Content-Type': 'application/json'
304
};
305
if (this._modelUrl.includes('openai.azure')) {
306
headers['api-key'] = this._apiKey;
307
} else {
308
headers['Authorization'] = `Bearer ${this._apiKey}`;
309
}
310
for (const [key, value] of Object.entries(this._customHeaders)) {
311
headers[key] = value;
312
}
313
return headers;
314
}
315
316
override cloneWithTokenOverride(modelMaxPromptTokens: number): IChatEndpoint {
317
const newModelInfo = { ...this.modelMetadata, maxInputTokens: modelMaxPromptTokens };
318
return this.instantiationService.createInstance(OpenAIEndpoint, newModelInfo, this._apiKey, this._modelUrl);
319
}
320
321
public override async makeChatRequest2(options: IMakeChatRequestOptions, token: CancellationToken): Promise<ChatResponse> {
322
// Apply ignoreStatefulMarker: false for initial request
323
const modifiedOptions: IMakeChatRequestOptions = { ...options, ignoreStatefulMarker: false };
324
const response = await super.makeChatRequest2(modifiedOptions, token);
325
return hydrateBYOKErrorMessages(response);
326
}
327
}
328
329