Skip to content

Commit eb6d3d7

Browse files
committed
Customizable headers for the OpenAI-compatible provider
1 parent 76f98f6 commit eb6d3d7

File tree

25 files changed

+279
-58
lines changed

25 files changed

+279
-58
lines changed

src/api/providers/openai.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,17 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
3535
const urlHost = this._getUrlHost(this.options.openAiBaseUrl)
3636
const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || options.openAiUseAzure
3737

38+
const headers = {
39+
...DEFAULT_HEADERS,
40+
...(this.options.openAiHeaders || {}),
41+
}
42+
3843
if (isAzureAiInference) {
3944
// Azure AI Inference Service (e.g., for DeepSeek) uses a different path structure
4045
this.client = new OpenAI({
4146
baseURL,
4247
apiKey,
43-
defaultHeaders: DEFAULT_HEADERS,
48+
defaultHeaders: headers,
4449
defaultQuery: { "api-version": this.options.azureApiVersion || "2024-05-01-preview" },
4550
})
4651
} else if (isAzureOpenAi) {
@@ -50,19 +55,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
5055
baseURL,
5156
apiKey,
5257
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
53-
defaultHeaders: {
54-
...DEFAULT_HEADERS,
55-
...(this.options.openAiHostHeader ? { Host: this.options.openAiHostHeader } : {}),
56-
},
58+
defaultHeaders: headers,
5759
})
5860
} else {
5961
this.client = new OpenAI({
6062
baseURL,
6163
apiKey,
62-
defaultHeaders: {
63-
...DEFAULT_HEADERS,
64-
...(this.options.openAiHostHeader ? { Host: this.options.openAiHostHeader } : {}),
65-
},
64+
defaultHeaders: headers,
6665
})
6766
}
6867
}
@@ -361,7 +360,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
361360
}
362361
}
363362

