Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2955,6 +2955,13 @@
"type": "boolean",
"default": false,
"description": "Whether the model supports thinking capabilities"
},
"requestHeaders": {
"type": "object",
"description": "Additional HTTP headers to include with requests to this model. These reserved headers are not allowed and ignored if present: ['api-key', 'authorization', 'content-type', 'openai-intent', 'x-github-api-version', 'x-initiator', 'x-interaction-id', 'x-interaction-type', 'x-onbehalf-extension-id', 'x-request-id', 'x-vscode-user-agent-library-version']",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
Expand Down Expand Up @@ -3024,6 +3031,13 @@
"type": "boolean",
"default": false,
"description": "Whether the model supports thinking capabilities"
},
"requestHeaders": {
"type": "object",
"description": "Additional HTTP headers to include with requests to this model. These reserved headers are not allowed and ignored if present: ['api-key', 'authorization', 'content-type', 'openai-intent', 'x-github-api-version', 'x-initiator', 'x-interaction-id', 'x-interaction-type', 'x-onbehalf-extension-id', 'x-request-id', 'x-vscode-user-agent-library-version']",
"additionalProperties": {
"type": "string"
}
}
},
"required": [
Expand Down Expand Up @@ -4267,4 +4281,4 @@
"string_decoder": "npm:[email protected]",
"node-gyp": "npm:[email protected]"
}
}
}
7 changes: 6 additions & 1 deletion src/extension/byok/common/byokProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface BYOKModelCapabilities {
vision: boolean;
thinking?: boolean;
editTools?: EndpointEditToolName[];
requestHeaders?: Record<string, string>;
}

export interface BYOKModelRegistry {
Expand Down Expand Up @@ -117,7 +118,7 @@ export function resolveModelInfo(modelId: string, providerName: string, knownMod
}
const modelName = knownModelInfo?.name || modelId;
const contextWinow = knownModelInfo ? (knownModelInfo.maxInputTokens + knownModelInfo.maxOutputTokens) : 128000;
return {
const modelInfo: IChatModelInformation = {
id: modelId,
name: modelName,
version: '1.0.0',
Expand All @@ -141,6 +142,10 @@ export function resolveModelInfo(modelId: string, providerName: string, knownMod
is_chat_fallback: false,
model_picker_enabled: true
};
if (knownModelInfo?.requestHeaders && Object.keys(knownModelInfo.requestHeaders).length > 0) {
modelInfo.requestHeaders = { ...knownModelInfo.requestHeaders };
}
return modelInfo;
}

export function byokKnownModelsToAPIInfo(providerName: string, knownModels: BYOKKnownModels | undefined): LanguageModelChatInformation[] {
Expand Down
132 changes: 131 additions & 1 deletion src/extension/byok/node/openAIEndpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,52 @@
}

export class OpenAIEndpoint extends ChatEndpoint {
// Reserved headers that cannot be overridden for security and functionality reasons
private static readonly _reservedHeaders: ReadonlySet<string> = new Set([
// Authentication & Authorization
'api-key',
'authorization',
'cookie',
'set-cookie',
// Content & Protocol
'content-type',
'content-length',
'transfer-encoding',
'host',
// Routing & Proxying
'proxy-authorization',
'proxy-authenticate',
'x-forwarded-for',
'x-forwarded-host',
'x-forwarded-proto',
'forwarded',
// Security & CORS
'origin',
'referer',
'sec-fetch-site',
'sec-fetch-mode',
'sec-fetch-dest',
// Application-specific
'openai-intent',
'x-github-api-version',
'x-initiator',
'x-interaction-id',
'x-interaction-type',
'x-onbehalf-extension-id',
'x-request-id',
'x-vscode-user-agent-library-version',
'user-agent',
]);

// RFC 7230 compliant header name pattern: token characters only
private static readonly _validHeaderNamePattern = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/;

// Maximum limits to prevent abuse
private static readonly _maxHeaderNameLength = 256;
private static readonly _maxHeaderValueLength = 8192;
private static readonly _maxCustomHeaderCount = 20;

private readonly _customHeaders: Record<string, string>;
constructor(
protected readonly modelMetadata: IChatModelInformation,
protected readonly _apiKey: string,
Expand All @@ -58,7 +104,7 @@
@IInstantiationService protected instantiationService: IInstantiationService,
@IConfigurationService configurationService: IConfigurationService,
@IExperimentationService expService: IExperimentationService,
@ILogService logService: ILogService
@ILogService protected logService: ILogService
) {
super(
modelMetadata,
Expand All @@ -74,6 +120,87 @@
expService,
logService
);
this._customHeaders = this._sanitizeCustomHeaders(modelMetadata.requestHeaders);
}

private _sanitizeCustomHeaders(headers: Readonly<Record<string, string>> | undefined): Record<string, string> {
if (!headers) {
return {};
}

const entries = Object.entries(headers);

if (entries.length > OpenAIEndpoint._maxCustomHeaderCount) {
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.`);
}

const sanitized: Record<string, string> = {};
let processedCount = 0;

for (const [rawKey, rawValue] of entries) {
if (processedCount >= OpenAIEndpoint._maxCustomHeaderCount) {
break;
}

const key = rawKey.trim();
if (!key) {
this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has empty header name, skipping.`);
continue;
}

if (key.length > OpenAIEndpoint._maxHeaderNameLength) {
this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has header name exceeding ${OpenAIEndpoint._maxHeaderNameLength} characters, skipping.`);
continue;
}

if (!OpenAIEndpoint._validHeaderNamePattern.test(key)) {
this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has invalid header name format: '${key}'. Header names must match RFC 7230 token characters (^[!#$%&'*+.^_`|~0-9A-Za-z-]+$). Skipping.`);

Check failure on line 157 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Linux)

Identifier expected.

Check failure on line 157 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Linux)

Expression expected.

Check failure on line 157 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Linux)

An identifier or keyword cannot immediately follow a numeric literal.

Check failure on line 157 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Windows)

Identifier expected.

Check failure on line 157 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Windows)

Expression expected.

Check failure on line 157 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Windows)

An identifier or keyword cannot immediately follow a numeric literal.
continue;
}

const lowerKey = key.toLowerCase();
if (OpenAIEndpoint._reservedHeaders.has(lowerKey)) {
this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' attempted to override reserved header '${key}', skipping.`);

Check failure on line 163 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Linux)