364-
export async function getOpenAiModels(baseUrl?: string, apiKey?: string, hostHeader?: string) {
363+
export async function getOpenAiModels(baseUrl?: string, apiKey?: string, openAiHeaders?: Record<string, string>) {
365364
try {
366365
if (!baseUrl) {
367366
return []
@@ -372,16 +371,15 @@ export async function getOpenAiModels(baseUrl?: string, apiKey?: string, hostHea
372371
}
373372

374373
const config: Record<string, any> = {}
375-
const headers: Record<string, string> = {}
374+
const headers: Record<string, string> = {
375+
...DEFAULT_HEADERS,
376+
...(openAiHeaders || {}),
377+
}
376378

377379
if (apiKey) {
378380
headers["Authorization"] = `Bearer ${apiKey}`
379381
}
380382

381-
if (hostHeader) {
382-
headers["Host"] = hostHeader
383-
}
384-
385383
if (Object.keys(headers).length > 0) {
386384
config["headers"] = headers
387385
}

src/core/config/ContextProxy.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,16 @@ export class ContextProxy {
192192
// If a value is not present in the new configuration, then it is assumed
193193
// that the setting's value should be `undefined` and therefore we
194194
// need to remove it from the state cache if it exists.
195+
196+
// Ensure openAiHeaders is always an object even when empty
197+
// This is critical for proper serialization/deserialization through IPC
198+
if (values.openAiHeaders !== undefined) {
199+
// Check if it's empty or null
200+
if (!values.openAiHeaders || Object.keys(values.openAiHeaders).length === 0) {
201+
values.openAiHeaders = {}
202+
}
203+
}
204+
195205
await this.setValues({
196206
...PROVIDER_SETTINGS_KEYS.filter((key) => !isSecretStateKey(key))
197207
.filter((key) => !!this.stateCache[key])

src/core/config/ProviderSettingsManager.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const providerProfilesSchema = z.object({
1717
.object({
1818
rateLimitSecondsMigrated: z.boolean().optional(),
1919
diffSettingsMigrated: z.boolean().optional(),
20+
openAiHeadersMigrated: z.boolean().optional(),
2021
})
2122
.optional(),
2223
})
@@ -38,6 +39,7 @@ export class ProviderSettingsManager {
3839
migrations: {
3940
rateLimitSecondsMigrated: true, // Mark as migrated on fresh installs
4041
diffSettingsMigrated: true, // Mark as migrated on fresh installs
42+
openAiHeadersMigrated: true, // Mark as migrated on fresh installs
4143
},
4244
}
4345

@@ -90,6 +92,7 @@ export class ProviderSettingsManager {
9092
providerProfiles.migrations = {
9193
rateLimitSecondsMigrated: false,
9294
diffSettingsMigrated: false,
95+
openAiHeadersMigrated: false,
9396
} // Initialize with default values
9497
isDirty = true
9598
}
@@ -106,6 +109,12 @@ export class ProviderSettingsManager {
106109
isDirty = true
107110
}
108111

112+
if (!providerProfiles.migrations.openAiHeadersMigrated) {
113+
await this.migrateOpenAiHeaders(providerProfiles)
114+
providerProfiles.migrations.openAiHeadersMigrated = true
115+
isDirty = true
116+
}
117+
109118
if (isDirty) {
110119
await this.store(providerProfiles)
111120
}
@@ -175,6 +184,30 @@ export class ProviderSettingsManager {
175184
}
176185
}
177186

187+
private async migrateOpenAiHeaders(providerProfiles: ProviderProfiles) {
188+
try {
189+
for (const [_name, apiConfig] of Object.entries(providerProfiles.apiConfigs)) {
190+
// Use type assertion to access the deprecated property safely
191+
const configAny = apiConfig as any
192+
193+
// Check if openAiHostHeader exists but openAiHeaders doesn't
194+
if (
195+
configAny.openAiHostHeader &&
196+
(!apiConfig.openAiHeaders || Object.keys(apiConfig.openAiHeaders || {}).length === 0)
197+
) {
198+
// Create the headers object with the Host value
199+
apiConfig.openAiHeaders = { Host: configAny.openAiHostHeader }
200+
201+
// Delete the old property to prevent re-migration
202+
// This prevents the header from reappearing after deletion
203+
configAny.openAiHostHeader = undefined
204+
}
205+
}
206+
} catch (error) {
207+
console.error(`[MigrateOpenAiHeaders] Failed to migrate OpenAI headers:`, error)
208+
}
209+
}
210+
178211
/**
179212
* List all available configs with metadata.
180213
*/

src/core/config/__tests__/ProviderSettingsManager.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ describe("ProviderSettingsManager", () => {
5656
migrations: {
5757
rateLimitSecondsMigrated: true,
5858
diffSettingsMigrated: true,
59+
openAiHeadersMigrated: true,
5960
},
6061
}),
6162
)

src/core/webview/webviewMessageHandler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
310310
const openAiModels = await getOpenAiModels(
311311
message?.values?.baseUrl,
312312
message?.values?.apiKey,
313-
message?.values?.hostHeader,
313+
message?.values?.openAiHeaders,
314314
)
315315

316316
provider.postMessageToWebview({ type: "openAiModels", openAiModels })

src/exports/roo-code.d.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ type ProviderSettings = {
5050
vertexRegion?: string | undefined
5151
openAiBaseUrl?: string | undefined
5252
openAiApiKey?: string | undefined
53-
openAiHostHeader?: string | undefined
5453
openAiLegacyFormat?: boolean | undefined
5554
openAiR1FormatEnabled?: boolean | undefined
5655
openAiModelId?: string | undefined
@@ -88,6 +87,12 @@ type ProviderSettings = {
8887
azureApiVersion?: string | undefined
8988
openAiStreamingEnabled?: boolean | undefined
9089
enableReasoningEffort?: boolean | undefined
90+
openAiHostHeader?: string | undefined
91+
openAiHeaders?:
92+
| {
93+
[x: string]: string
94+
}
95+
| undefined
9196
ollamaModelId?: string | undefined
9297
ollamaBaseUrl?: string | undefined
9398
vsCodeLmModelSelector?:

src/exports/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ type ProviderSettings = {
5151
vertexRegion?: string | undefined
5252
openAiBaseUrl?: string | undefined
5353
openAiApiKey?: string | undefined
54-
openAiHostHeader?: string | undefined
5554
openAiLegacyFormat?: boolean | undefined
5655
openAiR1FormatEnabled?: boolean | undefined
5756
openAiModelId?: string | undefined
@@ -89,6 +88,12 @@ type ProviderSettings = {
8988
azureApiVersion?: string | undefined
9089
openAiStreamingEnabled?: boolean | undefined
9190
enableReasoningEffort?: boolean | undefined
91+
openAiHostHeader?: string | undefined
92+
openAiHeaders?:
93+
| {
94+
[x: string]: string
95+
}
96+
| undefined
9297
ollamaModelId?: string | undefined
9398
ollamaBaseUrl?: string | undefined
9499
vsCodeLmModelSelector?:

src/schemas/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,6 @@ export const providerSettingsSchema = z.object({
370370
// OpenAI
371371
openAiBaseUrl: z.string().optional(),
372372
openAiApiKey: z.string().optional(),
373-
openAiHostHeader: z.string().optional(),
374373
openAiLegacyFormat: z.boolean().optional(),
375374
openAiR1FormatEnabled: z.boolean().optional(),
376375
openAiModelId: z.string().optional(),
@@ -379,6 +378,8 @@ export const providerSettingsSchema = z.object({
379378
azureApiVersion: z.string().optional(),
380379
openAiStreamingEnabled: z.boolean().optional(),
381380
enableReasoningEffort: z.boolean().optional(),
381+
openAiHostHeader: z.string().optional(), // Keep temporarily for backward compatibility during migration
382+
openAiHeaders: z.record(z.string(), z.string()).optional(),
382383
// Ollama
383384
ollamaModelId: z.string().optional(),
384385
ollamaBaseUrl: z.string().optional(),
@@ -469,7 +470,6 @@ const providerSettingsRecord: ProviderSettingsRecord = {
469470
// OpenAI
470471
openAiBaseUrl: undefined,
471472
openAiApiKey: undefined,
472-
openAiHostHeader: undefined,
473473
openAiLegacyFormat: undefined,
474474
openAiR1FormatEnabled: undefined,
475475
openAiModelId: undefined,
@@ -478,6 +478,8 @@ const providerSettingsRecord: ProviderSettingsRecord = {
478478
azureApiVersion: undefined,
479479
openAiStreamingEnabled: undefined,
480480
enableReasoningEffort: undefined,
481+
openAiHostHeader: undefined, // Keep temporarily for backward compatibility during migration
482+
openAiHeaders: undefined,
481483
// Ollama
482484
ollamaModelId: undefined,
483485
ollamaBaseUrl: undefined,

webview-ui/src/components/settings/ApiOptions.tsx

Lines changed: 93 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { useDebounce, useEvent } from "react-use"
33
import { Trans } from "react-i18next"
44
import { LanguageModelChatSelector } from "vscode"
55
import { Checkbox } from "vscrui"
6-
import { VSCodeLink, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
6+
import {
7+
VSCodeButton,
8+
VSCodeLink,
9+
VSCodeRadio,
10+
VSCodeRadioGroup,
11+
VSCodeTextField,
12+
} from "@vscode/webview-ui-toolkit/react"
713
import { ExternalLinkIcon } from "@radix-ui/react-icons"
814

915
import { ReasoningEffort as ReasoningEffortType } from "@roo/schemas"
@@ -77,7 +83,6 @@ const ApiOptions = ({
7783
const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
7884
const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion)
7985
const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl)
80-
const [openAiHostHeaderSelected, setOpenAiHostHeaderSelected] = useState(!!apiConfiguration?.openAiHostHeader)
8186
const [openAiLegacyFormatSelected, setOpenAiLegacyFormatSelected] = useState(!!apiConfiguration?.openAiLegacyFormat)
8287
const [googleGeminiBaseUrlSelected, setGoogleGeminiBaseUrlSelected] = useState(
8388
!!apiConfiguration?.googleGeminiBaseUrl,
@@ -123,7 +128,8 @@ const ApiOptions = ({
123128
values: {
124129
baseUrl: apiConfiguration?.openAiBaseUrl,
125130
apiKey: apiConfiguration?.openAiApiKey,
126-
hostHeader: apiConfiguration?.openAiHostHeader,
131+
customHeaders: {}, // Reserved for any additional headers
132+
openAiHeaders: apiConfiguration?.openAiHeaders,
127133
},
128134
})
129135
} else if (selectedProvider === "ollama") {
@@ -829,25 +835,90 @@ const ApiOptions = ({
829835
)}
830836
</div>
831837

832-
<div>
833-
<Checkbox
834-
checked={openAiHostHeaderSelected}
835-
onChange={(checked: boolean) => {
836-
setOpenAiHostHeaderSelected(checked)
837-
838-
if (!checked) {
839-
setApiConfigurationField("openAiHostHeader", "")
840-
}
841-
}}>
842-
{t("settings:providers.useHostHeader")}
843-
</Checkbox>
844-
{openAiHostHeaderSelected && (
845-
<VSCodeTextField
846-
value={apiConfiguration?.openAiHostHeader || ""}
847-
onInput={handleInputChange("openAiHostHeader")}
848-
placeholder="custom-api-hostname.example.com"
849-
className="w-full mt-1"
850-
/>
838+
{/* Custom Headers UI */}
839+
<div className="mb-4">
840+
<div className="flex justify-between items-center mb-2">
841+
<label className="block font-medium">{t("settings:providers.customHeaders")}</label>
842+
<VSCodeButton
843+
appearance="icon"
844+
title={t("settings:common.add")}
845+
onClick={() => {
846+
const currentHeaders = { ...(apiConfiguration?.openAiHeaders || {}) }
847+
// Use an empty string as key - user will fill it in
848+
// The key will be properly set when the user types in the field
849+
currentHeaders[""] = ""
850+
handleInputChange("openAiHeaders")({
851+
target: {
852+
value: currentHeaders,
853+
},
854+
})
855+
}}>
856+
<span className="codicon codicon-add"></span>
857+
</VSCodeButton>
858+
</div>
859+
{Object.keys(apiConfiguration?.openAiHeaders || {}).length === 0 ? (
860+
<div className="text-sm text-vscode-descriptionForeground">
861+
{t("settings:providers.noCustomHeaders")}
862+
</div>
863+
) : (
864+
Object.entries(apiConfiguration?.openAiHeaders || {}).map(([key, value], index) => (
865+
<div key={index} className="flex items-center mb-2">
866+
<VSCodeTextField
867+
value={key}
868+
className="flex-1 mr-2"
869+
placeholder={t("settings:providers.headerName")}
870+
onInput={(e: any) => {
871+
const currentHeaders = apiConfiguration?.openAiHeaders ?? {}
872+
const newValue = e.target.value
873+
874+
if (newValue && newValue !== key) {
875+
const { [key]: _, ...rest } = currentHeaders
876+
handleInputChange("openAiHeaders")({
877+
target: {
878+
value: {
879+
...rest,
880+
[newValue]: value,
881+
},
882+
},
883+
})
884+
}
885+
}}
886+
/>
887+
<VSCodeTextField
888+
value={value}
889+
className="flex-1 mr-2"
890+
placeholder={t("settings:providers.headerValue")}
891+
onInput={(e: any) => {
892+
handleInputChange("openAiHeaders")({
893+
target: {
894+
value: {
895+
...(apiConfiguration?.openAiHeaders ?? {}),
896+
[key]: e.target.value,
897+
},
898+
},
899+
})
900+
}}
901+
/>
902+
<VSCodeButton
903+
appearance="icon"
904+
title={t("settings:common.remove")}
905+
onClick={() => {
906+
const { [key]: _, ...rest } = apiConfiguration?.openAiHeaders ?? {}
907+
908+
// Ensure we always maintain an empty object even when removing the last header
909+
// This prevents the field from becoming undefined or null
910+
const newHeaders = Object.keys(rest).length === 0 ? {} : rest
911+
912+
handleInputChange("openAiHeaders")({
913+
target: {
914+
value: newHeaders,
915+
},
916+
})
917+
}}>
918+
<span className="codicon codicon-trash"></span>
919+
</VSCodeButton>
920+
</div>
921+
))
851922
)}
852923
</div>
853924

0 commit comments

Comments
 (0)