Identifier expected.

Check failure on line 163 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Linux)

Unexpected keyword or identifier.

Check failure on line 163 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Linux)

Unexpected keyword or identifier.

Check failure on line 163 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Linux)

Unexpected keyword or identifier.

Check failure on line 163 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Linux)

Unexpected keyword or identifier.

Check failure on line 163 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Linux)

';' expected.

Check failure on line 163 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Linux)

';' expected.

Check failure on line 163 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Windows)

Identifier expected.

Check failure on line 163 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Windows)

Unexpected keyword or identifier.

Check failure on line 163 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Windows)

Unexpected keyword or identifier.

Check failure on line 163 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Windows)

Unexpected keyword or identifier.

Check failure on line 163 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Windows)

Unexpected keyword or identifier.

Check failure on line 163 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Windows)

';' expected.

Check failure on line 163 in src/extension/byok/node/openAIEndpoint.ts

View workflow job for this annotation

GitHub Actions / Test (Windows)

';' expected.
continue;
}

const sanitizedValue = this._sanitizeHeaderValue(rawValue);
if (sanitizedValue === undefined) {
this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has invalid value for header '${key}': '${rawValue}', skipping.`);
continue;
}

sanitized[key] = sanitizedValue;
processedCount++;
}

return sanitized;
}

private _sanitizeHeaderValue(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}

const trimmed = value.trim();

if (trimmed.length > OpenAIEndpoint._maxHeaderValueLength) {
return undefined;
}

// Disallow control characters including CR, LF, and others (0x00-0x1F, 0x7F)
// This prevents HTTP header injection and response splitting attacks
if (/[\x00-\x1F\x7F]/.test(trimmed)) {
return undefined;
}

// Additional check for potential Unicode issues
// Reject headers with bidirectional override characters or zero-width characters
if (/[\u200B-\u200D\u202A-\u202E\uFEFF]/.test(trimmed)) {
return undefined;
}

return trimmed;
}

override createRequestBody(options: ICreateEndpointBodyOptions): IEndpointBody {
Expand Down Expand Up @@ -149,6 +276,9 @@
} else {
headers['Authorization'] = `Bearer ${this._apiKey}`;
}
for (const [key, value] of Object.entries(this._customHeaders)) {
headers[key] = value;
}
return headers;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface ModelConfig {
editTools?: EndpointEditToolName[];
requiresAPIKey?: boolean;
thinking?: boolean;
requestHeaders?: Record<string, string>;
}

interface ModelQuickPickItem extends QuickPickItem {
Expand Down
11 changes: 8 additions & 3 deletions src/extension/byok/vscode-node/customOAIProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function resolveCustomOAIUrl(modelId: string, url: string): string {
interface CustomOAIModelInfo extends LanguageModelChatInformation {
url: string;
thinking: boolean;
requestHeaders?: Record<string, string>;
}

export class CustomOAIBYOKModelProvider implements BYOKModelProvider<CustomOAIModelInfo> {
Expand Down Expand Up @@ -67,8 +68,8 @@ export class CustomOAIBYOKModelProvider implements BYOKModelProvider<CustomOAIMo
return resolveCustomOAIUrl(modelId, url);
}

private getUserModelConfig(): Record<string, { name: string; url: string; toolCalling: boolean; vision: boolean; maxInputTokens: number; maxOutputTokens: number; requiresAPIKey: boolean; thinking?: boolean; editTools?: EndpointEditToolName[] }> {
const modelConfig = this._configurationService.getConfig(this.getConfigKey()) as Record<string, { name: string; url: string; toolCalling: boolean; vision: boolean; maxInputTokens: number; maxOutputTokens: number; requiresAPIKey: boolean; thinking?: boolean; editTools?: EndpointEditToolName[] }>;
private getUserModelConfig(): Record<string, { name: string; url: string; toolCalling: boolean; vision: boolean; maxInputTokens: number; maxOutputTokens: number; requiresAPIKey: boolean; thinking?: boolean; editTools?: EndpointEditToolName[]; requestHeaders?: Record<string, string> }> {
const modelConfig = this._configurationService.getConfig(this.getConfigKey()) as Record<string, { name: string; url: string; toolCalling: boolean; vision: boolean; maxInputTokens: number; maxOutputTokens: number; requiresAPIKey: boolean; thinking?: boolean; editTools?: EndpointEditToolName[]; requestHeaders?: Record<string, string> }>;
return modelConfig;
}

Expand All @@ -90,6 +91,7 @@ export class CustomOAIBYOKModelProvider implements BYOKModelProvider<CustomOAIMo
maxOutputTokens: modelInfo.maxOutputTokens,
thinking: modelInfo.thinking,
editTools: modelInfo.editTools,
requestHeaders: modelInfo.requestHeaders ? { ...modelInfo.requestHeaders } : undefined
};
}
return models;
Expand Down Expand Up @@ -135,6 +137,7 @@ export class CustomOAIBYOKModelProvider implements BYOKModelProvider<CustomOAIMo
editTools: capabilities.editTools
},
thinking: capabilities.thinking || false,
requestHeaders: capabilities.requestHeaders,
};
return baseInfo;
}
Expand Down Expand Up @@ -173,6 +176,7 @@ export class CustomOAIBYOKModelProvider implements BYOKModelProvider<CustomOAIMo
url: model.url,
thinking: model.thinking,
editTools: model.capabilities.editTools?.filter(isEndpointEditToolName),
requestHeaders: model.requestHeaders,
});
const openAIChatEndpoint = this._instantiationService.createInstance(OpenAIEndpoint, modelInfo, apiKey ?? '', model.url);
return this._lmWrapper.provideLanguageModelResponse(openAIChatEndpoint, messages, options, options.requestInitiator, progress, token);
Expand All @@ -196,7 +200,8 @@ export class CustomOAIBYOKModelProvider implements BYOKModelProvider<CustomOAIMo
vision: !!model.capabilities?.imageInput || false,
name: model.name,
url: model.url,
thinking: model.thinking
thinking: model.thinking,
requestHeaders: model.requestHeaders
});
const openAIChatEndpoint = this._instantiationService.createInstance(OpenAIEndpoint, modelInfo, apiKey ?? '', model.url);
return this._lmWrapper.provideTokenCount(openAIChatEndpoint, text);
Expand Down
2 changes: 1 addition & 1 deletion src/platform/configuration/common/configurationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -799,7 +799,7 @@ export namespace ConfigKey {
/** BYOK */
export const OllamaEndpoint = defineSetting<string>('chat.byok.ollamaEndpoint', 'http://localhost:11434');
export const AzureModels = defineSetting<Record<string, { name: string; url: string; toolCalling: boolean; vision: boolean; maxInputTokens: number; maxOutputTokens: number; requiresAPIKey?: boolean; thinking?: boolean }>>('chat.azureModels', {});
export const CustomOAIModels = defineSetting<Record<string, { name: string; url: string; toolCalling: boolean; vision: boolean; maxInputTokens: number; maxOutputTokens: number; requiresAPIKey?: boolean; thinking?: boolean }>>('chat.customOAIModels', {});
export const CustomOAIModels = defineSetting<Record<string, { name: string; url: string; toolCalling: boolean; vision: boolean; maxInputTokens: number; maxOutputTokens: number; requiresAPIKey?: boolean; thinking?: boolean; requestHeaders?: Record<string, string> }>>('chat.customOAIModels', {});
export const AutoFixDiagnostics = defineSetting<boolean>('chat.agent.autoFix', true);
export const NotebookFollowCellExecution = defineSetting<boolean>('chat.notebook.followCellExecution.enabled', false);
export const UseAlternativeNESNotebookFormat = defineExpSetting<boolean>('chat.notebook.enhancedNextEditSuggestions.enabled', false);
Expand Down
1 change: 1 addition & 0 deletions src/platform/endpoint/common/endpointProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export interface IModelAPIResponse {
export type IChatModelInformation = IModelAPIResponse & {
capabilities: IChatModelCapabilities;
urlOrRequestMetadata?: string | RequestMetadata;
requestHeaders?: Readonly<Record<string, string>>;
};

export function isChatModelInformation(model: IModelAPIResponse): model is IChatModelInformation {
Expand Down
Loading